From 0a155257c5a3be8427ff1316bbcc65f33cc9fd96 Mon Sep 17 00:00:00 2001 From: "seven.zeng" <98709474@qq.com> Date: Mon, 31 Aug 2026 14:49:38 +0800 Subject: [PATCH 1/2] Fix notification watcher freezes and energy usage --- Sources/OTifierApp/Info.plist | 4 +- Sources/OTifierLib/NotificationWatcher.swift | 423 ++++++++++++++---- Tests/OTifierLibTests/OTPExtractorTests.swift | 23 + 3 files changed, 365 insertions(+), 85 deletions(-) diff --git a/Sources/OTifierApp/Info.plist b/Sources/OTifierApp/Info.plist index 0e79c6d..cf9f749 100644 --- a/Sources/OTifierApp/Info.plist +++ b/Sources/OTifierApp/Info.plist @@ -7,9 +7,9 @@ CFBundleIdentifier com.otifier.Otifier CFBundleVersion - 1.0.0 + 1.0.1 CFBundleShortVersionString - 1.0.0 + 1.0.1 CFBundleExecutable Otifier CFBundleIconFile diff --git a/Sources/OTifierLib/NotificationWatcher.swift b/Sources/OTifierLib/NotificationWatcher.swift index 5310d4e..ffede63 100644 --- a/Sources/OTifierLib/NotificationWatcher.swift +++ b/Sources/OTifierLib/NotificationWatcher.swift @@ -1,120 +1,305 @@ import Cocoa import ApplicationServices -/// Watches for macOS notification banners via the Accessibility API -/// and extracts OTP codes from their text content. -class NotificationWatcher { - private var pollTimer: Timer? - // FIFO of recently-seen notification texts. Array-backed so the oldest - // entry actually gets evicted; Set.removeFirst would drop a random one. +private let notificationCenterBundleID = "com.apple.notificationcenterui" +private let axMessagingTimeout: Float = 0.5 +private let observerBackstopInterval: TimeInterval = 4 +private let fallbackPollInterval: TimeInterval = 1 +private let fullScanSafetyInterval: TimeInterval = 30 + +private let notificationObserverCallback: AXObserverCallback = { _, _, _, refcon in + guard let refcon else { return } + let watcher = Unmanaged.fromOpaque(refcon).takeUnretainedValue() + watcher.notificationCenterDidChange() +} + +/// A privacy-preserving description of a Notification Center window. It only +/// contains geometry and window identifiers; notification text is never cached. +struct NotificationWindowFingerprint: Equatable { + let values: [String] +} + +/// Decides when the comparatively expensive Accessibility-tree scan is needed. +/// Window changes trigger a scan plus one follow-up (banner contents may be +/// populated just after the window is created); otherwise only a safety scan is +/// allowed through. +struct NotificationScanGate { + private(set) var previousFingerprint: NotificationWindowFingerprint? + private(set) var followUpScansRemaining = 0 + private(set) var lastScanAt = Date.distantPast + + mutating func shouldScan( + fingerprint: NotificationWindowFingerprint, + now: Date, + safetyInterval: TimeInterval = fullScanSafetyInterval + ) -> Bool { + if previousFingerprint != fingerprint { + previousFingerprint = fingerprint + followUpScansRemaining = 1 + lastScanAt = now + return true + } + + if followUpScansRemaining > 0 { + followUpScansRemaining -= 1 + lastScanAt = now + return true + } + + if now.timeIntervalSince(lastScanAt) >= safetyInterval { + lastScanAt = now + return true + } + + return false + } + + mutating func recordEventScan(at date: Date) { + lastScanAt = date + } + + mutating func reset() { + previousFingerprint = nil + followUpScansRemaining = 0 + lastScanAt = .distantPast + } +} + +/// Watches macOS notification banners through the Accessibility API and +/// extracts OTP codes from their text content. +/// +/// Accessibility calls are synchronous IPC. Notification Center can be slow or +/// temporarily unresponsive, so all AX work is isolated on a utility queue and +/// given a short messaging timeout. The UI thread therefore remains responsive +/// even if Notification Center does not answer. +// Mutable monitoring state is serialized on workerQueue. Callback properties are +// configured before start() and only read by that queue. +final class NotificationWatcher: @unchecked Sendable { + private let workerQueue = DispatchQueue( + label: "com.otifier.notification-watcher", + qos: .utility, + autoreleaseFrequency: .workItem + ) + + // The properties below are confined to workerQueue. + private var pollSource: DispatchSourceTimer? + private var eventScanWorkItem: DispatchWorkItem? + private var axObserver: AXObserver? + private var observedApplication: AXUIElement? + private var observedPID: pid_t? + private var isRunning = false + private var scanGate = NotificationScanGate() private var lastSeenTexts: [String] = [] - private let maxCacheSize = 50 private var lastPermissionCheck = Date.distantPast - private let permissionCheckInterval: TimeInterval = 5 + + private let maxCacheSize = 50 + private let permissionCheckInterval: TimeInterval = 10 + private let maxTreeDepth = 12 + private let maxElementsPerScan = 250 + var onOTPDetected: ((String, String) -> Void)? // (otp, sourceText) /// Called once if Accessibility permission is revoked while running. /// The watcher stops itself before invoking this. var onAXPermissionLost: (() -> Void)? - init() {} + func start() { + workerQueue.async { [weak self] in + guard let self, !self.isRunning else { return } + self.isRunning = true + self.scanGate.reset() + self.lastPermissionCheck = .distantPast - /// The AX element for the Notification Center process. Match by bundle id - /// first — `localizedName` varies by user locale. - private func getNotificationCenterApp() -> AXUIElement? { - for app in NSWorkspace.shared.runningApplications { - if app.bundleIdentifier == "com.apple.notificationcenterui" { - return AXUIElementCreateApplication(app.processIdentifier) - } + // A global process timeout also applies to child AX elements created + // while traversing Notification Center's hierarchy. + let systemWideElement = AXUIElementCreateSystemWide() + _ = AXUIElementSetMessagingTimeout(systemWideElement, axMessagingTimeout) + + self.refreshNotificationCenterConnection() + self.startBackstopTimer() + self.pollNotificationsIfNeeded(force: true) + } + } + + func stop() { + workerQueue.async { [weak self] in + self?.stopOnWorkerQueue() } - return nil } - /// Recursively walk the AX tree and collect all text values - private func collectTexts(from element: AXUIElement, depth: Int = 0, maxDepth: Int = 15) -> [String] { - guard depth < maxDepth else { return [] } - var texts: [String] = [] + deinit { + pollSource?.setEventHandler {} + pollSource?.cancel() + eventScanWorkItem?.cancel() + removeAXObserver() + } - let textAttributes: [String] = [ - kAXValueAttribute as String, - kAXTitleAttribute as String, - kAXDescriptionAttribute as String, - kAXHelpAttribute as String, - kAXRoleDescriptionAttribute as String, - ] + private func stopOnWorkerQueue() { + guard isRunning else { return } + isRunning = false - for attr in textAttributes { - var value: AnyObject? - let result = AXUIElementCopyAttributeValue(element, attr as CFString, &value) - if result == .success, let str = value as? String, !str.isEmpty { - texts.append(str) - } + pollSource?.setEventHandler {} + pollSource?.cancel() + pollSource = nil + eventScanWorkItem?.cancel() + eventScanWorkItem = nil + removeAXObserver() + observedPID = nil + scanGate.reset() + } + + private func startBackstopTimer() { + pollSource?.setEventHandler {} + pollSource?.cancel() + + let interval = axObserver == nil ? fallbackPollInterval : observerBackstopInterval + let timer = DispatchSource.makeTimerSource(queue: workerQueue) + timer.schedule( + deadline: .now() + interval, + repeating: interval, + leeway: .milliseconds(Int(interval * 200)) + ) + timer.setEventHandler { [weak self] in + self?.pollNotificationsIfNeeded(force: false) } + pollSource = timer + timer.resume() + } + + /// Called by the AX observer's main-run-loop callback. Actual processing is + /// coalesced and dispatched to the utility queue. + fileprivate func notificationCenterDidChange() { + workerQueue.async { [weak self] in + guard let self, self.isRunning else { return } - var children: AnyObject? - let childResult = AXUIElementCopyAttributeValue(element, kAXChildrenAttribute as CFString, &children) - if childResult == .success, let childArray = children as? [AXUIElement] { - for child in childArray { - texts.append(contentsOf: collectTexts(from: child, depth: depth + 1, maxDepth: maxDepth)) + // One banner can create several AX elements. Debouncing collapses + // that burst into a single tree traversal after text is populated. + self.eventScanWorkItem?.cancel() + let item = DispatchWorkItem { [weak self] in + guard let self, self.isRunning else { return } + self.scanGate.recordEventScan(at: Date()) + self.performNotificationScan() } + self.eventScanWorkItem = item + self.workerQueue.asyncAfter(deadline: .now() + .milliseconds(200), execute: item) } - - return texts } - /// Get text from all Notification Center windows and children (banner notifications) - private func getNotificationTexts() -> [String] { - guard let ncApp = getNotificationCenterApp() else { return [] } + private func pollNotificationsIfNeeded(force: Bool) { + guard isRunning else { return } - var allTexts: [String] = [] + let now = Date() + if now.timeIntervalSince(lastPermissionCheck) >= permissionCheckInterval { + lastPermissionCheck = now + if !AXIsProcessTrusted() { + let callback = onAXPermissionLost + stopOnWorkerQueue() + callback?() + return + } + } - // Check windows (banners appear as windows on some macOS versions) - var windowsValue: AnyObject? - if AXUIElementCopyAttributeValue(ncApp, kAXWindowsAttribute as CFString, &windowsValue) == .success, - let windows = windowsValue as? [AXUIElement] { - for window in windows { - allTexts.append(contentsOf: collectTexts(from: window)) + if observedPID == nil || NSRunningApplication(processIdentifier: observedPID!)?.isTerminated != false { + let previouslyObserved = observedPID + refreshNotificationCenterConnection() + if observedPID != previouslyObserved { + startBackstopTimer() } } - // Also check direct children (banners may appear here on newer macOS) - if allTexts.isEmpty { - var childrenValue: AnyObject? - if AXUIElementCopyAttributeValue(ncApp, kAXChildrenAttribute as CFString, &childrenValue) == .success, - let children = childrenValue as? [AXUIElement] { - for child in children { - // Skip the menu bar — we only want notification content - var roleValue: AnyObject? - AXUIElementCopyAttributeValue(child, kAXRoleAttribute as CFString, &roleValue) - if let role = roleValue as? String, role == "AXMenuBar" { continue } - allTexts.append(contentsOf: collectTexts(from: child, maxDepth: 10)) - } + guard let pid = observedPID else { return } + let fingerprint = notificationWindowFingerprint(for: pid) + let gateRequestedScan = scanGate.shouldScan(fingerprint: fingerprint, now: now) + if force || gateRequestedScan { + performNotificationScan() + } + } + + private func refreshNotificationCenterConnection() { + removeAXObserver() + observedPID = NSRunningApplication + .runningApplications(withBundleIdentifier: notificationCenterBundleID) + .first? + .processIdentifier + + guard let pid = observedPID else { return } + + let application = AXUIElementCreateApplication(pid) + _ = AXUIElementSetMessagingTimeout(application, axMessagingTimeout) + // Retain the application element even if this macOS version does not + // support the observer notifications; the window-change timer then acts + // as the compatibility fallback. + observedApplication = application + + var newObserver: AXObserver? + guard AXObserverCreate(pid, notificationObserverCallback, &newObserver) == .success, + let observer = newObserver else { return } + + let refcon = Unmanaged.passUnretained(self).toOpaque() + let notifications = [ + kAXCreatedNotification as CFString, + kAXWindowCreatedNotification as CFString, + ] + var registeredNotifications = 0 + for notification in notifications { + let result = AXObserverAddNotification(observer, application, notification, refcon) + if result == .success || result == .notificationAlreadyRegistered { + registeredNotifications += 1 } } - return allTexts + guard registeredNotifications > 0 else { return } + + axObserver = observer + CFRunLoopAddSource( + CFRunLoopGetMain(), + AXObserverGetRunLoopSource(observer), + .commonModes + ) } - func start() { - // 1.5s strikes a balance between responsiveness (banners stay on - // screen ~5s) and battery — every poll walks a chunk of the AX tree. - pollTimer = Timer.scheduledTimer(withTimeInterval: 1.5, repeats: true) { [weak self] _ in - self?.pollNotifications() + private func removeAXObserver() { + if let observer = axObserver { + CFRunLoopRemoveSource( + CFRunLoopGetMain(), + AXObserverGetRunLoopSource(observer), + .commonModes + ) } + axObserver = nil + observedApplication = nil } - private func pollNotifications() { - // Throttled re-check of AX permission. If the user revokes it mid-run, - // shut down the timer and let AppState surface the permission CTA. - if Date().timeIntervalSince(lastPermissionCheck) >= permissionCheckInterval { - lastPermissionCheck = Date() - if !AXIsProcessTrusted() { - stop() - onAXPermissionLost?() - return - } + private func notificationWindowFingerprint(for pid: pid_t) -> NotificationWindowFingerprint { + let options: CGWindowListOption = [.optionOnScreenOnly, .excludeDesktopElements] + guard let windows = CGWindowListCopyWindowInfo(options, kCGNullWindowID) as? [[String: Any]] else { + return NotificationWindowFingerprint(values: []) } - let texts = getNotificationTexts() + let values = windows.compactMap { info -> String? in + guard let ownerPID = info[kCGWindowOwnerPID as String] as? NSNumber, + ownerPID.int32Value == pid, + let windowNumber = info[kCGWindowNumber as String] as? NSNumber else { + return nil + } + + let layer = (info[kCGWindowLayer as String] as? NSNumber)?.intValue ?? 0 + let alpha = (info[kCGWindowAlpha as String] as? NSNumber)?.doubleValue ?? 0 + let boundsDescription: String + if let boundsDictionary = info[kCGWindowBounds as String] as? [String: Any], + let bounds = CGRect(dictionaryRepresentation: boundsDictionary as CFDictionary) { + boundsDescription = "\(Int(bounds.minX)),\(Int(bounds.minY)),\(Int(bounds.width)),\(Int(bounds.height))" + } else { + boundsDescription = "" + } + return "\(windowNumber.intValue):\(layer):\(alpha):\(boundsDescription)" + }.sorted() + + return NotificationWindowFingerprint(values: values) + } + + private func performNotificationScan() { + guard let application = observedApplication else { return } + let texts = getNotificationTexts(from: application) guard !texts.isEmpty else { return } let combined = texts.joined(separator: " | ") @@ -131,8 +316,80 @@ class NotificationWatcher { } } - func stop() { - pollTimer?.invalidate() - pollTimer = nil + /// Reads the six attributes needed for extraction in one cross-process IPC + /// call per element and caps traversal work so a malformed or very large AX + /// tree cannot monopolize the worker indefinitely. + private func collectTexts(from root: AXUIElement, maxDepth: Int) -> [String] { + let attributes: [CFString] = [ + kAXValueAttribute as CFString, + kAXTitleAttribute as CFString, + kAXDescriptionAttribute as CFString, + kAXHelpAttribute as CFString, + kAXRoleDescriptionAttribute as CFString, + kAXChildrenAttribute as CFString, + ] + + var result: [String] = [] + var queue: [(AXUIElement, Int)] = [(root, 0)] + var index = 0 + var visited = Set() + + while index < queue.count && visited.count < maxElementsPerScan { + let (element, depth) = queue[index] + index += 1 + guard depth < maxDepth else { continue } + + let identity = CFHash(element) + guard visited.insert(identity).inserted else { continue } + + var copiedValues: CFArray? + let copyResult = AXUIElementCopyMultipleAttributeValues( + element, + attributes as CFArray, + [], + &copiedValues + ) + guard copyResult == .success, let values = copiedValues as? [Any] else { continue } + + for value in values.prefix(5) { + if let text = value as? String, !text.isEmpty { + result.append(text) + } + } + + if values.count > 5, let children = values[5] as? [AXUIElement] { + queue.append(contentsOf: children.map { ($0, depth + 1) }) + } + } + + return result + } + + private func children(of element: AXUIElement, attribute: CFString) -> [AXUIElement] { + var value: AnyObject? + guard AXUIElementCopyAttributeValue(element, attribute, &value) == .success else { return [] } + return value as? [AXUIElement] ?? [] + } + + private func getNotificationTexts(from application: AXUIElement) -> [String] { + var allTexts: [String] = [] + + // Banners are windows on most macOS releases. Scan windows first so the + // node budget is spent on the most likely notification content. + for window in children(of: application, attribute: kAXWindowsAttribute as CFString) { + allTexts.append(contentsOf: collectTexts(from: window, maxDepth: maxTreeDepth)) + } + + // On newer releases banners may instead appear as direct children. + if allTexts.isEmpty { + for child in children(of: application, attribute: kAXChildrenAttribute as CFString) { + var roleValue: AnyObject? + _ = AXUIElementCopyAttributeValue(child, kAXRoleAttribute as CFString, &roleValue) + if let role = roleValue as? String, role == "AXMenuBar" { continue } + allTexts.append(contentsOf: collectTexts(from: child, maxDepth: min(maxTreeDepth, 10))) + } + } + + return allTexts } } diff --git a/Tests/OTifierLibTests/OTPExtractorTests.swift b/Tests/OTifierLibTests/OTPExtractorTests.swift index 719fe5b..02831b9 100644 --- a/Tests/OTifierLibTests/OTPExtractorTests.swift +++ b/Tests/OTifierLibTests/OTPExtractorTests.swift @@ -5,6 +5,15 @@ import Foundation var passed = 0 var failed = 0 +func assertTrue(_ condition: @autoclosure () -> Bool, _ message: String, file: String = #file, line: Int = #line) { + if condition() { + passed += 1 + } else { + failed += 1 + print("FAIL (\(file):\(line)): \(message)") + } +} + func assertEqual(_ actual: String?, _ expected: String?, file: String = #file, line: Int = #line) { if actual == expected { passed += 1 @@ -79,6 +88,20 @@ func assertNil(_ actual: String?, file: String = #file, line: Int = #line) { // --- Should NOT extract: edge cases around \b --- assertNil(extractOTP(from: "decode 12345 binary string")) // 'code' is inside 'decode' + // --- Notification scan throttling --- + var gate = NotificationScanGate() + let empty = NotificationWindowFingerprint(values: []) + let banner = NotificationWindowFingerprint(values: ["42:0:1.0:0,0,400,100"]) + let start = Date(timeIntervalSince1970: 1_000) + + assertTrue(gate.shouldScan(fingerprint: empty, now: start), "initial state must be scanned") + assertTrue(gate.shouldScan(fingerprint: empty, now: start.addingTimeInterval(1)), "initial scan gets one content follow-up") + assertTrue(!gate.shouldScan(fingerprint: empty, now: start.addingTimeInterval(2)), "unchanged idle state must not scan continuously") + assertTrue(gate.shouldScan(fingerprint: banner, now: start.addingTimeInterval(3)), "a new banner window must trigger a scan") + assertTrue(gate.shouldScan(fingerprint: banner, now: start.addingTimeInterval(4)), "a new banner gets one content follow-up") + assertTrue(!gate.shouldScan(fingerprint: banner, now: start.addingTimeInterval(5)), "stable banner must not trigger repeated scans") + assertTrue(gate.shouldScan(fingerprint: banner, now: start.addingTimeInterval(35)), "the safety interval must eventually rescan") + // --- Summary --- print("\nOTP Extractor Tests: \(passed) passed, \(failed) failed") if failed > 0 { From 0ae07cad4a9b59ce72564ad58cf3ed3c7cf18a5d Mon Sep 17 00:00:00 2001 From: "seven.zeng" <98709474@qq.com> Date: Mon, 31 Aug 2026 16:40:26 +0800 Subject: [PATCH 2/2] Add configurable OTP rules and localization --- Makefile | 13 +- README.md | 22 +- .../OTifierApp/AccessibilityDragPanel.swift | 8 +- Sources/OTifierApp/AppState.swift | 220 +++++++-- Sources/OTifierApp/Info.plist | 4 +- Sources/OTifierApp/LocalizationManager.swift | 105 +++++ Sources/OTifierApp/Localizations.json | 445 ++++++++++++++++++ Sources/OTifierApp/OTifierApp.swift | 1 + Sources/OTifierApp/OTifierMenu.swift | 178 ++++++- Sources/OTifierLib/NotificationWatcher.swift | 68 ++- Sources/OTifierLib/Notifier.swift | 14 +- Sources/OTifierLib/OTPExtractor.swift | 126 ++++- Tests/OTifierLibTests/OTPExtractorTests.swift | 63 +++ 13 files changed, 1167 insertions(+), 100 deletions(-) create mode 100644 Sources/OTifierApp/LocalizationManager.swift create mode 100644 Sources/OTifierApp/Localizations.json diff --git a/Makefile b/Makefile index 43a9469..2ca67ea 100644 --- a/Makefile +++ b/Makefile @@ -16,7 +16,10 @@ LIB_SOURCES = Sources/OTifierLib/OTPExtractor.swift \ APP_SOURCES = Sources/OTifierApp/OTifierApp.swift \ Sources/OTifierApp/AppState.swift \ Sources/OTifierApp/OTifierMenu.swift \ - Sources/OTifierApp/AccessibilityDragPanel.swift + Sources/OTifierApp/AccessibilityDragPanel.swift \ + Sources/OTifierApp/LocalizationManager.swift + +LOCALIZATION_CONFIG = Sources/OTifierApp/Localizations.json BUILD_DIR = .build APP_BUNDLE = $(BUILD_DIR)/Otifier.app @@ -60,7 +63,7 @@ $(BUILD_DIR)/ax-explorer: Sources/ax-explorer/main.swift | $(BUILD_DIR) app: $(APP_BUNDLE) -$(APP_BUNDLE): check-sparkle $(APP_SOURCES) $(LIB_SOURCES) Sources/OTifierApp/Info.plist AppIcon.icns | $(BUILD_DIR) +$(APP_BUNDLE): check-sparkle $(APP_SOURCES) $(LIB_SOURCES) $(LOCALIZATION_CONFIG) Sources/OTifierApp/Info.plist AppIcon.icns | $(BUILD_DIR) @echo "Building Otifier.app..." $(SWIFT) $(SWIFT_FLAGS) \ -F $(SPARKLE_DIR) -framework Sparkle \ @@ -72,12 +75,13 @@ $(APP_BUNDLE): check-sparkle $(APP_SOURCES) $(LIB_SOURCES) Sources/OTifierApp/In @cp $(BUILD_DIR)/OTifierApp $(APP_BUNDLE)/Contents/MacOS/Otifier @cp Sources/OTifierApp/Info.plist $(APP_BUNDLE)/Contents/Info.plist @cp AppIcon.icns $(APP_BUNDLE)/Contents/Resources/AppIcon.icns + @cp $(LOCALIZATION_CONFIG) $(APP_BUNDLE)/Contents/Resources/Localizations.json @rm -rf $(APP_BUNDLE)/Contents/Frameworks/Sparkle.framework @cp -R $(SPARKLE_FRAMEWORK) $(APP_BUNDLE)/Contents/Frameworks/ @codesign --force --sign - --deep $(APP_BUNDLE) @echo "Built $(APP_BUNDLE)" -release: check-sparkle $(APP_SOURCES) $(LIB_SOURCES) Sources/OTifierApp/Info.plist AppIcon.icns $(ENTITLEMENTS) | $(BUILD_DIR) +release: check-sparkle $(APP_SOURCES) $(LIB_SOURCES) $(LOCALIZATION_CONFIG) Sources/OTifierApp/Info.plist AppIcon.icns $(ENTITLEMENTS) | $(BUILD_DIR) @if [ -z "$(DEVELOPER_ID)" ]; then \ echo "ERROR: DEVELOPER_ID is not set."; \ echo "Set it via env var or Makefile.local, e.g.:"; \ @@ -97,6 +101,7 @@ release: check-sparkle $(APP_SOURCES) $(LIB_SOURCES) Sources/OTifierApp/Info.pli @cp $(BUILD_DIR)/OTifierApp $(APP_BUNDLE)/Contents/MacOS/Otifier @cp Sources/OTifierApp/Info.plist $(APP_BUNDLE)/Contents/Info.plist @cp AppIcon.icns $(APP_BUNDLE)/Contents/Resources/AppIcon.icns + @cp $(LOCALIZATION_CONFIG) $(APP_BUNDLE)/Contents/Resources/Localizations.json @rm -rf $(APP_BUNDLE)/Contents/Frameworks/Sparkle.framework @cp -R $(SPARKLE_FRAMEWORK) $(APP_BUNDLE)/Contents/Frameworks/ @$(MAKE) sign-sparkle @@ -222,7 +227,7 @@ site: test: $(BUILD_DIR)/test-runner $(BUILD_DIR)/test-runner -$(BUILD_DIR)/test-runner: Tests/OTifierLibTests/OTPExtractorTests.swift $(LIB_SOURCES) | $(BUILD_DIR) +$(BUILD_DIR)/test-runner: Tests/OTifierLibTests/OTPExtractorTests.swift $(LIB_SOURCES) $(LOCALIZATION_CONFIG) | $(BUILD_DIR) $(SWIFT) $(SWIFT_FLAGS) -o $@ Tests/OTifierLibTests/OTPExtractorTests.swift $(LIB_SOURCES) $(BUILD_DIR): diff --git a/README.md b/README.md index 1e8d30a..4d7b801 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,12 @@ email previews, app push notifications, etc. - **Just works** — no setup beyond granting Accessibility permission once - **Universal** — any macOS notification banner, including mirrored iPhone texts - **History** — recent codes in the menu bar; click any one to re-copy +- **Fallback tools** — choose a code candidate from a recently unrecognized + notification +- **Editable keywords** — review, add, or remove active recognition keywords and + reset the whole list to the project defaults at any time +- **Multilingual menu** — switch between English, Simplified Chinese, + Traditional Chinese, Japanese, Korean, Spanish, French, and German ## Install @@ -60,22 +66,26 @@ open .build/Otifier.app ``` iPhone notification → mirrored to Mac → notification banner - → AX tree poll (1.5s) → verification code match → clipboard + → AX change event → verification code match → clipboard ``` -Otifier polls the Notification Center's Accessibility tree every 1.5 seconds. -When a banner contains a verification code, it copies the code and shows a -small confirmation notification. +Otifier listens for changes to Notification Center's Accessibility tree. A +lightweight window-state check is used as a compatibility fallback. When a +banner contains a verification code, it copies the code and shows a small +confirmation notification.
Detection rules OTPs are matched via regex with keyword gating to avoid false positives: -- **Patterns**: `code: 123456`, `OTP: 1234`, `G-583920`, bare 4–8 digit codes -- **Keywords**: verification, code, OTP, one-time, 2FA, sign in, 验证码, … +- **Patterns**: `code: 123456`, `OTP: 1234`, `G-583920`, `583 920`, `583-920` +- **Keywords**: verification, code, OTP, one-time, 2FA, sign in, 验证码, + 动态口令, 短信码, 安全码, … - **Filtering**: rejects repeated digits (`1111`), order/tracking numbers, codes shorter than 4 digits +- **Extensibility**: edit the keyword list from the menu; automatic extraction + remains context-gated, and unmatched candidates can be selected manually
diff --git a/Sources/OTifierApp/AccessibilityDragPanel.swift b/Sources/OTifierApp/AccessibilityDragPanel.swift index 3f46bb5..c6ebd4f 100644 --- a/Sources/OTifierApp/AccessibilityDragPanel.swift +++ b/Sources/OTifierApp/AccessibilityDragPanel.swift @@ -15,7 +15,7 @@ final class AccessibilityDragPanelController: NSObject { private var trackingTimer: Timer? private var missingWindowSince: Date? - func show() { + func show(message: String) { if panel != nil { reposition() return @@ -36,7 +36,7 @@ final class AccessibilityDragPanelController: NSObject { panel.hasShadow = true panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary] - let hosting = NSHostingView(rootView: DragPanelContent()) + let hosting = NSHostingView(rootView: DragPanelContent(message: message)) hosting.frame = NSRect(x: 0, y: 0, width: panelWidth, height: panelHeight) panel.contentView = hosting @@ -152,13 +152,15 @@ final class AccessibilityDragPanelController: NSObject { private let arrowBlue = Color(red: 0x54 / 255.0, green: 0xB6 / 255.0, blue: 0xFF / 255.0) private struct DragPanelContent: View { + let message: String + var body: some View { VStack(alignment: .leading, spacing: 8) { HStack(spacing: 8) { Image(systemName: "arrow.up") .font(.system(size: 18, weight: .bold)) .foregroundStyle(arrowBlue) - Text("Drag Otifier to the list above to allow Accessibility") + Text(message) .font(.system(size: 13, weight: .medium)) .foregroundStyle(.primary) Spacer(minLength: 0) diff --git a/Sources/OTifierApp/AppState.swift b/Sources/OTifierApp/AppState.swift index ca40bb7..c42548f 100644 --- a/Sources/OTifierApp/AppState.swift +++ b/Sources/OTifierApp/AppState.swift @@ -4,29 +4,53 @@ import ApplicationServices import ServiceManagement private let launchAtLoginPromptShownKey = "launchAtLoginPromptShown" +private let otpKeywordsKey = "otpKeywords" +private let legacyCustomOTPKeywordsKey = "customOTPKeywords" +private let legacyBuiltInOTPKeywordsKey = "builtInOTPKeywords" struct OTPEntry: Identifiable { let id = UUID() let code: String - let source: String + let sourceKey: String let timestamp: Date - var timeAgo: String { + @MainActor + func timeAgo(using localization: LocalizationManager) -> String { let seconds = Int(Date().timeIntervalSince(timestamp)) - if seconds < 60 { return "\(seconds)s ago" } + if seconds < 60 { return localization.text("time.seconds_ago", seconds) } let minutes = seconds / 60 - if minutes < 60 { return "\(minutes)m ago" } - return "\(minutes / 60)h ago" + if minutes < 60 { return localization.text("time.minutes_ago", minutes) } + return localization.text("time.hours_ago", minutes / 60) + } +} + +struct UnrecognizedOTPEntry: Identifiable { + let id = UUID() + let text: String + let candidates: [String] + let timestamp: Date + + var preview: String { + let collapsed = text + .split(whereSeparator: { $0.isWhitespace }) + .joined(separator: " ") + guard collapsed.count > 90 else { return collapsed } + return String(collapsed.prefix(90)) + "…" } } @MainActor class AppState: ObservableObject { + let localization = LocalizationManager() + @Published var isMonitoring = true @Published var recentOTPs: [OTPEntry] = [] + @Published var recentUnrecognized: [UnrecognizedOTPEntry] = [] @Published var hasAccessibilityPermission = false @Published var launchAtLoginEnabled = false - @Published var statusMessage = "Starting..." + @Published var statusMessageKey = "status.starting" + @Published var otpKeywords: [String] = defaultOTPKeywords + @Published var isShowingRuleEditor = false private var notifWatcher: NotificationWatcher? private var cleanupTimer: Timer? @@ -34,6 +58,17 @@ class AppState: ObservableObject { private let dragPanelController = AccessibilityDragPanelController() init() { + let defaults = UserDefaults.standard + if let savedKeywords = defaults.stringArray(forKey: otpKeywordsKey) { + otpKeywords = savedKeywords + } else { + let legacyBuiltIn = defaults.stringArray(forKey: legacyBuiltInOTPKeywordsKey) ?? defaultOTPKeywords + let legacyCustom = defaults.stringArray(forKey: legacyCustomOTPKeywordsKey) ?? [] + otpKeywords = normalizedKeywords(from: (legacyBuiltIn + legacyCustom).joined(separator: "\n")) + defaults.set(otpKeywords, forKey: otpKeywordsKey) + defaults.removeObject(forKey: legacyBuiltInOTPKeywordsKey) + defaults.removeObject(forKey: legacyCustomOTPKeywordsKey) + } refreshLaunchAtLoginStatus() // Defer starting the watcher until we know permission is granted — // calling AX APIs without permission triggers macOS's own system prompt, @@ -69,9 +104,9 @@ class AppState: ObservableObject { guard notifWatcher == nil else { return } // idempotent — don't spawn duplicates let watcher = NotificationWatcher() - watcher.onOTPDetected = { [weak self] otp, source in + watcher.onOTPDetected = { [weak self] otp, _ in Task { @MainActor in - self?.addOTP(code: otp, source: "Notification") + self?.addOTP(code: otp, sourceKey: "source.notification") } } watcher.onAXPermissionLost = { [weak self] in @@ -79,9 +114,15 @@ class AppState: ObservableObject { self?.handleAXPermissionLost() } } + watcher.onUnrecognizedText = { [weak self] text, candidates in + Task { @MainActor in + self?.addUnrecognized(text: text, candidates: candidates) + } + } + watcher.updateKeywords(otpKeywords) watcher.start() notifWatcher = watcher - statusMessage = "Monitoring" + statusMessageKey = "status.monitoring" } private func handleAXPermissionLost() { @@ -89,13 +130,13 @@ class AppState: ObservableObject { // and let the menu's permission CTA take over. notifWatcher = nil hasAccessibilityPermission = false - statusMessage = "Accessibility permission required" + statusMessageKey = "status.permission_required" } func stopMonitoring() { notifWatcher?.stop() notifWatcher = nil - statusMessage = "Stopped" + statusMessageKey = "status.stopped" } func toggleMonitoring() { @@ -107,12 +148,12 @@ class AppState: ObservableObject { } } - func addOTP(code: String, source: String) { + func addOTP(code: String, sourceKey: String) { if recentOTPs.contains(where: { $0.code == code && Date().timeIntervalSince($0.timestamp) < 60 }) { return } - let entry = OTPEntry(code: code, source: source, timestamp: Date()) + let entry = OTPEntry(code: code, sourceKey: sourceKey, timestamp: Date()) recentOTPs.insert(entry, at: 0) if recentOTPs.count > 10 { @@ -120,26 +161,146 @@ class AppState: ObservableObject { } copyToClipboard(code) - showNotification(otp: code, source: source) + showNotification( + title: localization.text("notification.copied.title"), + body: localization.text("notification.copied.body") + ) } func copyOTP(_ entry: OTPEntry) { copyToClipboard(entry.code) } + func acceptCandidate(_ code: String, from entry: UnrecognizedOTPEntry) { + recentUnrecognized.removeAll { $0.id == entry.id } + addOTP(code: code, sourceKey: "source.manual") + } + + func promptToAddRule(for entry: UnrecognizedOTPEntry) { + let alert = NSAlert() + alert.messageText = localization.text("alert.add_rule.title") + alert.informativeText = localization.text("alert.add_rule.message") + alert.alertStyle = .informational + alert.addButton(withTitle: localization.text("alert.add_rule.confirm")) + alert.addButton(withTitle: localization.text("common.cancel")) + + let field = NSTextField(frame: NSRect(x: 0, y: 0, width: 320, height: 24)) + field.placeholderString = localization.text("alert.add_rule.placeholder") + alert.accessoryView = field + + NSApp.activate(ignoringOtherApps: true) + guard alert.runModal() == .alertFirstButtonReturn else { return } + addKeyword(field.stringValue) + + if let code = extractOTP(from: entry.text, keywords: otpKeywords) { + recentUnrecognized.removeAll { $0.id == entry.id } + addOTP(code: code, sourceKey: "source.keyword_rule") + } + } + + private func addKeyword(_ rawKeyword: String) { + let keyword = rawKeyword.trimmingCharacters(in: .whitespacesAndNewlines) + guard !keyword.isEmpty else { return } + guard !otpKeywords.contains(where: { $0.caseInsensitiveCompare(keyword) == .orderedSame }) else { + return + } + otpKeywords.append(keyword) + persistKeywords() + } + + private func persistKeywords() { + UserDefaults.standard.set(otpKeywords, forKey: otpKeywordsKey) + updateWatcherKeywords() + } + + func promptToEditKeywords() { + let alert = NSAlert() + alert.messageText = localization.text("alert.edit_keywords.title") + alert.informativeText = localization.text("alert.edit_keywords.message") + alert.alertStyle = .informational + alert.addButton(withTitle: localization.text("alert.edit_keywords.confirm")) + alert.addButton(withTitle: localization.text("common.cancel")) + + let scrollView = NSScrollView(frame: NSRect(x: 0, y: 0, width: 380, height: 180)) + scrollView.hasVerticalScroller = true + scrollView.borderType = .bezelBorder + let textView = NSTextView(frame: scrollView.bounds) + textView.isRichText = false + textView.font = .monospacedSystemFont(ofSize: 12, weight: .regular) + textView.string = otpKeywords.joined(separator: "\n") + scrollView.documentView = textView + alert.accessoryView = scrollView + + NSApp.activate(ignoringOtherApps: true) + guard alert.runModal() == .alertFirstButtonReturn else { return } + + otpKeywords = normalizedKeywords(from: textView.string) + persistKeywords() + } + + func resetKeywords() { + let alert = NSAlert() + alert.messageText = localization.text("alert.reset_keywords.title") + alert.informativeText = localization.text("alert.reset_keywords.message") + alert.alertStyle = .warning + alert.addButton(withTitle: localization.text("alert.reset_keywords.confirm")) + alert.addButton(withTitle: localization.text("common.cancel")) + + NSApp.activate(ignoringOtherApps: true) + guard alert.runModal() == .alertFirstButtonReturn else { return } + + otpKeywords = defaultOTPKeywords + persistKeywords() + } + + private func normalizedKeywords(from text: String) -> [String] { + let separators = CharacterSet(charactersIn: ",,\n\r") + var seen = Set() + return text.components(separatedBy: separators).compactMap { component in + let keyword = component.trimmingCharacters(in: .whitespacesAndNewlines) + guard !keyword.isEmpty else { return nil } + let identity = keyword.lowercased() + guard seen.insert(identity).inserted else { return nil } + return keyword + } + } + + private func updateWatcherKeywords() { + notifWatcher?.updateKeywords(otpKeywords) + } + + private func addUnrecognized(text: String, candidates: [String]) { + guard !candidates.isEmpty else { return } + if recentUnrecognized.contains(where: { $0.text == text }) { return } + + recentUnrecognized.insert( + UnrecognizedOTPEntry(text: text, candidates: candidates, timestamp: Date()), + at: 0 + ) + if recentUnrecognized.count > 5 { + recentUnrecognized = Array(recentUnrecognized.prefix(5)) + } + } + private func cleanupOldOTPs() { // Codes are short-lived secrets; keep them in the menu just long // enough for the user to re-copy if their first paste went somewhere // wrong. 2 min is long enough for that and short enough to reduce // shoulder-surfing risk if the menu is left open. recentOTPs.removeAll { Date().timeIntervalSince($0.timestamp) > 120 } + // Unrecognized notification text is deliberately memory-only and has the + // same short lifetime as recognized codes. + recentUnrecognized.removeAll { Date().timeIntervalSince($0.timestamp) > 120 } } func requestAccessibility() { let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility")! NSWorkspace.shared.open(url) DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in - self?.dragPanelController.show() + guard let self else { return } + self.dragPanelController.show( + message: self.localization.text("accessibility.drag_instruction") + ) } DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in self?.checkPermissions() @@ -151,13 +312,11 @@ class AppState: ObservableObject { guard !AXIsProcessTrusted() else { return } let alert = NSAlert() - alert.messageText = "Allow Otifier to read notification banners?" - alert.informativeText = """ - Otifier requires Accessibility permission to read notification banners and copy verification codes to your clipboard. - """ + alert.messageText = localization.text("alert.accessibility.title") + alert.informativeText = localization.text("alert.accessibility.message") alert.alertStyle = .warning - alert.addButton(withTitle: "Open System Settings") - alert.addButton(withTitle: "Not Now") + alert.addButton(withTitle: localization.text("menu.open_system_settings")) + alert.addButton(withTitle: localization.text("common.not_now")) NSApp.activate(ignoringOtherApps: true) @@ -201,12 +360,13 @@ class AppState: ObservableObject { try SMAppService.mainApp.unregister() } } catch { - let action = enabled ? "enable" : "disable" let alert = NSAlert() - alert.messageText = "Couldn't \(action) Launch at Login" + alert.messageText = localization.text( + enabled ? "alert.launch_error.enable" : "alert.launch_error.disable" + ) alert.informativeText = error.localizedDescription alert.alertStyle = .warning - alert.addButton(withTitle: "OK") + alert.addButton(withTitle: localization.text("common.ok")) NSApp.activate(ignoringOtherApps: true) alert.runModal() } @@ -221,15 +381,11 @@ class AppState: ObservableObject { defaults.set(true, forKey: launchAtLoginPromptShownKey) let alert = NSAlert() - alert.messageText = "Launch Otifier on restart?" - alert.informativeText = """ - Verification codes can arrive at any time, so Otifier is most \ - useful when it's already running. You can change this anytime \ - from the menu. - """ + alert.messageText = localization.text("alert.launch_prompt.title") + alert.informativeText = localization.text("alert.launch_prompt.message") alert.alertStyle = .informational - alert.addButton(withTitle: "Launch on restart") - alert.addButton(withTitle: "Not Now") + alert.addButton(withTitle: localization.text("alert.launch_prompt.confirm")) + alert.addButton(withTitle: localization.text("common.not_now")) NSApp.activate(ignoringOtherApps: true) diff --git a/Sources/OTifierApp/Info.plist b/Sources/OTifierApp/Info.plist index cf9f749..c8c540e 100644 --- a/Sources/OTifierApp/Info.plist +++ b/Sources/OTifierApp/Info.plist @@ -7,9 +7,9 @@ CFBundleIdentifier com.otifier.Otifier CFBundleVersion - 1.0.1 + 1.0.2 CFBundleShortVersionString - 1.0.1 + 1.0.2 CFBundleExecutable Otifier CFBundleIconFile diff --git a/Sources/OTifierApp/LocalizationManager.swift b/Sources/OTifierApp/LocalizationManager.swift new file mode 100644 index 0000000..bb5cc26 --- /dev/null +++ b/Sources/OTifierApp/LocalizationManager.swift @@ -0,0 +1,105 @@ +import Foundation +import Combine + +private let selectedLanguageDefaultsKey = "selectedLanguageCode" + +struct AppLanguage: Codable, Identifiable { + let code: String + let name: String + let strings: [String: String] + + var id: String { code } +} + +private struct LocalizationCatalog: Codable { + let defaultLanguage: String + let languages: [AppLanguage] +} + +@MainActor +final class LocalizationManager: ObservableObject { + @Published private(set) var selectedLanguageCode: String + private(set) var availableLanguages: [AppLanguage] + + private let defaultLanguageCode: String + private let languagesByCode: [String: AppLanguage] + private let defaults: UserDefaults + + init(bundle: Bundle = .main, defaults: UserDefaults = .standard) { + let catalog = Self.loadCatalog(from: bundle) + self.defaults = defaults + defaultLanguageCode = catalog.defaultLanguage + availableLanguages = catalog.languages + languagesByCode = Dictionary(uniqueKeysWithValues: catalog.languages.map { ($0.code, $0) }) + + if let saved = defaults.string(forKey: selectedLanguageDefaultsKey), + languagesByCode[saved] != nil { + selectedLanguageCode = saved + } else { + selectedLanguageCode = Self.bestLanguageCode( + for: Locale.preferredLanguages, + available: catalog.languages.map(\.code), + fallback: catalog.defaultLanguage + ) + } + } + + func setLanguage(_ code: String) { + guard languagesByCode[code] != nil else { return } + selectedLanguageCode = code + defaults.set(code, forKey: selectedLanguageDefaultsKey) + } + + func text(_ key: String, _ arguments: CVarArg...) -> String { + let selected = languagesByCode[selectedLanguageCode]?.strings[key] + let fallback = languagesByCode[defaultLanguageCode]?.strings[key] + let template = selected ?? fallback ?? key + guard !arguments.isEmpty else { return template } + return String( + format: template, + locale: Locale(identifier: selectedLanguageCode), + arguments: arguments + ) + } + + private static func loadCatalog(from bundle: Bundle) -> LocalizationCatalog { + guard let url = bundle.url(forResource: "Localizations", withExtension: "json"), + let data = try? Data(contentsOf: url), + let catalog = try? JSONDecoder().decode(LocalizationCatalog.self, from: data), + !catalog.languages.isEmpty, + catalog.languages.contains(where: { $0.code == catalog.defaultLanguage }) else { + return LocalizationCatalog( + defaultLanguage: "en", + languages: [AppLanguage(code: "en", name: "English", strings: [:])] + ) + } + return catalog + } + + private static func bestLanguageCode( + for preferredLanguages: [String], + available: [String], + fallback: String + ) -> String { + for preferred in preferredLanguages { + if let exact = available.first(where: { $0.caseInsensitiveCompare(preferred) == .orderedSame }) { + return exact + } + + let normalized = preferred.lowercased() + if normalized.hasPrefix("zh-hant") || normalized.hasPrefix("zh-tw") || normalized.hasPrefix("zh-hk"), + available.contains("zh-Hant") { + return "zh-Hant" + } + if normalized.hasPrefix("zh"), available.contains("zh-Hans") { + return "zh-Hans" + } + + let base = normalized.split(separator: "-").first.map(String.init) ?? normalized + if let match = available.first(where: { $0.lowercased() == base }) { + return match + } + } + return available.contains(fallback) ? fallback : (available.first ?? "en") + } +} diff --git a/Sources/OTifierApp/Localizations.json b/Sources/OTifierApp/Localizations.json new file mode 100644 index 0000000..955fcba --- /dev/null +++ b/Sources/OTifierApp/Localizations.json @@ -0,0 +1,445 @@ +{ + "defaultLanguage": "en", + "languages": [ + { + "code": "en", + "name": "English", + "strings": { + "menu.no_codes": "No OTP codes detected yet", + "menu.recent_codes": "Recent Codes", + "menu.permission.title": "Accessibility permission required", + "menu.permission.description": "Otifier needs Accessibility access to read notification banners and detect OTP codes.", + "menu.open_system_settings": "Open System Settings", + "menu.unrecognized.title": "Unrecognized Notifications", + "menu.unrecognized.add_rule": "Add Rule…", + "menu.unrecognized.privacy": "Notification text stays in memory for 2 minutes and is never saved.", + "menu.rules.title": "Recognition Rules", + "menu.rules.keywords": "Keywords", + "menu.rules.edit_keywords": "Edit Keywords…", + "menu.rules.reset_keywords": "Reset to Defaults", + "menu.language": "Language", + "menu.launch_at_login": "Launch at Login", + "menu.quit": "Quit Otifier", + "menu.version": "Version %@", + "status.starting": "Starting…", + "status.monitoring": "Monitoring", + "status.permission_required": "Accessibility permission required", + "status.stopped": "Stopped", + "source.notification": "Notification", + "source.manual": "Manual", + "source.keyword_rule": "Keyword Rule", + "time.seconds_ago": "%d sec ago", + "time.minutes_ago": "%d min ago", + "time.hours_ago": "%d hr ago", + "alert.add_rule.title": "Add OTP keyword", + "alert.add_rule.message": "Enter a word or phrase that identifies verification codes in messages like this. Only the keyword is saved; notification text is not persisted.", + "alert.add_rule.confirm": "Add Rule", + "alert.add_rule.placeholder": "For example: dynamic code or 动态口令", + "alert.edit_keywords.title": "Edit Keywords", + "alert.edit_keywords.message": "Enter one keyword per line, or separate keywords with commas. Changes apply immediately to new notifications.", + "alert.edit_keywords.confirm": "Save", + "alert.reset_keywords.title": "Reset keywords?", + "alert.reset_keywords.message": "This replaces the current keyword list with the project defaults.", + "alert.reset_keywords.confirm": "Reset", + "alert.accessibility.title": "Allow Otifier to read notification banners?", + "alert.accessibility.message": "Otifier requires Accessibility permission to read notification banners and copy verification codes to your clipboard.", + "alert.launch_error.enable": "Couldn't enable Launch at Login", + "alert.launch_error.disable": "Couldn't disable Launch at Login", + "alert.launch_prompt.title": "Launch Otifier on restart?", + "alert.launch_prompt.message": "Verification codes can arrive at any time, so Otifier is most useful when it's already running. You can change this anytime from the menu.", + "alert.launch_prompt.confirm": "Launch on restart", + "common.cancel": "Cancel", + "common.not_now": "Not Now", + "common.ok": "OK", + "notification.copied.title": "Verification code copied", + "notification.copied.body": "Paste it where you need it.", + "accessibility.drag_instruction": "Drag Otifier to the list above to allow Accessibility" + } + }, + { + "code": "zh-Hans", + "name": "简体中文", + "strings": { + "menu.no_codes": "尚未检测到验证码", + "menu.recent_codes": "最近验证码", + "menu.permission.title": "需要辅助功能权限", + "menu.permission.description": "Otifier 需要辅助功能权限来读取通知横幅并识别验证码。", + "menu.open_system_settings": "打开系统设置", + "menu.unrecognized.title": "未识别的通知", + "menu.unrecognized.add_rule": "添加规则…", + "menu.unrecognized.privacy": "通知文本仅在内存中保留2分钟,不会保存到磁盘。", + "menu.rules.title": "识别规则", + "menu.rules.keywords": "关键字", + "menu.rules.edit_keywords": "编辑关键字…", + "menu.rules.reset_keywords": "重置为默认值", + "menu.language": "语言", + "menu.launch_at_login": "登录时启动", + "menu.quit": "退出 Otifier", + "menu.version": "版本 %@", + "status.starting": "正在启动…", + "status.monitoring": "正在监控", + "status.permission_required": "需要辅助功能权限", + "status.stopped": "已停止", + "source.notification": "通知", + "source.manual": "手动选择", + "source.keyword_rule": "关键字规则", + "time.seconds_ago": "%d秒前", + "time.minutes_ago": "%d分钟前", + "time.hours_ago": "%d小时前", + "alert.add_rule.title": "添加验证码关键词", + "alert.add_rule.message": "请输入可识别此类验证码短信的词语或短语。只保存关键词,不会持久化通知文本。", + "alert.add_rule.confirm": "添加规则", + "alert.add_rule.placeholder": "例如:动态口令", + "alert.edit_keywords.title": "编辑关键字", + "alert.edit_keywords.message": "每行输入一个关键字,也可以用逗号分隔。更改会立即用于新通知。", + "alert.edit_keywords.confirm": "保存", + "alert.reset_keywords.title": "重置关键字?", + "alert.reset_keywords.message": "这会用项目默认关键字替换当前的全部关键字。", + "alert.reset_keywords.confirm": "重置", + "alert.accessibility.title": "允许 Otifier 读取通知横幅?", + "alert.accessibility.message": "Otifier 需要辅助功能权限来读取通知横幅,并将验证码复制到剪贴板。", + "alert.launch_error.enable": "无法启用登录时启动", + "alert.launch_error.disable": "无法关闭登录时启动", + "alert.launch_prompt.title": "重新启动电脑时运行 Otifier?", + "alert.launch_prompt.message": "验证码可能随时到达,因此 Otifier 在后台运行时最有用。您可以随时在菜单中更改此设置。", + "alert.launch_prompt.confirm": "登录时启动", + "common.cancel": "取消", + "common.not_now": "暂不", + "common.ok": "确定", + "notification.copied.title": "验证码已复制", + "notification.copied.body": "可直接粘贴使用。", + "accessibility.drag_instruction": "将 Otifier 拖到上方列表中以允许辅助功能权限" + } + }, + { + "code": "zh-Hant", + "name": "繁體中文", + "strings": { + "menu.no_codes": "尚未偵測到驗證碼", + "menu.recent_codes": "最近驗證碼", + "menu.permission.title": "需要輔助使用權限", + "menu.permission.description": "Otifier 需要輔助使用權限來讀取通知橫幅並識別驗證碼。", + "menu.open_system_settings": "開啟系統設定", + "menu.unrecognized.title": "未識別的通知", + "menu.unrecognized.add_rule": "加入規則…", + "menu.unrecognized.privacy": "通知文字僅在記憶體中保留2分鐘,不會儲存到磁碟。", + "menu.rules.title": "識別規則", + "menu.rules.keywords": "關鍵字", + "menu.rules.edit_keywords": "編輯關鍵字…", + "menu.rules.reset_keywords": "重設為預設值", + "menu.language": "語言", + "menu.launch_at_login": "登入時啟動", + "menu.quit": "結束 Otifier", + "menu.version": "版本 %@", + "status.starting": "正在啟動…", + "status.monitoring": "正在監控", + "status.permission_required": "需要輔助使用權限", + "status.stopped": "已停止", + "source.notification": "通知", + "source.manual": "手動選擇", + "source.keyword_rule": "關鍵字規則", + "time.seconds_ago": "%d秒前", + "time.minutes_ago": "%d分鐘前", + "time.hours_ago": "%d小時前", + "alert.add_rule.title": "加入驗證碼關鍵字", + "alert.add_rule.message": "請輸入可識別此類驗證碼訊息的詞語或短語。只會儲存關鍵字,不會保留通知文字。", + "alert.add_rule.confirm": "加入規則", + "alert.add_rule.placeholder": "例如:動態口令", + "alert.edit_keywords.title": "編輯關鍵字", + "alert.edit_keywords.message": "每行輸入一個關鍵字,也可以用逗號分隔。變更會立即套用至新通知。", + "alert.edit_keywords.confirm": "儲存", + "alert.reset_keywords.title": "重設關鍵字?", + "alert.reset_keywords.message": "這會以專案預設關鍵字取代目前的全部關鍵字。", + "alert.reset_keywords.confirm": "重設", + "alert.accessibility.title": "允許 Otifier 讀取通知橫幅?", + "alert.accessibility.message": "Otifier 需要輔助使用權限來讀取通知橫幅,並將驗證碼複製到剪貼簿。", + "alert.launch_error.enable": "無法啟用登入時啟動", + "alert.launch_error.disable": "無法關閉登入時啟動", + "alert.launch_prompt.title": "重新啟動電腦時執行 Otifier?", + "alert.launch_prompt.message": "驗證碼可能隨時到達,因此 Otifier 在背景執行時最有用。您可以隨時從選單變更此設定。", + "alert.launch_prompt.confirm": "登入時啟動", + "common.cancel": "取消", + "common.not_now": "稍後再說", + "common.ok": "確定", + "notification.copied.title": "驗證碼已複製", + "notification.copied.body": "可直接貼上使用。", + "accessibility.drag_instruction": "將 Otifier 拖到上方列表以允許輔助使用權限" + } + }, + { + "code": "ja", + "name": "日本語", + "strings": { + "menu.no_codes": "認証コードはまだ検出されていません", + "menu.recent_codes": "最近の認証コード", + "menu.permission.title": "アクセシビリティ権限が必要です", + "menu.permission.description": "通知バナーを読み取り、認証コードを検出するにはアクセシビリティ権限が必要です。", + "menu.open_system_settings": "システム設定を開く", + "menu.unrecognized.title": "未認識の通知", + "menu.unrecognized.add_rule": "ルールを追加…", + "menu.unrecognized.privacy": "通知テキストはメモリに2分間だけ保持され、保存されません。", + "menu.rules.title": "認識ルール", + "menu.rules.keywords": "キーワード", + "menu.rules.edit_keywords": "キーワードを編集…", + "menu.rules.reset_keywords": "デフォルトに戻す", + "menu.language": "言語", + "menu.launch_at_login": "ログイン時に起動", + "menu.quit": "Otifierを終了", + "menu.version": "バージョン %@", + "status.starting": "起動中…", + "status.monitoring": "監視中", + "status.permission_required": "アクセシビリティ権限が必要です", + "status.stopped": "停止中", + "source.notification": "通知", + "source.manual": "手動選択", + "source.keyword_rule": "キーワードルール", + "time.seconds_ago": "%d秒前", + "time.minutes_ago": "%d分前", + "time.hours_ago": "%d時間前", + "alert.add_rule.title": "OTPキーワードを追加", + "alert.add_rule.message": "この種類の認証コードを識別する単語やフレーズを入力してください。キーワードのみ保存され、通知テキストは保存されません。", + "alert.add_rule.confirm": "ルールを追加", + "alert.add_rule.placeholder": "例:ワンタイムコード", + "alert.edit_keywords.title": "キーワードを編集", + "alert.edit_keywords.message": "1行に1つ入力するか、カンマで区切ってください。変更は新しい通知にすぐ適用されます。", + "alert.edit_keywords.confirm": "保存", + "alert.reset_keywords.title": "キーワードをリセットしますか?", + "alert.reset_keywords.message": "現在のキーワード一覧をプロジェクトのデフォルトに戻します。", + "alert.reset_keywords.confirm": "リセット", + "alert.accessibility.title": "Otifierに通知バナーの読み取りを許可しますか?", + "alert.accessibility.message": "通知バナーを読み取り、認証コードをクリップボードへコピーするにはアクセシビリティ権限が必要です。", + "alert.launch_error.enable": "ログイン時の起動を有効にできませんでした", + "alert.launch_error.disable": "ログイン時の起動を無効にできませんでした", + "alert.launch_prompt.title": "再起動後にOtifierを起動しますか?", + "alert.launch_prompt.message": "認証コードはいつでも届くため、Otifierは常時起動していると便利です。この設定はメニューからいつでも変更できます。", + "alert.launch_prompt.confirm": "ログイン時に起動", + "common.cancel": "キャンセル", + "common.not_now": "後で", + "common.ok": "OK", + "notification.copied.title": "認証コードをコピーしました", + "notification.copied.body": "必要な場所に貼り付けてください。", + "accessibility.drag_instruction": "Otifierを上のリストへドラッグしてアクセシビリティを許可してください" + } + }, + { + "code": "ko", + "name": "한국어", + "strings": { + "menu.no_codes": "아직 인증 코드가 감지되지 않았습니다", + "menu.recent_codes": "최근 인증 코드", + "menu.permission.title": "손쉬운 사용 권한 필요", + "menu.permission.description": "알림 배너를 읽고 인증 코드를 감지하려면 손쉬운 사용 권한이 필요합니다.", + "menu.open_system_settings": "시스템 설정 열기", + "menu.unrecognized.title": "인식되지 않은 알림", + "menu.unrecognized.add_rule": "규칙 추가…", + "menu.unrecognized.privacy": "알림 텍스트는 메모리에 2분 동안만 유지되며 저장되지 않습니다.", + "menu.rules.title": "인식 규칙", + "menu.rules.keywords": "키워드", + "menu.rules.edit_keywords": "키워드 편집…", + "menu.rules.reset_keywords": "기본값으로 재설정", + "menu.language": "언어", + "menu.launch_at_login": "로그인 시 실행", + "menu.quit": "Otifier 종료", + "menu.version": "버전 %@", + "status.starting": "시작 중…", + "status.monitoring": "모니터링 중", + "status.permission_required": "손쉬운 사용 권한 필요", + "status.stopped": "중지됨", + "source.notification": "알림", + "source.manual": "수동 선택", + "source.keyword_rule": "키워드 규칙", + "time.seconds_ago": "%d초 전", + "time.minutes_ago": "%d분 전", + "time.hours_ago": "%d시간 전", + "alert.add_rule.title": "OTP 키워드 추가", + "alert.add_rule.message": "이와 같은 인증 코드를 식별할 단어나 문구를 입력하세요. 키워드만 저장되며 알림 텍스트는 저장되지 않습니다.", + "alert.add_rule.confirm": "규칙 추가", + "alert.add_rule.placeholder": "예: 일회용 코드", + "alert.edit_keywords.title": "키워드 편집", + "alert.edit_keywords.message": "한 줄에 하나씩 입력하거나 쉼표로 구분하세요. 변경 사항은 새 알림에 즉시 적용됩니다.", + "alert.edit_keywords.confirm": "저장", + "alert.reset_keywords.title": "키워드를 재설정할까요?", + "alert.reset_keywords.message": "현재 키워드 목록을 프로젝트 기본값으로 바꿉니다.", + "alert.reset_keywords.confirm": "재설정", + "alert.accessibility.title": "Otifier가 알림 배너를 읽도록 허용하시겠습니까?", + "alert.accessibility.message": "알림 배너를 읽고 인증 코드를 클립보드에 복사하려면 손쉬운 사용 권한이 필요합니다.", + "alert.launch_error.enable": "로그인 시 실행을 활성화할 수 없습니다", + "alert.launch_error.disable": "로그인 시 실행을 비활성화할 수 없습니다", + "alert.launch_prompt.title": "재시작 후 Otifier를 실행할까요?", + "alert.launch_prompt.message": "인증 코드는 언제든 도착할 수 있으므로 Otifier를 항상 실행해 두는 것이 좋습니다. 이 설정은 메뉴에서 언제든 변경할 수 있습니다.", + "alert.launch_prompt.confirm": "로그인 시 실행", + "common.cancel": "취소", + "common.not_now": "나중에", + "common.ok": "확인", + "notification.copied.title": "인증 코드가 복사되었습니다", + "notification.copied.body": "필요한 곳에 붙여넣으세요.", + "accessibility.drag_instruction": "Otifier를 위 목록으로 드래그하여 손쉬운 사용을 허용하세요" + } + }, + { + "code": "es", + "name": "Español", + "strings": { + "menu.no_codes": "Aún no se han detectado códigos", + "menu.recent_codes": "Códigos recientes", + "menu.permission.title": "Se requiere permiso de accesibilidad", + "menu.permission.description": "Otifier necesita acceso de Accesibilidad para leer las notificaciones y detectar códigos.", + "menu.open_system_settings": "Abrir Ajustes del Sistema", + "menu.unrecognized.title": "Notificaciones no reconocidas", + "menu.unrecognized.add_rule": "Añadir regla…", + "menu.unrecognized.privacy": "El texto se mantiene en memoria durante 2 minutos y nunca se guarda.", + "menu.rules.title": "Reglas de reconocimiento", + "menu.rules.keywords": "Palabras clave", + "menu.rules.edit_keywords": "Editar palabras clave…", + "menu.rules.reset_keywords": "Restablecer valores", + "menu.language": "Idioma", + "menu.launch_at_login": "Abrir al iniciar sesión", + "menu.quit": "Salir de Otifier", + "menu.version": "Versión %@", + "status.starting": "Iniciando…", + "status.monitoring": "Supervisando", + "status.permission_required": "Se requiere permiso de accesibilidad", + "status.stopped": "Detenido", + "source.notification": "Notificación", + "source.manual": "Selección manual", + "source.keyword_rule": "Regla de palabra clave", + "time.seconds_ago": "hace %d s", + "time.minutes_ago": "hace %d min", + "time.hours_ago": "hace %d h", + "alert.add_rule.title": "Añadir palabra clave OTP", + "alert.add_rule.message": "Introduce una palabra o frase que identifique estos códigos. Solo se guarda la palabra clave; el texto de la notificación no se conserva.", + "alert.add_rule.confirm": "Añadir regla", + "alert.add_rule.placeholder": "Por ejemplo: código dinámico", + "alert.edit_keywords.title": "Editar palabras clave", + "alert.edit_keywords.message": "Introduce una palabra por línea o sepáralas con comas. Los cambios se aplican inmediatamente a las notificaciones nuevas.", + "alert.edit_keywords.confirm": "Guardar", + "alert.reset_keywords.title": "¿Restablecer palabras clave?", + "alert.reset_keywords.message": "La lista actual se sustituirá por los valores predeterminados del proyecto.", + "alert.reset_keywords.confirm": "Restablecer", + "alert.accessibility.title": "¿Permitir que Otifier lea las notificaciones?", + "alert.accessibility.message": "Otifier necesita permiso de Accesibilidad para leer notificaciones y copiar códigos al portapapeles.", + "alert.launch_error.enable": "No se pudo activar el inicio automático", + "alert.launch_error.disable": "No se pudo desactivar el inicio automático", + "alert.launch_prompt.title": "¿Abrir Otifier después de reiniciar?", + "alert.launch_prompt.message": "Los códigos pueden llegar en cualquier momento, por lo que Otifier es más útil si permanece abierto. Puedes cambiarlo en el menú.", + "alert.launch_prompt.confirm": "Abrir al iniciar sesión", + "common.cancel": "Cancelar", + "common.not_now": "Ahora no", + "common.ok": "Aceptar", + "notification.copied.title": "Código copiado", + "notification.copied.body": "Pégalo donde lo necesites.", + "accessibility.drag_instruction": "Arrastra Otifier a la lista superior para permitir Accesibilidad" + } + }, + { + "code": "fr", + "name": "Français", + "strings": { + "menu.no_codes": "Aucun code détecté pour le moment", + "menu.recent_codes": "Codes récents", + "menu.permission.title": "Autorisation d’accessibilité requise", + "menu.permission.description": "Otifier a besoin de l’accès d’accessibilité pour lire les notifications et détecter les codes.", + "menu.open_system_settings": "Ouvrir Réglages Système", + "menu.unrecognized.title": "Notifications non reconnues", + "menu.unrecognized.add_rule": "Ajouter une règle…", + "menu.unrecognized.privacy": "Le texte reste en mémoire pendant 2 minutes et n’est jamais enregistré.", + "menu.rules.title": "Règles de reconnaissance", + "menu.rules.keywords": "Mots-clés", + "menu.rules.edit_keywords": "Modifier les mots-clés…", + "menu.rules.reset_keywords": "Valeurs par défaut", + "menu.language": "Langue", + "menu.launch_at_login": "Ouvrir à la connexion", + "menu.quit": "Quitter Otifier", + "menu.version": "Version %@", + "status.starting": "Démarrage…", + "status.monitoring": "Surveillance active", + "status.permission_required": "Autorisation d’accessibilité requise", + "status.stopped": "Arrêté", + "source.notification": "Notification", + "source.manual": "Sélection manuelle", + "source.keyword_rule": "Règle de mot-clé", + "time.seconds_ago": "il y a %d s", + "time.minutes_ago": "il y a %d min", + "time.hours_ago": "il y a %d h", + "alert.add_rule.title": "Ajouter un mot-clé OTP", + "alert.add_rule.message": "Saisissez un mot ou une phrase identifiant ces codes. Seul le mot-clé est enregistré ; le texte de la notification ne l’est pas.", + "alert.add_rule.confirm": "Ajouter la règle", + "alert.add_rule.placeholder": "Par exemple : code dynamique", + "alert.edit_keywords.title": "Modifier les mots-clés", + "alert.edit_keywords.message": "Saisissez un mot-clé par ligne ou séparez-les par des virgules. Les changements s’appliquent immédiatement aux nouvelles notifications.", + "alert.edit_keywords.confirm": "Enregistrer", + "alert.reset_keywords.title": "Réinitialiser les mots-clés ?", + "alert.reset_keywords.message": "La liste actuelle sera remplacée par les valeurs par défaut du projet.", + "alert.reset_keywords.confirm": "Réinitialiser", + "alert.accessibility.title": "Autoriser Otifier à lire les notifications ?", + "alert.accessibility.message": "Otifier a besoin de l’autorisation d’accessibilité pour lire les notifications et copier les codes dans le presse-papiers.", + "alert.launch_error.enable": "Impossible d’activer l’ouverture à la connexion", + "alert.launch_error.disable": "Impossible de désactiver l’ouverture à la connexion", + "alert.launch_prompt.title": "Ouvrir Otifier après le redémarrage ?", + "alert.launch_prompt.message": "Les codes peuvent arriver à tout moment ; Otifier est donc plus utile lorsqu’il reste ouvert. Vous pouvez modifier ce réglage dans le menu.", + "alert.launch_prompt.confirm": "Ouvrir à la connexion", + "common.cancel": "Annuler", + "common.not_now": "Pas maintenant", + "common.ok": "OK", + "notification.copied.title": "Code copié", + "notification.copied.body": "Collez-le à l’endroit souhaité.", + "accessibility.drag_instruction": "Faites glisser Otifier dans la liste ci-dessus pour autoriser l’accessibilité" + } + }, + { + "code": "de", + "name": "Deutsch", + "strings": { + "menu.no_codes": "Noch keine Codes erkannt", + "menu.recent_codes": "Letzte Codes", + "menu.permission.title": "Bedienungshilfen-Zugriff erforderlich", + "menu.permission.description": "Otifier benötigt Bedienungshilfen-Zugriff, um Mitteilungen zu lesen und Codes zu erkennen.", + "menu.open_system_settings": "Systemeinstellungen öffnen", + "menu.unrecognized.title": "Nicht erkannte Mitteilungen", + "menu.unrecognized.add_rule": "Regel hinzufügen…", + "menu.unrecognized.privacy": "Mitteilungstext bleibt 2 Minuten im Speicher und wird nie gespeichert.", + "menu.rules.title": "Erkennungsregeln", + "menu.rules.keywords": "Schlüsselwörter", + "menu.rules.edit_keywords": "Schlüsselwörter bearbeiten…", + "menu.rules.reset_keywords": "Standard wiederherstellen", + "menu.language": "Sprache", + "menu.launch_at_login": "Bei Anmeldung öffnen", + "menu.quit": "Otifier beenden", + "menu.version": "Version %@", + "status.starting": "Wird gestartet…", + "status.monitoring": "Überwachung aktiv", + "status.permission_required": "Bedienungshilfen-Zugriff erforderlich", + "status.stopped": "Gestoppt", + "source.notification": "Mitteilung", + "source.manual": "Manuelle Auswahl", + "source.keyword_rule": "Schlüsselwortregel", + "time.seconds_ago": "vor %d Sek.", + "time.minutes_ago": "vor %d Min.", + "time.hours_ago": "vor %d Std.", + "alert.add_rule.title": "OTP-Schlüsselwort hinzufügen", + "alert.add_rule.message": "Gib ein Wort oder eine Wortgruppe ein, die solche Codes kennzeichnet. Nur das Schlüsselwort wird gespeichert, nicht der Mitteilungstext.", + "alert.add_rule.confirm": "Regel hinzufügen", + "alert.add_rule.placeholder": "Zum Beispiel: dynamischer Code", + "alert.edit_keywords.title": "Schlüsselwörter bearbeiten", + "alert.edit_keywords.message": "Geben Sie ein Schlüsselwort pro Zeile ein oder trennen Sie sie durch Kommas. Änderungen gelten sofort für neue Mitteilungen.", + "alert.edit_keywords.confirm": "Speichern", + "alert.reset_keywords.title": "Schlüsselwörter zurücksetzen?", + "alert.reset_keywords.message": "Die aktuelle Liste wird durch die Projektstandards ersetzt.", + "alert.reset_keywords.confirm": "Zurücksetzen", + "alert.accessibility.title": "Otifier das Lesen von Mitteilungen erlauben?", + "alert.accessibility.message": "Otifier benötigt Bedienungshilfen-Zugriff, um Mitteilungen zu lesen und Codes in die Zwischenablage zu kopieren.", + "alert.launch_error.enable": "Öffnen bei Anmeldung konnte nicht aktiviert werden", + "alert.launch_error.disable": "Öffnen bei Anmeldung konnte nicht deaktiviert werden", + "alert.launch_prompt.title": "Otifier nach dem Neustart öffnen?", + "alert.launch_prompt.message": "Codes können jederzeit eintreffen. Otifier ist daher am nützlichsten, wenn es bereits läuft. Diese Einstellung kann jederzeit im Menü geändert werden.", + "alert.launch_prompt.confirm": "Bei Anmeldung öffnen", + "common.cancel": "Abbrechen", + "common.not_now": "Nicht jetzt", + "common.ok": "OK", + "notification.copied.title": "Code kopiert", + "notification.copied.body": "Füge ihn an der gewünschten Stelle ein.", + "accessibility.drag_instruction": "Ziehe Otifier in die obige Liste, um Bedienungshilfen zu erlauben" + } + } + ] +} diff --git a/Sources/OTifierApp/OTifierApp.swift b/Sources/OTifierApp/OTifierApp.swift index afe7da6..4e315f8 100644 --- a/Sources/OTifierApp/OTifierApp.swift +++ b/Sources/OTifierApp/OTifierApp.swift @@ -13,6 +13,7 @@ struct OTifierApp: App { var body: some Scene { MenuBarExtra { OTifierMenu(state: appState) + .environmentObject(appState.localization) } label: { Image(systemName: "rectangle.and.pencil.and.ellipsis") } diff --git a/Sources/OTifierApp/OTifierMenu.swift b/Sources/OTifierApp/OTifierMenu.swift index ad1559c..3775f39 100644 --- a/Sources/OTifierApp/OTifierMenu.swift +++ b/Sources/OTifierApp/OTifierMenu.swift @@ -2,6 +2,7 @@ import SwiftUI struct OTifierMenu: View { @ObservedObject var state: AppState + @EnvironmentObject private var localization: LocalizationManager var body: some View { VStack(alignment: .leading, spacing: 0) { @@ -32,13 +33,66 @@ struct OTifierMenu: View { PermissionPanel(state: state) } + if !state.recentUnrecognized.isEmpty { + Divider() + UnrecognizedPanel(state: state) + } + + Divider() + + Button { + state.isShowingRuleEditor.toggle() + } label: { + HStack { + Label(localization.text("menu.rules.title"), systemImage: "text.badge.checkmark") + Spacer() + Image(systemName: state.isShowingRuleEditor ? "chevron.up" : "chevron.down") + .font(.caption2) + .foregroundStyle(.secondary) + } + } + .buttonStyle(.plain) + .padding(.horizontal, 12) + .padding(.vertical, 7) + + if state.isShowingRuleEditor { + RulesPanel(state: state) + } + + Divider() + + Menu { + ForEach(localization.availableLanguages) { language in + Button { + localization.setLanguage(language.code) + } label: { + HStack { + Text(language.name) + if language.code == localization.selectedLanguageCode { + Image(systemName: "checkmark") + } + } + } + } + } label: { + HStack { + Label(localization.text("menu.language"), systemImage: "globe") + Spacer() + Text(currentLanguageName) + .foregroundStyle(.secondary) + } + } + .menuStyle(.borderlessButton) + .padding(.horizontal, 12) + .padding(.vertical, 6) + Divider() Toggle(isOn: Binding( get: { state.launchAtLoginEnabled }, set: { state.setLaunchAtLogin($0) } )) { - Text("Launch at Login") + Text(localization.text("menu.launch_at_login")) } .toggleStyle(.checkbox) .padding(.horizontal, 12) @@ -46,38 +100,138 @@ struct OTifierMenu: View { Divider() - Button("Quit Otifier") { + Button(localization.text("menu.quit")) { NSApplication.shared.terminate(nil) } .buttonStyle(.plain) .padding(.horizontal, 12) - .padding(.vertical, 8) + .padding(.top, 8) + .padding(.bottom, 5) + + Text(localization.text("menu.version", appVersion)) + .font(.caption2) + .foregroundStyle(.tertiary) + .frame(maxWidth: .infinity) + .padding(.bottom, 8) } - .frame(width: 280) + .frame(width: 340) .onAppear { state.checkPermissions() state.refreshLaunchAtLoginStatus() } } + + private var currentLanguageName: String { + localization.availableLanguages.first { + $0.code == localization.selectedLanguageCode + }?.name ?? localization.selectedLanguageCode + } + + private var appVersion: String { + Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "—" + } +} + +struct UnrecognizedPanel: View { + @ObservedObject var state: AppState + @EnvironmentObject private var localization: LocalizationManager + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + Text(localization.text("menu.unrecognized.title")) + .font(.caption) + .foregroundStyle(.secondary) + + ForEach(state.recentUnrecognized) { entry in + VStack(alignment: .leading, spacing: 5) { + Text(entry.preview) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(2) + + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 6) { + ForEach(entry.candidates, id: \.self) { candidate in + Button(candidate) { + state.acceptCandidate(candidate, from: entry) + } + .font(.system(.caption, design: .monospaced, weight: .semibold)) + .controlSize(.small) + } + + Button(localization.text("menu.unrecognized.add_rule")) { + state.promptToAddRule(for: entry) + } + .controlSize(.small) + } + } + } + .padding(7) + .background(.quaternary.opacity(0.6), in: RoundedRectangle(cornerRadius: 7)) + } + + Text(localization.text("menu.unrecognized.privacy")) + .font(.caption2) + .foregroundStyle(.tertiary) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + } +} + +struct RulesPanel: View { + @ObservedObject var state: AppState + @EnvironmentObject private var localization: LocalizationManager + + var body: some View { + VStack(alignment: .leading, spacing: 7) { + Text(localization.text("menu.rules.keywords")) + .font(.caption) + .foregroundStyle(.secondary) + + ScrollView(.horizontal, showsIndicators: false) { + Text(state.otpKeywords.joined(separator: " · ")) + .font(.system(.caption2, design: .monospaced)) + .textSelection(.enabled) + } + + HStack(spacing: 8) { + Button(localization.text("menu.rules.edit_keywords")) { + state.promptToEditKeywords() + } + .controlSize(.small) + + Button(localization.text("menu.rules.reset_keywords")) { + state.resetKeywords() + } + .controlSize(.small) + .disabled(state.otpKeywords == defaultOTPKeywords) + } + + } + .padding(.horizontal, 12) + .padding(.bottom, 8) + } } struct MonitoringPanel: View { @ObservedObject var state: AppState + @EnvironmentObject private var localization: LocalizationManager var body: some View { if state.recentOTPs.isEmpty { VStack(spacing: 4) { - Text("No OTP codes detected yet") + Text(localization.text("menu.no_codes")) .font(.subheadline) .foregroundStyle(.secondary) - Text(state.statusMessage) + Text(localization.text(state.statusMessageKey)) .font(.caption) .foregroundStyle(.tertiary) } .frame(maxWidth: .infinity) .padding(.vertical, 16) } else { - Text("Recent Codes") + Text(localization.text("menu.recent_codes")) .font(.caption) .foregroundStyle(.secondary) .padding(.horizontal, 12) @@ -96,17 +250,18 @@ struct MonitoringPanel: View { struct PermissionPanel: View { @ObservedObject var state: AppState + @EnvironmentObject private var localization: LocalizationManager var body: some View { VStack(spacing: 8) { - Text("Accessibility permission required") + Text(localization.text("menu.permission.title")) .font(.subheadline) .foregroundStyle(.secondary) - Text("Otifier needs Accessibility access to read notification banners and detect OTP codes.") + Text(localization.text("menu.permission.description")) .font(.caption) .foregroundStyle(.tertiary) .multilineTextAlignment(.center) - Button("Open System Settings") { + Button(localization.text("menu.open_system_settings")) { state.requestAccessibility() } .controlSize(.small) @@ -120,6 +275,7 @@ struct PermissionPanel: View { struct OTPRow: View { let entry: OTPEntry let onCopy: () -> Void + @EnvironmentObject private var localization: LocalizationManager var body: some View { Button(action: onCopy) { @@ -127,7 +283,7 @@ struct OTPRow: View { VStack(alignment: .leading, spacing: 2) { Text(entry.code) .font(.system(.title3, design: .monospaced, weight: .semibold)) - Text("\(entry.source) · \(entry.timeAgo)") + Text("\(localization.text(entry.sourceKey)) · \(entry.timeAgo(using: localization))") .font(.caption) .foregroundStyle(.secondary) } diff --git a/Sources/OTifierLib/NotificationWatcher.swift b/Sources/OTifierLib/NotificationWatcher.swift index ffede63..6aba89f 100644 --- a/Sources/OTifierLib/NotificationWatcher.swift +++ b/Sources/OTifierLib/NotificationWatcher.swift @@ -89,8 +89,9 @@ final class NotificationWatcher: @unchecked Sendable { private var observedPID: pid_t? private var isRunning = false private var scanGate = NotificationScanGate() - private var lastSeenTexts: [String] = [] + private var recentlySeenTextHashes: [Int: Date] = [:] private var lastPermissionCheck = Date.distantPast + private var keywords = defaultOTPKeywords private let maxCacheSize = 50 private let permissionCheckInterval: TimeInterval = 10 @@ -98,6 +99,10 @@ final class NotificationWatcher: @unchecked Sendable { private let maxElementsPerScan = 250 var onOTPDetected: ((String, String) -> Void)? // (otp, sourceText) + /// Reports notifications that contain numeric candidates but do not match an + /// automatic extraction rule. The app keeps these in memory only so the user + /// can copy a candidate or teach Otifier a keyword. + var onUnrecognizedText: ((String, [String]) -> Void)? /// Called once if Accessibility permission is revoked while running. /// The watcher stops itself before invoking this. var onAXPermissionLost: (() -> Void)? @@ -126,6 +131,12 @@ final class NotificationWatcher: @unchecked Sendable { } } + func updateKeywords(_ keywords: [String]) { + workerQueue.async { [weak self] in + self?.keywords = keywords + } + } + deinit { pollSource?.setEventHandler {} pollSource?.cancel() @@ -299,20 +310,33 @@ final class NotificationWatcher: @unchecked Sendable { private func performNotificationScan() { guard let application = observedApplication else { return } - let texts = getNotificationTexts(from: application) - guard !texts.isEmpty else { return } - - let combined = texts.joined(separator: " | ") - guard !lastSeenTexts.contains(combined) else { return } + let textGroups = getNotificationTextGroups(from: application) + guard !textGroups.isEmpty else { return } + + for texts in textGroups { + let combined = texts.joined(separator: " | ") + let combinedHash = combined.hashValue + let now = Date() + recentlySeenTextHashes = recentlySeenTextHashes.filter { + now.timeIntervalSince($0.value) < 120 + } + guard recentlySeenTextHashes[combinedHash] == nil else { continue } - lastSeenTexts.append(combined) - if lastSeenTexts.count > maxCacheSize { - lastSeenTexts.removeFirst() - } + recentlySeenTextHashes[combinedHash] = now + if recentlySeenTextHashes.count > maxCacheSize, + let oldest = recentlySeenTextHashes.min(by: { $0.value < $1.value })?.key { + recentlySeenTextHashes.removeValue(forKey: oldest) + } - let fullText = texts.joined(separator: " ") - if let otp = extractOTP(from: fullText) { - onOTPDetected?(otp, fullText) + let fullText = texts.joined(separator: " ") + if let otp = extractOTP(from: fullText, keywords: keywords) { + onOTPDetected?(otp, fullText) + } else { + let candidates = extractOTPCandidates(from: fullText) + if !candidates.isEmpty { + onUnrecognizedText?(fullText, candidates) + } + } } } @@ -371,25 +395,29 @@ final class NotificationWatcher: @unchecked Sendable { return value as? [AXUIElement] ?? [] } - private func getNotificationTexts(from application: AXUIElement) -> [String] { - var allTexts: [String] = [] + private func getNotificationTextGroups(from application: AXUIElement) -> [[String]] { + var groups: [[String]] = [] // Banners are windows on most macOS releases. Scan windows first so the - // node budget is spent on the most likely notification content. + // node budget is spent on the most likely notification content. Keep + // each window separate so unrelated notification text cannot suppress or + // influence another notification's extraction result. for window in children(of: application, attribute: kAXWindowsAttribute as CFString) { - allTexts.append(contentsOf: collectTexts(from: window, maxDepth: maxTreeDepth)) + let texts = collectTexts(from: window, maxDepth: maxTreeDepth) + if !texts.isEmpty { groups.append(texts) } } // On newer releases banners may instead appear as direct children. - if allTexts.isEmpty { + if groups.isEmpty { for child in children(of: application, attribute: kAXChildrenAttribute as CFString) { var roleValue: AnyObject? _ = AXUIElementCopyAttributeValue(child, kAXRoleAttribute as CFString, &roleValue) if let role = roleValue as? String, role == "AXMenuBar" { continue } - allTexts.append(contentsOf: collectTexts(from: child, maxDepth: min(maxTreeDepth, 10))) + let texts = collectTexts(from: child, maxDepth: min(maxTreeDepth, 10)) + if !texts.isEmpty { groups.append(texts) } } } - return allTexts + return groups } } diff --git a/Sources/OTifierLib/Notifier.swift b/Sources/OTifierLib/Notifier.swift index 8b0de23..69bcd67 100644 --- a/Sources/OTifierLib/Notifier.swift +++ b/Sources/OTifierLib/Notifier.swift @@ -17,7 +17,7 @@ private let notificationDelegate: NotificationDelegate = { return delegate }() -func showNotification(otp: String, source: String) { +func showNotification(title: String, body: String) { _ = notificationDelegate let center = UNUserNotificationCenter.current() @@ -26,27 +26,27 @@ func showNotification(otp: String, source: String) { case .notDetermined: center.requestAuthorization(options: [.alert, .sound]) { granted, _ in if granted { - deliver(otp: otp, source: source) + deliver(title: title, body: body) } } case .authorized, .provisional, .ephemeral: - deliver(otp: otp, source: source) + deliver(title: title, body: body) case .denied: break @unknown default: - deliver(otp: otp, source: source) + deliver(title: title, body: body) } } } -private func deliver(otp: String, source: String) { +private func deliver(title: String, body: String) { // Deliberately omit the OTP digits from the notification — macOS persists // delivered notifications in Notification Center history (and on disk under // ~/Library/Group Containers/group.com.apple.usernoted/), so digits here // would linger after dismissal. The code is already on the clipboard. let content = UNMutableNotificationContent() - content.title = "Verification code copied" - content.body = "Paste it where you need it." + content.title = title + content.body = body content.sound = .default let request = UNNotificationRequest( diff --git a/Sources/OTifierLib/OTPExtractor.swift b/Sources/OTifierLib/OTPExtractor.swift index cd9d497..88e972f 100644 --- a/Sources/OTifierLib/OTPExtractor.swift +++ b/Sources/OTifierLib/OTPExtractor.swift @@ -9,23 +9,35 @@ import Foundation private let proximityChars = 60 -private let keywordRegex = try! NSRegularExpression( - pattern: #"\b(?:code|otp|passcode|verification|2fa|mfa|verify|one[- ]?time|sign[- ]?in|auth(?:entication)?|login)\b|验证码|código|コード"#, - options: [.caseInsensitive] -) +/// The editable default keyword set. These values are recognition data, not UI +/// translations, so changing the menu language never changes the active rules. +public let defaultOTPKeywords: [String] = [ + "code", "otp", "passcode", "verification", "2fa", "mfa", "verify", + "one-time", "one time", "onetime", "sign-in", "sign in", "signin", + "auth", "authentication", "login", + "验证码", "动态口令", "短信码", "安全码", "校验码", "登录码", "认证码", + "确认码", "授权码", "一次性密码", "一次性口令", "código", "コード", +] -private let candidateRegex = try! NSRegularExpression(pattern: #"\b\d{4,8}\b"#) +// ASCII word boundaries do not work for text such as "178452为您的验证码": +// ICU treats both digits and Han characters as word characters. Bound only on +// Latin letters/digits instead, and accept one visual separator in grouped codes. +private let candidateRegex = try! NSRegularExpression( + pattern: #"(? = ["code", "verify", "login"] + +private struct CandidateMatch { + let code: String + let range: NSRange +} + /// Extract an OTP code from a text string, if present. /// Returns the code, or nil if no plausible OTP was found. -public func extractOTP(from text: String) -> String? { +public func extractOTP( + from text: String, + keywords: [String] = defaultOTPKeywords +) -> String? { let lower = text.lowercased() + let keywordMatches = keywords.map { keyword in + (keyword, matchingKeywordRanges(of: keyword, in: text)) + } + let keywordRanges = keywordMatches.flatMap { $0.1 } + // Negative context (order/receipt/phone/appointment/…) wins unless a strong, // unambiguous OTP marker is also present — e.g. "Your verification code for // order #X is …" should still extract. let hasNegative = negativeKeywords.contains { lower.contains($0) } - let hasStrong = strongOTPMarkers.contains { lower.contains($0) } + let hasConfiguredStrongKeyword = keywordMatches.contains { keyword, ranges in + !ranges.isEmpty && !weakOTPKeywords.contains(keyword.lowercased()) + } + let hasStrong = strongOTPMarkers.contains { lower.contains($0) } || hasConfiguredStrongKeyword if hasNegative && !hasStrong { return nil } let fullRange = NSRange(text.startIndex.. String? { if isValidOTPCode(code) { return code } } - let keywordRanges = keywordRegex.matches(in: text, options: [], range: fullRange).map { $0.range } guard !keywordRanges.isEmpty else { return nil } - let candidates = candidateRegex.matches(in: text, options: [], range: fullRange) + let candidates = candidateMatches(in: text) for cand in candidates { - guard let r = Range(cand.range, in: text) else { continue } - let code = String(text[r]) - guard isValidOTPCode(code) else { continue } + let code = cand.code let candStart = cand.range.location let candEnd = candStart + cand.range.length @@ -94,6 +122,74 @@ public func extractOTP(from text: String) -> String? { return nil } +/// Returns plausible numeric candidates without requiring an OTP keyword. This +/// is used only by the in-memory "unrecognized notification" UI; automatic +/// copying remains gated by extractOTP's contextual checks. +public func extractOTPCandidates(from text: String) -> [String] { + var seen = Set() + return candidateMatches(in: text).compactMap { candidate in + seen.insert(candidate.code).inserted ? candidate.code : nil + } +} + +private func candidateMatches(in text: String) -> [CandidateMatch] { + let fullRange = NSRange(text.startIndex.. [NSRange] { + let keyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines) + guard !keyword.isEmpty else { return [] } + + let source = text as NSString + var ranges: [NSRange] = [] + var searchRange = NSRange(location: 0, length: source.length) + while searchRange.length > 0 { + let match = source.range(of: keyword, options: [.caseInsensitive], range: searchRange) + guard match.location != NSNotFound else { break } + ranges.append(match) + let nextLocation = match.location + max(match.length, 1) + searchRange = NSRange(location: nextLocation, length: source.length - nextLocation) + } + return ranges +} + +/// Literal keyword matching with the same ASCII-letter boundary behavior as +/// the original regular expression. This prevents the editable keyword "code" +/// from matching inside words such as "decode", while still working next to +/// Han characters and digits. +private func matchingKeywordRanges(of keyword: String, in text: String) -> [NSRange] { + literalRanges(of: keyword, in: text).filter { range in + let source = text as NSString + let keywordSource = keyword as NSString + let first = keywordSource.length > 0 ? keywordSource.character(at: 0) : 0 + let last = keywordSource.length > 0 ? keywordSource.character(at: keywordSource.length - 1) : 0 + + if isASCIILetter(first), range.location > 0, + isASCIILetter(source.character(at: range.location - 1)) { + return false + } + + let end = range.location + range.length + if isASCIILetter(last), end < source.length, + isASCIILetter(source.character(at: end)) { + return false + } + return true + } +} + +private func isASCIILetter(_ value: unichar) -> Bool { + (65...90).contains(value) || (97...122).contains(value) +} + private func isValidOTPCode(_ code: String) -> Bool { guard (4...8).contains(code.count) else { return false } guard let first = code.first else { return false } diff --git a/Tests/OTifierLibTests/OTPExtractorTests.swift b/Tests/OTifierLibTests/OTPExtractorTests.swift index 02831b9..07e6f3c 100644 --- a/Tests/OTifierLibTests/OTPExtractorTests.swift +++ b/Tests/OTifierLibTests/OTPExtractorTests.swift @@ -53,6 +53,40 @@ func assertNil(_ actual: String?, file: String = #file, line: Int = #line) { assertEqual(extractOTP(from: "コード: 192847"), "192847") assertEqual(extractOTP(from: "MFA code 554433"), "554433") + // --- Should extract: Chinese text adjacent to the code --- + assertEqual( + extractOTP(from: "【深圳十万佳信息技术服务有限公司】178452为您的验证码,请于5分钟内填写。如非本人操作,请忽略本条短信"), + "178452" + ) + assertEqual(extractOTP(from: "您的动态口令为583920,请勿泄露"), "583920") + + // --- Should extract: codes grouped for readability --- + assertEqual(extractOTP(from: "Your verification code is 583 920"), "583920") + assertEqual(extractOTP(from: "验证码:482-910"), "482910") + + // --- Explicit user-defined keywords --- + assertNil(extractOTP(from: "交易令牌 482910,请立即使用")) + assertEqual( + extractOTP(from: "交易令牌 482910,请立即使用", keywords: defaultOTPKeywords + ["交易令牌"]), + "482910" + ) + assertTrue(defaultOTPKeywords.contains("验证码"), "default keywords should expose the actual built-in rules") + assertNil( + extractOTP(from: "验证码 482910", keywords: ["otp"]) + ) + assertEqual( + extractOTP(from: "交易令牌 482910,请立即使用", keywords: ["交易令牌"]), + "482910" + ) + assertNil( + extractOTP(from: "decode 482910 binary string", keywords: ["code"]) + ) + assertEqual(extractOTP(from: "验证码 #583920"), "583920") + assertTrue( + extractOTPCandidates(from: "Reference 123456 and backup 482-910") == ["123456", "482910"], + "manual candidate extraction should include continuous and grouped codes" + ) + // --- Should extract: boundary lengths --- assertEqual(extractOTP(from: "Your code: 1234"), "1234") // exactly 4 assertEqual(extractOTP(from: "Your code: 12345678"), "12345678") // exactly 8 @@ -102,6 +136,35 @@ func assertNil(_ actual: String?, file: String = #file, line: Int = #line) { assertTrue(!gate.shouldScan(fingerprint: banner, now: start.addingTimeInterval(5)), "stable banner must not trigger repeated scans") assertTrue(gate.shouldScan(fingerprint: banner, now: start.addingTimeInterval(35)), "the safety interval must eventually rescan") + // --- JSON localization catalog --- + do { + let configURL = URL(fileURLWithPath: "Sources/OTifierApp/Localizations.json") + let data = try Data(contentsOf: configURL) + let root = try JSONSerialization.jsonObject(with: data) as? [String: Any] + let defaultLanguage = root?["defaultLanguage"] as? String + let languages = root?["languages"] as? [[String: Any]] ?? [] + let languageCodes = Set(languages.compactMap { $0["code"] as? String }) + + assertEqual(defaultLanguage, "en") + assertTrue( + languageCodes == Set(["en", "zh-Hans", "zh-Hant", "ja", "ko", "es", "fr", "de"]), + "localization config should contain the eight supported languages" + ) + + let english = languages.first { ($0["code"] as? String) == "en" } + let requiredKeys = Set((english?["strings"] as? [String: String] ?? [:]).keys) + assertTrue(!requiredKeys.isEmpty, "English localization must define the fallback key set") + + for language in languages { + let code = language["code"] as? String ?? "unknown" + let keys = Set((language["strings"] as? [String: String] ?? [:]).keys) + assertTrue(keys == requiredKeys, "localization \(code) must match the English key set") + } + } catch { + failed += 1 + print("FAIL: could not validate localization catalog: \(error)") + } + // --- Summary --- print("\nOTP Extractor Tests: \(passed) passed, \(failed) failed") if failed > 0 {