diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index 63de18b..0000000 Binary files a/.DS_Store and /dev/null differ diff --git a/.gitignore b/.gitignore index 03c5975..25d4b4f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,19 @@ +# macOS system files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes + +# Xcode user data & derived data build/ +DerivedData/ +*.xcodeproj/xcuserdata/ +*.xcodeproj/project.xcworkspace/xcuserdata/ +*.xcworkspace/xcuserdata/ +*.xcuserstate + +# Temporary scratch & release build artifacts +scratch/ dmg-source/ *.dmg diff --git a/Purrl.icns b/Purrl.icns deleted file mode 100644 index cf3d1ee..0000000 Binary files a/Purrl.icns and /dev/null differ diff --git a/Purrl.xcodeproj/project.pbxproj b/Purrl.xcodeproj/project.pbxproj index 01ed41c..639d149 100644 --- a/Purrl.xcodeproj/project.pbxproj +++ b/Purrl.xcodeproj/project.pbxproj @@ -182,10 +182,11 @@ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_IDENTITY = "-"; COPY_PHASE_STRIP = NO; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; - DEVELOPMENT_TEAM = 67NY9CT7W7; + DEVELOPMENT_TEAM = ""; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -249,10 +250,11 @@ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_IDENTITY = "-"; COPY_PHASE_STRIP = NO; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - DEVELOPMENT_TEAM = 67NY9CT7W7; + DEVELOPMENT_TEAM = ""; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -279,11 +281,13 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_STYLE = Automatic; + CODE_SIGN_IDENTITY = "-"; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; + CODE_SIGN_STYLE = Manual; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; DEAD_CODE_STRIPPING = YES; - DEVELOPMENT_TEAM = 67NY9CT7W7; + DEVELOPMENT_TEAM = ""; ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; @@ -314,11 +318,13 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_STYLE = Automatic; + CODE_SIGN_IDENTITY = "-"; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; + CODE_SIGN_STYLE = Manual; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; DEAD_CODE_STRIPPING = YES; - DEVELOPMENT_TEAM = 67NY9CT7W7; + DEVELOPMENT_TEAM = ""; ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; diff --git a/Purrl.xcodeproj/project.xcworkspace/xcuserdata/wenujaliyanamana.xcuserdatad/UserInterfaceState.xcuserstate b/Purrl.xcodeproj/project.xcworkspace/xcuserdata/wenujaliyanamana.xcuserdatad/UserInterfaceState.xcuserstate deleted file mode 100644 index 021320b..0000000 Binary files a/Purrl.xcodeproj/project.xcworkspace/xcuserdata/wenujaliyanamana.xcuserdatad/UserInterfaceState.xcuserstate and /dev/null differ diff --git a/Purrl/AppDelegate.swift b/Purrl/AppDelegate.swift index 6161ae1..b3a5fff 100644 --- a/Purrl/AppDelegate.swift +++ b/Purrl/AppDelegate.swift @@ -86,6 +86,16 @@ class AppDelegate: NSObject, NSApplicationDelegate { menu.setSubmenu(toothMenu, for: toothParent) menu.addItem(toothParent) + let onlyScrollableItem = NSMenuItem( + title: scrollEngine.onlyScrollableContent + ? NSLocalizedString("menu.onlyScrollableEnabled", comment: "") + : NSLocalizedString("menu.onlyScrollable", comment: ""), + action: #selector(toggleOnlyScrollable), + keyEquivalent: "" + ) + onlyScrollableItem.target = self + menu.addItem(onlyScrollableItem) + menu.addItem(NSMenuItem.separator()) let launchAtLoginItem = NSMenuItem( @@ -146,6 +156,11 @@ class AppDelegate: NSObject, NSApplicationDelegate { rebuildMenu() } + @objc func toggleOnlyScrollable() { + scrollEngine.onlyScrollableContent.toggle() + rebuildMenu() + } + @objc func selectTooth(_ sender: NSMenuItem) { guard let value = sender.representedObject as? CGFloat else { return } scrollEngine.toothSize = value diff --git a/Purrl/ScrollHapticEngine.swift b/Purrl/ScrollHapticEngine.swift index 1c7e352..458c5f8 100644 --- a/Purrl/ScrollHapticEngine.swift +++ b/Purrl/ScrollHapticEngine.swift @@ -14,6 +14,8 @@ class ScrollHapticEngine { private var lastToothTime: TimeInterval = 0 private let minToothInterval: TimeInterval = 0.012 + private let scrollDetector = ScrollabilityDetector() + // Configurable, con persistencia var toothSize: CGFloat { get { UserDefaults.standard.object(forKey: "toothSize") as? CGFloat ?? 8.0 } @@ -25,6 +27,11 @@ class ScrollHapticEngine { set { UserDefaults.standard.set(newValue, forKey: "isEnabled") } } + var onlyScrollableContent: Bool { + get { UserDefaults.standard.object(forKey: "onlyScrollableContent") as? Bool ?? true } + set { UserDefaults.standard.set(newValue, forKey: "onlyScrollableContent") } + } + func start() { globalMonitor = NSEvent.addGlobalMonitorForEvents(matching: .scrollWheel) { [weak self] event in self?.handleScroll(event) @@ -44,6 +51,9 @@ class ScrollHapticEngine { private func handleScroll(_ event: NSEvent) { guard isEnabled else { return } + if onlyScrollableContent { + guard scrollDetector.isScrollableAtCurrentCursor() else { return } + } let delta = abs(event.scrollingDeltaY) guard delta > 0 else { return } diff --git a/Purrl/ScrollabilityDetector.swift b/Purrl/ScrollabilityDetector.swift new file mode 100644 index 0000000..0d1360b --- /dev/null +++ b/Purrl/ScrollabilityDetector.swift @@ -0,0 +1,110 @@ +// +// ScrollabilityDetector.swift +// Purrl +// +// Created by Purrl on 08/08/2026. +// + +import Cocoa +import ApplicationServices + +class ScrollabilityDetector { + private var cachedLocation: CGPoint = .zero + private var cachedIsScrollable: Bool = true + private var cachedTime: TimeInterval = 0 + private let cacheDuration: TimeInterval = 0.04 // 40ms cache for instant responsiveness + private let cacheDistanceThreshold: CGFloat = 1.0 // 1 point movement threshold + + /// Returns whether the UI element under the current mouse cursor is in a scrollable view or region. + func isScrollableAtCurrentCursor() -> Bool { + guard let cgEvent = CGEvent(source: nil) else { return true } + let point = cgEvent.location + let now = ProcessInfo.processInfo.systemUptime + + // Use cached decision if cursor location hasn't moved and cache is fresh + let distance = hypot(point.x - cachedLocation.x, point.y - cachedLocation.y) + if distance < cacheDistanceThreshold && (now - cachedTime) < cacheDuration { + return cachedIsScrollable + } + + let isScrollable = checkScrollability(at: point) + + cachedLocation = point + cachedTime = now + cachedIsScrollable = isScrollable + return isScrollable + } + + private func checkScrollability(at point: CGPoint) -> Bool { + // If process is not trusted for Accessibility, default to true + guard AXIsProcessTrusted() else { return true } + + let systemWide = AXUIElementCreateSystemWide() + var elementRef: AXUIElement? + let result = AXUIElementCopyElementAtPosition(systemWide, Float(point.x), Float(point.y), &elementRef) + + guard result == .success, let startElement = elementRef else { + return true + } + + var current: AXUIElement? = startElement + var depth = 0 + let maxDepth = 15 + + // Recognized scrollable container roles in macOS Accessibility + let scrollableRoles: Set = [ + kAXScrollAreaRole, + "AXScrollView", + "AXWebArea", + kAXTableRole, + kAXOutlineRole, + kAXListRole, + kAXTextAreaRole, + kAXScrollBarRole, + "AXGrid", + "AXBrowser" + ] + + while let elem = current, depth < maxDepth { + // 1. Inspect role, subrole, and title + var roleRef: CFTypeRef? + AXUIElementCopyAttributeValue(elem, kAXRoleAttribute as CFString, &roleRef) + let roleString = (roleRef as? String) ?? "" + + var subroleRef: CFTypeRef? + AXUIElementCopyAttributeValue(elem, kAXSubroleAttribute as CFString, &subroleRef) + let subroleString = (subroleRef as? String) ?? "" + + var titleRef: CFTypeRef? + AXUIElementCopyAttributeValue(elem, kAXTitleAttribute as CFString, &titleRef) + let titleString = (titleRef as? String) ?? "" + + // Non-scrollable system regions (Menu bar, Status bar, Dock) + if roleString == kAXMenuBarRole || roleString == "AXMenuExtra" || roleString == kAXMenuRole || roleString == kAXMenuItemRole || subroleString == "AXDockItem" { + return false + } + + // Finder Desktop background check + if subroleString == "AXDesktopArea" || (roleString == kAXScrollAreaRole && titleString == "Desktop") { + return false + } + + // If ancestor is a valid scrollable container + if scrollableRoles.contains(roleString) { + return true + } + + // Inspect parent element up the hierarchy + var parentRef: CFTypeRef? + let parentResult = AXUIElementCopyAttributeValue(elem, kAXParentAttribute as CFString, &parentRef) + if parentResult == .success, let parent = parentRef { + current = (parent as! AXUIElement) + } else { + current = nil + } + depth += 1 + } + + return false + } +} diff --git a/Purrl/en.lproj/Localizable.strings b/Purrl/en.lproj/Localizable.strings index cad164b..3fe0e92 100644 --- a/Purrl/en.lproj/Localizable.strings +++ b/Purrl/en.lproj/Localizable.strings @@ -6,6 +6,8 @@ "toggle.enabled" = "Enabled ✓"; "toggle.disabled" = "Disabled"; "menu.texture" = "Purr texture"; +"menu.onlyScrollable" = "Only Scrollable Content"; +"menu.onlyScrollableEnabled" = "Only Scrollable Content ✓"; "menu.launchAtLogin" = "Launch at Login"; "menu.launchAtLoginEnabled" = "Launch at Login ✓"; "menu.checkForUpdates" = "Check for Updates…"; diff --git a/Purrl/es.lproj/Localizable.strings b/Purrl/es.lproj/Localizable.strings index 91ded45..90d1bb5 100644 --- a/Purrl/es.lproj/Localizable.strings +++ b/Purrl/es.lproj/Localizable.strings @@ -6,6 +6,8 @@ "toggle.enabled" = "Activado ✓"; "toggle.disabled" = "Desactivado"; "menu.texture" = "Textura del scroll"; +"menu.onlyScrollable" = "Solo contenido desplazable"; +"menu.onlyScrollableEnabled" = "Solo contenido desplazable ✓"; "menu.launchAtLogin" = "Iniciar con el sistema"; "menu.launchAtLoginEnabled" = "Iniciar con el sistema ✓"; "menu.checkForUpdates" = "Buscar actualizaciones…"; diff --git a/build.sh b/build.sh index 57c04f6..3cfaefd 100755 --- a/build.sh +++ b/build.sh @@ -22,6 +22,7 @@ rm -f "$ICNS_NAME" echo "==> Building $APP_NAME (Release)..." xcodebuild -scheme "$SCHEME" -configuration "$CONFIGURATION" \ -derivedDataPath "$DERIVED_DATA_PATH" \ + CODE_SIGN_IDENTITY="-" DEVELOPMENT_TEAM="" \ clean build APP_PATH="$DERIVED_DATA_PATH/Build/Products/$CONFIGURATION/$APP_NAME.app" diff --git a/project.xml b/project.xml index 2a69a4b..5978c51 100644 --- a/project.xml +++ b/project.xml @@ -274,8 +274,10 @@ YES DEBUG_INFORMATION_FORMAT dwarf + CODE_SIGN_IDENTITY + - DEVELOPMENT_TEAM - 67NY9CT7W7 + ENABLE_STRICT_OBJC_MSGSEND YES ENABLE_TESTABILITY @@ -403,8 +405,10 @@ YES DEBUG_INFORMATION_FORMAT dwarf-with-dsym + CODE_SIGN_IDENTITY + - DEVELOPMENT_TEAM - 67NY9CT7W7 + ENABLE_NS_ASSERTIONS NO ENABLE_STRICT_OBJC_MSGSEND @@ -470,15 +474,17 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME AccentColor CODE_SIGN_STYLE - Automatic + Manual COMBINE_HIDPI_IMAGES YES CURRENT_PROJECT_VERSION 1 DEAD_CODE_STRIPPING YES + CODE_SIGN_IDENTITY + - DEVELOPMENT_TEAM - 3T9J355KZF + ENABLE_APP_SANDBOX NO ENABLE_HARDENED_RUNTIME @@ -533,15 +539,17 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME AccentColor CODE_SIGN_STYLE - Automatic + Manual COMBINE_HIDPI_IMAGES YES CURRENT_PROJECT_VERSION 1 DEAD_CODE_STRIPPING YES + CODE_SIGN_IDENTITY + - DEVELOPMENT_TEAM - 3T9J355KZF + ENABLE_APP_SANDBOX NO ENABLE_HARDENED_RUNTIME