diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..2aab0a2 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_style = space +indent_size = 2 +insert_final_newline = true +trim_trailing_whitespace = true + +[Makefile] +indent_style = tab diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md deleted file mode 100644 index 307cfe8..0000000 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ /dev/null @@ -1,56 +0,0 @@ ---- -name: Bug report -about: Report a reproducible WindowKit bug -title: "[Bug]: " -labels: bug -assignees: "" ---- - -## Summary - -Describe the issue in one or two sentences. - -Do not include vulnerability details or sensitive data in public issues. Follow `SECURITY.md` for -security reports. - -## Environment - -- WindowKit version or commit: -- macOS version: -- Swift or Xcode version: -- Affected area: WindowKit / WindowKitAppKit / documentation / not sure -- Reproducibility: always / sometimes / once - -Optional, if relevant: - -- Installation method: Swift Package Manager / other -- Mac architecture: Apple silicon / Intel -- Sandboxed app: yes/no -- Display setup: single display / multiple displays / scaled display / external display - -## Reproduction - -Provide the smallest code sample or test case that reproduces the issue. -Use placeholders instead of private app names, bundle identifiers, process identifiers, window -titles, logs, secrets, tokens, or personal data. - -1. -2. -3. - -## Expected Behavior - -Describe the window snapshot, hit-test result, placement frame, screen selection, window state, -role metadata, or thrown error you expected. - -## Actual Behavior - -Include the exact error, thrown error type, failing assertion, or relevant public API result. -Redact sensitive values. - -## Additional Context - -Optional: Add any other public, non-sensitive context that helps explain the issue. -If useful, mention whether it reproduces with `WindowKit` only, `WindowKitAppKit`, Accessibility -permission granted or denied, a focused-window target, or a specific Core Graphics window -identifier. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml deleted file mode 100644 index f515e18..0000000 --- a/.github/ISSUE_TEMPLATE/config.yml +++ /dev/null @@ -1,5 +0,0 @@ -blank_issues_enabled: false -contact_links: - - name: Security reports - url: https://github.com/naviapps/window-kit/security/policy - about: Report vulnerabilities privately instead of opening a public issue. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md deleted file mode 100644 index 2e9ce15..0000000 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -name: Feature request -about: Suggest a focused WindowKit improvement -title: "[Feature]: " -labels: enhancement -assignees: "" ---- - -## Summary - -Describe the requested improvement in one or two sentences. - -Do not include secrets, tokens, personal data, private logs, or real user window titles in public -issues. Follow `SECURITY.md` for security-sensitive requests. - -## Use Case - -Describe the host-app or library integration problem this would solve. - -## Scope - -Explain why this belongs in WindowKit's window discovery, snapshots, hit-testing, placement, -screens, control protocols, AppKit integration, Accessibility integration, role metadata, or -documentation. If you are not sure, say so. - -Keep permissions onboarding, gesture recognition, persistence, shortcuts, UI, app activation policy, -window rule engines, presets, and automation policy outside this package. - -## Proposed Change - -Describe the API, behavior, documentation, or packaging change you want. - -## Additional Context - -Optional: Add any other public, non-sensitive context that helps explain the request. Include any -host-app implementation, separate package, or existing API you considered if useful. diff --git a/.github/dependabot.yml b/.github/dependabot.yml index f142e72..1ab32ff 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -4,5 +4,8 @@ updates: directory: / schedule: interval: weekly - day: monday - open-pull-requests-limit: 2 + + - package-ecosystem: swift + directory: / + schedule: + interval: weekly diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index aa55a76..520fdeb 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -2,26 +2,13 @@ - -## Changes - -- - -## Related Issue - -Optional: Link the related issue. - -## Review Checklist - -- [ ] Public API changes are intentional and documented, if any -- [ ] Host-app responsibilities stay outside WindowKit -- [ ] No generated build output, secrets, tokens, private keys, local paths, private logs, or real - user window titles are included - ## Validation -- [ ] `just check` -- [ ] Documentation or `CHANGELOG.md` updated if needed +- [ ] `make check` +- [ ] Documentation or `CHANGELOG.md` updated if public behavior changed -If any validation was not run, explain why. +## Checklist -## Notes +- [ ] Public API changes are intentional. +- [ ] Host-app responsibilities stay outside this package. +- [ ] No generated output, dependency caches, local state, secrets, private logs, local paths, or app-internal references are included. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 77fd93a..21dd31d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,24 +14,19 @@ concurrency: cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: - test: + check: name: SwiftPM runs-on: macos-15 timeout-minutes: 15 - env: - MACOSX_DEPLOYMENT_TARGET: "12.0" steps: - name: Check out repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: persist-credentials: false - name: Show Swift version run: swift --version - - name: Lint formatting - run: swift format lint --recursive --strict Sources Tests Package.swift - - - name: Run tests - run: swift test -Xswiftc -strict-concurrency=complete -Xswiftc -warnings-as-errors + - name: Run checks + run: make check diff --git a/.gitignore b/.gitignore index 5a07cc4..031f0e0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,23 +1,15 @@ -# macOS -.DS_Store - -# SwiftPM .build/ .swiftpm/ +.DS_Store +.envrc +.env +.env.* +!.env.example Package.resolved - -# Local tool state - -# Xcode -xcuserdata/ +*.doccarchive DerivedData/ +xcuserdata/ *.xcodeproj *.xcworkspace *.xcresult -*.xcuserstate - -# Local secrets and logs -.env -.env.* -!.env.example *.log diff --git a/.swift-format b/.swift-format index d5dad78..0f54008 100644 --- a/.swift-format +++ b/.swift-format @@ -6,19 +6,49 @@ "maximumBlankLines": 1, "respectsExistingLineBreaks": true, "rules": { - "AllPublicDeclarationsHaveDocumentation": true, - "AlwaysUseLowerCamelCase": true, - "DoNotUseSemicolons": true, - "IdentifiersMustBeASCII": true, - "NeverForceUnwrap": true, - "NeverUseForceTry": true, - "NeverUseImplicitlyUnwrappedOptionals": true, - "NoBlockComments": true, - "NoParensAroundConditions": true, + "AllPublicDeclarationsHaveDocumentation": false, + "AlwaysUseLiteralForEmptyCollectionInit": false, + "AlwaysUseLowerCamelCase": false, + "AmbiguousTrailingClosureOverload": false, + "AvoidRetroactiveConformances": false, + "BeginDocumentationCommentWithOneLineSummary": false, + "DoNotUseSemicolons": false, + "DontRepeatTypeInStaticProperties": false, + "FileScopedDeclarationPrivacy": false, + "FullyIndirectEnum": false, + "GroupNumericLiterals": false, + "IdentifiersMustBeASCII": false, + "NeverForceUnwrap": false, + "NeverUseForceTry": false, + "NeverUseImplicitlyUnwrappedOptionals": false, + "NoAccessLevelOnExtensionDeclaration": false, + "NoAssignmentInExpressions": false, + "NoBlockComments": false, + "NoCasesWithOnlyFallthrough": false, + "NoEmptyLinesOpeningClosingBraces": false, + "NoEmptyTrailingClosureParentheses": false, + "NoLabelsInCasePatterns": false, + "NoLeadingUnderscores": false, + "NoParensAroundConditions": false, + "NoPlaygroundLiterals": false, + "NoVoidReturnOnFunctionSignature": false, "OmitExplicitReturns": false, - "OrderedImports": true, - "UseTripleSlashForDocumentationComments": true, - "ValidateDocumentationComments": true + "OneCasePerLine": false, + "OneVariableDeclarationPerLine": false, + "OnlyOneTrailingClosureArgument": false, + "OrderedImports": false, + "ReplaceForEachWithForLoop": false, + "ReturnVoidInsteadOfEmptyTuple": false, + "TypeNamesShouldBeCapitalized": false, + "UseEarlyExits": false, + "UseExplicitNilCheckInConditions": false, + "UseLetInEveryBoundCaseVariable": false, + "UseShorthandTypeNames": false, + "UseSingleLinePropertyGetter": false, + "UseSynthesizedInitializer": false, + "UseTripleSlashForDocumentationComments": false, + "UseWhereClausesInForLoops": false, + "ValidateDocumentationComments": false }, "version": 1 } diff --git a/CHANGELOG.md b/CHANGELOG.md index 677d371..ceecbaa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,98 @@ Released versions follow semantic versioning. No changes yet. +## [2.0.0] - 2026-07-12 + +### Breaking + +- Moved live Core Graphics window-list querying from `WindowKit` to `WindowKitAppKit`. + Consumers that construct `WindowSnapshotQuery` or use `WindowSnapshotQueryError` must import + `WindowKitAppKit`. +- Replaced `WindowSnapshotQuery` Boolean topmost limiting with the typed + `WindowSnapshotQueryLimit` contract. +- Moved the concrete `WindowHitTester` from `WindowKit` to `WindowKitAppKit`; `WindowKit` now keeps + only the platform-neutral `WindowHitTesting` contract and result models. +- Replaced screen-selection Boolean fallback parameters with the typed `WindowScreenFallback` + contract. +- Removed fixed `WindowPlacement` convenience cases such as `leftHalf`, `rightHalf`, and thirds/quarters. + Use `WindowPlacement.grid(columns:rows:column:row:columnSpan:rowSpan:)` for fixed regions and + `WindowPlacement.centered(widthRatio:heightRatio:)` for centered sizing so placement has one + canonical representation instead of duplicated public cases. +- Removed the redundant `WindowSnapshotQuery.appKit(...)` factory. Use `WindowSnapshotQuery(...)` + directly from `WindowKitAppKit`. +- Collapsed `WindowSnapshotQuery` public initializer overloads into one initializer with default + system dependencies. +- Moved `WindowOwnerBundleIdentifierProviding` from `WindowKit` to `WindowKitAppKit` because owner + bundle identifier lookup is an AppKit-backed process metadata concern, not a core snapshot model. +- Renamed the public Accessibility failure surface to `WindowAccessibilityError`. +- Replaced action-only Accessibility error context with the typed `WindowAccessibilityOperation` + contract for frame, state, command, and role operations. +- Changed `WindowAccessibilityError.accessibilityOperationFailed` to preserve both the operation and + its typed `WindowAccessibilityOperationFailure` classification. +- Renamed `WindowAccessibilityOperationFailure.unknown` to `unrecognizedAXError` to make + future/unsupported AXError mapping explicit. +- Renamed `WindowAccessibilityOperationFailure.success` to `unexpectedSuccess` so the public + failure classification does not expose success as a failure case. +- Replaced the ambiguous `WindowAccessibilityError.targetWindowUnavailable` case with + `applicationUnavailable(WindowTarget)` and `windowUnavailable(WindowTarget)` so public errors keep + the target context that failed. +- Renamed `WindowRole.unknown(role:subrole:)` to + `unclassified(accessibilityRole:accessibilitySubrole:)` so unmatched role metadata keeps explicit + Accessibility labels instead of a vague unknown case. +- Stopped classifying role-unavailable windows from subrole metadata alone; those partial + Accessibility values now remain `WindowRole.unclassified(accessibilityRole:accessibilitySubrole:)`. +- Removed `UnavailableWindowOwnerBundleIdentifierProvider`. Hosts that intentionally omit bundle + identifiers should provide their own `WindowOwnerBundleIdentifierProviding` implementation. +- Split single-window resolution from `WindowSnapshotProviding` into + `WindowSnapshotResolving`, so consumers depend only on the query capability they use. +- Removed snapshot layer and minimized-state fields that the standard-layer Core Graphics query did + not meaningfully expose; live minimized state remains available through `WindowStateProviding`. +- Replaced separate public AX frame, state, command, and role adapters with one + `AXWindowController` conforming to the narrow protocols and the composed `WindowControlling` API. + +### Changed + +- Updated installation guidance to use the 2.0.0 release line. +- Raised the package requirements to macOS 14 and Swift 6.0. +- Expanded DocC documentation and public API tests for WindowKit and WindowKitAppKit. +- Refined snapshot filtering around included and excluded owner, process, and window identifiers. +- Added `WindowSnapshotFilter.includes(_:)` so host-owned providers and AppKit adapters share the + same pure snapshot-filtering contract. +- Added zero-argument `WindowState()` construction for unknown live state values. +- Made `WindowScreenSelector` order input screen lists before applying ordered-index and fallback + selection, so callers do not need to pre-sort screens for deterministic selection. +- Made `WindowPlacementCalculator` a stateless static API. +- Made missing screen lists fail explicitly instead of reporting successful placement without a + mutation. +- Consolidated AppKit window-list hit testing into the public `WindowHitTester` implementation. +- Consolidated Accessibility client and resolver assembly into an internal `AXWindowAccess` boundary + so live adapters share one system setup path. +- Matched Accessibility window-id resolution against the returned Core Graphics window identifier + before using owner process metadata. +- Tightened Accessibility window-id attribute decoding so non-number, fractional, negative, and + out-of-range values fail instead of being rounded into a `UInt32`. +- Rejected non-finite Accessibility frame coordinates and dimensions before creating AX values. +- Built Accessibility frame position and size values before writing either attribute, so conversion + failures do not partially update a live window frame. +- Preserved unknown Accessibility role metadata even when a known subrole label is present, so + app-facing role classification does not narrow future role kinds prematurely. +- Added focused-window placement coverage for WindowPlacer. +- Exposed `NSScreenProvider` as the AppKit-backed `WindowScreenProviding` adapter. +- Exposed `AXWindowController` as the AppKit-backed window control and role adapter. +- Expanded generated-output exclusions for local tool state. +- Added README links to release notes. +- Clarified that security updates target the latest released version. + ## [1.0.0] - 2026-05-19 -- Initial stable public release. -- Includes window discovery, querying, hit-testing, placement, screen movement, and live AppKit/Accessibility integrations. +### Added + +- Initial public release. +- Included window discovery, querying, hit-testing, placement, screen movement, and live + AppKit/Accessibility integrations. + +## [0.1.0] - 2026-05-19 + +### Added + +- Initial public package release. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a87dd6e..c554b0d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,47 +1,13 @@ # Contributing -Thank you for your interest in improving WindowKit. - -## Scope - -WindowKit focuses on macOS window discovery, querying, hit-testing, screen selection, placement -geometry, and live AppKit or Accessibility window control. -Host-app workflows such as permissions onboarding, shortcuts, persistence, UI, and automation -policy should stay outside this package. - -Please keep changes focused. Avoid bundling unrelated refactors, formatting-only rewrites, and -behavior changes in the same pull request. - -## Development - -Local development commands use `just`. - -Run the full local check before opening a pull request: +Run the package check before submitting changes: ```sh -just check +make check ``` -For focused local work, you can run the formatter check or test suite separately: - -```sh -just lint -just test -``` - -## Pull Requests - -Before submitting a pull request: - -- Keep the public API surface minimal and documented. -- Add or update tests for behavior changes. -- Update documentation or `CHANGELOG.md` when needed. -- Do not commit generated build output or local tool state such as `.build/`, `.swiftpm/`, or - `.serena/`. -- Do not include secrets, tokens, private keys, local paths, window titles from real users, or - app-specific internal references. +Keep changes focused. Update README.md or CHANGELOG.md when public behavior changes. -## Security +Do not commit generated output, dependency caches, local tool state, secrets, tokens, private keys, personal data, local paths, private app metadata, or app-specific internal references. -Do not report vulnerabilities in public issues or pull requests. Follow -[SECURITY.md](SECURITY.md) instead. +Report vulnerabilities privately. See SECURITY.md. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..dc0b800 --- /dev/null +++ b/Makefile @@ -0,0 +1,23 @@ +SWIFT_FORMAT_PATHS := Sources Tests Package.swift +SWIFT_STRICT_FLAGS := -Xswiftc -strict-concurrency=complete -Xswiftc -warnings-as-errors + +.DEFAULT_GOAL := check + +.PHONY: check build build-release format lint test + +check: lint build build-release test + +build: + swift build $(SWIFT_STRICT_FLAGS) + +build-release: + swift build -c release $(SWIFT_STRICT_FLAGS) + +format: + swift format format --recursive --in-place $(SWIFT_FORMAT_PATHS) + +lint: + swift format lint --recursive --strict $(SWIFT_FORMAT_PATHS) + +test: + swift test $(SWIFT_STRICT_FLAGS) diff --git a/Package.swift b/Package.swift index 952cd66..f5881a7 100644 --- a/Package.swift +++ b/Package.swift @@ -1,9 +1,9 @@ -// swift-tools-version: 5.10 +// swift-tools-version: 6.0 import PackageDescription let package = Package( name: "WindowKit", - platforms: [.macOS(.v12)], + platforms: [.macOS(.v14)], products: [ .library( name: "WindowKit", @@ -35,6 +35,5 @@ let package = Package( "WindowKit", ] ), - ], - swiftLanguageVersions: [.v5] + ] ) diff --git a/README.md b/README.md index 2b1c5ca..7b7d99d 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,12 @@ # WindowKit -[![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) [![CI](https://github.com/naviapps/window-kit/actions/workflows/ci.yml/badge.svg)](https://github.com/naviapps/window-kit/actions/workflows/ci.yml) +[![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) [![Swift versions](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fnaviapps%2Fwindow-kit%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/naviapps/window-kit) [![Supported platforms](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fnaviapps%2Fwindow-kit%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/naviapps/window-kit) -WindowKit is a Swift package for macOS window discovery, querying, hit-testing, placement, and -live window control. +WindowKit is a Swift package for macOS window models, hit-testing contracts, placement geometry, +live window discovery, and live window control. It is a foundation library for building window managers, tiling tools, automation utilities, and productivity apps. @@ -15,18 +15,18 @@ permissions onboarding, gesture recognition, persistence, shortcuts, UI, and aut The package is split into two libraries: -- `WindowKit`: window snapshots, querying, hit-testing, screen models, placement values, and +- `WindowKit`: window snapshots, filters, hit-testing contracts, screen models, placement values, and control/provider protocols. -- `WindowKitAppKit`: live AppKit and Accessibility implementations plus AX-specific window role - metadata. +- `WindowKitAppKit`: live Core Graphics, AppKit, and Accessibility-backed facades for querying, + hit-testing, and placement. ## Why WindowKit? -- Clean separation between Core Graphics-backed window-list primitives and live AppKit or - Accessibility integrations. +- Clean separation between reusable window value/geometry contracts and live Core Graphics, + AppKit, or Accessibility integrations. - Explicit host-app responsibility boundaries for permissions, shortcuts, persistence, UI, and automation policy. -- Testable window abstractions for host apps that need mocks or deterministic geometry checks. +- Protocol-based window abstractions for host apps that need deterministic geometry checks. - Point-based hit-testing for cursor-based window discovery. - Reusable placement and screen-movement primitives for deterministic window geometry. @@ -37,7 +37,7 @@ WindowKit can serve as the window systems layer beneath: - tiling window managers and keyboard-driven layout tools - cursor-based window pickers and snap tools - launcher extensions that need focused-window or point-based window actions -- automation utilities that need testable geometry and Accessibility-backed control +- automation utilities that need deterministic geometry and Accessibility-backed control ## Architecture @@ -46,12 +46,12 @@ Host App | v WindowKit - window-list queries, models, hit-testing, placement, control/provider protocols + models, filters, hit-testing contracts, placement, control/provider protocols | | contracts v WindowKitAppKit - AppKit screens, running applications, AX window controllers, role metadata + Core Graphics queries, AppKit screens, running applications, AX controllers, role metadata | v macOS AppKit / Accessibility APIs @@ -59,20 +59,20 @@ macOS AppKit / Accessibility APIs ## Requirements -- macOS 12 or later -- Swift 5.10 or later +- macOS 14 or later +- Swift 6.0 or later ## Installation Add this package to your Swift Package dependencies: ```swift -.package(url: "https://github.com/naviapps/window-kit.git", from: "1.0.0") +.package(url: "https://github.com/naviapps/window-kit.git", from: "2.0.0") ``` -Then add the product that matches your use case. Use `WindowKit` by itself for window-list -querying, models, hit-testing, and geometry. Add both products when you use live AppKit or -Accessibility integration: +Then add the product that matches your use case. Use `WindowKit` by itself for models, protocols, +hit-testing abstractions, and geometry. Add both products when you use live Core Graphics, AppKit, +or Accessibility integration: ```swift .product(name: "WindowKit", package: "window-kit"), @@ -86,14 +86,18 @@ Accessibility integration: ## Basic Usage -Use `WindowKit` when you need window-list querying, window models, and geometry: +Use `WindowKit` when you need window models, protocols, and geometry: ```swift +import CoreGraphics import WindowKit -let calculator = WindowPlacementCalculator() -let frame = calculator.frame( - for: .leftHalf, +let screen = WindowScreen( + frame: CGRect(x: 0, y: 0, width: 1440, height: 900), + visibleFrame: CGRect(x: 0, y: 44, width: 1440, height: 812) +) +let frame = WindowPlacementCalculator.frame( + for: .grid(columns: 2, rows: 1, column: 0, row: 0, columnSpan: 1, rowSpan: 1), in: screen.visibleFrame, inset: 8 ) @@ -102,48 +106,57 @@ let frame = calculator.frame( Use `WindowKitAppKit` when you want live macOS integration: ```swift +import CoreGraphics import WindowKit import WindowKitAppKit -let query = WindowSnapshotQuery.appKit( - filter: WindowSnapshotFilter(excludesMinimizedWindows: true) -) - -let snapshots = try query.snapshots() -let snapshot = try query.snapshot(for: windowIdentifier) -let placer = WindowPlacer() -try placer.place(.rightHalf, for: .focused) -let state = try AXWindowStateProvider().state(for: .focused) +@MainActor +func placeFocusedWindow() throws -> (selected: WindowSnapshot?, topmost: WindowSnapshot?) { + let query = WindowSnapshotQuery() + + let snapshots = try query.snapshots() + let firstWindowIdentifier = snapshots.first?.windowIdentifier + let selectedSnapshot = try firstWindowIdentifier.flatMap { try query.snapshot(for: $0) } + let hitTester = WindowHitTester() + let mouseLocation = CGPoint(x: 120, y: 80) + let topmost = hitTester.topmost(at: mouseLocation) + + let placer = WindowPlacer() + try placer.place( + .grid(columns: 2, rows: 1, column: 1, row: 0, columnSpan: 1, rowSpan: 1), + for: .focused + ) + return (selectedSnapshot, topmost) +} ``` Window snapshots are immutable app-facing models for filtering, hit-testing, placement decisions, and deterministic tests without holding live system references. Snapshots include the process identifier, Core Graphics window identifier, owner metadata, title, -frame, layer, and minimized metadata when macOS provides it. -`WindowSnapshotQuery.snapshots()` throws `WindowSnapshotQueryError.listUnavailable` when macOS -does not return a readable window list. +and a finite standardized frame. +In `WindowKitAppKit`, `WindowSnapshotQuery.snapshots()` throws +`WindowSnapshotQueryError.windowListUnavailable` when macOS does not return a readable window list. `WindowSnapshotQuery.snapshot(for:)` reads a single known Core Graphics window identifier and still applies the query filter. Use `WindowSnapshotProviding` when host-app services should depend on a mockable snapshot source -instead of a concrete query. +instead of a concrete query, and `WindowSnapshotResolving` when they only resolve a known window. `WindowSnapshotFilter` supports both include and exclude sets for owner bundle identifiers, owner process identifiers, and window identifiers. Include sets are allow-lists when non-empty; exclude -sets are applied after include sets. `WindowSnapshotQuery` can also limit snapshots to the first -matching entry in the order returned by the macOS window list. - -Hit-testing is available through a small app-facing facade: - -```swift -let hitTester = WindowHitTester.appKit() -let snapshot = hitTester.topmost(at: mouseLocation) -``` +sets are applied after include sets. The AppKit query can also limit snapshots to the first matching +entry in the order returned by the macOS window list. Use `WindowSnapshotFilter.includes(_:)` when +host-owned providers need the same deterministic filtering semantics without depending on +`WindowKitAppKit`. -Use `WindowHitTesting` when host-app services should depend on a mockable hit-testing source instead -of a concrete hit tester. +Hit-testing is available through the same small app-facing facade shown above. Use +`WindowHitTesting` when host-app services should depend on a mockable hit-testing source instead of a +concrete hit tester. ## Responsibility Boundary +WindowKit owns platform-neutral window values, placement calculations, screen selection, and +protocol contracts. WindowKitAppKit owns AppKit and Accessibility-backed live adapters. + WindowKit intentionally does not own: - permission request or onboarding UI @@ -161,17 +174,20 @@ columns, rows, and spans into the available grid. Grid columns start from the le from the bottom in the selected screen-area coordinate space. Centered placements clamp width and height ratios to `0...1`. +`WindowScreen` standardizes full and visible frames at construction. `WindowScreenTarget.containingWindow` selects the screen containing the target window center. WindowKit screen-selection primitives support forgiving fallback semantics so host apps can decide whether a missing screen is recoverable or exceptional. Use `WindowScreenSelector` when custom host-app controllers need the same deterministic screen -ordering and target resolution as `WindowPlacer`. -Use `WindowScreenMovement` when custom host-app controllers need the same deterministic +ordering and target resolution as `WindowPlacer`; selector APIs order input screen lists before +applying ordered-index and fallback selection. +Use `WindowScreenMovement` when custom host-app controllers need the same finite, deterministic screen-to-screen movement geometry without using live Accessibility control. -In `WindowKitAppKit`, `WindowPlacer` uses forgiving screen selection by default. When a requested -point, display identifier, ordered index, or current window screen cannot be matched, placement and -screen moves fall back instead of throwing. Pass `allowsScreenFallback: false` to +In `WindowKitAppKit`, `WindowPlacer` uses forgiving screen selection by default. When screens exist +but a requested point, display identifier, ordered index, or current window screen cannot be +matched, placement and screen moves fall back instead of throwing. An empty screen list always +throws. Pass `screenFallback: .none` to `WindowPlacer.place` or `WindowPlacer.moveToAdjacentScreen` when a missing screen should throw `WindowPlacerError.screenNotFound`. @@ -181,36 +197,53 @@ screen moves fall back instead of throwing. Pass `allowsScreenFallback: false` t for declaring and guiding the required permissions, handling denied access, and choosing when to prompt users. -AX window operations throw `AXWindowError` for permission failures, missing windows, invalid -Accessibility values, and underlying AX errors. -Target-specific resolution failures include the `WindowTarget` that could not be resolved. - Window discovery, querying, and hit-testing read window metadata returned by macOS, including -process identifiers, app names, bundle identifiers, window titles, frames, and layers when the -system makes those fields available. Host apps are responsible for their own privacy disclosures, +process identifiers, app names, bundle identifiers, window titles, and frames for standard-layer +windows. Host apps are responsible for their own privacy disclosures, logging choices, and any Screen Recording or Accessibility permission flows needed by their product. +Raw AX resolver details are implementation details. The public Accessibility-backed +`AXWindowController` surfaces app-facing failures through `WindowAccessibilityError`, with +application and window resolution failures preserving the `WindowTarget` that could not be +resolved. Each failure includes a `WindowAccessibilityOperation`; underlying failures are further +classified by `WindowAccessibilityOperationFailure` instead of raw AX error values. +Unrecognized future AXError values are surfaced as +`WindowAccessibilityOperationFailure.unrecognizedAXError`. +AX success reported through a failure path is surfaced as +`WindowAccessibilityOperationFailure.unexpectedSuccess` instead of exposing success as a failure. +Unmatched window role metadata is represented as +`WindowRole.unclassified(accessibilityRole:accessibilitySubrole:)` so host apps can inspect the raw +Accessibility role labels without treating them as a known classification. +Role-unavailable values are not classified from subrole metadata alone. +Host apps should depend on the `WindowKit` protocols and construct the public `WindowKitAppKit` +adapters only at their live system boundary. + The package does not request permissions, collect analytics, transmit data, or persist window data by itself. ## Development -Local development commands use `just`. - -Run all local checks: +Run the package check with: ```sh -just check +make check ``` -`WindowKit` querying, hit-testing, and geometry primitives are designed to be tested without live -AppKit or Accessibility dependencies. `WindowKitAppKit` keeps AppKit and Accessibility behavior -behind small controllers and providers so host apps can substitute their own implementations in -tests. +GitHub Actions runs the same check on pull requests and pushes to `main`. + +`WindowKit` models, protocols, hit-testing abstractions, and geometry primitives are designed to be +tested without live Core Graphics, AppKit, or Accessibility dependencies. `WindowKitAppKit` keeps +live system behavior behind small controllers and providers so host apps can substitute their own +implementations in tests. + +## Contributing + +See [CONTRIBUTING.md](CONTRIBUTING.md). Release notes are in [CHANGELOG.md](CHANGELOG.md). + +## Security -For focused local work, run `just lint` or `just test`. The GitHub Actions CI runs the same checks -as `just check` on pull requests and pushes to `main`. +Report vulnerabilities privately. See [SECURITY.md](SECURITY.md). ## License diff --git a/SECURITY.md b/SECURITY.md index 2b93343..960e589 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,32 +2,35 @@ ## Supported Versions -Before the first public release, security fixes target the default branch. After the first public -release, security updates are provided for the latest released version of WindowKit. +Security updates are provided for the latest released version. ## Reporting a Vulnerability -Please report security issues through GitHub's private vulnerability reporting for this repository. +Report security issues through GitHub private vulnerability reporting for this repository. -Do not open a public GitHub issue or pull request for vulnerabilities, suspected credential -exposure, privacy-sensitive behavior, or local window data exposure. +Do not open a public issue, pull request, or discussion for vulnerabilities, suspected credential +exposure, privacy-sensitive behavior, or issues that could expose private user data. -If private vulnerability reporting is not enabled, open a public issue asking for a private security -contact channel, but do not include vulnerability details, exploit steps, logs, secrets, tokens, -private app names, bundle identifiers, process identifiers, window titles, or personal data. +If private vulnerability reporting is unavailable, open a public issue asking for a private +security contact channel. Do not include vulnerability details, exploit steps, logs, secrets, +tokens, private keys, personal data, local paths, private app metadata, or app-specific internal +references. -When reporting an issue, include: +For private reports, include: - Affected package version or commit +- Affected dependency versions if relevant - A clear description of the behavior - Reproduction steps or a minimal proof of concept -- Expected impact and affected window, screen, hit-testing, or Accessibility behavior +- Expected impact +- Affected public API, target, or subsystem + +Use placeholders instead of secrets, tokens, private keys, personal data, local paths, private app +metadata, or app-specific internal references. We will acknowledge valid reports as soon as practical and coordinate fixes before public disclosure. -For non-security bugs, use the public bug report template. - ## Scope Security-sensitive areas include: @@ -35,10 +38,11 @@ Security-sensitive areas include: - Live Accessibility window control - Window metadata returned by macOS APIs - Hit-testing and focused-window selection +- Window placement and screen-selection behavior - Window metadata exposed to host apps, such as titles, app names, bundle identifiers, or process identifiers WindowKit does not collect, transmit, or persist window data by itself. Host applications are -responsible for their own permission onboarding, logging, telemetry, and privacy policies. -Window metadata availability and permission prompts are controlled by macOS and the host -application, not by this package. +responsible for permission onboarding, logging, telemetry, privacy disclosures, and any Screen +Recording or Accessibility permission flows. Window metadata availability and permission prompts +are controlled by macOS and the host application, not by this package. diff --git a/Sources/WindowKit/Control/WindowControlling.swift b/Sources/WindowKit/Control/WindowControlling.swift new file mode 100644 index 0000000..7825124 --- /dev/null +++ b/Sources/WindowKit/Control/WindowControlling.swift @@ -0,0 +1,6 @@ +/// Complete app-facing contract for reading and controlling a window. +/// +/// Consumers that need only one capability should depend on the narrower inherited protocol. +public protocol WindowControlling: + WindowCommandControlling, WindowFrameControlling, WindowStateProviding +{} diff --git a/Sources/WindowKit/Control/WindowFrameControlling.swift b/Sources/WindowKit/Control/WindowFrameControlling.swift index 8c3a2bc..d3ca262 100644 --- a/Sources/WindowKit/Control/WindowFrameControlling.swift +++ b/Sources/WindowKit/Control/WindowFrameControlling.swift @@ -1,12 +1,5 @@ import CoreGraphics -/// Reads window frames. -public protocol WindowFrameProviding: Sendable { - /// Returns the current frame for a target window. - @MainActor - func frame(for target: WindowTarget) throws -> CGRect -} - /// Reads and writes window frames. public protocol WindowFrameControlling: WindowFrameProviding { /// Sets the frame for a target window. diff --git a/Sources/WindowKit/Control/WindowFrameProviding.swift b/Sources/WindowKit/Control/WindowFrameProviding.swift new file mode 100644 index 0000000..6ffcd0b --- /dev/null +++ b/Sources/WindowKit/Control/WindowFrameProviding.swift @@ -0,0 +1,8 @@ +import CoreGraphics + +/// Reads window frames. +public protocol WindowFrameProviding: Sendable { + /// Returns the current frame for a target window. + @MainActor + func frame(for target: WindowTarget) throws -> CGRect +} diff --git a/Sources/WindowKit/Control/WindowState.swift b/Sources/WindowKit/Control/WindowState.swift index 48f1cfc..5965d1b 100644 --- a/Sources/WindowKit/Control/WindowState.swift +++ b/Sources/WindowKit/Control/WindowState.swift @@ -6,15 +6,8 @@ public struct WindowState: Equatable, Hashable, Sendable, Codable { public let isFullScreen: Bool? /// Creates a window state value. - public init(isMinimized: Bool?, isFullScreen: Bool?) { + public init(isMinimized: Bool? = nil, isFullScreen: Bool? = nil) { self.isMinimized = isMinimized self.isFullScreen = isFullScreen } } - -/// Reads state for a target window. -public protocol WindowStateProviding: Sendable { - /// Returns the current state for a target window. - @MainActor - func state(for target: WindowTarget) throws -> WindowState -} diff --git a/Sources/WindowKit/Control/WindowStateProviding.swift b/Sources/WindowKit/Control/WindowStateProviding.swift new file mode 100644 index 0000000..7d1c810 --- /dev/null +++ b/Sources/WindowKit/Control/WindowStateProviding.swift @@ -0,0 +1,6 @@ +/// Reads state for a target window. +public protocol WindowStateProviding: Sendable { + /// Returns the current state for a target window. + @MainActor + func state(for target: WindowTarget) throws -> WindowState +} diff --git a/Sources/WindowKit/Geometry/CGRect+FiniteStandardized.swift b/Sources/WindowKit/Geometry/CGRect+FiniteStandardized.swift new file mode 100644 index 0000000..3646bf5 --- /dev/null +++ b/Sources/WindowKit/Geometry/CGRect+FiniteStandardized.swift @@ -0,0 +1,27 @@ +import CoreGraphics + +extension CGRect { + var finiteStandardized: CGRect { + if origin.x.isFinite, origin.y.isFinite, width.isFinite, height.isFinite { + return standardized + } + + return CGRect( + x: finiteOrigin(origin.x, offset: width), + y: finiteOrigin(origin.y, offset: height), + width: finiteMagnitude(width), + height: finiteMagnitude(height) + ) + } + + private func finiteOrigin(_ value: CGFloat, offset: CGFloat) -> CGFloat { + guard value.isFinite else { return 0 } + guard offset.isFinite, offset < 0 else { return value } + return value + offset + } + + private func finiteMagnitude(_ value: CGFloat) -> CGFloat { + guard value.isFinite else { return 0 } + return abs(value) + } +} diff --git a/Sources/WindowKit/HitTesting/WindowHitTestMissReason.swift b/Sources/WindowKit/HitTesting/WindowHitTestMissReason.swift new file mode 100644 index 0000000..a2e0df4 --- /dev/null +++ b/Sources/WindowKit/HitTesting/WindowHitTestMissReason.swift @@ -0,0 +1,7 @@ +/// Reason a window hit-test did not produce a matching window. +public enum WindowHitTestMissReason: Equatable, Hashable, Sendable, Codable { + /// The hit-testing provider could not read its window source. + case sourceUnavailable + /// No matching window contained the tested point. + case noMatchingWindowAtPoint +} diff --git a/Sources/WindowKit/HitTesting/WindowHitTestResult.swift b/Sources/WindowKit/HitTesting/WindowHitTestResult.swift index af0c20b..d1b1e50 100644 --- a/Sources/WindowKit/HitTesting/WindowHitTestResult.swift +++ b/Sources/WindowKit/HitTesting/WindowHitTestResult.swift @@ -1,11 +1,3 @@ -/// Reason a window hit-test did not produce a matching window. -public enum WindowHitTestMissReason: Equatable, Hashable, Sendable, Codable { - /// The system window list could not be read. - case listUnavailable - /// No matching window contained the tested point. - case noMatchingWindowAtPoint -} - /// Result of a point-based window hit-test. public enum WindowHitTestResult: Equatable, Hashable, Sendable, Codable { /// A matching window snapshot was found. diff --git a/Sources/WindowKit/HitTesting/WindowHitTester.swift b/Sources/WindowKit/HitTesting/WindowHitTester.swift deleted file mode 100644 index 12d1e3d..0000000 --- a/Sources/WindowKit/HitTesting/WindowHitTester.swift +++ /dev/null @@ -1,35 +0,0 @@ -import CoreGraphics - -/// Hit-testing facade for selecting the topmost matching window at a point. -public struct WindowHitTester: WindowHitTesting, Sendable { - private let provider: any WindowHitTestProviding - - /// Creates a hit tester backed by the system window list. - /// - /// - Parameters: - /// - pointTransform: Converts the input point to the same global coordinate space as snapshot - /// frames before hit-testing. - /// - ownerBundleIdentifierProvider: Resolves owner bundle identifiers for returned snapshots. - /// - filter: Filters candidate windows before hit-testing. - public init( - pointTransform: @escaping @Sendable (CGPoint) -> CGPoint = { $0 }, - ownerBundleIdentifierProvider: any WindowOwnerBundleIdentifierProviding = - NullWindowOwnerBundleIdentifierProvider(), - filter: WindowSnapshotFilter = .init() - ) { - provider = WindowListHitTestProvider( - pointTransform: pointTransform, - ownerBundleIdentifierProvider: ownerBundleIdentifierProvider, - filter: filter - ) - } - - init(provider: any WindowHitTestProviding) { - self.provider = provider - } - - /// Returns a hit-test result for the topmost matching window at the given point. - public func hitTest(at point: CGPoint) -> WindowHitTestResult { - provider.hitTest(at: point) - } -} diff --git a/Sources/WindowKit/HitTesting/WindowListHitTestProvider.swift b/Sources/WindowKit/HitTesting/WindowListHitTestProvider.swift deleted file mode 100644 index 6d35e95..0000000 --- a/Sources/WindowKit/HitTesting/WindowListHitTestProvider.swift +++ /dev/null @@ -1,80 +0,0 @@ -import CoreGraphics - -protocol WindowHitTestProviding: Sendable { - /// Returns a hit-test result for the topmost matching window at a given point. - /// - /// The `point` must be in the same global coordinate space as snapshot frames. - func hitTest(at point: CGPoint) -> WindowHitTestResult -} - -final class WindowListHitTestProvider: WindowHitTestProviding, Sendable { - private let options: CGWindowListOption - private let listProvider: any WindowListProviding - private let pointTransform: @Sendable (CGPoint) -> CGPoint - private let mapper: WindowListSnapshotMapper - - /// - Parameters: - /// - options: Core Graphics window-list options used for hit-testing. - /// - allowedLayers: Window layers accepted by the snapshot mapper. - /// - pointTransform: Converts the input point to the same global coordinate space as snapshot - /// frames before hit-testing. - /// - ownerBundleIdentifierProvider: Resolves owner bundle identifiers for returned snapshots. - /// - filter: Filters candidate windows before hit-testing. - convenience init( - options: CGWindowListOption = [.optionOnScreenOnly, .excludeDesktopElements], - allowedLayers: Set = [0], - pointTransform: @escaping @Sendable (CGPoint) -> CGPoint = { $0 }, - ownerBundleIdentifierProvider: any WindowOwnerBundleIdentifierProviding = - NullWindowOwnerBundleIdentifierProvider(), - filter: WindowSnapshotFilter = .init() - ) { - self.init( - options: options, - allowedLayers: allowedLayers, - listProvider: SystemWindowListProvider(), - pointTransform: pointTransform, - ownerBundleIdentifierProvider: ownerBundleIdentifierProvider, - filter: filter - ) - } - - init( - options: CGWindowListOption = [.optionOnScreenOnly, .excludeDesktopElements], - allowedLayers: Set = [0], - listProvider: any WindowListProviding, - pointTransform: @escaping @Sendable (CGPoint) -> CGPoint = { $0 }, - ownerBundleIdentifierProvider: any WindowOwnerBundleIdentifierProviding = - NullWindowOwnerBundleIdentifierProvider(), - filter: WindowSnapshotFilter = .init() - ) { - self.options = options - self.listProvider = listProvider - self.pointTransform = pointTransform - mapper = WindowListSnapshotMapper( - allowedLayers: allowedLayers, - ownerBundleIdentifierProvider: ownerBundleIdentifierProvider, - filter: filter - ) - } - - func hitTest(at point: CGPoint) -> WindowHitTestResult { - let transformedPoint = pointTransform(point) - guard - let windowList = listProvider.windowList( - options: options, - windowIdentifier: kCGNullWindowID - ) - else { - return .miss(.listUnavailable) - } - - guard - let selected = windowList.lazy.compactMap({ - self.mapper.snapshot(from: $0, containing: transformedPoint) - }).first - else { - return .miss(.noMatchingWindowAtPoint) - } - return .hit(selected) - } -} diff --git a/Sources/WindowKit/Placement/WindowPlacement.swift b/Sources/WindowKit/Placement/WindowPlacement.swift index db4a80b..4e8b0fc 100644 --- a/Sources/WindowKit/Placement/WindowPlacement.swift +++ b/Sources/WindowKit/Placement/WindowPlacement.swift @@ -4,42 +4,6 @@ import CoreGraphics public enum WindowPlacement: Equatable, Hashable, Sendable, Codable { /// Fill the selected screen area. case fill - /// Left half of the selected screen area. - case leftHalf - /// Right half of the selected screen area. - case rightHalf - /// Top half of the selected screen area. - case topHalf - /// Bottom half of the selected screen area. - case bottomHalf - /// Top-left quarter of the selected screen area. - case topLeftQuarter - /// Top-right quarter of the selected screen area. - case topRightQuarter - /// Bottom-left quarter of the selected screen area. - case bottomLeftQuarter - /// Bottom-right quarter of the selected screen area. - case bottomRightQuarter - /// Left third of the selected screen area. - case leftThird - /// Center third of the selected screen area. - case centerThird - /// Right third of the selected screen area. - case rightThird - /// Top third of the selected screen area. - case topThird - /// Middle third of the selected screen area. - case middleThird - /// Bottom third of the selected screen area. - case bottomThird - /// Left two-thirds of the selected screen area. - case leftTwoThirds - /// Right two-thirds of the selected screen area. - case rightTwoThirds - /// Top two-thirds of the selected screen area. - case topTwoThirds - /// Bottom two-thirds of the selected screen area. - case bottomTwoThirds /// Rectangular region in a grid. /// /// Columns are zero-based from the left. Rows are zero-based from the bottom in the same diff --git a/Sources/WindowKit/Placement/WindowPlacementCalculator.swift b/Sources/WindowKit/Placement/WindowPlacementCalculator.swift index b13cca8..26bf08b 100644 --- a/Sources/WindowKit/Placement/WindowPlacementCalculator.swift +++ b/Sources/WindowKit/Placement/WindowPlacementCalculator.swift @@ -1,21 +1,19 @@ import CoreGraphics /// Deterministically calculates frames for ``WindowPlacement`` values. -public struct WindowPlacementCalculator: Sendable { - /// Creates a placement calculator. - public init() {} - +public enum WindowPlacementCalculator { /// Calculates a frame for a placement inside the given screen area. /// - /// The screen area is standardized before placement, and the inset is clamped so it cannot invert - /// the returned frame. + /// The screen area is standardized before placement. Non-finite screen area components are + /// replaced with finite zero-origin bounds, and the inset is clamped so it cannot invert the + /// returned frame. /// /// - Parameters: /// - placement: Placement value to resolve. /// - screenArea: Screen area used as the placement bounds. /// - inset: Uniform inset applied before resolving the placement. /// - Returns: A deterministic frame for the requested placement. - public func frame( + public static func frame( for placement: WindowPlacement, in screenArea: CGRect, inset: CGFloat = 0 @@ -24,63 +22,17 @@ public struct WindowPlacementCalculator: Sendable { return resolveFrame(placement, in: area) } - private func insetArea(_ area: CGRect, by inset: CGFloat) -> CGRect { - let standardizedArea = area.standardized + private static func insetArea(_ area: CGRect, by inset: CGFloat) -> CGRect { + let standardizedArea = area.finiteStandardized let maximumInset = max(0, min(standardizedArea.width, standardizedArea.height) / 2) let clampedInset = clamped(inset, lowerBound: 0, upperBound: maximumInset) return standardizedArea.insetBy(dx: clampedInset, dy: clampedInset) } - private func resolveFrame(_ placement: WindowPlacement, in area: CGRect) -> CGRect { + private static func resolveFrame(_ placement: WindowPlacement, in area: CGRect) -> CGRect { switch placement { case .fill: return area - case .leftHalf: - return CGRect(x: area.minX, y: area.minY, width: area.width / 2, height: area.height) - case .rightHalf: - return CGRect(x: area.midX, y: area.minY, width: area.width / 2, height: area.height) - case .topHalf: - return CGRect(x: area.minX, y: area.midY, width: area.width, height: area.height / 2) - case .bottomHalf: - return CGRect(x: area.minX, y: area.minY, width: area.width, height: area.height / 2) - case .topLeftQuarter: - return CGRect(x: area.minX, y: area.midY, width: area.width / 2, height: area.height / 2) - case .topRightQuarter: - return CGRect(x: area.midX, y: area.midY, width: area.width / 2, height: area.height / 2) - case .bottomLeftQuarter: - return CGRect(x: area.minX, y: area.minY, width: area.width / 2, height: area.height / 2) - case .bottomRightQuarter: - return CGRect(x: area.midX, y: area.minY, width: area.width / 2, height: area.height / 2) - case .leftThird: - return CGRect(x: area.minX, y: area.minY, width: area.width / 3, height: area.height) - case .centerThird: - return CGRect( - x: area.minX + (area.width / 3), y: area.minY, width: area.width / 3, height: area.height) - case .rightThird: - return CGRect( - x: area.minX + (2 * area.width / 3), y: area.minY, width: area.width / 3, - height: area.height) - case .topThird: - return CGRect( - x: area.minX, y: area.minY + (2 * area.height / 3), width: area.width, - height: area.height / 3) - case .middleThird: - return CGRect( - x: area.minX, y: area.minY + (area.height / 3), width: area.width, height: area.height / 3) - case .bottomThird: - return CGRect(x: area.minX, y: area.minY, width: area.width, height: area.height / 3) - case .leftTwoThirds: - return CGRect(x: area.minX, y: area.minY, width: area.width * 2 / 3, height: area.height) - case .rightTwoThirds: - return CGRect( - x: area.minX + (area.width / 3), y: area.minY, width: area.width * 2 / 3, - height: area.height) - case .topTwoThirds: - return CGRect( - x: area.minX, y: area.minY + (area.height / 3), width: area.width, - height: area.height * 2 / 3) - case .bottomTwoThirds: - return CGRect(x: area.minX, y: area.minY, width: area.width, height: area.height * 2 / 3) case let .grid(columns, rows, column, row, columnSpan, rowSpan): return grid( area, @@ -96,7 +48,7 @@ public struct WindowPlacementCalculator: Sendable { } } - private func grid( + private static func grid( _ area: CGRect, columns: Int, rows: Int, @@ -121,7 +73,8 @@ public struct WindowPlacementCalculator: Sendable { ) } - private func centered(_ area: CGRect, widthRatio: CGFloat, heightRatio: CGFloat) -> CGRect { + private static func centered(_ area: CGRect, widthRatio: CGFloat, heightRatio: CGFloat) -> CGRect + { let normalizedWidthRatio = clamped(widthRatio, lowerBound: 0, upperBound: 1) let normalizedHeightRatio = clamped(heightRatio, lowerBound: 0, upperBound: 1) let width = area.width * normalizedWidthRatio @@ -134,7 +87,8 @@ public struct WindowPlacementCalculator: Sendable { ) } - private func clamped(_ value: CGFloat, lowerBound: CGFloat, upperBound: CGFloat) -> CGFloat { + private static func clamped(_ value: CGFloat, lowerBound: CGFloat, upperBound: CGFloat) -> CGFloat + { guard value.isFinite else { return value > upperBound ? upperBound : lowerBound } diff --git a/Sources/WindowKit/Screens/WindowScreen.swift b/Sources/WindowKit/Screens/WindowScreen.swift index f8a03e4..f98a72f 100644 --- a/Sources/WindowKit/Screens/WindowScreen.swift +++ b/Sources/WindowKit/Screens/WindowScreen.swift @@ -4,9 +4,9 @@ import CoreGraphics public struct WindowScreen: Equatable, Hashable, Sendable, Codable { /// Core Graphics display identifier, when available. public let displayIdentifier: CGDirectDisplayID? - /// Full screen frame. + /// Standardized full screen frame. public let frame: CGRect - /// Visible frame excluding system-reserved areas such as the menu bar and Dock. + /// Standardized visible frame excluding system-reserved areas such as the menu bar and Dock. public let visibleFrame: CGRect /// Whether this screen is the main screen. public let isMain: Bool @@ -19,8 +19,8 @@ public struct WindowScreen: Equatable, Hashable, Sendable, Codable { isMain: Bool = false ) { self.displayIdentifier = displayIdentifier - self.frame = frame - self.visibleFrame = visibleFrame + self.frame = frame.finiteStandardized + self.visibleFrame = visibleFrame.finiteStandardized self.isMain = isMain } @@ -34,10 +34,3 @@ public struct WindowScreen: Equatable, Hashable, Sendable, Codable { } } } - -/// Provides app-facing screen values. -public protocol WindowScreenProviding: Sendable { - /// Returns the current screens. - @MainActor - func screens() -> [WindowScreen] -} diff --git a/Sources/WindowKit/Screens/WindowScreenFallback.swift b/Sources/WindowKit/Screens/WindowScreenFallback.swift new file mode 100644 index 0000000..3013fb6 --- /dev/null +++ b/Sources/WindowKit/Screens/WindowScreenFallback.swift @@ -0,0 +1,7 @@ +/// Screen-selection behavior when a requested screen cannot be resolved. +public enum WindowScreenFallback: Equatable, Hashable, Sendable, Codable { + /// Use the first deterministically ordered screen when one exists. + case firstScreen + /// Do not fall back; return nil or throw from higher-level operations. + case none +} diff --git a/Sources/WindowKit/Screens/WindowScreenMovement.swift b/Sources/WindowKit/Screens/WindowScreenMovement.swift index fc96c7a..c27ffbd 100644 --- a/Sources/WindowKit/Screens/WindowScreenMovement.swift +++ b/Sources/WindowKit/Screens/WindowScreenMovement.swift @@ -4,8 +4,8 @@ import CoreGraphics public enum WindowScreenMovement { /// Centers a window in a target visible frame while preserving as much size as fits. /// - /// Input rectangles are standardized before calculation. The returned frame is clamped to the - /// target visible frame when the source window is larger than the target. + /// Input rectangles are made finite and standardized before calculation. The returned frame is + /// clamped to the target visible frame when the source window is larger than the target. /// /// - Parameters: /// - windowFrame: Current window frame. @@ -14,8 +14,8 @@ public enum WindowScreenMovement { public static func centerPreservingSize(windowFrame: CGRect, in visibleFrame: CGRect) -> CGRect { - let window = normalized(windowFrame) - let target = normalized(visibleFrame) + let window = windowFrame.finiteStandardized + let target = visibleFrame.finiteStandardized let width = clamped(window.width, lowerBound: 0, upperBound: target.width) let height = clamped(window.height, lowerBound: 0, upperBound: target.height) return CGRect( @@ -28,8 +28,8 @@ public enum WindowScreenMovement { /// Moves a window between visible frames while preserving relative position. /// - /// Input rectangles are standardized before calculation. The returned frame is clamped to the - /// target visible frame when the source window is larger than the target. + /// Input rectangles are made finite and standardized before calculation. The returned frame is + /// clamped to the target visible frame when the source window is larger than the target. /// /// - Parameters: /// - windowFrame: Current window frame. @@ -41,17 +41,20 @@ public enum WindowScreenMovement { from sourceVisibleFrame: CGRect, to targetVisibleFrame: CGRect ) -> CGRect { - let window = normalized(windowFrame) - let source = normalized(sourceVisibleFrame) - let target = normalized(targetVisibleFrame) + let window = windowFrame.finiteStandardized + let source = sourceVisibleFrame.finiteStandardized + let target = targetVisibleFrame.finiteStandardized let width = clamped(window.width, lowerBound: 0, upperBound: target.width) let height = clamped(window.height, lowerBound: 0, upperBound: target.height) - let sourceAvailableWidth = max(1, source.width - window.width) - let sourceAvailableHeight = max(1, source.height - window.height) - - let xProgress = clamped((window.minX - source.minX) / sourceAvailableWidth) - let yProgress = clamped((window.minY - source.minY) / sourceAvailableHeight) + let xProgress = relativeProgress( + offset: window.minX - source.minX, + availableLength: source.width - window.width + ) + let yProgress = relativeProgress( + offset: window.minY - source.minY, + availableLength: source.height - window.height + ) let targetAvailableWidth = max(0, target.width - width) let targetAvailableHeight = max(0, target.height - height) @@ -64,26 +67,9 @@ public enum WindowScreenMovement { ) } - private static func normalized(_ rect: CGRect) -> CGRect { - if rect.origin.x.isFinite, rect.origin.y.isFinite, rect.width.isFinite, rect.height.isFinite { - return rect.standardized - } - - let width = finiteMagnitude(rect.width) - let height = finiteMagnitude(rect.height) - return CGRect( - x: rect.width < 0 ? rect.origin.x + rect.width : rect.origin.x, - y: rect.height < 0 ? rect.origin.y + rect.height : rect.origin.y, - width: width, - height: height - ) - } - - private static func finiteMagnitude(_ value: CGFloat) -> CGFloat { - guard value.isFinite else { - return value > 0 ? .greatestFiniteMagnitude : 0 - } - return abs(value) + private static func relativeProgress(offset: CGFloat, availableLength: CGFloat) -> CGFloat { + guard availableLength > 0 else { return 0.5 } + return clamped(offset / availableLength) } private static func clamped( diff --git a/Sources/WindowKit/Screens/WindowScreenProviding.swift b/Sources/WindowKit/Screens/WindowScreenProviding.swift new file mode 100644 index 0000000..d29330f --- /dev/null +++ b/Sources/WindowKit/Screens/WindowScreenProviding.swift @@ -0,0 +1,6 @@ +/// Provides app-facing screen values. +public protocol WindowScreenProviding: Sendable { + /// Returns the current screens. + @MainActor + func screens() -> [WindowScreen] +} diff --git a/Sources/WindowKit/Screens/WindowScreenSelector.swift b/Sources/WindowKit/Screens/WindowScreenSelector.swift index 284d7e3..fea55ca 100644 --- a/Sources/WindowKit/Screens/WindowScreenSelector.swift +++ b/Sources/WindowKit/Screens/WindowScreenSelector.swift @@ -4,84 +4,103 @@ import CoreGraphics public enum WindowScreenSelector { /// Returns screens in deterministic left-to-right, then bottom-to-top order. public static func orderedScreens(from screens: [WindowScreen]) -> [WindowScreen] { - screens.sorted { lhs, rhs in - let lhsFrame = lhs.frame.standardized - let rhsFrame = rhs.frame.standardized + screens.enumerated().sorted { lhsEntry, rhsEntry in + let lhs = lhsEntry.element + let rhs = rhsEntry.element + let lhsFrame = lhs.frame + let rhsFrame = rhs.frame if lhsFrame.minX != rhsFrame.minX { return lhsFrame.minX < rhsFrame.minX } if lhsFrame.minY != rhsFrame.minY { return lhsFrame.minY < rhsFrame.minY } if lhsFrame.width != rhsFrame.width { return lhsFrame.width < rhsFrame.width } if lhsFrame.height != rhsFrame.height { return lhsFrame.height < rhsFrame.height } switch (lhs.displayIdentifier, rhs.displayIdentifier) { case let (lhsDisplayIdentifier?, rhsDisplayIdentifier?): - return lhsDisplayIdentifier < rhsDisplayIdentifier + if lhsDisplayIdentifier != rhsDisplayIdentifier { + return lhsDisplayIdentifier < rhsDisplayIdentifier + } + return lhsEntry.offset < rhsEntry.offset case (.some, .none): return true - case (.none, .some), (.none, .none): + case (.none, .some): return false + case (.none, .none): + return lhsEntry.offset < rhsEntry.offset } - } + }.map(\.element) } - /// Selects a screen target from an ordered screen list. + /// Selects a screen target from a screen list. + /// + /// Screens are ordered deterministically before selection. Unmatched targets fall back to the + /// first ordered screen by default. Pass ``WindowScreenFallback/none`` + /// when a missing screen should return `nil`. public static func screen( for target: WindowScreenTarget, windowFrame: CGRect, - in orderedScreens: [WindowScreen], - fallsBackToFirstScreen: Bool = true + in screens: [WindowScreen], + fallback: WindowScreenFallback = .firstScreen ) -> WindowScreen? { + let orderedScreens = orderedScreens(from: screens) guard !orderedScreens.isEmpty else { return nil } switch target { case .main: return orderedScreens.first { $0.isMain } - ?? fallbackScreen(from: orderedScreens, fallsBackToFirstScreen: fallsBackToFirstScreen) + ?? fallbackScreen(from: orderedScreens, fallback: fallback) case .containingWindow: return orderedScreens.first { - $0.frame.standardized.contains(windowFrame.standardized.center) + $0.frame.contains(windowFrame.standardized.center) } - ?? fallbackScreen(from: orderedScreens, fallsBackToFirstScreen: fallsBackToFirstScreen) + ?? fallbackScreen(from: orderedScreens, fallback: fallback) case let .point(point): - return orderedScreens.first { $0.frame.standardized.contains(point) } - ?? fallbackScreen(from: orderedScreens, fallsBackToFirstScreen: fallsBackToFirstScreen) + return orderedScreens.first { $0.frame.contains(point) } + ?? fallbackScreen(from: orderedScreens, fallback: fallback) case let .displayIdentifier(displayIdentifier): return orderedScreens.first { $0.displayIdentifier == displayIdentifier } - ?? fallbackScreen(from: orderedScreens, fallsBackToFirstScreen: fallsBackToFirstScreen) + ?? fallbackScreen(from: orderedScreens, fallback: fallback) case let .orderedIndex(index): guard orderedScreens.indices.contains(index) else { - return fallbackScreen(from: orderedScreens, fallsBackToFirstScreen: fallsBackToFirstScreen) + return fallbackScreen(from: orderedScreens, fallback: fallback) } return orderedScreens[index] } } - /// Returns the index of the screen containing a point. + /// Returns the deterministic ordered index of the screen containing a point. public static func index(containing point: CGPoint, in screens: [WindowScreen]) -> Int? { - screens.firstIndex { $0.frame.standardized.contains(point) } + orderedScreens(from: screens).firstIndex { $0.frame.contains(point) } } /// Returns the adjacent ordered screen index for a cycling direction. /// - /// The starting index is normalized into the available range. Empty screen lists return `nil`. + /// The starting index is normalized into the available range. Empty and negative screen counts + /// return `nil`. public static func adjacentIndex( from index: Int, direction: WindowScreenCycleDirection, screenCount: Int ) -> Int? { guard screenCount > 0 else { return nil } - let normalizedIndex = ((index % screenCount) + screenCount) % screenCount + let remainder = index % screenCount + let normalizedIndex = remainder >= 0 ? remainder : remainder + screenCount switch direction { case .next: - return (normalizedIndex + 1) % screenCount + return normalizedIndex == screenCount - 1 ? 0 : normalizedIndex + 1 case .previous: - return (normalizedIndex - 1 + screenCount) % screenCount + return normalizedIndex == 0 ? screenCount - 1 : normalizedIndex - 1 } } private static func fallbackScreen( from orderedScreens: [WindowScreen], - fallsBackToFirstScreen: Bool + fallback: WindowScreenFallback ) -> WindowScreen? { - fallsBackToFirstScreen ? orderedScreens.first : nil + switch fallback { + case .firstScreen: + orderedScreens.first + case .none: + nil + } } } diff --git a/Sources/WindowKit/Screens/WindowScreenTarget.swift b/Sources/WindowKit/Screens/WindowScreenTarget.swift index e9d8921..374b33b 100644 --- a/Sources/WindowKit/Screens/WindowScreenTarget.swift +++ b/Sources/WindowKit/Screens/WindowScreenTarget.swift @@ -1,6 +1,6 @@ import CoreGraphics -/// Selects a screen from an ordered screen list. +/// Selects a screen from a screen list. public enum WindowScreenTarget: Equatable, Hashable, Sendable, Codable { /// The main screen. case main @@ -10,6 +10,6 @@ public enum WindowScreenTarget: Equatable, Hashable, Sendable, Codable { case point(CGPoint) /// The screen matching a Core Graphics display identifier. case displayIdentifier(CGDirectDisplayID) - /// A screen by deterministic ordered index. + /// A screen by `WindowScreenSelector`'s deterministic ordered index. case orderedIndex(Int) } diff --git a/Sources/WindowKit/Snapshots/WindowListProviding.swift b/Sources/WindowKit/Snapshots/WindowListProviding.swift deleted file mode 100644 index e08683d..0000000 --- a/Sources/WindowKit/Snapshots/WindowListProviding.swift +++ /dev/null @@ -1,17 +0,0 @@ -import CoreGraphics - -typealias WindowListEntry = [String: Any] - -enum WindowListEntryKeys { - static let isMinimized = "kCGWindowIsMinimized" -} - -protocol WindowListProviding: Sendable { - func windowList(options: CGWindowListOption, windowIdentifier: CGWindowID) -> [WindowListEntry]? -} - -struct SystemWindowListProvider: WindowListProviding { - func windowList(options: CGWindowListOption, windowIdentifier: CGWindowID) -> [WindowListEntry]? { - CGWindowListCopyWindowInfo(options, windowIdentifier) as? [WindowListEntry] - } -} diff --git a/Sources/WindowKit/Snapshots/WindowListSnapshotMapper.swift b/Sources/WindowKit/Snapshots/WindowListSnapshotMapper.swift deleted file mode 100644 index d6143de..0000000 --- a/Sources/WindowKit/Snapshots/WindowListSnapshotMapper.swift +++ /dev/null @@ -1,88 +0,0 @@ -import CoreGraphics -import Foundation - -struct WindowListSnapshotMapper: Sendable { - private let allowedLayers: Set - private let ownerBundleIdentifierProvider: any WindowOwnerBundleIdentifierProviding - private let filter: WindowSnapshotFilter - - init( - allowedLayers: Set = [0], - ownerBundleIdentifierProvider: any WindowOwnerBundleIdentifierProviding = - NullWindowOwnerBundleIdentifierProvider(), - filter: WindowSnapshotFilter = .init() - ) { - self.allowedLayers = allowedLayers - self.ownerBundleIdentifierProvider = ownerBundleIdentifierProvider - self.filter = filter - } - - func snapshot(from entry: WindowListEntry, containing point: CGPoint? = nil) -> WindowSnapshot? { - guard let layerValue = entry[kCGWindowLayer as String] as? NSNumber, - let boundsDictionary = entry[kCGWindowBounds as String] as? NSDictionary, - let rawBounds = CGRect(dictionaryRepresentation: boundsDictionary), - let ownerProcessIdentifierValue = entry[kCGWindowOwnerPID as String] as? NSNumber, - let windowIdentifierValue = entry[kCGWindowNumber as String] as? NSNumber - else { - return nil - } - - let frame = rawBounds.standardized - let layer = layerValue.intValue - guard allowedLayers.contains(layer) else { return nil } - if let point, !frame.contains(point) { - return nil - } - - let ownerProcessIdentifier = pid_t(ownerProcessIdentifierValue.int32Value) - let windowIdentifier = CGWindowID(windowIdentifierValue.uint32Value) - let isMinimized = (entry[WindowListEntryKeys.isMinimized] as? NSNumber)?.boolValue - - if !filter.includedOwnerProcessIdentifiers.isEmpty, - !filter.includedOwnerProcessIdentifiers.contains(ownerProcessIdentifier) - { - return nil - } - if !filter.includedWindowIdentifiers.isEmpty, - !filter.includedWindowIdentifiers.contains(windowIdentifier) - { - return nil - } - if filter.excludedOwnerProcessIdentifiers.contains(ownerProcessIdentifier) { - return nil - } - if filter.excludedWindowIdentifiers.contains(windowIdentifier) { - return nil - } - if filter.excludesMinimizedWindows, isMinimized == true { - return nil - } - - let ownerBundleIdentifier = ownerBundleIdentifierProvider.bundleIdentifier( - forProcessIdentifier: ownerProcessIdentifier) - - if !filter.includedOwnerBundleIdentifiers.isEmpty { - guard let ownerBundleIdentifier, - filter.includedOwnerBundleIdentifiers.contains(ownerBundleIdentifier) - else { - return nil - } - } - if let ownerBundleIdentifier, - filter.excludedOwnerBundleIdentifiers.contains(ownerBundleIdentifier) - { - return nil - } - - return WindowSnapshot( - ownerProcessIdentifier: ownerProcessIdentifier, - windowIdentifier: windowIdentifier, - ownerName: entry[kCGWindowOwnerName as String] as? String, - ownerBundleIdentifier: ownerBundleIdentifier, - title: entry[kCGWindowName as String] as? String, - frame: frame, - layer: layer, - isMinimized: isMinimized - ) - } -} diff --git a/Sources/WindowKit/Snapshots/WindowOwnerBundleIdentifierProvider.swift b/Sources/WindowKit/Snapshots/WindowOwnerBundleIdentifierProvider.swift deleted file mode 100644 index b06cbb8..0000000 --- a/Sources/WindowKit/Snapshots/WindowOwnerBundleIdentifierProvider.swift +++ /dev/null @@ -1,18 +0,0 @@ -import Darwin - -/// Resolves an owner bundle identifier for a process identifier. -public protocol WindowOwnerBundleIdentifierProviding: Sendable { - /// Returns the bundle identifier for the given process identifier, when available. - func bundleIdentifier(forProcessIdentifier pid: pid_t) -> String? -} - -/// Bundle identifier provider that always returns `nil`. -public struct NullWindowOwnerBundleIdentifierProvider: WindowOwnerBundleIdentifierProviding { - /// Creates a null bundle identifier provider. - public init() {} - - /// Always returns `nil`. - public func bundleIdentifier(forProcessIdentifier _: pid_t) -> String? { - nil - } -} diff --git a/Sources/WindowKit/Snapshots/WindowSnapshot.swift b/Sources/WindowKit/Snapshots/WindowSnapshot.swift index c03cf7f..4196f0c 100644 --- a/Sources/WindowKit/Snapshots/WindowSnapshot.swift +++ b/Sources/WindowKit/Snapshots/WindowSnapshot.swift @@ -1,6 +1,6 @@ import CoreGraphics -/// Immutable app-facing representation of a macOS window list entry. +/// Immutable app-facing representation of a macOS window. public struct WindowSnapshot: Equatable, Hashable, Sendable, Codable { /// Process identifier of the owning application. public let ownerProcessIdentifier: pid_t @@ -12,13 +12,10 @@ public struct WindowSnapshot: Equatable, Hashable, Sendable, Codable { public let ownerBundleIdentifier: String? /// Window title, when available. public let title: String? - /// Window frame in global screen coordinates. + /// Standardized window frame in global screen coordinates. + /// + /// Non-finite components are replaced with finite zero-origin bounds. public let frame: CGRect - /// Core Graphics window layer. - public let layer: Int - /// Whether the window is minimized, when the system window list includes that value. - public let isMinimized: Bool? - /// Creates an immutable window snapshot. public init( ownerProcessIdentifier: pid_t, @@ -26,17 +23,13 @@ public struct WindowSnapshot: Equatable, Hashable, Sendable, Codable { ownerName: String? = nil, ownerBundleIdentifier: String? = nil, title: String? = nil, - frame: CGRect, - layer: Int, - isMinimized: Bool? = nil + frame: CGRect ) { self.ownerProcessIdentifier = ownerProcessIdentifier self.windowIdentifier = windowIdentifier self.ownerName = ownerName self.ownerBundleIdentifier = ownerBundleIdentifier self.title = title - self.frame = frame - self.layer = layer - self.isMinimized = isMinimized + self.frame = frame.finiteStandardized } } diff --git a/Sources/WindowKit/Snapshots/WindowSnapshotFilter.swift b/Sources/WindowKit/Snapshots/WindowSnapshotFilter.swift index 245e10f..c9b98dc 100644 --- a/Sources/WindowKit/Snapshots/WindowSnapshotFilter.swift +++ b/Sources/WindowKit/Snapshots/WindowSnapshotFilter.swift @@ -1,6 +1,6 @@ import CoreGraphics -/// Filters applied when converting system window list entries into ``WindowSnapshot`` values. +/// Filters applied to ``WindowSnapshot`` values produced by query and hit-testing adapters. public struct WindowSnapshotFilter: Equatable, Hashable, Sendable, Codable { /// Owner bundle identifiers to include in results. /// @@ -10,26 +10,24 @@ public struct WindowSnapshotFilter: Equatable, Hashable, Sendable, Codable { /// /// Empty means all process identifiers are included. public let includedOwnerProcessIdentifiers: Set - /// Window identifiers to include in results. Empty means all window identifiers are included. + /// Window identifiers to include in results. + /// + /// Empty means all window identifiers are included. public let includedWindowIdentifiers: Set - /// Owner bundle identifiers to exclude from results. + /// Owner bundle identifiers to exclude from results after include filters are applied. public let excludedOwnerBundleIdentifiers: Set - /// Owner process identifiers to exclude from results. + /// Owner process identifiers to exclude from results after include filters are applied. public let excludedOwnerProcessIdentifiers: Set - /// Window identifiers to exclude from results. + /// Window identifiers to exclude from results after include filters are applied. public let excludedWindowIdentifiers: Set - /// Whether minimized windows should be excluded. - public let excludesMinimizedWindows: Bool - - /// Creates a filter for window list queries and hit-testing. + /// Creates a filter for snapshot queries and hit-testing. public init( includedOwnerBundleIdentifiers: Set = [], includedOwnerProcessIdentifiers: Set = [], includedWindowIdentifiers: Set = [], excludedOwnerBundleIdentifiers: Set = [], excludedOwnerProcessIdentifiers: Set = [], - excludedWindowIdentifiers: Set = [], - excludesMinimizedWindows: Bool = false + excludedWindowIdentifiers: Set = [] ) { self.includedOwnerBundleIdentifiers = includedOwnerBundleIdentifiers self.includedOwnerProcessIdentifiers = includedOwnerProcessIdentifiers @@ -37,6 +35,38 @@ public struct WindowSnapshotFilter: Equatable, Hashable, Sendable, Codable { self.excludedOwnerBundleIdentifiers = excludedOwnerBundleIdentifiers self.excludedOwnerProcessIdentifiers = excludedOwnerProcessIdentifiers self.excludedWindowIdentifiers = excludedWindowIdentifiers - self.excludesMinimizedWindows = excludesMinimizedWindows + } + + /// Returns whether the snapshot satisfies this filter. + public func includes(_ snapshot: WindowSnapshot) -> Bool { + if !includedOwnerProcessIdentifiers.isEmpty, + !includedOwnerProcessIdentifiers.contains(snapshot.ownerProcessIdentifier) + { + return false + } + if !includedWindowIdentifiers.isEmpty, + !includedWindowIdentifiers.contains(snapshot.windowIdentifier) + { + return false + } + if !includedOwnerBundleIdentifiers.isEmpty { + guard let ownerBundleIdentifier = snapshot.ownerBundleIdentifier, + includedOwnerBundleIdentifiers.contains(ownerBundleIdentifier) + else { + return false + } + } + if excludedOwnerProcessIdentifiers.contains(snapshot.ownerProcessIdentifier) { + return false + } + if excludedWindowIdentifiers.contains(snapshot.windowIdentifier) { + return false + } + if let ownerBundleIdentifier = snapshot.ownerBundleIdentifier, + excludedOwnerBundleIdentifiers.contains(ownerBundleIdentifier) + { + return false + } + return true } } diff --git a/Sources/WindowKit/Snapshots/WindowSnapshotProviding.swift b/Sources/WindowKit/Snapshots/WindowSnapshotProviding.swift index 4f2dcdc..785eabe 100644 --- a/Sources/WindowKit/Snapshots/WindowSnapshotProviding.swift +++ b/Sources/WindowKit/Snapshots/WindowSnapshotProviding.swift @@ -1,10 +1,5 @@ -import CoreGraphics - /// App-facing abstraction for reading immutable window snapshots. public protocol WindowSnapshotProviding: Sendable { - /// Reads the current system window list and returns matching snapshots. + /// Reads the current window snapshots. func snapshots() throws -> [WindowSnapshot] - - /// Reads a snapshot for a known Core Graphics window identifier. - func snapshot(for windowIdentifier: CGWindowID) throws -> WindowSnapshot? } diff --git a/Sources/WindowKit/Snapshots/WindowSnapshotResolving.swift b/Sources/WindowKit/Snapshots/WindowSnapshotResolving.swift new file mode 100644 index 0000000..cfbd2aa --- /dev/null +++ b/Sources/WindowKit/Snapshots/WindowSnapshotResolving.swift @@ -0,0 +1,7 @@ +import CoreGraphics + +/// App-facing abstraction for resolving one immutable window snapshot by identifier. +public protocol WindowSnapshotResolving: Sendable { + /// Reads a snapshot for a known window identifier. + func snapshot(for windowIdentifier: CGWindowID) throws -> WindowSnapshot? +} diff --git a/Sources/WindowKit/WindowKit.docc/WindowKit.md b/Sources/WindowKit/WindowKit.docc/WindowKit.md index 3b49df6..a020252 100644 --- a/Sources/WindowKit/WindowKit.docc/WindowKit.md +++ b/Sources/WindowKit/WindowKit.docc/WindowKit.md @@ -1,84 +1,111 @@ # ``WindowKit`` -Core Graphics-backed window-list querying and value models for hit-testing, placement, and -describing macOS windows. +Value models, protocols, hit-testing contracts, and placement geometry for describing macOS +windows. ## Overview -Use WindowKit when you want reusable window-list and geometry primitives: +Use WindowKit when you want reusable window value and geometry primitives: -- ``WindowSnapshot`` and ``WindowSnapshotQuery`` for window metadata. -- ``WindowHitTester`` for point-based selection. +- ``WindowSnapshot`` and ``WindowSnapshotFilter`` for window metadata and filtering contracts. +- ``WindowHitTesting`` and its result models for provider-backed point selection. - ``WindowPlacement`` and ``WindowPlacementCalculator`` for deterministic geometry. - ``WindowScreenSelector`` for deterministic screen ordering and target resolution. - ``WindowScreenMovement`` for deterministic screen-to-screen movement geometry. - ``WindowTarget`` and control/provider protocols for host-app or platform-specific implementations. -- ``WindowSnapshotProviding`` and ``WindowHitTesting`` for testable host-app services. +- ``WindowSnapshotProviding``, ``WindowSnapshotResolving``, and ``WindowHitTesting`` for host-app + window services. -Window snapshots are immutable app-facing models for filtering, hit-testing, placement decisions, -and deterministic tests without holding live system references. Snapshots include minimized -metadata when macOS provides it. -``WindowSnapshotQuery/snapshots()`` throws ``WindowSnapshotQueryError/listUnavailable`` when macOS -does not return a readable window list. -``WindowSnapshotQuery/snapshot(for:)`` reads a single known Core Graphics window identifier and -still applies the query filter. +Live query implementations live in `WindowKitAppKit`. WindowKit keeps the reusable value models, +provider protocols, and deterministic geometry helpers platform-neutral. -``WindowSnapshotFilter`` include sets act as allow-lists when non-empty. Exclude sets are applied -after include sets. ``WindowSnapshotQuery`` can also keep the first matching entry in the order -returned by the macOS window list. +Create snapshots in tests or host-owned providers without retaining live system objects: -``WindowPlacementCalculator`` clamps insets to the available area. Grid placements clamp invalid -columns, rows, and spans into the available grid. Grid columns start from the left, and rows start -from the bottom in the selected screen-area coordinate space. Centered placements clamp width and -height ratios to `0...1`. +```swift +import CoreGraphics +import WindowKit -``WindowScreenTarget/containingWindow`` selects the screen containing the target window center. -WindowKit screen-selection primitives support forgiving fallback semantics so host apps can decide -whether a missing screen is recoverable or exceptional at their integration boundary. +let snapshot = WindowSnapshot( + ownerProcessIdentifier: 42, + windowIdentifier: 1001, + ownerName: "Editor", + ownerBundleIdentifier: "com.example.editor", + title: "Document", + frame: CGRect(x: 120, y: 80, width: 900, height: 640) +) +``` -WindowKit does not request permissions, install event monitors, persist rules, run shortcuts, or own -user interface. -Those responsibilities stay in the host app. +## Responsibility Boundary + +WindowKit owns window snapshots, query filters, hit-testing protocols and models, placement +geometry, screen-selection primitives, and control/provider protocols. + +WindowKit does not request permissions, install event monitors, persist rules, +run shortcuts, own user interface, collect analytics, transmit data, or store +window history. Those responsibilities stay in the host app. ## Topics ### Querying -- ``WindowSnapshotQuery`` - ``WindowSnapshotProviding`` -- ``WindowSnapshotQueryError`` +- ``WindowSnapshotResolving`` - ``WindowSnapshotFilter`` +- ``WindowSnapshotFilter/init(includedOwnerBundleIdentifiers:includedOwnerProcessIdentifiers:includedWindowIdentifiers:excludedOwnerBundleIdentifiers:excludedOwnerProcessIdentifiers:excludedWindowIdentifiers:)`` +- ``WindowSnapshotFilter/includes(_:)`` - ``WindowSnapshot`` -- ``WindowOwnerBundleIdentifierProviding`` -- ``NullWindowOwnerBundleIdentifierProvider`` +- ``WindowSnapshot/init(ownerProcessIdentifier:windowIdentifier:ownerName:ownerBundleIdentifier:title:frame:)`` ### Hit Testing -- ``WindowHitTester`` - ``WindowHitTesting`` +- ``WindowHitTesting/hitTest(at:)`` +- ``WindowHitTesting/topmost(at:)`` - ``WindowHitTestResult`` +- ``WindowHitTestResult/snapshot`` +- ``WindowHitTestResult/missReason`` - ``WindowHitTestMissReason`` ### Placement - ``WindowPlacement`` - ``WindowPlacementCalculator`` +- ``WindowPlacementCalculator/frame(for:in:inset:)`` ### Screens - ``WindowScreen`` +- ``WindowScreen/init(displayIdentifier:frame:visibleFrame:isMain:)`` +- ``WindowScreen/frame(for:)`` - ``WindowScreenProviding`` +- ``WindowScreenProviding/screens()`` - ``WindowScreenSelector`` +- ``WindowScreenSelector/orderedScreens(from:)`` +- ``WindowScreenSelector/screen(for:windowFrame:in:fallback:)`` +- ``WindowScreenSelector/index(containing:in:)`` +- ``WindowScreenSelector/adjacentIndex(from:direction:screenCount:)`` - ``WindowScreenMovement`` +- ``WindowScreenMovement/centerPreservingSize(windowFrame:in:)`` +- ``WindowScreenMovement/preservingRelativePosition(windowFrame:from:to:)`` - ``WindowScreenTarget`` - ``WindowScreenArea`` +- ``WindowScreenFallback`` - ``WindowScreenCycleDirection`` ### Control and Provider Protocols - ``WindowTarget`` +- ``WindowControlling`` - ``WindowFrameProviding`` +- ``WindowFrameProviding/frame(for:)`` - ``WindowFrameControlling`` +- ``WindowFrameControlling/setFrame(_:for:)`` - ``WindowCommandControlling`` +- ``WindowCommandControlling/setMinimized(_:isMinimized:)`` +- ``WindowCommandControlling/setFullScreen(_:isFullScreen:)`` +- ``WindowCommandControlling/raise(_:)`` +- ``WindowCommandControlling/lower(_:)`` - ``WindowState`` +- ``WindowState/init(isMinimized:isFullScreen:)`` - ``WindowStateProviding`` +- ``WindowStateProviding/state(for:)`` diff --git a/Sources/WindowKitAppKit/Accessibility/AXAPI.swift b/Sources/WindowKitAppKit/Accessibility/AXAPI.swift deleted file mode 100644 index a3910f6..0000000 --- a/Sources/WindowKitAppKit/Accessibility/AXAPI.swift +++ /dev/null @@ -1,16 +0,0 @@ -import ApplicationServices - -@MainActor -enum AXAPI { - static var isProcessTrusted: () -> Bool = AXIsProcessTrusted - static var createSystemWideElement: () -> AXUIElement = AXUIElementCreateSystemWide - static var createApplicationElement: (pid_t) -> AXUIElement = AXUIElementCreateApplication - static var copyAttributeValue: - (AXUIElement, CFString, UnsafeMutablePointer) - -> AXError = AXUIElementCopyAttributeValue - static var setAttributeValue: (AXUIElement, CFString, CFTypeRef) -> AXError = - AXUIElementSetAttributeValue - static var valueGet: (AXValue, AXValueType, UnsafeMutableRawPointer) -> Bool = AXValueGetValue - static var valueCreate: (AXValueType, UnsafeRawPointer) -> AXValue? = AXValueCreate - static var performAction: (AXUIElement, CFString) -> AXError = AXUIElementPerformAction -} diff --git a/Sources/WindowKitAppKit/Accessibility/AXClient+UIElement.swift b/Sources/WindowKitAppKit/Accessibility/AXClient+UIElement.swift new file mode 100644 index 0000000..d78707e --- /dev/null +++ b/Sources/WindowKitAppKit/Accessibility/AXClient+UIElement.swift @@ -0,0 +1,180 @@ +import ApplicationServices +import CoreGraphics + +@MainActor +extension AXClient { + func attributeElement(_ attribute: AXWindowAttribute, of element: AXUIElement) throws + -> AXUIElement? + { + guard let value = try optionalAttributeValue(attribute, of: element) else { return nil } + guard CFGetTypeID(value) == AXUIElementGetTypeID() else { + throw AXWindowError.invalidAXValue + } + return typedReference(value, as: AXUIElement.self) + } + + func attributeElements(_ attribute: AXWindowAttribute, of element: AXUIElement) throws + -> [AXUIElement]? + { + guard let value = try optionalAttributeValue(attribute, of: element) else { return nil } + guard CFGetTypeID(value) == CFArrayGetTypeID() else { + throw AXWindowError.invalidAXValue + } + guard let array = value as? [AXUIElement] else { + throw AXWindowError.invalidAXValue + } + return array + } + + func attributeUInt32(_ attribute: AXWindowAttribute, of element: AXUIElement) throws -> UInt32? { + guard let value = try optionalAttributeValue(attribute, of: element) else { return nil } + guard CFGetTypeID(value) == CFNumberGetTypeID() else { + throw AXWindowError.invalidAXValue + } + guard let number = value as? NSNumber else { + throw AXWindowError.invalidAXValue + } + return try exactUInt32(number) + } + + func attributeString(_ attribute: AXWindowAttribute, of element: AXUIElement) throws -> String? { + guard let value = try optionalAttributeValue(attribute, of: element) else { return nil } + guard let string = value as? String else { + throw AXWindowError.invalidAXValue + } + return string + } + + func attributeBool(_ attribute: AXWindowAttribute, of element: AXUIElement) throws -> Bool? { + guard let value = try optionalAttributeValue(attribute, of: element) else { return nil } + guard CFGetTypeID(value) == CFBooleanGetTypeID() else { + throw AXWindowError.invalidAXValue + } + guard let boolValue = value as? Bool else { + throw AXWindowError.invalidAXValue + } + return boolValue + } + + func attributeCGPoint(_ attribute: AXWindowAttribute, of element: AXUIElement) throws -> CGPoint { + let value = try attributeAXValue(attribute, of: element) + var point = CGPoint.zero + guard valueGet(value, .cgPoint, &point) else { + throw AXWindowError.invalidAXValue + } + guard point.x.isFinite, point.y.isFinite else { + throw AXWindowError.invalidAXValue + } + return point + } + + func attributeCGSize(_ attribute: AXWindowAttribute, of element: AXUIElement) throws -> CGSize { + let value = try attributeAXValue(attribute, of: element) + var size = CGSize.zero + guard valueGet(value, .cgSize, &size) else { + throw AXWindowError.invalidAXValue + } + guard size.width.isFinite, size.height.isFinite else { + throw AXWindowError.invalidAXValue + } + return size + } + + func performAction(_ action: AXWindowAction, on element: AXUIElement) throws { + let error = performAction(element, action.accessibilityName as CFString) + if error == .actionUnsupported { + throw AXWindowError.unsupportedOperation + } + guard error == .success else { + throw AXWindowError.underlyingAccessibilityError(error) + } + } + + func setAttributeBool(_ attribute: AXWindowAttribute, value: Bool, on element: AXUIElement) throws + { + let boolValue: CFBoolean = value ? kCFBooleanTrue : kCFBooleanFalse + let error = setAttributeValue(element, attribute.accessibilityName as CFString, boolValue) + guard error == .success else { + throw AXWindowError.underlyingAccessibilityError(error) + } + } + + func setAttributeValue(_ attribute: AXWindowAttribute, value: AXValue, on element: AXUIElement) + throws + { + let error = setAttributeValue(element, attribute.accessibilityName as CFString, value) + guard error == .success else { + throw AXWindowError.underlyingAccessibilityError(error) + } + } + + private func attributeAXValue(_ attribute: AXWindowAttribute, of element: AXUIElement) throws + -> AXValue + { + let value = try requiredAttributeValue(attribute, of: element) + guard CFGetTypeID(value) == AXValueGetTypeID() else { + throw AXWindowError.invalidAXValue + } + return typedReference(value, as: AXValue.self) + } + + private func typedReference( + _ value: CFTypeRef, + as _: Value.Type + ) -> Value { + Unmanaged + .fromOpaque(Unmanaged.passUnretained(value).toOpaque()) + .takeUnretainedValue() + } + + private func exactUInt32(_ number: NSNumber) throws -> UInt32 { + guard + number.compare(NSNumber(value: UInt32.min)) != .orderedAscending, + number.compare(NSNumber(value: UInt32.max)) != .orderedDescending + else { + throw AXWindowError.invalidAXValue + } + + let value = number.uint32Value + guard NSNumber(value: value).compare(number) == .orderedSame else { + throw AXWindowError.invalidAXValue + } + return value + } + + private func optionalAttributeValue(_ attribute: AXWindowAttribute, of element: AXUIElement) + throws + -> CFTypeRef? + { + let result = copyAttributeValue(attribute, of: element) + if result.error == .attributeUnsupported || result.error == .noValue { + return nil + } + guard result.error == .success else { + throw AXWindowError.underlyingAccessibilityError(result.error) + } + return result.value + } + + private func requiredAttributeValue(_ attribute: AXWindowAttribute, of element: AXUIElement) + throws + -> CFTypeRef + { + let result = copyAttributeValue(attribute, of: element) + guard result.error == .success else { + throw AXWindowError.underlyingAccessibilityError(result.error) + } + guard let value = result.value else { + throw AXWindowError.invalidAXValue + } + return value + } + + private func copyAttributeValue(_ attribute: AXWindowAttribute, of element: AXUIElement) -> ( + error: AXError, value: CFTypeRef? + ) { + var value: CFTypeRef? + let error = copyAttributeValue(element, attribute.accessibilityName as CFString, &value) + return (error, value) + } +} diff --git a/Sources/WindowKitAppKit/Accessibility/AXClient.swift b/Sources/WindowKitAppKit/Accessibility/AXClient.swift new file mode 100644 index 0000000..ac1662b --- /dev/null +++ b/Sources/WindowKitAppKit/Accessibility/AXClient.swift @@ -0,0 +1,53 @@ +import ApplicationServices + +@MainActor +struct AXClient { + let isProcessTrusted: () -> Bool + let createSystemWideElement: () -> AXUIElement + let createApplicationElement: (pid_t) -> AXUIElement + let copyAttributeValue: + (AXUIElement, CFString, UnsafeMutablePointer) + -> AXError + let setAttributeValue: (AXUIElement, CFString, CFTypeRef) -> AXError + let valueGet: (AXValue, AXValueType, UnsafeMutableRawPointer) -> Bool + let valueCreate: (AXValueType, UnsafeRawPointer) -> AXValue? + let performAction: (AXUIElement, CFString) -> AXError + + static let live = AXClient( + isProcessTrusted: AXIsProcessTrusted, + createSystemWideElement: AXUIElementCreateSystemWide, + createApplicationElement: AXUIElementCreateApplication, + copyAttributeValue: AXUIElementCopyAttributeValue, + setAttributeValue: AXUIElementSetAttributeValue, + valueGet: AXValueGetValue, + valueCreate: AXValueCreate, + performAction: AXUIElementPerformAction + ) + + init( + isProcessTrusted: @escaping () -> Bool = AXIsProcessTrusted, + createSystemWideElement: @escaping () -> AXUIElement = AXUIElementCreateSystemWide, + createApplicationElement: @escaping (pid_t) -> AXUIElement = AXUIElementCreateApplication, + copyAttributeValue: + @escaping ( + AXUIElement, + CFString, + UnsafeMutablePointer + ) -> AXError = AXUIElementCopyAttributeValue, + setAttributeValue: @escaping (AXUIElement, CFString, CFTypeRef) -> AXError = + AXUIElementSetAttributeValue, + valueGet: @escaping (AXValue, AXValueType, UnsafeMutableRawPointer) -> Bool = + AXValueGetValue, + valueCreate: @escaping (AXValueType, UnsafeRawPointer) -> AXValue? = AXValueCreate, + performAction: @escaping (AXUIElement, CFString) -> AXError = AXUIElementPerformAction + ) { + self.isProcessTrusted = isProcessTrusted + self.createSystemWideElement = createSystemWideElement + self.createApplicationElement = createApplicationElement + self.copyAttributeValue = copyAttributeValue + self.setAttributeValue = setAttributeValue + self.valueGet = valueGet + self.valueCreate = valueCreate + self.performAction = performAction + } +} diff --git a/Sources/WindowKitAppKit/Accessibility/AXUIElementAttributes.swift b/Sources/WindowKitAppKit/Accessibility/AXUIElementAttributes.swift deleted file mode 100644 index 6864d78..0000000 --- a/Sources/WindowKitAppKit/Accessibility/AXUIElementAttributes.swift +++ /dev/null @@ -1,129 +0,0 @@ -import ApplicationServices -import CoreGraphics - -@MainActor -extension AXUIElement { - func attributeElement(_ attribute: String) throws -> AXUIElement? { - guard let value = try optionalAttributeValue(attribute) else { return nil } - guard CFGetTypeID(value) == AXUIElementGetTypeID() else { - throw AXWindowError.invalidAXValue - } - return unsafeDowncast(value, to: AXUIElement.self) - } - - func attributeElements(_ attribute: String) throws -> [AXUIElement]? { - guard let value = try optionalAttributeValue(attribute) else { return nil } - guard CFGetTypeID(value) == CFArrayGetTypeID() else { - throw AXWindowError.invalidAXValue - } - guard let array = value as? [AXUIElement] else { - throw AXWindowError.invalidAXValue - } - return array - } - - func attributeUInt32(_ attribute: String) throws -> UInt32? { - guard let value = try optionalAttributeValue(attribute) else { return nil } - guard let number = value as? NSNumber else { - throw AXWindowError.invalidAXValue - } - return number.uint32Value - } - - func attributeString(_ attribute: String) throws -> String? { - guard let value = try optionalAttributeValue(attribute) else { return nil } - guard let string = value as? String else { - throw AXWindowError.invalidAXValue - } - return string - } - - func attributeBool(_ attribute: String) throws -> Bool? { - guard let value = try optionalAttributeValue(attribute) else { return nil } - guard CFGetTypeID(value) == CFBooleanGetTypeID() else { - throw AXWindowError.invalidAXValue - } - return CFBooleanGetValue(unsafeDowncast(value, to: CFBoolean.self)) - } - - func attributeCGPoint(_ attribute: String) throws -> CGPoint { - let value = try attributeAXValue(attribute) - var point = CGPoint.zero - guard AXAPI.valueGet(value, .cgPoint, &point) else { - throw AXWindowError.invalidAXValue - } - return point - } - - func attributeCGSize(_ attribute: String) throws -> CGSize { - let value = try attributeAXValue(attribute) - var size = CGSize.zero - guard AXAPI.valueGet(value, .cgSize, &size) else { - throw AXWindowError.invalidAXValue - } - return size - } - - func performAction(_ action: String) throws { - let error = AXAPI.performAction(self, action as CFString) - if error == .actionUnsupported { - throw AXWindowError.unsupportedAction(action) - } - guard error == .success else { - throw AXWindowError.underlyingAccessibilityError(error) - } - } - - func setAttributeBool(_ attribute: String, value: Bool) throws { - let boolValue: CFBoolean = value ? kCFBooleanTrue : kCFBooleanFalse - let error = AXAPI.setAttributeValue(self, attribute as CFString, boolValue) - guard error == .success else { - throw AXWindowError.underlyingAccessibilityError(error) - } - } - - func setAttributeValue(_ attribute: String, value: AXValue) throws { - let error = AXAPI.setAttributeValue(self, attribute as CFString, value) - guard error == .success else { - throw AXWindowError.underlyingAccessibilityError(error) - } - } - - private func attributeAXValue(_ attribute: String) throws -> AXValue { - let value = try requiredAttributeValue(attribute) - guard CFGetTypeID(value) == AXValueGetTypeID() else { - throw AXWindowError.invalidAXValue - } - return unsafeDowncast(value, to: AXValue.self) - } - - private func optionalAttributeValue(_ attribute: String) throws -> CFTypeRef? { - let result = copyAttributeValue(attribute) - if result.error == .attributeUnsupported || result.error == .noValue { - return nil - } - guard result.error == .success else { - throw AXWindowError.underlyingAccessibilityError(result.error) - } - return result.value - } - - private func requiredAttributeValue(_ attribute: String) throws -> CFTypeRef { - let result = copyAttributeValue(attribute) - guard result.error == .success else { - throw AXWindowError.underlyingAccessibilityError(result.error) - } - guard let value = result.value else { - throw AXWindowError.invalidAXValue - } - return value - } - - private func copyAttributeValue(_ attribute: String) -> ( - error: AXError, value: CFTypeRef? - ) { - var value: CFTypeRef? - let error = AXAPI.copyAttributeValue(self, attribute as CFString, &value) - return (error, value) - } -} diff --git a/Sources/WindowKitAppKit/Accessibility/AXValueConversion.swift b/Sources/WindowKitAppKit/Accessibility/AXValueConversion.swift index b7d98a9..c3be8a8 100644 --- a/Sources/WindowKitAppKit/Accessibility/AXValueConversion.swift +++ b/Sources/WindowKitAppKit/Accessibility/AXValueConversion.swift @@ -3,9 +3,12 @@ import CoreGraphics @MainActor extension CGPoint { - func axValue() throws -> AXValue { + func axValue(using accessibilityClient: AXClient) throws -> AXValue { + guard x.isFinite, y.isFinite else { + throw AXWindowError.invalidAXValue + } var point = self - let value = withUnsafePointer(to: &point) { AXAPI.valueCreate(.cgPoint, $0) } + let value = withUnsafePointer(to: &point) { accessibilityClient.valueCreate(.cgPoint, $0) } guard let value else { throw AXWindowError.invalidAXValue } @@ -15,9 +18,12 @@ extension CGPoint { @MainActor extension CGSize { - func axValue() throws -> AXValue { + func axValue(using accessibilityClient: AXClient) throws -> AXValue { + guard width.isFinite, height.isFinite else { + throw AXWindowError.invalidAXValue + } var size = self - let value = withUnsafePointer(to: &size) { AXAPI.valueCreate(.cgSize, $0) } + let value = withUnsafePointer(to: &size) { accessibilityClient.valueCreate(.cgSize, $0) } guard let value else { throw AXWindowError.invalidAXValue } diff --git a/Sources/WindowKitAppKit/Accessibility/AXWindowAccess.swift b/Sources/WindowKitAppKit/Accessibility/AXWindowAccess.swift new file mode 100644 index 0000000..d8662e3 --- /dev/null +++ b/Sources/WindowKitAppKit/Accessibility/AXWindowAccess.swift @@ -0,0 +1,38 @@ +import ApplicationServices + +@MainActor +struct AXWindowAccess { + let accessibilityClient: AXClient + let resolver: AXWindowElementResolver + + static func live() -> AXWindowAccess { + let accessibilityClient = AXClient.live + return AXWindowAccess( + accessibilityClient: accessibilityClient, + resolver: AXWindowElementResolver(accessibilityClient: accessibilityClient) + ) + } + + init( + accessibilityClient: AXClient = .live, + systemWideElement: AXUIElement, + applicationChecker: any RunningApplicationChecking = + NSRunningApplicationChecker(), + windowListProvider: any WindowListProviding = SystemWindowListProvider() + ) { + self.init( + accessibilityClient: accessibilityClient, + resolver: AXWindowElementResolver( + accessibilityClient: accessibilityClient, + systemWideElement: systemWideElement, + applicationChecker: applicationChecker, + windowListProvider: windowListProvider + ) + ) + } + + private init(accessibilityClient: AXClient, resolver: AXWindowElementResolver) { + self.accessibilityClient = accessibilityClient + self.resolver = resolver + } +} diff --git a/Sources/WindowKitAppKit/Accessibility/AXWindowCommandController.swift b/Sources/WindowKitAppKit/Accessibility/AXWindowCommandController.swift deleted file mode 100644 index 5e7ab9f..0000000 --- a/Sources/WindowKitAppKit/Accessibility/AXWindowCommandController.swift +++ /dev/null @@ -1,53 +0,0 @@ -import ApplicationServices -import WindowKit - -/// Controls live macOS window commands through Accessibility actions and attributes. -@MainActor -public struct AXWindowCommandController: WindowCommandControlling { - private let resolver: AXWindowElementResolver - - /// Creates a command controller backed by the system-wide Accessibility element. - public init() { - self.init( - systemWideElement: AXAPI.createSystemWideElement(), - applicationChecker: NSRunningApplicationChecker() - ) - } - - init( - systemWideElement: AXUIElement, - applicationChecker: any RunningApplicationChecking = - NSRunningApplicationChecker(), - windowListInfoProvider: any CGWindowListInfoProviding = SystemCGWindowListInfoProvider() - ) { - resolver = AXWindowElementResolver( - systemWideElement: systemWideElement, - applicationChecker: applicationChecker, - windowListInfoProvider: windowListInfoProvider - ) - } - - /// Sets the target window's minimized state. - public func setMinimized(_ target: WindowTarget, isMinimized: Bool) throws { - let window = try resolver.windowElement(for: target) - try window.setAttributeBool(kAXMinimizedAttribute, value: isMinimized) - } - - /// Sets the target window's full-screen state. - public func setFullScreen(_ target: WindowTarget, isFullScreen: Bool) throws { - let window = try resolver.windowElement(for: target) - try window.setAttributeBool(AXWindowAttributeName.fullScreen, value: isFullScreen) - } - - /// Raises the target window above other windows when the system allows it. - public func raise(_ target: WindowTarget) throws { - let window = try resolver.windowElement(for: target) - try window.performAction(kAXRaiseAction) - } - - /// Lowers the target window when the system exposes the Accessibility action. - public func lower(_ target: WindowTarget) throws { - let window = try resolver.windowElement(for: target) - try window.performAction(AXWindowActionName.lower) - } -} diff --git a/Sources/WindowKitAppKit/Accessibility/AXWindowController.swift b/Sources/WindowKitAppKit/Accessibility/AXWindowController.swift new file mode 100644 index 0000000..494a607 --- /dev/null +++ b/Sources/WindowKitAppKit/Accessibility/AXWindowController.swift @@ -0,0 +1,121 @@ +import ApplicationServices +import CoreGraphics +import WindowKit + +/// Reads and controls live macOS windows through Accessibility. +@MainActor +public struct AXWindowController: WindowControlling, WindowRoleProviding { + private let access: AXWindowAccess + private let roleResolver = WindowRoleResolver() + + /// Creates a controller backed by the system-wide Accessibility element. + public init() { + access = .live() + } + + init( + accessibilityClient: AXClient = .live, + systemWideElement: AXUIElement, + applicationChecker: any RunningApplicationChecking = + NSRunningApplicationChecker(), + windowListProvider: any WindowListProviding = SystemWindowListProvider() + ) { + access = AXWindowAccess( + accessibilityClient: accessibilityClient, + systemWideElement: systemWideElement, + applicationChecker: applicationChecker, + windowListProvider: windowListProvider + ) + } + + /// Returns the current frame of the target window. + public func frame(for target: WindowTarget) throws -> CGRect { + try translateAXWindowErrors(for: .readFrame) { + let window = try access.resolver.windowElement(for: target) + let position = try access.accessibilityClient.attributeCGPoint(.position, of: window) + let size = try access.accessibilityClient.attributeCGSize(.size, of: window) + return CGRect(origin: position, size: size) + } + } + + /// Sets the frame of the target window. + public func setFrame(_ frame: CGRect, for target: WindowTarget) throws { + try translateAXWindowErrors(for: .setFrame) { + let window = try access.resolver.windowElement(for: target) + let standardizedFrame = frame.standardized + let positionValue = try standardizedFrame.origin.axValue(using: access.accessibilityClient) + let sizeValue = try standardizedFrame.size.axValue(using: access.accessibilityClient) + + try access.accessibilityClient.setAttributeValue( + .position, + value: positionValue, + on: window) + try access.accessibilityClient.setAttributeValue( + .size, + value: sizeValue, + on: window) + } + } + + /// Returns the current app-facing state for the target window. + public func state(for target: WindowTarget) throws -> WindowState { + try translateAXWindowErrors(for: .readState) { + let window = try access.resolver.windowElement(for: target) + let isMinimized = try access.accessibilityClient.attributeBool(.minimized, of: window) + let isFullScreen = try access.accessibilityClient.attributeBool(.fullScreen, of: window) + return WindowState(isMinimized: isMinimized, isFullScreen: isFullScreen) + } + } + + /// Sets the target window's minimized state. + public func setMinimized(_ target: WindowTarget, isMinimized: Bool) throws { + try translateAXWindowErrors(for: .setMinimized) { + let window = try access.resolver.windowElement(for: target) + try access.accessibilityClient.setAttributeBool( + .minimized, + value: isMinimized, + on: window) + } + } + + /// Sets the target window's full-screen state. + public func setFullScreen(_ target: WindowTarget, isFullScreen: Bool) throws { + try translateAXWindowErrors(for: .setFullScreen) { + let window = try access.resolver.windowElement(for: target) + try access.accessibilityClient.setAttributeBool( + .fullScreen, + value: isFullScreen, + on: window) + } + } + + /// Raises the target window above other windows when the system allows it. + public func raise(_ target: WindowTarget) throws { + try translateAXWindowErrors(for: .raise) { + let window = try access.resolver.windowElement(for: target) + try access.accessibilityClient.performAction(.raise, on: window) + } + } + + /// Lowers the target window when the system exposes the Accessibility action. + public func lower(_ target: WindowTarget) throws { + try translateAXWindowErrors(for: .lower) { + let window = try access.resolver.windowElement(for: target) + try access.accessibilityClient.performAction(.lower, on: window) + } + } + + /// Returns the app-facing role classification for the target window. + public func role(for target: WindowTarget) throws -> WindowRole { + try translateAXWindowErrors(for: .readRole) { + let window = try access.resolver.windowElement(for: target) + let accessibilityRole = try access.accessibilityClient.attributeString(.role, of: window) + let accessibilitySubrole = try access.accessibilityClient.attributeString( + .subrole, of: window) + return roleResolver.resolve( + accessibilityRole: accessibilityRole, + accessibilitySubrole: accessibilitySubrole + ) + } + } +} diff --git a/Sources/WindowKitAppKit/Accessibility/AXWindowElementResolver.swift b/Sources/WindowKitAppKit/Accessibility/AXWindowElementResolver.swift index c8164be..c1f89aa 100644 --- a/Sources/WindowKitAppKit/Accessibility/AXWindowElementResolver.swift +++ b/Sources/WindowKitAppKit/Accessibility/AXWindowElementResolver.swift @@ -4,18 +4,34 @@ import WindowKit @MainActor struct AXWindowElementResolver { + private let accessibilityClient: AXClient private let systemWideElement: AXUIElement private let applicationChecker: any RunningApplicationChecking - private let windowListInfoProvider: any CGWindowListInfoProviding + private let windowListProvider: any WindowListProviding init( + accessibilityClient: AXClient, + applicationChecker: any RunningApplicationChecking = NSRunningApplicationChecker(), + windowListProvider: any WindowListProviding = SystemWindowListProvider() + ) { + self.init( + accessibilityClient: accessibilityClient, + systemWideElement: accessibilityClient.createSystemWideElement(), + applicationChecker: applicationChecker, + windowListProvider: windowListProvider + ) + } + + init( + accessibilityClient: AXClient = .live, systemWideElement: AXUIElement, applicationChecker: any RunningApplicationChecking, - windowListInfoProvider: any CGWindowListInfoProviding = SystemCGWindowListInfoProvider() + windowListProvider: any WindowListProviding = SystemWindowListProvider() ) { + self.accessibilityClient = accessibilityClient self.systemWideElement = systemWideElement self.applicationChecker = applicationChecker - self.windowListInfoProvider = windowListInfoProvider + self.windowListProvider = windowListProvider } func windowElement(for target: WindowTarget) throws -> AXUIElement { @@ -31,13 +47,17 @@ struct AXWindowElementResolver { try requireAccessibilityTrust() guard - let focusedApplication = try systemWideElement.attributeElement( - kAXFocusedApplicationAttribute) + let focusedApplication = try accessibilityClient.attributeElement( + .focusedApplication, + of: systemWideElement) else { throw AXWindowError.applicationNotFound(.focused) } - guard let focusedWindow = try focusedApplication.attributeElement(kAXFocusedWindowAttribute) + guard + let focusedWindow = try accessibilityClient.attributeElement( + .focusedWindow, + of: focusedApplication) else { throw AXWindowError.windowNotFound(.focused) } @@ -52,30 +72,31 @@ struct AXWindowElementResolver { let target = WindowTarget.windowIdentifier(windowIdentifier) guard - let windowListInfo = windowListInfoProvider.windowListInfo( - options: [.optionIncludingWindow, .excludeDesktopElements], - relativeToWindow: windowIdentifier - ), - let ownerProcessIdentifierValue = windowListInfo.first?[kCGWindowOwnerPID as String] - as? NSNumber + let windowListEntry = windowListProvider.windowList( + for: .window(windowIdentifier) + )?.first(where: { $0.windowIdentifier == windowIdentifier }), + let ownerProcessIdentifier = windowListEntry.ownerProcessIdentifier else { throw AXWindowError.windowNotFound(target) } - let ownerProcessIdentifier = pid_t(ownerProcessIdentifierValue.int32Value) guard applicationChecker.isRunningApplication(processIdentifier: ownerProcessIdentifier) else { throw AXWindowError.applicationNotFound(target) } - let appElement = AXAPI.createApplicationElement(ownerProcessIdentifier) - guard let windows = try appElement.attributeElements(kAXWindowsAttribute) else { + let appElement = accessibilityClient.createApplicationElement(ownerProcessIdentifier) + guard + let windows = try accessibilityClient.attributeElements(.windows, of: appElement) + else { throw AXWindowError.windowNotFound(target) } for window in windows { - if let axId = try window.attributeUInt32(AXWindowAttributeName.windowIdentifier), + if let axId = try accessibilityClient.attributeUInt32( + .windowIdentifier, + of: window), axId == windowIdentifier { return window @@ -86,7 +107,7 @@ struct AXWindowElementResolver { } private func requireAccessibilityTrust() throws { - guard AXAPI.isProcessTrusted() else { + guard accessibilityClient.isProcessTrusted() else { throw AXWindowError.accessibilityNotTrusted } } diff --git a/Sources/WindowKitAppKit/Accessibility/AXWindowError.swift b/Sources/WindowKitAppKit/Accessibility/AXWindowError.swift index 492635e..91873da 100644 --- a/Sources/WindowKitAppKit/Accessibility/AXWindowError.swift +++ b/Sources/WindowKitAppKit/Accessibility/AXWindowError.swift @@ -2,7 +2,7 @@ import ApplicationServices import WindowKit /// Errors thrown by AX window operations. -public enum AXWindowError: Error, Equatable, Sendable { +enum AXWindowError: Error, Equatable, Sendable { /// The current process is not trusted for Accessibility access. case accessibilityNotTrusted /// The target application could not be resolved. @@ -12,7 +12,84 @@ public enum AXWindowError: Error, Equatable, Sendable { /// An Accessibility value had an unexpected type or shape. case invalidAXValue /// The requested Accessibility action is not supported by the target window. - case unsupportedAction(String) + case unsupportedOperation /// The Accessibility API returned an underlying error. case underlyingAccessibilityError(AXError) } + +extension AXWindowError { + func windowAccessibilityError( + for operation: WindowAccessibilityOperation + ) -> WindowAccessibilityError { + switch self { + case .accessibilityNotTrusted: + .accessibilityNotTrusted + case .applicationNotFound(let target): + .applicationUnavailable(target) + case .windowNotFound(let target): + .windowUnavailable(target) + case .invalidAXValue: + .invalidAccessibilityValue(operation) + case .unsupportedOperation: + .unsupportedAccessibilityOperation(operation) + case .underlyingAccessibilityError(let error): + .accessibilityOperationFailed( + operation: operation, + failure: error.windowKitOperationFailure + ) + } + } +} + +@MainActor +func translateAXWindowErrors( + for operation: WindowAccessibilityOperation, + _ body: () throws -> Value +) throws -> Value { + do { + return try body() + } catch let error as AXWindowError { + throw error.windowAccessibilityError(for: operation) + } +} + +private extension AXError { + var windowKitOperationFailure: WindowAccessibilityOperationFailure { + switch self { + case .success: + .unexpectedSuccess + case .failure: + .failure + case .illegalArgument: + .illegalArgument + case .invalidUIElement: + .invalidUIElement + case .invalidUIElementObserver: + .invalidUIElementObserver + case .cannotComplete: + .cannotComplete + case .attributeUnsupported: + .attributeUnsupported + case .actionUnsupported: + .actionUnsupported + case .notificationUnsupported: + .notificationUnsupported + case .notImplemented: + .notImplemented + case .notificationAlreadyRegistered: + .notificationAlreadyRegistered + case .notificationNotRegistered: + .notificationNotRegistered + case .apiDisabled: + .apiDisabled + case .noValue: + .noValue + case .parameterizedAttributeUnsupported: + .parameterizedAttributeUnsupported + case .notEnoughPrecision: + .notEnoughPrecision + @unknown default: + .unrecognizedAXError + } + } +} diff --git a/Sources/WindowKitAppKit/Accessibility/AXWindowFrameController.swift b/Sources/WindowKitAppKit/Accessibility/AXWindowFrameController.swift deleted file mode 100644 index c83e7d2..0000000 --- a/Sources/WindowKitAppKit/Accessibility/AXWindowFrameController.swift +++ /dev/null @@ -1,45 +0,0 @@ -import ApplicationServices -import CoreGraphics -import WindowKit - -/// Reads and writes live macOS window frames through Accessibility attributes. -@MainActor -public struct AXWindowFrameController: WindowFrameControlling { - private let resolver: AXWindowElementResolver - - /// Creates a frame controller backed by the system-wide Accessibility element. - public init() { - self.init( - systemWideElement: AXAPI.createSystemWideElement(), - applicationChecker: NSRunningApplicationChecker() - ) - } - - init( - systemWideElement: AXUIElement, - applicationChecker: any RunningApplicationChecking = - NSRunningApplicationChecker(), - windowListInfoProvider: any CGWindowListInfoProviding = SystemCGWindowListInfoProvider() - ) { - resolver = AXWindowElementResolver( - systemWideElement: systemWideElement, - applicationChecker: applicationChecker, - windowListInfoProvider: windowListInfoProvider - ) - } - - /// Returns the current frame of the target window. - public func frame(for target: WindowTarget) throws -> CGRect { - let window = try resolver.windowElement(for: target) - let position = try window.attributeCGPoint(kAXPositionAttribute) - let size = try window.attributeCGSize(kAXSizeAttribute) - return CGRect(origin: position, size: size) - } - - /// Sets the frame of the target window. - public func setFrame(_ frame: CGRect, for target: WindowTarget) throws { - let window = try resolver.windowElement(for: target) - try window.setAttributeValue(kAXPositionAttribute, value: frame.origin.axValue()) - try window.setAttributeValue(kAXSizeAttribute, value: frame.size.axValue()) - } -} diff --git a/Sources/WindowKitAppKit/Accessibility/AXWindowNames.swift b/Sources/WindowKitAppKit/Accessibility/AXWindowNames.swift index 2897572..996a64d 100644 --- a/Sources/WindowKitAppKit/Accessibility/AXWindowNames.swift +++ b/Sources/WindowKitAppKit/Accessibility/AXWindowNames.swift @@ -1,8 +1,57 @@ -enum AXWindowAttributeName { - static let fullScreen = "AXFullScreen" - static let windowIdentifier = "AXWindowID" +import ApplicationServices + +enum AXWindowAttribute { + case focusedApplication + case focusedWindow + case fullScreen + case minimized + case position + case role + case size + case subrole + case windowIdentifier + case windows +} + +extension AXWindowAttribute { + var accessibilityName: String { + switch self { + case .focusedApplication: + kAXFocusedApplicationAttribute as String + case .focusedWindow: + kAXFocusedWindowAttribute as String + case .fullScreen: + "AXFullScreen" + case .minimized: + kAXMinimizedAttribute as String + case .position: + kAXPositionAttribute as String + case .role: + kAXRoleAttribute as String + case .size: + kAXSizeAttribute as String + case .subrole: + kAXSubroleAttribute as String + case .windowIdentifier: + "AXWindowID" + case .windows: + kAXWindowsAttribute as String + } + } +} + +enum AXWindowAction { + case raise + case lower } -enum AXWindowActionName { - static let lower = "AXLower" +extension AXWindowAction { + var accessibilityName: String { + switch self { + case .raise: + kAXRaiseAction as String + case .lower: + "AXLower" + } + } } diff --git a/Sources/WindowKitAppKit/Accessibility/AXWindowStateProvider.swift b/Sources/WindowKitAppKit/Accessibility/AXWindowStateProvider.swift deleted file mode 100644 index 92ef61d..0000000 --- a/Sources/WindowKitAppKit/Accessibility/AXWindowStateProvider.swift +++ /dev/null @@ -1,37 +0,0 @@ -import ApplicationServices -import WindowKit - -/// Reads live macOS window state through Accessibility attributes. -@MainActor -public struct AXWindowStateProvider: WindowStateProviding { - private let resolver: AXWindowElementResolver - - /// Creates a state provider backed by the system-wide Accessibility element. - public init() { - self.init( - systemWideElement: AXAPI.createSystemWideElement(), - applicationChecker: NSRunningApplicationChecker() - ) - } - - init( - systemWideElement: AXUIElement, - applicationChecker: any RunningApplicationChecking = - NSRunningApplicationChecker(), - windowListInfoProvider: any CGWindowListInfoProviding = SystemCGWindowListInfoProvider() - ) { - resolver = AXWindowElementResolver( - systemWideElement: systemWideElement, - applicationChecker: applicationChecker, - windowListInfoProvider: windowListInfoProvider - ) - } - - /// Returns the current app-facing state for the target window. - public func state(for target: WindowTarget) throws -> WindowState { - let window = try resolver.windowElement(for: target) - let isMinimized = try window.attributeBool(kAXMinimizedAttribute) - let isFullScreen = try window.attributeBool(AXWindowAttributeName.fullScreen) - return WindowState(isMinimized: isMinimized, isFullScreen: isFullScreen) - } -} diff --git a/Sources/WindowKitAppKit/Accessibility/CGWindowListInfoProviding.swift b/Sources/WindowKitAppKit/Accessibility/CGWindowListInfoProviding.swift deleted file mode 100644 index 74420df..0000000 --- a/Sources/WindowKitAppKit/Accessibility/CGWindowListInfoProviding.swift +++ /dev/null @@ -1,16 +0,0 @@ -import CoreGraphics - -typealias CGWindowListInfoEntry = [String: Any] - -protocol CGWindowListInfoProviding: Sendable { - func windowListInfo(options: CGWindowListOption, relativeToWindow: CGWindowID) - -> [CGWindowListInfoEntry]? -} - -struct SystemCGWindowListInfoProvider: CGWindowListInfoProviding { - func windowListInfo(options: CGWindowListOption, relativeToWindow: CGWindowID) - -> [CGWindowListInfoEntry]? - { - CGWindowListCopyWindowInfo(options, relativeToWindow) as? [CGWindowListInfoEntry] - } -} diff --git a/Sources/WindowKitAppKit/Accessibility/WindowAccessibilityError.swift b/Sources/WindowKitAppKit/Accessibility/WindowAccessibilityError.swift new file mode 100644 index 0000000..d786d66 --- /dev/null +++ b/Sources/WindowKitAppKit/Accessibility/WindowAccessibilityError.swift @@ -0,0 +1,78 @@ +import WindowKit + +/// Errors surfaced by public Accessibility-backed window adapters. +public enum WindowAccessibilityError: Error, Equatable, Hashable, Sendable { + /// The current process is not trusted for Accessibility access. + case accessibilityNotTrusted + /// The target application could not be resolved. + case applicationUnavailable(WindowTarget) + /// The target window could not be resolved. + case windowUnavailable(WindowTarget) + /// Accessibility returned an invalid value during an operation. + case invalidAccessibilityValue(WindowAccessibilityOperation) + /// The target window does not support an operation. + case unsupportedAccessibilityOperation(WindowAccessibilityOperation) + /// The underlying Accessibility operation failed. + case accessibilityOperationFailed( + operation: WindowAccessibilityOperation, + failure: WindowAccessibilityOperationFailure + ) +} + +/// Public classification of Accessibility-backed window operations. +public enum WindowAccessibilityOperation: String, Equatable, Hashable, Sendable, Codable { + /// Read a window frame. + case readFrame + /// Set a window frame. + case setFrame + /// Read window state. + case readState + /// Set minimized state. + case setMinimized + /// Set full-screen state. + case setFullScreen + /// Raise a window. + case raise + /// Lower a window. + case lower + /// Read a window role. + case readRole +} + +/// Public, stable classification of Accessibility operation failures. +public enum WindowAccessibilityOperationFailure: String, Equatable, Hashable, Sendable, Codable { + /// Accessibility returned success where a failure was expected. + case unexpectedSuccess + /// Accessibility returned a generic failure. + case failure + /// Accessibility rejected an argument. + case illegalArgument + /// Accessibility reported an invalid UI element. + case invalidUIElement + /// Accessibility reported an invalid UI element observer. + case invalidUIElementObserver + /// Accessibility could not complete the requested operation. + case cannotComplete + /// Accessibility does not support the requested attribute. + case attributeUnsupported + /// Accessibility does not support the requested action. + case actionUnsupported + /// Accessibility does not support the requested notification. + case notificationUnsupported + /// Accessibility has not implemented the requested operation. + case notImplemented + /// The notification was already registered. + case notificationAlreadyRegistered + /// The notification was not registered. + case notificationNotRegistered + /// The Accessibility API is disabled. + case apiDisabled + /// Accessibility reported no value. + case noValue + /// Accessibility does not support the requested parameterized attribute. + case parameterizedAttributeUnsupported + /// Accessibility could not provide enough precision. + case notEnoughPrecision + /// Accessibility returned an AXError case this package does not recognize yet. + case unrecognizedAXError +} diff --git a/Sources/WindowKitAppKit/Factories/AppKitFactories.swift b/Sources/WindowKitAppKit/Factories/AppKitFactories.swift deleted file mode 100644 index cd94c14..0000000 --- a/Sources/WindowKitAppKit/Factories/AppKitFactories.swift +++ /dev/null @@ -1,30 +0,0 @@ -import CoreGraphics -import WindowKit - -extension WindowSnapshotQuery { - /// Creates a query configured with AppKit-backed owner bundle identifier lookup. - public static func appKit( - filter: WindowSnapshotFilter = .init(), - limitsToTopmostWindow: Bool = false - ) -> Self { - Self( - ownerBundleIdentifierProvider: NSRunningApplicationBundleIdentifierProvider(), - filter: filter, - limitsToTopmostWindow: limitsToTopmostWindow - ) - } -} - -extension WindowHitTester { - /// Creates a hit tester configured with AppKit-backed owner bundle identifier lookup. - public static func appKit( - pointTransform: @escaping @Sendable (CGPoint) -> CGPoint = { $0 }, - filter: WindowSnapshotFilter = .init() - ) -> Self { - Self( - pointTransform: pointTransform, - ownerBundleIdentifierProvider: NSRunningApplicationBundleIdentifierProvider(), - filter: filter - ) - } -} diff --git a/Sources/WindowKitAppKit/HitTesting/WindowHitTester.swift b/Sources/WindowKitAppKit/HitTesting/WindowHitTester.swift new file mode 100644 index 0000000..d845caa --- /dev/null +++ b/Sources/WindowKitAppKit/HitTesting/WindowHitTester.swift @@ -0,0 +1,56 @@ +import CoreGraphics +import WindowKit + +/// System-backed hit tester for selecting the topmost matching window at a point. +public struct WindowHitTester: WindowHitTesting, Sendable { + private let request: WindowListRequest + private let listProvider: any WindowListProviding + private let pointTransform: @Sendable (CGPoint) -> CGPoint + private let mapper: WindowListSnapshotMapper + + /// Creates a hit tester configured with AppKit-backed window list and owner bundle lookup. + public init( + pointTransform: @escaping @Sendable (CGPoint) -> CGPoint = { $0 }, + filter: WindowSnapshotFilter = .init() + ) { + self.init( + listProvider: SystemWindowListProvider(), + pointTransform: pointTransform, + filter: filter + ) + } + + init( + request: WindowListRequest = .onScreenWindows, + listProvider: any WindowListProviding, + pointTransform: @escaping @Sendable (CGPoint) -> CGPoint = { $0 }, + ownerBundleIdentifierProvider: any WindowOwnerBundleIdentifierProviding = + NSRunningApplicationBundleIdentifierProvider(), + filter: WindowSnapshotFilter = .init() + ) { + self.request = request + self.listProvider = listProvider + self.pointTransform = pointTransform + mapper = WindowListSnapshotMapper( + ownerBundleIdentifierProvider: ownerBundleIdentifierProvider, + filter: filter + ) + } + + /// Returns the topmost matching window at the given point. + public func hitTest(at point: CGPoint) -> WindowHitTestResult { + guard let windowList = listProvider.windowList(for: request) else { + return .miss(.sourceUnavailable) + } + + let transformedPoint = pointTransform(point) + guard + let selected = windowList.lazy + .compactMap({ mapper.snapshot(from: $0, containing: transformedPoint) }) + .first + else { + return .miss(.noMatchingWindowAtPoint) + } + return .hit(selected) + } +} diff --git a/Sources/WindowKitAppKit/Placement/WindowPlacer.swift b/Sources/WindowKitAppKit/Placement/WindowPlacer.swift index bc19b6c..3102651 100644 --- a/Sources/WindowKitAppKit/Placement/WindowPlacer.swift +++ b/Sources/WindowKitAppKit/Placement/WindowPlacer.swift @@ -1,81 +1,61 @@ import CoreGraphics import WindowKit -/// Errors thrown by live window placement operations. -public enum WindowPlacerError: Error, Equatable, Sendable { - /// The requested screen target could not be resolved. - case screenNotFound(WindowScreenTarget) -} - /// Places live macOS windows using WindowKit placement values and screen providers. @MainActor public struct WindowPlacer { private let frameController: any WindowFrameControlling private let screenProvider: any WindowScreenProviding - private let placementCalculator: WindowPlacementCalculator /// Creates a placer backed by Accessibility window frame control and AppKit screens. public init() { self.init( - frameController: AXWindowFrameController(), - screenProvider: NSScreenProvider(), - placementCalculator: .init() - ) - } - - init( - applicationChecker: any RunningApplicationChecking = - NSRunningApplicationChecker(), - placementCalculator: WindowPlacementCalculator = .init() - ) { - self.init( - frameController: AXWindowFrameController( - systemWideElement: AXAPI.createSystemWideElement(), - applicationChecker: applicationChecker - ), - screenProvider: NSScreenProvider(), - placementCalculator: placementCalculator + frameController: AXWindowController(), + screenProvider: NSScreenProvider() ) } /// Creates a placer with caller-supplied frame and screen providers. public init( frameController: any WindowFrameControlling, - screenProvider: any WindowScreenProviding, - placementCalculator: WindowPlacementCalculator = .init() + screenProvider: any WindowScreenProviding ) { self.frameController = frameController self.screenProvider = screenProvider - self.placementCalculator = placementCalculator } /// Places a target window according to a placement value. + /// + /// Unresolved screen targets use the first ordered screen by default. Missing screens always + /// throw ``WindowPlacerError/screenNotFound(_:)``. Pass + /// ``WindowKit/WindowScreenFallback/none`` to also reject an unmatched target when screens exist. public func place( _ placement: WindowPlacement, for target: WindowTarget, screen screenTarget: WindowScreenTarget = .containingWindow, area: WindowScreenArea = .visible, inset: CGFloat = 0, - allowsScreenFallback: Bool = true + screenFallback: WindowScreenFallback = .firstScreen ) throws { - let windowFrame = try frameController.frame(for: target) - let screens = WindowScreenSelector.orderedScreens(from: screenProvider.screens()) + let screens = screenProvider.screens() + guard !screens.isEmpty else { throw WindowPlacerError.screenNotFound(screenTarget) } + let windowFrame = + screenTarget == .containingWindow + ? try frameController.frame(for: target) + : .zero guard let selectedScreen = WindowScreenSelector.screen( for: screenTarget, windowFrame: windowFrame, in: screens, - fallsBackToFirstScreen: allowsScreenFallback + fallback: screenFallback ) else { - if !allowsScreenFallback { - throw WindowPlacerError.screenNotFound(screenTarget) - } - return + throw WindowPlacerError.screenNotFound(screenTarget) } - let frame = placementCalculator.frame( + let frame = WindowPlacementCalculator.frame( for: placement, in: selectedScreen.frame(for: area), inset: inset @@ -84,24 +64,25 @@ public struct WindowPlacer { } /// Moves a target window to the next or previous ordered screen. + /// + /// A window outside all known screens is treated as if it were on the first ordered screen by + /// default. Missing screens always throw ``WindowPlacerError/screenNotFound(_:)``. Pass + /// ``WindowKit/WindowScreenFallback/none`` to also reject a window outside every known screen. public func moveToAdjacentScreen( _ direction: WindowScreenCycleDirection, for target: WindowTarget, preservingRelativePosition: Bool = true, - allowsScreenFallback: Bool = true + screenFallback: WindowScreenFallback = .firstScreen ) throws { - let window = try frameController.frame(for: target) let screens = WindowScreenSelector.orderedScreens(from: screenProvider.screens()) - guard !screens.isEmpty else { - try handleMissingContainingWindow(allowsScreenFallback: allowsScreenFallback) - return - } + guard !screens.isEmpty else { throw WindowPlacerError.screenNotFound(.containingWindow) } + let window = try frameController.frame(for: target) let currentScreenIndex: Int if let index = WindowScreenSelector.index(containing: window.center, in: screens) { currentScreenIndex = index } else { - try handleMissingContainingWindow(allowsScreenFallback: allowsScreenFallback) + try handleMissingContainingWindow(screenFallback: screenFallback) currentScreenIndex = 0 } guard @@ -111,7 +92,7 @@ public struct WindowPlacer { screenCount: screens.count ) else { - try handleMissingContainingWindow(allowsScreenFallback: allowsScreenFallback) + try handleMissingContainingWindow(screenFallback: screenFallback) return } @@ -130,8 +111,8 @@ public struct WindowPlacer { try frameController.setFrame(nextFrame, for: target) } - private func handleMissingContainingWindow(allowsScreenFallback: Bool) throws { - if !allowsScreenFallback { + private func handleMissingContainingWindow(screenFallback: WindowScreenFallback) throws { + if screenFallback == .none { throw WindowPlacerError.screenNotFound(.containingWindow) } } diff --git a/Sources/WindowKitAppKit/Placement/WindowPlacerError.swift b/Sources/WindowKitAppKit/Placement/WindowPlacerError.swift new file mode 100644 index 0000000..688f682 --- /dev/null +++ b/Sources/WindowKitAppKit/Placement/WindowPlacerError.swift @@ -0,0 +1,7 @@ +import WindowKit + +/// Errors thrown by live window placement operations. +public enum WindowPlacerError: Error, Equatable, Hashable, Sendable { + /// The requested screen target could not be resolved. + case screenNotFound(WindowScreenTarget) +} diff --git a/Sources/WindowKitAppKit/Roles/AXWindowRoleProvider.swift b/Sources/WindowKitAppKit/Roles/AXWindowRoleProvider.swift deleted file mode 100644 index 190855e..0000000 --- a/Sources/WindowKitAppKit/Roles/AXWindowRoleProvider.swift +++ /dev/null @@ -1,38 +0,0 @@ -import ApplicationServices -import WindowKit - -/// Reads Accessibility role metadata for target windows. -@MainActor -public struct AXWindowRoleProvider: WindowRoleProviding { - private let resolver: AXWindowElementResolver - private let roleResolver = WindowRoleResolver() - - /// Creates a role provider backed by the system-wide Accessibility element. - public init() { - self.init( - systemWideElement: AXAPI.createSystemWideElement(), - applicationChecker: NSRunningApplicationChecker() - ) - } - - init( - systemWideElement: AXUIElement, - applicationChecker: any RunningApplicationChecking = - NSRunningApplicationChecker(), - windowListInfoProvider: any CGWindowListInfoProviding = SystemCGWindowListInfoProvider() - ) { - resolver = AXWindowElementResolver( - systemWideElement: systemWideElement, - applicationChecker: applicationChecker, - windowListInfoProvider: windowListInfoProvider - ) - } - - /// Returns the app-facing role classification for a target window. - public func role(for target: WindowTarget) throws -> WindowRole { - let window = try resolver.windowElement(for: target) - let role = try window.attributeString(kAXRoleAttribute) - let subrole = try window.attributeString(kAXSubroleAttribute) - return roleResolver.resolve(role: role, subrole: subrole) - } -} diff --git a/Sources/WindowKitAppKit/Roles/WindowRole.swift b/Sources/WindowKitAppKit/Roles/WindowRole.swift index de16a21..f185274 100644 --- a/Sources/WindowKitAppKit/Roles/WindowRole.swift +++ b/Sources/WindowKitAppKit/Roles/WindowRole.swift @@ -1,5 +1,3 @@ -import WindowKit - /// App-facing classification of an Accessibility window role and subrole. public enum WindowRole: Equatable, Hashable, Sendable, Codable { /// Standard document or application window. @@ -14,13 +12,6 @@ public enum WindowRole: Equatable, Hashable, Sendable, Codable { case popover /// System dialog window. case systemDialog - /// Role or subrole that does not match a known classification. - case unknown(role: String?, subrole: String?) -} - -/// App-facing abstraction for reading window role classifications. -@MainActor -public protocol WindowRoleProviding: Sendable { - /// Returns the role classification for a target window. - func role(for target: WindowTarget) throws -> WindowRole + /// Accessibility role metadata that does not match a known app-facing classification. + case unclassified(accessibilityRole: String?, accessibilitySubrole: String?) } diff --git a/Sources/WindowKitAppKit/Roles/WindowRoleProviding.swift b/Sources/WindowKitAppKit/Roles/WindowRoleProviding.swift new file mode 100644 index 0000000..4e5110c --- /dev/null +++ b/Sources/WindowKitAppKit/Roles/WindowRoleProviding.swift @@ -0,0 +1,8 @@ +import WindowKit + +/// App-facing abstraction for reading window role classifications. +@MainActor +public protocol WindowRoleProviding: Sendable { + /// Returns the role classification for a target window. + func role(for target: WindowTarget) throws -> WindowRole +} diff --git a/Sources/WindowKitAppKit/Roles/WindowRoleResolver.swift b/Sources/WindowKitAppKit/Roles/WindowRoleResolver.swift index a17b786..6b8729a 100644 --- a/Sources/WindowKitAppKit/Roles/WindowRoleResolver.swift +++ b/Sources/WindowKitAppKit/Roles/WindowRoleResolver.swift @@ -3,53 +3,131 @@ import ApplicationServices struct WindowRoleResolver: Sendable { init() {} - func resolve(role: String?, subrole: String?) -> WindowRole { - let roleValue = role ?? "" - let subroleValue = subrole ?? "" + func resolve(accessibilityRole: String?, accessibilitySubrole: String?) -> WindowRole { + let roleValue = accessibilityRole.flatMap(AXWindowRoleValue.init(accessibilityName:)) + let subroleValue = accessibilitySubrole.flatMap(AXWindowSubroleValue.init(accessibilityName:)) - if roleValue == AXRoleName.sheet { + switch roleValue { + case .sheet: return .sheet - } - if roleValue == AXRoleName.popover { + case .popover: return .popover - } - if roleValue == AXRoleName.panel { + case .panel: return .panel + case .window: + return resolveWindowSubrole( + subroleValue, + accessibilityRole: accessibilityRole, + accessibilitySubrole: accessibilitySubrole + ) + case nil: + return .unclassified( + accessibilityRole: accessibilityRole, + accessibilitySubrole: accessibilitySubrole + ) } + } - if roleValue == AXRoleName.window - && (subroleValue.isEmpty || subroleValue == AXSubroleName.standardWindow) - { - return .standard + private func resolveWindowSubrole( + _ subroleValue: AXWindowSubroleValue?, + accessibilityRole: String?, + accessibilitySubrole: String? + ) -> WindowRole { + if accessibilitySubrole != nil && subroleValue == nil { + return .unclassified( + accessibilityRole: accessibilityRole, + accessibilitySubrole: accessibilitySubrole + ) } - if subroleValue == AXSubroleName.floatingWindow - || subroleValue == AXSubroleName.systemFloatingWindow - { + switch subroleValue { + case nil, .standardWindow: + return .standard + case .dialog: + return .dialog + case .floatingWindow, .systemFloatingWindow: return .panel - } - if subroleValue == AXSubroleName.systemDialog { + case .systemDialog: return .systemDialog } - if subroleValue == AXSubroleName.dialog { - return .dialog + } +} + +enum AXWindowRoleValue: Sendable { + case panel + case popover + case sheet + case window +} + +extension AXWindowRoleValue { + init?(accessibilityName: String) { + switch accessibilityName { + case Self.panel.accessibilityName: + self = .panel + case Self.popover.accessibilityName: + self = .popover + case Self.sheet.accessibilityName: + self = .sheet + case Self.window.accessibilityName: + self = .window + default: + return nil } + } - return .unknown(role: role, subrole: subrole) + var accessibilityName: String { + switch self { + case .panel: + "AXPanel" + case .popover: + kAXPopoverRole as String + case .sheet: + kAXSheetRole as String + case .window: + kAXWindowRole as String + } } } -private enum AXRoleName { - static let popover = kAXPopoverRole as String - static let panel = "AXPanel" - static let sheet = kAXSheetRole as String - static let window = kAXWindowRole as String +enum AXWindowSubroleValue: Sendable { + case dialog + case floatingWindow + case standardWindow + case systemDialog + case systemFloatingWindow } -private enum AXSubroleName { - static let dialog = kAXDialogSubrole as String - static let floatingWindow = kAXFloatingWindowSubrole as String - static let standardWindow = kAXStandardWindowSubrole as String - static let systemDialog = kAXSystemDialogSubrole as String - static let systemFloatingWindow = kAXSystemFloatingWindowSubrole as String +extension AXWindowSubroleValue { + init?(accessibilityName: String) { + switch accessibilityName { + case Self.dialog.accessibilityName: + self = .dialog + case Self.floatingWindow.accessibilityName: + self = .floatingWindow + case Self.standardWindow.accessibilityName: + self = .standardWindow + case Self.systemDialog.accessibilityName: + self = .systemDialog + case Self.systemFloatingWindow.accessibilityName: + self = .systemFloatingWindow + default: + return nil + } + } + + var accessibilityName: String { + switch self { + case .dialog: + kAXDialogSubrole as String + case .floatingWindow: + kAXFloatingWindowSubrole as String + case .standardWindow: + kAXStandardWindowSubrole as String + case .systemDialog: + kAXSystemDialogSubrole as String + case .systemFloatingWindow: + kAXSystemFloatingWindowSubrole as String + } + } } diff --git a/Sources/WindowKitAppKit/RunningApplications/NSRunningApplicationAdapters.swift b/Sources/WindowKitAppKit/RunningApplications/NSRunningApplicationAdapters.swift deleted file mode 100644 index feaf715..0000000 --- a/Sources/WindowKitAppKit/RunningApplications/NSRunningApplicationAdapters.swift +++ /dev/null @@ -1,25 +0,0 @@ -import AppKit -import WindowKit - -/// Bundle identifier provider backed by `NSRunningApplication`. -public struct NSRunningApplicationBundleIdentifierProvider: WindowOwnerBundleIdentifierProviding { - /// Creates a running-application bundle identifier provider. - public init() {} - - /// Returns the bundle identifier for a running application process. - public func bundleIdentifier(forProcessIdentifier pid: pid_t) -> String? { - NSRunningApplication(processIdentifier: pid)?.bundleIdentifier - } -} - -protocol RunningApplicationChecking: Sendable { - func isRunningApplication(processIdentifier: pid_t) -> Bool -} - -struct NSRunningApplicationChecker: RunningApplicationChecking { - init() {} - - func isRunningApplication(processIdentifier pid: pid_t) -> Bool { - NSRunningApplication(processIdentifier: pid) != nil - } -} diff --git a/Sources/WindowKitAppKit/RunningApplications/NSRunningApplicationBundleIdentifierProvider.swift b/Sources/WindowKitAppKit/RunningApplications/NSRunningApplicationBundleIdentifierProvider.swift new file mode 100644 index 0000000..920bbc3 --- /dev/null +++ b/Sources/WindowKitAppKit/RunningApplications/NSRunningApplicationBundleIdentifierProvider.swift @@ -0,0 +1,14 @@ +import AppKit + +/// Bundle identifier provider backed by `NSRunningApplication`. +public struct NSRunningApplicationBundleIdentifierProvider: + WindowOwnerBundleIdentifierProviding +{ + /// Creates a running-application bundle identifier provider. + public init() {} + + /// Returns the bundle identifier for a running application process. + public func bundleIdentifier(forProcessIdentifier pid: pid_t) -> String? { + NSRunningApplication(processIdentifier: pid)?.bundleIdentifier + } +} diff --git a/Sources/WindowKitAppKit/RunningApplications/NSRunningApplicationChecker.swift b/Sources/WindowKitAppKit/RunningApplications/NSRunningApplicationChecker.swift new file mode 100644 index 0000000..7931a37 --- /dev/null +++ b/Sources/WindowKitAppKit/RunningApplications/NSRunningApplicationChecker.swift @@ -0,0 +1,13 @@ +import AppKit + +protocol RunningApplicationChecking: Sendable { + func isRunningApplication(processIdentifier: pid_t) -> Bool +} + +struct NSRunningApplicationChecker: RunningApplicationChecking { + init() {} + + func isRunningApplication(processIdentifier pid: pid_t) -> Bool { + NSRunningApplication(processIdentifier: pid) != nil + } +} diff --git a/Sources/WindowKitAppKit/Snapshots/WindowListEntry.swift b/Sources/WindowKitAppKit/Snapshots/WindowListEntry.swift new file mode 100644 index 0000000..871d7ea --- /dev/null +++ b/Sources/WindowKitAppKit/Snapshots/WindowListEntry.swift @@ -0,0 +1,119 @@ +import CoreGraphics +import Foundation + +struct WindowListEntry: Sendable { + let layer: Int? + let frame: CGRect? + let ownerProcessIdentifier: pid_t? + let windowIdentifier: CGWindowID? + let ownerName: String? + let title: String? + + init( + layer: Int? = nil, + frame: CGRect? = nil, + ownerProcessIdentifier: pid_t? = nil, + windowIdentifier: CGWindowID? = nil, + ownerName: String? = nil, + title: String? = nil + ) { + self.layer = layer + self.frame = frame + self.ownerProcessIdentifier = ownerProcessIdentifier + self.windowIdentifier = windowIdentifier + self.ownerName = ownerName + self.title = title + } +} + +extension WindowListEntry { + init(cgWindowInfo values: NSDictionary) { + let boundsDictionary = values[CGWindowInfoKey.bounds.dictionaryName] as? NSDictionary + self.init( + layer: Self.layer(from: values[CGWindowInfoKey.layer.dictionaryName]), + frame: boundsDictionary.flatMap(CGRect.init(dictionaryRepresentation:)), + ownerProcessIdentifier: Self.processIdentifier( + from: values[CGWindowInfoKey.ownerProcessIdentifier.dictionaryName]), + windowIdentifier: Self.windowIdentifier( + from: values[CGWindowInfoKey.windowIdentifier.dictionaryName]), + ownerName: values[CGWindowInfoKey.ownerName.dictionaryName] as? String, + title: values[CGWindowInfoKey.title.dictionaryName] as? String + ) + } + + private static func layer(from value: Any?) -> Int? { + guard let integer = integerValue(from: value), + integer >= Int64(Int.min), + integer <= Int64(Int.max) + else { + return nil + } + return Int(integer) + } + + private static func processIdentifier(from value: Any?) -> pid_t? { + guard let integer = integerValue(from: value), + integer >= 0, + integer <= Int64(Int32.max) + else { + return nil + } + return pid_t(integer) + } + + private static func windowIdentifier(from value: Any?) -> CGWindowID? { + guard let integer = integerValue(from: value), + integer >= 0, + integer <= Int64(UInt32.max) + else { + return nil + } + return CGWindowID(integer) + } + + private static func integerValue(from value: Any?) -> Int64? { + guard let number = value as? NSNumber, + CFGetTypeID(number) != CFBooleanGetTypeID() + else { + return nil + } + + let doubleValue = number.doubleValue + guard doubleValue.isFinite, + doubleValue.rounded(.towardZero) == doubleValue, + doubleValue >= Double(Int64.min), + doubleValue <= Double(Int64.max) + else { + return nil + } + return number.int64Value + } +} + +enum CGWindowInfoKey { + case bounds + case layer + case ownerName + case ownerProcessIdentifier + case title + case windowIdentifier +} + +extension CGWindowInfoKey { + var dictionaryName: String { + switch self { + case .bounds: + kCGWindowBounds as String + case .layer: + kCGWindowLayer as String + case .ownerName: + kCGWindowOwnerName as String + case .ownerProcessIdentifier: + kCGWindowOwnerPID as String + case .title: + kCGWindowName as String + case .windowIdentifier: + kCGWindowNumber as String + } + } +} diff --git a/Sources/WindowKitAppKit/Snapshots/WindowListProviding.swift b/Sources/WindowKitAppKit/Snapshots/WindowListProviding.swift new file mode 100644 index 0000000..aaa55d3 --- /dev/null +++ b/Sources/WindowKitAppKit/Snapshots/WindowListProviding.swift @@ -0,0 +1,13 @@ +import CoreGraphics +import Foundation + +protocol WindowListProviding: Sendable { + func windowList(for request: WindowListRequest) -> [WindowListEntry]? +} + +struct SystemWindowListProvider: WindowListProviding { + func windowList(for request: WindowListRequest) -> [WindowListEntry]? { + (CGWindowListCopyWindowInfo(request.options, request.windowIdentifier) as? [NSDictionary])? + .map(WindowListEntry.init(cgWindowInfo:)) + } +} diff --git a/Sources/WindowKitAppKit/Snapshots/WindowListRequest.swift b/Sources/WindowKitAppKit/Snapshots/WindowListRequest.swift new file mode 100644 index 0000000..3a2be50 --- /dev/null +++ b/Sources/WindowKitAppKit/Snapshots/WindowListRequest.swift @@ -0,0 +1,19 @@ +import CoreGraphics + +struct WindowListRequest: Equatable, Sendable { + let options: CGWindowListOption + let windowIdentifier: CGWindowID + + static let onScreenWindows = windows(options: [.optionOnScreenOnly, .excludeDesktopElements]) + + static func windows(options: CGWindowListOption) -> WindowListRequest { + WindowListRequest(options: options, windowIdentifier: kCGNullWindowID) + } + + static func window( + _ windowIdentifier: CGWindowID, + options: CGWindowListOption = [.optionIncludingWindow, .excludeDesktopElements] + ) -> WindowListRequest { + WindowListRequest(options: options, windowIdentifier: windowIdentifier) + } +} diff --git a/Sources/WindowKitAppKit/Snapshots/WindowListSnapshotMapper.swift b/Sources/WindowKitAppKit/Snapshots/WindowListSnapshotMapper.swift new file mode 100644 index 0000000..0aae7e4 --- /dev/null +++ b/Sources/WindowKitAppKit/Snapshots/WindowListSnapshotMapper.swift @@ -0,0 +1,49 @@ +import CoreGraphics +import WindowKit + +struct WindowListSnapshotMapper: Sendable { + private let ownerBundleIdentifierProvider: any WindowOwnerBundleIdentifierProviding + private let filter: WindowSnapshotFilter + + init( + ownerBundleIdentifierProvider: any WindowOwnerBundleIdentifierProviding = + NSRunningApplicationBundleIdentifierProvider(), + filter: WindowSnapshotFilter = .init() + ) { + self.ownerBundleIdentifierProvider = ownerBundleIdentifierProvider + self.filter = filter + } + + func snapshot(from entry: WindowListEntry, containing point: CGPoint? = nil) -> WindowSnapshot? { + guard let rawBounds = entry.frame, + let ownerProcessIdentifier = entry.ownerProcessIdentifier, + let windowIdentifier = entry.windowIdentifier, + let layer = entry.layer + else { + return nil + } + + guard layer == 0, rawBounds.hasFiniteComponents else { return nil } + let frame = rawBounds.standardized + if let point, !frame.contains(point) { return nil } + + let ownerBundleIdentifier = ownerBundleIdentifierProvider.bundleIdentifier( + forProcessIdentifier: ownerProcessIdentifier) + + let snapshot = WindowSnapshot( + ownerProcessIdentifier: ownerProcessIdentifier, + windowIdentifier: windowIdentifier, + ownerName: entry.ownerName, + ownerBundleIdentifier: ownerBundleIdentifier, + title: entry.title, + frame: frame + ) + return filter.includes(snapshot) ? snapshot : nil + } +} + +private extension CGRect { + var hasFiniteComponents: Bool { + origin.x.isFinite && origin.y.isFinite && width.isFinite && height.isFinite + } +} diff --git a/Sources/WindowKitAppKit/Snapshots/WindowOwnerBundleIdentifierProviding.swift b/Sources/WindowKitAppKit/Snapshots/WindowOwnerBundleIdentifierProviding.swift new file mode 100644 index 0000000..d97e170 --- /dev/null +++ b/Sources/WindowKitAppKit/Snapshots/WindowOwnerBundleIdentifierProviding.swift @@ -0,0 +1,7 @@ +import Darwin + +/// Resolves owner bundle identifiers while enriching window snapshots. +public protocol WindowOwnerBundleIdentifierProviding: Sendable { + /// Returns the bundle identifier for the given process identifier, when available. + func bundleIdentifier(forProcessIdentifier pid: pid_t) -> String? +} diff --git a/Sources/WindowKit/Snapshots/WindowSnapshotQuery.swift b/Sources/WindowKitAppKit/Snapshots/WindowSnapshotQuery.swift similarity index 56% rename from Sources/WindowKit/Snapshots/WindowSnapshotQuery.swift rename to Sources/WindowKitAppKit/Snapshots/WindowSnapshotQuery.swift index 23e4182..8829e6e 100644 --- a/Sources/WindowKit/Snapshots/WindowSnapshotQuery.swift +++ b/Sources/WindowKitAppKit/Snapshots/WindowSnapshotQuery.swift @@ -1,44 +1,45 @@ import CoreGraphics +import WindowKit /// Queries the macOS window list and returns immutable app-facing window snapshots. -public struct WindowSnapshotQuery: WindowSnapshotProviding, Sendable { - private let options: CGWindowListOption - private let singleWindowOptions: CGWindowListOption +public struct WindowSnapshotQuery: WindowSnapshotProviding, WindowSnapshotResolving, Sendable { + private let request: WindowListRequest + private let makeSingleWindowRequest: @Sendable (CGWindowID) -> WindowListRequest private let listProvider: any WindowListProviding - private let limitsToTopmostWindow: Bool + private let limit: WindowSnapshotQueryLimit private let mapper: WindowListSnapshotMapper /// Creates a system-backed window snapshot query. public init( ownerBundleIdentifierProvider: any WindowOwnerBundleIdentifierProviding = - NullWindowOwnerBundleIdentifierProvider(), + NSRunningApplicationBundleIdentifierProvider(), filter: WindowSnapshotFilter = .init(), - limitsToTopmostWindow: Bool = false + limit: WindowSnapshotQueryLimit = .allMatchingWindows ) { self.init( listProvider: SystemWindowListProvider(), ownerBundleIdentifierProvider: ownerBundleIdentifierProvider, filter: filter, - limitsToTopmostWindow: limitsToTopmostWindow + limit: limit ) } init( - options: CGWindowListOption = [.optionOnScreenOnly, .excludeDesktopElements], - singleWindowOptions: CGWindowListOption = [.optionIncludingWindow, .excludeDesktopElements], - allowedLayers: Set = [0], + request: WindowListRequest = .onScreenWindows, + makeSingleWindowRequest: @escaping @Sendable (CGWindowID) -> WindowListRequest = { + .window($0) + }, listProvider: any WindowListProviding, ownerBundleIdentifierProvider: any WindowOwnerBundleIdentifierProviding = - NullWindowOwnerBundleIdentifierProvider(), + NSRunningApplicationBundleIdentifierProvider(), filter: WindowSnapshotFilter = .init(), - limitsToTopmostWindow: Bool = false + limit: WindowSnapshotQueryLimit = .allMatchingWindows ) { - self.options = options - self.singleWindowOptions = singleWindowOptions + self.request = request + self.makeSingleWindowRequest = makeSingleWindowRequest self.listProvider = listProvider - self.limitsToTopmostWindow = limitsToTopmostWindow + self.limit = limit mapper = WindowListSnapshotMapper( - allowedLayers: allowedLayers, ownerBundleIdentifierProvider: ownerBundleIdentifierProvider, filter: filter ) @@ -46,16 +47,11 @@ public struct WindowSnapshotQuery: WindowSnapshotProviding, Sendable { /// Reads the current system window list and returns matching snapshots. public func snapshots() throws -> [WindowSnapshot] { - guard - let windowList = listProvider.windowList( - options: options, - windowIdentifier: kCGNullWindowID - ) - else { - throw WindowSnapshotQueryError.listUnavailable + guard let windowList = listProvider.windowList(for: request) else { + throw WindowSnapshotQueryError.windowListUnavailable } - if limitsToTopmostWindow { + if limit == .topmostMatchingWindow { for entry in windowList { if let snapshot = mapper.snapshot(from: entry) { return [snapshot] @@ -69,10 +65,9 @@ public struct WindowSnapshotQuery: WindowSnapshotProviding, Sendable { /// Reads a snapshot for a known Core Graphics window identifier. public func snapshot(for windowIdentifier: CGWindowID) throws -> WindowSnapshot? { guard - let windowList = listProvider.windowList( - options: singleWindowOptions, windowIdentifier: windowIdentifier) + let windowList = listProvider.windowList(for: makeSingleWindowRequest(windowIdentifier)) else { - throw WindowSnapshotQueryError.listUnavailable + throw WindowSnapshotQueryError.windowListUnavailable } return windowList.lazy diff --git a/Sources/WindowKit/Snapshots/WindowSnapshotQueryError.swift b/Sources/WindowKitAppKit/Snapshots/WindowSnapshotQueryError.swift similarity index 55% rename from Sources/WindowKit/Snapshots/WindowSnapshotQueryError.swift rename to Sources/WindowKitAppKit/Snapshots/WindowSnapshotQueryError.swift index 41fc59a..73c39e4 100644 --- a/Sources/WindowKit/Snapshots/WindowSnapshotQueryError.swift +++ b/Sources/WindowKitAppKit/Snapshots/WindowSnapshotQueryError.swift @@ -1,5 +1,5 @@ /// Error thrown when a window snapshot query cannot read the system window list. -public enum WindowSnapshotQueryError: Error, Equatable, Sendable { +public enum WindowSnapshotQueryError: Error, Equatable, Hashable, Sendable { /// The system window list could not be read. - case listUnavailable + case windowListUnavailable } diff --git a/Sources/WindowKitAppKit/Snapshots/WindowSnapshotQueryLimit.swift b/Sources/WindowKitAppKit/Snapshots/WindowSnapshotQueryLimit.swift new file mode 100644 index 0000000..0cdfec0 --- /dev/null +++ b/Sources/WindowKitAppKit/Snapshots/WindowSnapshotQueryLimit.swift @@ -0,0 +1,7 @@ +/// Controls how many matching snapshots a ``WindowSnapshotQuery`` returns. +public enum WindowSnapshotQueryLimit: Equatable, Hashable, Sendable, Codable { + /// Return every matching window from the system window list. + case allMatchingWindows + /// Return only the first matching window from the system window list. + case topmostMatchingWindow +} diff --git a/Sources/WindowKitAppKit/WindowKitAppKit.docc/WindowKitAppKit.md b/Sources/WindowKitAppKit/WindowKitAppKit.docc/WindowKitAppKit.md index c583891..3d59e86 100644 --- a/Sources/WindowKitAppKit/WindowKitAppKit.docc/WindowKitAppKit.md +++ b/Sources/WindowKitAppKit/WindowKitAppKit.docc/WindowKitAppKit.md @@ -4,34 +4,43 @@ AppKit and Accessibility implementations plus AX-specific window role metadata. ## Overview -Use WindowKitAppKit when a macOS host app needs live window information or control through AppKit -and Accessibility APIs. +Use WindowKitAppKit when a macOS host app needs live window information or placement through AppKit +and Accessibility-backed facades. -The package provides concrete implementations for WindowKit protocols: +The package provides live Core Graphics, AppKit, and Accessibility-backed facades and concrete +implementations for WindowKit protocols: -- AppKit-backed factories on `WindowSnapshotQuery` and `WindowHitTester`. -- ``AXWindowFrameController`` for reading and setting window frames. -- ``AXWindowCommandController`` for changing minimized state, full-screen state, and window - ordering. -- ``AXWindowStateProvider`` for reading live window state through Accessibility. -- ``AXWindowRoleProvider`` and ``WindowRoleProviding`` for reading Accessibility role metadata. +- ``WindowSnapshotQuery`` for Core Graphics window-list snapshots. +- ``WindowHitTester`` for Core Graphics window-list hit testing. - ``WindowPlacer`` for placing live windows using `WindowPlacement` values. +- Accessibility-backed ``AXWindowController`` for frame, state, command, and role protocols. +- ``WindowRole`` and ``WindowRoleProviding`` for app-facing role metadata. -``WindowRole`` is a classification value for host-app decisions. It does not define placement -rules, presets, automation policy, or app workflows. +Host apps should prefer `WindowKit` protocols at their own boundaries and construct +`WindowKitAppKit` concrete adapters where live AppKit or Accessibility access is needed. +``WindowRole`` is role metadata for host decisions; it does not define placement rules, presets, +automation policy, or app workflows. -``WindowPlacer`` uses forgiving screen selection by default. Pass `allowsScreenFallback: false` to -``WindowPlacer/place(_:for:screen:area:inset:allowsScreenFallback:)`` or -``WindowPlacer/moveToAdjacentScreen(_:for:preservingRelativePosition:allowsScreenFallback:)`` to -throw ``WindowPlacerError/screenNotFound(_:)`` when a requested or current screen cannot be -resolved. +Use the AppKit-backed query and hit tester when a host app needs current window snapshots: -Unsupported Accessibility actions throw ``AXWindowError/unsupportedAction(_:)``. Target-specific -resolution failures include the unresolved `WindowTarget`. Other underlying Accessibility failures -are preserved as ``AXWindowError/underlyingAccessibilityError(_:)``. +```swift +import WindowKit +import WindowKitAppKit -Host apps remain responsible for permission onboarding, user-facing prompts, shortcut handling, -automation policy, persistence, telemetry, and privacy disclosures. +let query = WindowSnapshotQuery() +let snapshots = try query.snapshots() +let hitTester = WindowHitTester() +let topmost = hitTester.topmost(at: .zero) +``` + +## Responsibility Boundary + +WindowKitAppKit owns AppKit-backed querying and hit-testing initializers, +Accessibility-backed live window placement helpers, and role metadata. + +Host apps remain responsible for permission onboarding, user-facing prompts, +shortcut handling, automation policy, persistence, telemetry, analytics, and +privacy disclosures. WindowKitAppKit does not request permissions, collect analytics, transmit data, or persist window data by itself. Metadata availability and permission prompts are controlled by macOS and the host @@ -39,28 +48,52 @@ application. ## Topics -### Running Applications - +### Querying and Hit Testing + +- ``WindowSnapshotQuery`` +- ``WindowSnapshotQuery/init(ownerBundleIdentifierProvider:filter:limit:)`` +- ``WindowSnapshotQuery/snapshots()`` +- ``WindowSnapshotQuery/snapshot(for:)`` +- ``WindowSnapshotQueryLimit`` +- ``WindowSnapshotQueryError`` +- ``WindowHitTester`` +- ``WindowHitTester/init(pointTransform:filter:)`` +- ``WindowOwnerBundleIdentifierProviding`` +- ``WindowOwnerBundleIdentifierProviding/bundleIdentifier(forProcessIdentifier:)`` - ``NSRunningApplicationBundleIdentifierProvider`` - -### Accessibility Implementations - -- ``AXWindowFrameController`` -- ``AXWindowCommandController`` -- ``AXWindowStateProvider`` -- ``AXWindowError`` +- ``NSRunningApplicationBundleIdentifierProvider/init()`` +- ``NSRunningApplicationBundleIdentifierProvider/bundleIdentifier(forProcessIdentifier:)`` ### Placement - ``WindowPlacer`` +- ``WindowPlacer/init()`` +- ``WindowPlacer/init(frameController:screenProvider:)`` +- ``WindowPlacer/place(_:for:screen:area:inset:screenFallback:)`` +- ``WindowPlacer/moveToAdjacentScreen(_:for:preservingRelativePosition:screenFallback:)`` - ``WindowPlacerError`` +- ``NSScreenProvider`` +- ``NSScreenProvider/init()`` +- ``NSScreenProvider/screens()`` + +### Accessibility Access + +- ``WindowAccessibilityError`` +- ``WindowAccessibilityOperation`` +- ``WindowAccessibilityOperationFailure`` +- ``AXWindowController`` +- ``AXWindowController/init()`` +- ``AXWindowController/frame(for:)`` +- ``AXWindowController/setFrame(_:for:)`` +- ``AXWindowController/state(for:)`` +- ``AXWindowController/setMinimized(_:isMinimized:)`` +- ``AXWindowController/setFullScreen(_:isFullScreen:)`` +- ``AXWindowController/raise(_:)`` +- ``AXWindowController/lower(_:)`` +- ``AXWindowController/role(for:)`` ### Roles - ``WindowRole`` - ``WindowRoleProviding`` -- ``AXWindowRoleProvider`` - -### Screens - -- ``NSScreenProvider`` +- ``WindowRoleProviding/role(for:)`` diff --git a/Tests/WindowKitAppKitTests/Accessibility/AXClientUIElementTests.swift b/Tests/WindowKitAppKitTests/Accessibility/AXClientUIElementTests.swift new file mode 100644 index 0000000..8c0e387 --- /dev/null +++ b/Tests/WindowKitAppKitTests/Accessibility/AXClientUIElementTests.swift @@ -0,0 +1,194 @@ +import ApplicationServices +import CoreGraphics +import XCTest + +@testable import WindowKitAppKit + +@MainActor +final class AXClientUIElementTests: XCTestCase { + func testOptionalAttributeElementReturnsNilWhenAttributeHasNoValue() throws { + for error in [AXError.attributeUnsupported, .noValue] { + let accessibilityClient = AXClient.testing(copyAttributeValue: { _, attribute, value in + XCTAssertEqual(attribute as String, AXWindowAttribute.focusedWindow.accessibilityName) + value.pointee = nil + return error + }) + + let element = try accessibilityClient.attributeElement( + .focusedWindow, + of: AXUIElementCreateSystemWide() + ) + + XCTAssertNil(element) + } + } + + func testOptionalAttributeElementRejectsUnexpectedValueType() { + let accessibilityClient = AXClient.testing(copyAttributeValue: { _, _, value in + value.pointee = "invalid" as CFString + return .success + }) + + XCTAssertThrowsError( + try accessibilityClient.attributeElement(.focusedWindow, of: AXUIElementCreateSystemWide()) + ) { error in + XCTAssertEqual(error as? AXWindowError, .invalidAXValue) + } + } + + func testAttributeElementsRejectsNonArrayValues() { + let accessibilityClient = AXClient.testing(copyAttributeValue: { _, _, value in + value.pointee = "invalid" as CFString + return .success + }) + + XCTAssertThrowsError( + try accessibilityClient.attributeElements(.windows, of: AXUIElementCreateSystemWide()) + ) { error in + XCTAssertEqual(error as? AXWindowError, .invalidAXValue) + } + } + + func testAttributeBoolRequiresCFBooleanValues() { + let accessibilityClient = AXClient.testing(copyAttributeValue: { _, _, value in + value.pointee = NSNumber(value: Int32(1)) + return .success + }) + + XCTAssertThrowsError( + try accessibilityClient.attributeBool(.minimized, of: AXUIElementCreateSystemWide()) + ) { error in + XCTAssertEqual(error as? AXWindowError, .invalidAXValue) + } + } + + func testAttributeUInt32ReturnsExactUnsignedIntegerValue() throws { + let accessibilityClient = AXClient.testing(copyAttributeValue: { _, _, value in + value.pointee = NSNumber(value: UInt32.max) + return .success + }) + + let windowIdentifier = try accessibilityClient.attributeUInt32( + .windowIdentifier, + of: AXUIElementCreateSystemWide() + ) + + XCTAssertEqual(windowIdentifier, UInt32.max) + } + + func testAttributeUInt32RejectsNonExactUnsignedIntegerValues() { + let invalidValues: [NSNumber] = [ + kCFBooleanTrue, + NSNumber(value: -1), + NSNumber(value: 1.5), + NSNumber(value: UInt64(UInt32.max) + 1), + ] + + for invalidValue in invalidValues { + let accessibilityClient = AXClient.testing(copyAttributeValue: { _, _, value in + value.pointee = invalidValue + return .success + }) + + XCTAssertThrowsError( + try accessibilityClient.attributeUInt32( + .windowIdentifier, of: AXUIElementCreateSystemWide()) + ) { error in + XCTAssertEqual(error as? AXWindowError, .invalidAXValue) + } + } + } + + func testRequiredCGPointAttributeRejectsMissingSuccessfulValue() { + let accessibilityClient = AXClient.testing(copyAttributeValue: { _, _, value in + value.pointee = nil + return .success + }) + + XCTAssertThrowsError( + try accessibilityClient.attributeCGPoint(.position, of: AXUIElementCreateSystemWide()) + ) { error in + XCTAssertEqual(error as? AXWindowError, .invalidAXValue) + } + } + + func testRequiredCGSizeAttributeRejectsWrongAXValueType() throws { + var point = CGPoint(x: 12, y: 34) + let pointValue = try XCTUnwrap(AXValueCreate(.cgPoint, &point)) + let accessibilityClient = AXClient.testing(copyAttributeValue: { _, _, value in + value.pointee = pointValue + return .success + }) + + XCTAssertThrowsError( + try accessibilityClient.attributeCGSize(.size, of: AXUIElementCreateSystemWide()) + ) { error in + XCTAssertEqual(error as? AXWindowError, .invalidAXValue) + } + } + + func testRequiredCGPointAttributeRejectsNonFiniteValue() throws { + var point = CGPoint(x: CGFloat.nan, y: 34) + let pointValue = try XCTUnwrap(AXValueCreate(.cgPoint, &point)) + let accessibilityClient = AXClient.testing(copyAttributeValue: { _, _, value in + value.pointee = pointValue + return .success + }) + + XCTAssertThrowsError( + try accessibilityClient.attributeCGPoint(.position, of: AXUIElementCreateSystemWide()) + ) { error in + XCTAssertEqual(error as? AXWindowError, .invalidAXValue) + } + } + + func testRequiredCGSizeAttributeRejectsNonFiniteValue() throws { + var size = CGSize(width: 56, height: CGFloat.infinity) + let sizeValue = try XCTUnwrap(AXValueCreate(.cgSize, &size)) + let accessibilityClient = AXClient.testing(copyAttributeValue: { _, _, value in + value.pointee = sizeValue + return .success + }) + + XCTAssertThrowsError( + try accessibilityClient.attributeCGSize(.size, of: AXUIElementCreateSystemWide()) + ) { error in + XCTAssertEqual(error as? AXWindowError, .invalidAXValue) + } + } + + func testSetAttributeBoolWritesTypedBooleanAttribute() throws { + var capturedAttribute: String? + var capturedTypeID: CFTypeID? + var capturedValue: Bool? + let accessibilityClient = AXClient.testing(setAttributeValue: { _, attribute, value in + capturedAttribute = attribute as String + capturedTypeID = CFGetTypeID(value) + capturedValue = value as? Bool + return .success + }) + + try accessibilityClient.setAttributeBool( + .minimized, + value: true, + on: AXUIElementCreateSystemWide() + ) + + XCTAssertEqual(capturedAttribute, AXWindowAttribute.minimized.accessibilityName) + XCTAssertEqual(capturedTypeID, CFBooleanGetTypeID()) + XCTAssertEqual(capturedValue, true) + } + + func testPerformActionMapsUnsupportedActionToTypedError() { + let accessibilityClient = AXClient.testing(performAction: { _, action in + XCTAssertEqual(action as String, AXWindowAction.lower.accessibilityName) + return .actionUnsupported + }) + + XCTAssertThrowsError( + try accessibilityClient.performAction(.lower, on: AXUIElementCreateSystemWide()) + ) { error in + XCTAssertEqual(error as? AXWindowError, .unsupportedOperation) + } + } +} diff --git a/Tests/WindowKitAppKitTests/Accessibility/AXValueConversionTests.swift b/Tests/WindowKitAppKitTests/Accessibility/AXValueConversionTests.swift index 40fb4b9..a4f0142 100644 --- a/Tests/WindowKitAppKitTests/Accessibility/AXValueConversionTests.swift +++ b/Tests/WindowKitAppKitTests/Accessibility/AXValueConversionTests.swift @@ -7,7 +7,7 @@ import XCTest @MainActor final class AXValueConversionTests: XCTestCase { func testCGPointConvertsToAXValue() throws { - let value = try CGPoint(x: 12, y: 34).axValue() + let value = try CGPoint(x: 12, y: 34).axValue(using: .testing()) var point = CGPoint.zero XCTAssertTrue(AXValueGetValue(value, .cgPoint, &point)) @@ -15,7 +15,7 @@ final class AXValueConversionTests: XCTestCase { } func testCGSizeConvertsToAXValue() throws { - let value = try CGSize(width: 56, height: 78).axValue() + let value = try CGSize(width: 56, height: 78).axValue(using: .testing()) var size = CGSize.zero XCTAssertTrue(AXValueGetValue(value, .cgSize, &size)) @@ -23,51 +23,76 @@ final class AXValueConversionTests: XCTestCase { } func testCGPointConversionPassesCGPointTypeAndValue() throws { - defer { AXAPI.reset() } var capturedType: AXValueType? var capturedPoint = CGPoint.zero - AXAPI.valueCreate = { type, pointer in + let accessibilityClient = AXClient.testing(valueCreate: { type, pointer in capturedType = type capturedPoint = pointer.load(as: CGPoint.self) return AXValueCreate(type, pointer) - } + }) - _ = try CGPoint(x: 12, y: 34).axValue() + _ = try CGPoint(x: 12, y: 34).axValue(using: accessibilityClient) XCTAssertEqual(capturedType, .cgPoint) XCTAssertEqual(capturedPoint, CGPoint(x: 12, y: 34)) } + func testCGPointConversionRejectsNonFiniteCoordinates() { + let points = [ + CGPoint(x: CGFloat.nan, y: 0), + CGPoint(x: 0, y: CGFloat.infinity), + CGPoint(x: -CGFloat.infinity, y: 0), + ] + + for point in points { + XCTAssertThrowsError(try point.axValue(using: AXClient.testing())) { error in + XCTAssertEqual(error as? AXWindowError, .invalidAXValue) + } + } + } + + func testCGSizeConversionRejectsNonFiniteDimensions() { + let sizes = [ + CGSize(width: CGFloat.nan, height: 1), + CGSize(width: 1, height: CGFloat.infinity), + CGSize(width: -CGFloat.infinity, height: 1), + ] + + for size in sizes { + XCTAssertThrowsError(try size.axValue(using: AXClient.testing())) { error in + XCTAssertEqual(error as? AXWindowError, .invalidAXValue) + } + } + } + func testCGSizeConversionPassesCGSizeTypeAndValue() throws { - defer { AXAPI.reset() } var capturedType: AXValueType? var capturedSize = CGSize.zero - AXAPI.valueCreate = { type, pointer in + let accessibilityClient = AXClient.testing(valueCreate: { type, pointer in capturedType = type capturedSize = pointer.load(as: CGSize.self) return AXValueCreate(type, pointer) - } + }) - _ = try CGSize(width: 56, height: 78).axValue() + _ = try CGSize(width: 56, height: 78).axValue(using: accessibilityClient) XCTAssertEqual(capturedType, .cgSize) XCTAssertEqual(capturedSize, CGSize(width: 56, height: 78)) } func testCGPointConversionThrowsWhenAXValueCreationFails() { - defer { AXAPI.reset() } - AXAPI.valueCreate = { _, _ in nil } + let accessibilityClient = AXClient.testing(valueCreate: { _, _ in nil }) - XCTAssertThrowsError(try CGPoint(x: 12, y: 34).axValue()) { error in + XCTAssertThrowsError(try CGPoint(x: 12, y: 34).axValue(using: accessibilityClient)) { error in XCTAssertEqual(error as? AXWindowError, .invalidAXValue) } } func testCGSizeConversionThrowsWhenAXValueCreationFails() { - defer { AXAPI.reset() } - AXAPI.valueCreate = { _, _ in nil } + let accessibilityClient = AXClient.testing(valueCreate: { _, _ in nil }) - XCTAssertThrowsError(try CGSize(width: 56, height: 78).axValue()) { error in + XCTAssertThrowsError(try CGSize(width: 56, height: 78).axValue(using: accessibilityClient)) { + error in XCTAssertEqual(error as? AXWindowError, .invalidAXValue) } } diff --git a/Tests/WindowKitAppKitTests/Accessibility/AXWindowCommandControllerTests.swift b/Tests/WindowKitAppKitTests/Accessibility/AXWindowControllerCommandTests.swift similarity index 51% rename from Tests/WindowKitAppKitTests/Accessibility/AXWindowCommandControllerTests.swift rename to Tests/WindowKitAppKitTests/Accessibility/AXWindowControllerCommandTests.swift index deee9cd..ab0aa7f 100644 --- a/Tests/WindowKitAppKitTests/Accessibility/AXWindowCommandControllerTests.swift +++ b/Tests/WindowKitAppKitTests/Accessibility/AXWindowControllerCommandTests.swift @@ -1,30 +1,29 @@ import ApplicationServices +import WindowKit import XCTest -@testable import WindowKit @testable import WindowKitAppKit @MainActor -final class AXWindowCommandControllerTests: XCTestCase { +final class AXWindowControllerCommandTests: XCTestCase { func testSetMinimizedWritesMinimizedAttribute() throws { - defer { AXAPI.reset() } - AXAPI.isProcessTrusted = { true } - let fixture = FocusedWindowAXFixture() - fixture.installCopyAttributeValue() var writes: [BooleanAttributeWrite] = [] - AXAPI.setAttributeValue = { element, attribute, value in + let accessibilityClient = fixture.accessibilityClient(setAttributeValue: { + element, attribute, value in XCTAssertEqual(CFEqual(element, fixture.windowElement), true) - guard let boolValue = (value as? NSNumber)?.boolValue else { + guard CFGetTypeID(value) == CFBooleanGetTypeID(), + let boolValue = value as? Bool + else { XCTFail("Expected Boolean attribute value") return .success } writes.append(BooleanAttributeWrite(attribute: attribute as String, value: boolValue)) return .success - } + }) - let controller = fixture.commandController() + let controller = fixture.commandController(accessibilityClient: accessibilityClient) try controller.setMinimized(.focused, isMinimized: true) try controller.setMinimized(.focused, isMinimized: false) @@ -32,32 +31,35 @@ final class AXWindowCommandControllerTests: XCTestCase { XCTAssertEqual( writes, [ - BooleanAttributeWrite(attribute: kAXMinimizedAttribute as String, value: true), - BooleanAttributeWrite(attribute: kAXMinimizedAttribute as String, value: false), + BooleanAttributeWrite( + attribute: AXWindowAttribute.minimized.accessibilityName, + value: true), + BooleanAttributeWrite( + attribute: AXWindowAttribute.minimized.accessibilityName, + value: false), ]) } func testSetFullScreenWritesFullScreenAttribute() throws { - defer { AXAPI.reset() } - AXAPI.isProcessTrusted = { true } - - let fullScreenAttribute = AXWindowAttributeName.fullScreen + let fullScreenAttribute = AXWindowAttribute.fullScreen.accessibilityName let fixture = FocusedWindowAXFixture() - fixture.installCopyAttributeValue() var writes: [BooleanAttributeWrite] = [] - AXAPI.setAttributeValue = { element, attribute, value in + let accessibilityClient = fixture.accessibilityClient(setAttributeValue: { + element, attribute, value in XCTAssertEqual(CFEqual(element, fixture.windowElement), true) - guard let boolValue = (value as? NSNumber)?.boolValue else { + guard CFGetTypeID(value) == CFBooleanGetTypeID(), + let boolValue = value as? Bool + else { XCTFail("Expected Boolean attribute value") return .success } writes.append(BooleanAttributeWrite(attribute: attribute as String, value: boolValue)) return .success - } + }) - let controller = fixture.commandController() + let controller = fixture.commandController(accessibilityClient: accessibilityClient) try controller.setFullScreen(.focused, isFullScreen: true) try controller.setFullScreen(.focused, isFullScreen: false) @@ -71,93 +73,87 @@ final class AXWindowCommandControllerTests: XCTestCase { } func testSetMinimizedThrowsUnderlyingAccessibilityErrorWhenWriteFails() { - defer { AXAPI.reset() } - AXAPI.isProcessTrusted = { true } - let fixture = FocusedWindowAXFixture() - fixture.installCopyAttributeValue() - AXAPI.setAttributeValue = { element, attribute, _ in + let accessibilityClient = fixture.accessibilityClient(setAttributeValue: { + element, attribute, _ in XCTAssertEqual(CFEqual(element, fixture.windowElement), true) - XCTAssertEqual(attribute as String, kAXMinimizedAttribute as String) + XCTAssertEqual(attribute as String, AXWindowAttribute.minimized.accessibilityName) return .cannotComplete - } + }) - let controller = fixture.commandController() + let controller = fixture.commandController(accessibilityClient: accessibilityClient) XCTAssertThrowsError(try controller.setMinimized(.focused, isMinimized: true)) { error in - XCTAssertEqual(error as? AXWindowError, .underlyingAccessibilityError(.cannotComplete)) + XCTAssertEqual( + error as? WindowAccessibilityError, + .accessibilityOperationFailed(operation: .setMinimized, failure: .cannotComplete) + ) } } func testRaisePerformsRaiseAction() throws { - defer { AXAPI.reset() } let fixture = FocusedWindowAXFixture() - let controller = fixture.commandController() var performedActions: [String] = [] - AXAPI.isProcessTrusted = { true } - fixture.installCopyAttributeValue() - AXAPI.performAction = { element, action in + let accessibilityClient = fixture.accessibilityClient(performAction: { element, action in XCTAssertEqual(CFEqual(element, fixture.windowElement), true) performedActions.append(action as String) return .success - } + }) + let controller = fixture.commandController(accessibilityClient: accessibilityClient) try controller.raise(.focused) - XCTAssertEqual(performedActions, [kAXRaiseAction as String]) + XCTAssertEqual(performedActions, [AXWindowAction.raise.accessibilityName]) } func testRaiseThrowsUnderlyingAccessibilityErrorWhenActionFails() { - defer { AXAPI.reset() } let fixture = FocusedWindowAXFixture() - let controller = fixture.commandController() - AXAPI.isProcessTrusted = { true } - fixture.installCopyAttributeValue() - AXAPI.performAction = { element, action in + let accessibilityClient = fixture.accessibilityClient(performAction: { element, action in XCTAssertEqual(CFEqual(element, fixture.windowElement), true) - XCTAssertEqual(action as String, kAXRaiseAction as String) + XCTAssertEqual(action as String, AXWindowAction.raise.accessibilityName) return .cannotComplete - } + }) + let controller = fixture.commandController(accessibilityClient: accessibilityClient) XCTAssertThrowsError(try controller.raise(.focused)) { error in - XCTAssertEqual(error as? AXWindowError, .underlyingAccessibilityError(.cannotComplete)) + XCTAssertEqual( + error as? WindowAccessibilityError, + .accessibilityOperationFailed(operation: .raise, failure: .cannotComplete) + ) } } func testLowerPerformsLowerAction() throws { - defer { AXAPI.reset() } let fixture = FocusedWindowAXFixture() - let controller = fixture.commandController() var performedActions: [String] = [] - AXAPI.isProcessTrusted = { true } - fixture.installCopyAttributeValue() - AXAPI.performAction = { element, action in + let accessibilityClient = fixture.accessibilityClient(performAction: { element, action in XCTAssertEqual(CFEqual(element, fixture.windowElement), true) performedActions.append(action as String) return .success - } + }) + let controller = fixture.commandController(accessibilityClient: accessibilityClient) try controller.lower(.focused) - XCTAssertEqual(performedActions, [AXWindowActionName.lower]) + XCTAssertEqual(performedActions, [AXWindowAction.lower.accessibilityName]) } func testLowerThrowsUnsupportedActionWhenLowerActionIsUnsupported() { - defer { AXAPI.reset() } let fixture = FocusedWindowAXFixture() - let controller = fixture.commandController() - AXAPI.isProcessTrusted = { true } - fixture.installCopyAttributeValue() - AXAPI.performAction = { element, action in + let accessibilityClient = fixture.accessibilityClient(performAction: { element, action in XCTAssertEqual(CFEqual(element, fixture.windowElement), true) - XCTAssertEqual(action as String, AXWindowActionName.lower) + XCTAssertEqual(action as String, AXWindowAction.lower.accessibilityName) return .actionUnsupported - } + }) + let controller = fixture.commandController(accessibilityClient: accessibilityClient) XCTAssertThrowsError(try controller.lower(.focused)) { error in - XCTAssertEqual(error as? AXWindowError, .unsupportedAction(AXWindowActionName.lower)) + XCTAssertEqual( + error as? WindowAccessibilityError, + .unsupportedAccessibilityOperation(.lower) + ) } } } diff --git a/Tests/WindowKitAppKitTests/Accessibility/AXWindowControllerFrameTests.swift b/Tests/WindowKitAppKitTests/Accessibility/AXWindowControllerFrameTests.swift new file mode 100644 index 0000000..dc1b91b --- /dev/null +++ b/Tests/WindowKitAppKitTests/Accessibility/AXWindowControllerFrameTests.swift @@ -0,0 +1,229 @@ +import ApplicationServices +import CoreGraphics +import XCTest + +import WindowKit +@testable import WindowKitAppKit + +@MainActor +final class AXWindowControllerFrameTests: XCTestCase { + func testFrameReadsPositionAndSizeAttributes() throws { + var position = CGPoint(x: 30, y: 40) + var size = CGSize(width: 500, height: 300) + guard let positionValue = AXValueCreate(.cgPoint, &position), + let sizeValue = AXValueCreate(.cgSize, &size) + else { + XCTFail("Failed to create AXValue") + return + } + + let positionAttribute = AXWindowAttribute.position.accessibilityName + let sizeAttribute = AXWindowAttribute.size.accessibilityName + + let fixture = FocusedWindowAXFixture() + let accessibilityClient = fixture.accessibilityClient { element, attribute, value in + XCTAssertEqual(CFEqual(element, fixture.windowElement), true) + switch attribute as String { + case positionAttribute: + value.pointee = positionValue + return .success + case sizeAttribute: + value.pointee = sizeValue + return .success + default: + value.pointee = nil + return .noValue + } + } + + let frame = try fixture.frameController(accessibilityClient: accessibilityClient) + .frame(for: .focused) + + XCTAssertEqual(frame.origin, CGPoint(x: 30, y: 40)) + XCTAssertEqual(frame.size, CGSize(width: 500, height: 300)) + } + + func testFrameStandardizesReadAttributes() throws { + var position = CGPoint(x: 100, y: 100) + var size = CGSize(width: -80, height: -60) + guard let positionValue = AXValueCreate(.cgPoint, &position), + let sizeValue = AXValueCreate(.cgSize, &size) + else { + XCTFail("Failed to create AXValue") + return + } + + let fixture = FocusedWindowAXFixture() + let accessibilityClient = fixture.accessibilityClient { _, attribute, value in + switch attribute as String { + case AXWindowAttribute.position.accessibilityName: + value.pointee = positionValue + return .success + case AXWindowAttribute.size.accessibilityName: + value.pointee = sizeValue + return .success + default: + value.pointee = nil + return .noValue + } + } + + let frame = try fixture.frameController(accessibilityClient: accessibilityClient) + .frame(for: .focused) + + XCTAssertEqual(frame, CGRect(x: 20, y: 40, width: 80, height: 60)) + } + + func testFrameThrowsUnderlyingAccessibilityErrorWhenPositionReadFails() { + let fixture = FocusedWindowAXFixture() + var readAttributes: [String] = [] + let accessibilityClient = fixture.accessibilityClient { element, attribute, value in + XCTAssertEqual(CFEqual(element, fixture.windowElement), true) + readAttributes.append(attribute as String) + value.pointee = nil + return .cannotComplete + } + + XCTAssertThrowsError( + try fixture.frameController(accessibilityClient: accessibilityClient).frame(for: .focused) + ) { error in + XCTAssertEqual( + error as? WindowAccessibilityError, + .accessibilityOperationFailed(operation: .readFrame, failure: .cannotComplete) + ) + } + XCTAssertEqual(readAttributes, [AXWindowAttribute.position.accessibilityName]) + } + + func testSetFrameWritesPositionAndSizeAttributes() throws { + let fixture = FocusedWindowAXFixture() + + var writes: [FrameAttributeWrite] = [] + let positionAttribute = AXWindowAttribute.position.accessibilityName + let sizeAttribute = AXWindowAttribute.size.accessibilityName + let accessibilityClient = fixture.accessibilityClient(setAttributeValue: { + element, attribute, value in + XCTAssertEqual(CFEqual(element, fixture.windowElement), true) + guard CFGetTypeID(value) == AXValueGetTypeID() else { + XCTFail("Expected AXValue") + return .success + } + let axValue = typedReference(value, as: AXValue.self) + switch attribute as String { + case positionAttribute: + var point = CGPoint.zero + XCTAssertTrue(AXValueGetValue(axValue, .cgPoint, &point)) + writes.append(.position(point)) + case sizeAttribute: + var size = CGSize.zero + XCTAssertTrue(AXValueGetValue(axValue, .cgSize, &size)) + writes.append(.size(size)) + default: + XCTFail("Unexpected attribute \(attribute)") + } + return .success + }) + + try fixture.frameController(accessibilityClient: accessibilityClient) + .setFrame(CGRect(x: 1, y: 2, width: 3, height: 4), for: .focused) + + XCTAssertEqual( + writes, + [ + .position(CGPoint(x: 1, y: 2)), + .size(CGSize(width: 3, height: 4)), + ]) + } + + func testSetFrameStandardizesFrameBeforeWritingAttributes() throws { + let fixture = FocusedWindowAXFixture() + + var writes: [FrameAttributeWrite] = [] + let accessibilityClient = fixture.accessibilityClient(setAttributeValue: { + _, attribute, value in + let axValue = typedReference(value, as: AXValue.self) + switch attribute as String { + case AXWindowAttribute.position.accessibilityName: + var point = CGPoint.zero + XCTAssertTrue(AXValueGetValue(axValue, .cgPoint, &point)) + writes.append(.position(point)) + case AXWindowAttribute.size.accessibilityName: + var size = CGSize.zero + XCTAssertTrue(AXValueGetValue(axValue, .cgSize, &size)) + writes.append(.size(size)) + default: + XCTFail("Unexpected attribute \(attribute)") + } + return .success + }) + + try fixture.frameController(accessibilityClient: accessibilityClient) + .setFrame(CGRect(x: 100, y: 100, width: -80, height: -60), for: .focused) + + XCTAssertEqual( + writes, + [ + .position(CGPoint(x: 20, y: 40)), + .size(CGSize(width: 80, height: 60)), + ]) + } + + func testSetFrameThrowsUnderlyingAccessibilityErrorWhenPositionWriteFails() { + let fixture = FocusedWindowAXFixture() + + var writtenAttributes: [String] = [] + let accessibilityClient = fixture.accessibilityClient(setAttributeValue: { _, attribute, _ in + writtenAttributes.append(attribute as String) + return .cannotComplete + }) + + XCTAssertThrowsError( + try fixture.frameController(accessibilityClient: accessibilityClient) + .setFrame(CGRect(x: 1, y: 2, width: 3, height: 4), for: .focused) + ) { error in + XCTAssertEqual( + error as? WindowAccessibilityError, + .accessibilityOperationFailed(operation: .setFrame, failure: .cannotComplete) + ) + } + XCTAssertEqual(writtenAttributes, [AXWindowAttribute.position.accessibilityName]) + } + + func testSetFrameDoesNotPartiallyWriteWhenSizeValueCreationFails() { + let fixture = FocusedWindowAXFixture() + + var writtenAttributes: [String] = [] + let accessibilityClient = fixture.accessibilityClient( + setAttributeValue: { _, attribute, _ in + writtenAttributes.append(attribute as String) + return .success + }, + valueCreate: { type, pointer in + guard type != .cgSize else { return nil } + return AXValueCreate(type, pointer) + } + ) + + XCTAssertThrowsError( + try fixture.frameController(accessibilityClient: accessibilityClient) + .setFrame(CGRect(x: 1, y: 2, width: 3, height: 4), for: .focused) + ) { error in + XCTAssertEqual(error as? WindowAccessibilityError, .invalidAccessibilityValue(.setFrame)) + } + XCTAssertEqual(writtenAttributes, []) + } +} + +private enum FrameAttributeWrite: Equatable { + case position(CGPoint) + case size(CGSize) +} + +private func typedReference( + _ value: CFTypeRef, + as _: Value.Type +) -> Value { + Unmanaged + .fromOpaque(Unmanaged.passUnretained(value).toOpaque()) + .takeUnretainedValue() +} diff --git a/Tests/WindowKitAppKitTests/Accessibility/AXWindowControllerRoleTests.swift b/Tests/WindowKitAppKitTests/Accessibility/AXWindowControllerRoleTests.swift new file mode 100644 index 0000000..2ed366f --- /dev/null +++ b/Tests/WindowKitAppKitTests/Accessibility/AXWindowControllerRoleTests.swift @@ -0,0 +1,188 @@ +import WindowKit +import XCTest + +@testable import WindowKitAppKit + +@MainActor +final class AXWindowControllerRoleTests: XCTestCase { + func testRoleReturnsResolvedAccessibilityRole() throws { + let fixture = FocusedWindowAXFixture() + let roleAttribute = AXWindowAttribute.role.accessibilityName + let subroleAttribute = AXWindowAttribute.subrole.accessibilityName + var readAttributes: [String] = [] + + let accessibilityClient = fixture.accessibilityClient { element, attribute, value in + XCTAssertEqual(CFEqual(element, fixture.windowElement), true) + switch attribute as String { + case roleAttribute: + readAttributes.append(attribute as String) + value.pointee = AXWindowRoleValue.sheet.accessibilityName as CFString + return .success + case subroleAttribute: + readAttributes.append(attribute as String) + value.pointee = nil + return .noValue + default: + value.pointee = nil + return .noValue + } + } + + let provider = fixture.roleProvider(accessibilityClient: accessibilityClient) + + XCTAssertEqual(try provider.role(for: .focused), .sheet) + XCTAssertEqual(readAttributes, [roleAttribute, subroleAttribute]) + } + + func testRoleReturnsResolvedAccessibilitySubrole() throws { + let fixture = FocusedWindowAXFixture() + let roleAttribute = AXWindowAttribute.role.accessibilityName + let subroleAttribute = AXWindowAttribute.subrole.accessibilityName + + let accessibilityClient = fixture.accessibilityClient { element, attribute, value in + XCTAssertEqual(CFEqual(element, fixture.windowElement), true) + switch attribute as String { + case roleAttribute: + value.pointee = AXWindowRoleValue.window.accessibilityName as CFString + return .success + case subroleAttribute: + value.pointee = AXWindowSubroleValue.dialog.accessibilityName as CFString + return .success + default: + value.pointee = nil + return .noValue + } + } + + let provider = fixture.roleProvider(accessibilityClient: accessibilityClient) + + XCTAssertEqual(try provider.role(for: .focused), .dialog) + } + + func testRoleReturnsUnclassifiedWhenRoleMetadataIsUnavailable() throws { + let fixture = FocusedWindowAXFixture() + + let provider = fixture.roleProvider() + + XCTAssertEqual( + try provider.role(for: .focused), + .unclassified(accessibilityRole: nil, accessibilitySubrole: nil)) + } + + func testRolePreservesSubroleMetadataWhenRoleIsUnavailable() throws { + let fixture = FocusedWindowAXFixture() + let roleAttribute = AXWindowAttribute.role.accessibilityName + let subroleAttribute = AXWindowAttribute.subrole.accessibilityName + + let accessibilityClient = fixture.accessibilityClient { element, attribute, value in + XCTAssertEqual(CFEqual(element, fixture.windowElement), true) + switch attribute as String { + case roleAttribute: + value.pointee = nil + return .noValue + case subroleAttribute: + value.pointee = AXWindowSubroleValue.dialog.accessibilityName as CFString + return .success + default: + value.pointee = nil + return .noValue + } + } + + let provider = fixture.roleProvider(accessibilityClient: accessibilityClient) + + XCTAssertEqual( + try provider.role(for: .focused), + .unclassified( + accessibilityRole: nil, + accessibilitySubrole: AXWindowSubroleValue.dialog.accessibilityName + ) + ) + } + + func testRoleThrowsUnderlyingAccessibilityErrorWhenRoleReadFails() { + let fixture = FocusedWindowAXFixture() + let roleAttribute = AXWindowAttribute.role.accessibilityName + var readAttributes: [String] = [] + + let accessibilityClient = fixture.accessibilityClient { element, attribute, value in + XCTAssertEqual(CFEqual(element, fixture.windowElement), true) + switch attribute as String { + case roleAttribute: + readAttributes.append(attribute as String) + value.pointee = nil + return .cannotComplete + default: + value.pointee = nil + return .noValue + } + } + + let provider = fixture.roleProvider(accessibilityClient: accessibilityClient) + + XCTAssertThrowsError(try provider.role(for: .focused)) { error in + XCTAssertEqual( + error as? WindowAccessibilityError, + .accessibilityOperationFailed(operation: .readRole, failure: .cannotComplete) + ) + } + XCTAssertEqual(readAttributes, [roleAttribute]) + } + + func testRoleThrowsUnderlyingAccessibilityErrorWhenSubroleReadFails() { + let fixture = FocusedWindowAXFixture() + let roleAttribute = AXWindowAttribute.role.accessibilityName + let subroleAttribute = AXWindowAttribute.subrole.accessibilityName + var readAttributes: [String] = [] + + let accessibilityClient = fixture.accessibilityClient { element, attribute, value in + XCTAssertEqual(CFEqual(element, fixture.windowElement), true) + switch attribute as String { + case roleAttribute: + readAttributes.append(attribute as String) + value.pointee = AXWindowRoleValue.window.accessibilityName as CFString + return .success + case subroleAttribute: + readAttributes.append(attribute as String) + value.pointee = nil + return .cannotComplete + default: + value.pointee = nil + return .noValue + } + } + + let provider = fixture.roleProvider(accessibilityClient: accessibilityClient) + + XCTAssertThrowsError(try provider.role(for: .focused)) { error in + XCTAssertEqual( + error as? WindowAccessibilityError, + .accessibilityOperationFailed(operation: .readRole, failure: .cannotComplete) + ) + } + XCTAssertEqual(readAttributes, [roleAttribute, subroleAttribute]) + } + + func testRoleThrowsInvalidAXValueForUnexpectedRoleType() { + let fixture = FocusedWindowAXFixture() + let roleAttribute = AXWindowAttribute.role.accessibilityName + + let accessibilityClient = fixture.accessibilityClient { element, attribute, value in + XCTAssertEqual(CFEqual(element, fixture.windowElement), true) + switch attribute as String { + case roleAttribute: + value.pointee = NSNumber(value: 1) + return .success + default: + value.pointee = nil + return .noValue + } + } + + let provider = fixture.roleProvider(accessibilityClient: accessibilityClient) + + XCTAssertThrowsError(try provider.role(for: .focused)) { error in + XCTAssertEqual(error as? WindowAccessibilityError, .invalidAccessibilityValue(.readRole)) + } + } +} diff --git a/Tests/WindowKitAppKitTests/Accessibility/AXWindowStateProviderTests.swift b/Tests/WindowKitAppKitTests/Accessibility/AXWindowControllerStateTests.swift similarity index 59% rename from Tests/WindowKitAppKitTests/Accessibility/AXWindowStateProviderTests.swift rename to Tests/WindowKitAppKitTests/Accessibility/AXWindowControllerStateTests.swift index 17e73ed..71085e5 100644 --- a/Tests/WindowKitAppKitTests/Accessibility/AXWindowStateProviderTests.swift +++ b/Tests/WindowKitAppKitTests/Accessibility/AXWindowControllerStateTests.swift @@ -1,21 +1,18 @@ import ApplicationServices +import WindowKit import XCTest -@testable import WindowKit @testable import WindowKitAppKit @MainActor -final class AXWindowStateProviderTests: XCTestCase { +final class AXWindowControllerStateTests: XCTestCase { func testStateReturnsExpectedValuesForFocusedWindow() throws { - defer { AXAPI.reset() } - AXAPI.isProcessTrusted = { true } - - let minimizedAttribute = kAXMinimizedAttribute as String - let fullScreenAttribute = AXWindowAttributeName.fullScreen + let minimizedAttribute = AXWindowAttribute.minimized.accessibilityName + let fullScreenAttribute = AXWindowAttribute.fullScreen.accessibilityName var readAttributes: [String] = [] let fixture = FocusedWindowAXFixture() - fixture.installCopyAttributeValue { element, attribute, value in + let accessibilityClient = fixture.accessibilityClient { element, attribute, value in XCTAssertEqual(CFEqual(element, fixture.windowElement), true) switch attribute as String { case minimizedAttribute: @@ -32,7 +29,7 @@ final class AXWindowStateProviderTests: XCTestCase { } } - let provider = fixture.stateProvider() + let provider = fixture.stateProvider(accessibilityClient: accessibilityClient) let state = try provider.state(for: .focused) XCTAssertEqual(state.isMinimized, true) @@ -41,15 +38,12 @@ final class AXWindowStateProviderTests: XCTestCase { } func testStateReturnsNilForUnavailableValues() throws { - defer { AXAPI.reset() } - AXAPI.isProcessTrusted = { true } - - let minimizedAttribute = kAXMinimizedAttribute as String - let fullScreenAttribute = AXWindowAttributeName.fullScreen + let minimizedAttribute = AXWindowAttribute.minimized.accessibilityName + let fullScreenAttribute = AXWindowAttribute.fullScreen.accessibilityName var readAttributes: [String] = [] let fixture = FocusedWindowAXFixture() - fixture.installCopyAttributeValue { element, attribute, value in + let accessibilityClient = fixture.accessibilityClient { element, attribute, value in XCTAssertEqual(CFEqual(element, fixture.windowElement), true) switch attribute as String { case minimizedAttribute, fullScreenAttribute: @@ -62,7 +56,7 @@ final class AXWindowStateProviderTests: XCTestCase { } } - let provider = fixture.stateProvider() + let provider = fixture.stateProvider(accessibilityClient: accessibilityClient) let state = try provider.state(for: .focused) XCTAssertNil(state.isMinimized) @@ -71,23 +65,22 @@ final class AXWindowStateProviderTests: XCTestCase { } func testStateThrowsWhenAccessibilityNotTrusted() { - defer { AXAPI.reset() } - AXAPI.isProcessTrusted = { false } - - let provider = AXWindowStateProvider() + let systemElement = AXUIElementCreateSystemWide() + let provider = AXWindowController( + accessibilityClient: .testing(isProcessTrusted: { false }), + systemWideElement: systemElement, + applicationChecker: RunningApplicationCheckerStub(isRunning: false) + ) XCTAssertThrowsError(try provider.state(for: .focused)) { error in - XCTAssertEqual(error as? AXWindowError, .accessibilityNotTrusted) + XCTAssertEqual(error as? WindowAccessibilityError, .accessibilityNotTrusted) } } func testStateThrowsInvalidAXValue() { - defer { AXAPI.reset() } - AXAPI.isProcessTrusted = { true } - - let minimizedAttribute = kAXMinimizedAttribute as String + let minimizedAttribute = AXWindowAttribute.minimized.accessibilityName let fixture = FocusedWindowAXFixture() - fixture.installCopyAttributeValue { element, attribute, value in + let accessibilityClient = fixture.accessibilityClient { element, attribute, value in XCTAssertEqual(CFEqual(element, fixture.windowElement), true) switch attribute as String { case minimizedAttribute: @@ -99,20 +92,17 @@ final class AXWindowStateProviderTests: XCTestCase { } } - let provider = fixture.stateProvider() + let provider = fixture.stateProvider(accessibilityClient: accessibilityClient) XCTAssertThrowsError(try provider.state(for: .focused)) { error in - XCTAssertEqual(error as? AXWindowError, .invalidAXValue) + XCTAssertEqual(error as? WindowAccessibilityError, .invalidAccessibilityValue(.readState)) } } func testStateThrowsUnderlyingAccessibilityError() { - defer { AXAPI.reset() } - AXAPI.isProcessTrusted = { true } - - let minimizedAttribute = kAXMinimizedAttribute as String + let minimizedAttribute = AXWindowAttribute.minimized.accessibilityName let fixture = FocusedWindowAXFixture() - fixture.installCopyAttributeValue { element, attribute, value in + let accessibilityClient = fixture.accessibilityClient { element, attribute, value in XCTAssertEqual(CFEqual(element, fixture.windowElement), true) switch attribute as String { case minimizedAttribute: @@ -124,9 +114,12 @@ final class AXWindowStateProviderTests: XCTestCase { } } - let provider = fixture.stateProvider() + let provider = fixture.stateProvider(accessibilityClient: accessibilityClient) XCTAssertThrowsError(try provider.state(for: .focused)) { error in - XCTAssertEqual(error as? AXWindowError, .underlyingAccessibilityError(.notImplemented)) + XCTAssertEqual( + error as? WindowAccessibilityError, + .accessibilityOperationFailed(operation: .readState, failure: .notImplemented) + ) } } } diff --git a/Tests/WindowKitAppKitTests/Accessibility/AXWindowElementResolverTests.swift b/Tests/WindowKitAppKitTests/Accessibility/AXWindowElementResolverTests.swift index 4f79b7d..5032ae2 100644 --- a/Tests/WindowKitAppKitTests/Accessibility/AXWindowElementResolverTests.swift +++ b/Tests/WindowKitAppKitTests/Accessibility/AXWindowElementResolverTests.swift @@ -1,22 +1,20 @@ import ApplicationServices +import os import XCTest -@testable import WindowKit +import WindowKit @testable import WindowKitAppKit @MainActor final class AXWindowElementResolverTests: XCTestCase { func testFocusedTargetReturnsFocusedWindow() throws { - defer { AXAPI.reset() } - AXAPI.isProcessTrusted = { true } - let systemElement = AXUIElementCreateSystemWide() let appElement = AXUIElementCreateApplication(getpid()) let windowElement = AXUIElementCreateApplication(getpid()) - let focusedApplicationAttribute = kAXFocusedApplicationAttribute as String - let focusedWindowAttribute = kAXFocusedWindowAttribute as String - AXAPI.copyAttributeValue = { element, attribute, value in + let focusedApplicationAttribute = AXWindowAttribute.focusedApplication.accessibilityName + let focusedWindowAttribute = AXWindowAttribute.focusedWindow.accessibilityName + let accessibilityClient = AXClient.testing(copyAttributeValue: { element, attribute, value in switch attribute as String { case focusedApplicationAttribute: XCTAssertEqual(CFEqual(element, systemElement), true) @@ -30,9 +28,10 @@ final class AXWindowElementResolverTests: XCTestCase { value.pointee = nil return .noValue } - } + }) let resolver = AXWindowElementResolver( + accessibilityClient: accessibilityClient, systemWideElement: systemElement, applicationChecker: RunningApplicationCheckerStub(isRunning: false) ) @@ -42,10 +41,8 @@ final class AXWindowElementResolverTests: XCTestCase { } func testFocusedTargetThrowsWhenAccessibilityNotTrusted() { - defer { AXAPI.reset() } - AXAPI.isProcessTrusted = { false } - let resolver = AXWindowElementResolver( + accessibilityClient: .testing(isProcessTrusted: { false }), systemWideElement: AXUIElementCreateSystemWide(), applicationChecker: RunningApplicationCheckerStub(isRunning: false) ) @@ -56,14 +53,13 @@ final class AXWindowElementResolverTests: XCTestCase { } func testFocusedTargetThrowsWhenFocusedApplicationMissing() { - defer { AXAPI.reset() } - AXAPI.isProcessTrusted = { true } - AXAPI.copyAttributeValue = { _, _, value in + let accessibilityClient = AXClient.testing(copyAttributeValue: { _, _, value in value.pointee = nil return .noValue - } + }) let resolver = AXWindowElementResolver( + accessibilityClient: accessibilityClient, systemWideElement: AXUIElementCreateSystemWide(), applicationChecker: RunningApplicationCheckerStub(isRunning: false) ) @@ -74,16 +70,14 @@ final class AXWindowElementResolverTests: XCTestCase { } func testFocusedTargetThrowsInvalidAXValueWhenFocusedApplicationHasUnexpectedType() { - defer { AXAPI.reset() } - AXAPI.isProcessTrusted = { true } - - AXAPI.copyAttributeValue = { _, attribute, value in - XCTAssertEqual(attribute as String, kAXFocusedApplicationAttribute as String) + let accessibilityClient = AXClient.testing(copyAttributeValue: { _, attribute, value in + XCTAssertEqual(attribute as String, AXWindowAttribute.focusedApplication.accessibilityName) value.pointee = "invalid" as CFString return .success - } + }) let resolver = AXWindowElementResolver( + accessibilityClient: accessibilityClient, systemWideElement: AXUIElementCreateSystemWide(), applicationChecker: RunningApplicationCheckerStub(isRunning: false) ) @@ -94,16 +88,14 @@ final class AXWindowElementResolverTests: XCTestCase { } func testFocusedTargetThrowsUnderlyingAccessibilityErrorWhenFocusedApplicationReadFails() { - defer { AXAPI.reset() } - AXAPI.isProcessTrusted = { true } - - AXAPI.copyAttributeValue = { _, attribute, value in - XCTAssertEqual(attribute as String, kAXFocusedApplicationAttribute as String) + let accessibilityClient = AXClient.testing(copyAttributeValue: { _, attribute, value in + XCTAssertEqual(attribute as String, AXWindowAttribute.focusedApplication.accessibilityName) value.pointee = nil return .cannotComplete - } + }) let resolver = AXWindowElementResolver( + accessibilityClient: accessibilityClient, systemWideElement: AXUIElementCreateSystemWide(), applicationChecker: RunningApplicationCheckerStub(isRunning: false) ) @@ -114,13 +106,10 @@ final class AXWindowElementResolverTests: XCTestCase { } func testFocusedTargetThrowsWhenFocusedWindowMissing() { - defer { AXAPI.reset() } - AXAPI.isProcessTrusted = { true } - let appElement = AXUIElementCreateApplication(getpid()) - let focusedApplicationAttribute = kAXFocusedApplicationAttribute as String - let focusedWindowAttribute = kAXFocusedWindowAttribute as String - AXAPI.copyAttributeValue = { _, attribute, value in + let focusedApplicationAttribute = AXWindowAttribute.focusedApplication.accessibilityName + let focusedWindowAttribute = AXWindowAttribute.focusedWindow.accessibilityName + let accessibilityClient = AXClient.testing(copyAttributeValue: { _, attribute, value in switch attribute as String { case focusedApplicationAttribute: value.pointee = appElement @@ -132,9 +121,10 @@ final class AXWindowElementResolverTests: XCTestCase { value.pointee = nil return .noValue } - } + }) let resolver = AXWindowElementResolver( + accessibilityClient: accessibilityClient, systemWideElement: AXUIElementCreateSystemWide(), applicationChecker: RunningApplicationCheckerStub(isRunning: false) ) @@ -145,10 +135,8 @@ final class AXWindowElementResolverTests: XCTestCase { } func testWindowIdentifierTargetThrowsWhenAccessibilityNotTrusted() { - defer { AXAPI.reset() } - AXAPI.isProcessTrusted = { false } - let resolver = AXWindowElementResolver( + accessibilityClient: .testing(isProcessTrusted: { false }), systemWideElement: AXUIElementCreateSystemWide(), applicationChecker: RunningApplicationCheckerStub(isRunning: false) ) @@ -159,14 +147,12 @@ final class AXWindowElementResolverTests: XCTestCase { } func testWindowIdentifierTargetThrowsWhenRunningApplicationMissing() { - defer { AXAPI.reset() } - AXAPI.isProcessTrusted = { true } - let applicationChecker = RecordingRunningApplicationChecker(isRunning: false) let resolver = AXWindowElementResolver( + accessibilityClient: .testing(), systemWideElement: AXUIElementCreateSystemWide(), applicationChecker: applicationChecker, - windowListInfoProvider: CGWindowListInfoProviderStub(ownerProcessIdentifier: 1234) + windowListProvider: WindowListProviderStub(ownerProcessIdentifier: 1234) ) XCTAssertThrowsError(try resolver.windowElement(for: .windowIdentifier(42))) { error in @@ -175,14 +161,12 @@ final class AXWindowElementResolverTests: XCTestCase { XCTAssertEqual(applicationChecker.requests, [1234]) } - func testWindowIdentifierTargetThrowsWhenWindowListInfoIsMissing() { - defer { AXAPI.reset() } - AXAPI.isProcessTrusted = { true } - + func testWindowIdentifierTargetThrowsWhenWindowListIsMissing() { let resolver = AXWindowElementResolver( + accessibilityClient: .testing(), systemWideElement: AXUIElementCreateSystemWide(), applicationChecker: RunningApplicationCheckerStub(isRunning: false), - windowListInfoProvider: CGWindowListInfoProviderStub(entries: nil) + windowListProvider: WindowListProviderStub(entries: nil) ) XCTAssertThrowsError(try resolver.windowElement(for: .windowIdentifier(42))) { error in @@ -191,13 +175,11 @@ final class AXWindowElementResolverTests: XCTestCase { } func testWindowIdentifierTargetThrowsWhenOwnerProcessIdentifierIsMissing() { - defer { AXAPI.reset() } - AXAPI.isProcessTrusted = { true } - let resolver = AXWindowElementResolver( + accessibilityClient: .testing(), systemWideElement: AXUIElementCreateSystemWide(), applicationChecker: RunningApplicationCheckerStub(isRunning: false), - windowListInfoProvider: CGWindowListInfoProviderStub(entries: [[:]]) + windowListProvider: WindowListProviderStub(entries: [WindowListEntry()]) ) XCTAssertThrowsError(try resolver.windowElement(for: .windowIdentifier(42))) { error in @@ -206,16 +188,13 @@ final class AXWindowElementResolverTests: XCTestCase { } func testWindowIdentifierTargetReturnsMatchingAccessibilityWindow() throws { - defer { AXAPI.reset() } - AXAPI.isProcessTrusted = { true } - let matchedWindow = AXUIElementCreateApplication(getpid()) - AXAPI.copyAttributeValue = { element, attribute, value in + let accessibilityClient = AXClient.testing(copyAttributeValue: { element, attribute, value in switch attribute as String { - case kAXWindowsAttribute: + case AXWindowAttribute.windows.accessibilityName: value.pointee = [matchedWindow] as CFArray return .success - case AXWindowAttributeName.windowIdentifier: + case AXWindowAttribute.windowIdentifier.accessibilityName: XCTAssertEqual(CFEqual(element, matchedWindow), true) value.pointee = NSNumber(value: UInt32(42)) return .success @@ -223,87 +202,127 @@ final class AXWindowElementResolverTests: XCTestCase { value.pointee = nil return .noValue } - } + }) let resolver = AXWindowElementResolver( + accessibilityClient: accessibilityClient, systemWideElement: AXUIElementCreateSystemWide(), applicationChecker: RunningApplicationCheckerStub(), - windowListInfoProvider: CGWindowListInfoProviderStub(ownerProcessIdentifier: 1234) + windowListProvider: WindowListProviderStub(ownerProcessIdentifier: 1234) ) let resolved = try resolver.windowElement(for: .windowIdentifier(42)) XCTAssertEqual(CFEqual(resolved, matchedWindow), true) } - func testWindowIdentifierTargetRequestsSingleWindowListInfo() throws { - defer { AXAPI.reset() } - AXAPI.isProcessTrusted = { true } - - let windowListInfoProvider = RecordingCGWindowListInfoProvider( - entries: [[kCGWindowOwnerPID as String: NSNumber(value: pid_t(1234))]] + func testWindowIdentifierTargetRequestsSingleWindowList() throws { + let windowListProvider = RecordingWindowListProvider( + entries: [WindowListEntry(ownerProcessIdentifier: 1234, windowIdentifier: 42)] ) let matchedWindow = AXUIElementCreateApplication(getpid()) - AXAPI.copyAttributeValue = { _, attribute, value in + let accessibilityClient = AXClient.testing(copyAttributeValue: { _, attribute, value in switch attribute as String { - case kAXWindowsAttribute: + case AXWindowAttribute.windows.accessibilityName: value.pointee = [matchedWindow] as CFArray return .success - case AXWindowAttributeName.windowIdentifier: + case AXWindowAttribute.windowIdentifier.accessibilityName: value.pointee = NSNumber(value: UInt32(42)) return .success default: value.pointee = nil return .noValue } - } + }) let resolver = AXWindowElementResolver( + accessibilityClient: accessibilityClient, systemWideElement: AXUIElementCreateSystemWide(), applicationChecker: RunningApplicationCheckerStub(), - windowListInfoProvider: windowListInfoProvider + windowListProvider: windowListProvider ) _ = try resolver.windowElement(for: .windowIdentifier(42)) - XCTAssertEqual(windowListInfoProvider.requests.count, 1) - XCTAssertEqual(windowListInfoProvider.requests.first?.relativeToWindow, 42) + XCTAssertEqual(windowListProvider.requests.count, 1) + XCTAssertEqual(windowListProvider.requests.first?.windowIdentifier, 42) XCTAssertEqual( - windowListInfoProvider.requests.first?.options, + windowListProvider.requests.first?.options, [.optionIncludingWindow, .excludeDesktopElements] ) } func testWindowIdentifierTargetCreatesApplicationElementForOwnerProcessIdentifier() throws { - defer { AXAPI.reset() } - AXAPI.isProcessTrusted = { true } + let appElement = AXUIElementCreateApplication(1234) + let matchedWindow = AXUIElementCreateApplication(getpid()) + var createdApplicationProcessIdentifier: pid_t? + let accessibilityClient = AXClient.testing( + createApplicationElement: { processIdentifier in + createdApplicationProcessIdentifier = processIdentifier + return appElement + }, + copyAttributeValue: { element, attribute, value in + switch attribute as String { + case AXWindowAttribute.windows.accessibilityName: + XCTAssertEqual(CFEqual(element, appElement), true) + value.pointee = [matchedWindow] as CFArray + return .success + case AXWindowAttribute.windowIdentifier.accessibilityName: + XCTAssertEqual(CFEqual(element, matchedWindow), true) + value.pointee = NSNumber(value: UInt32(42)) + return .success + default: + value.pointee = nil + return .noValue + } + } + ) + + let resolver = AXWindowElementResolver( + accessibilityClient: accessibilityClient, + systemWideElement: AXUIElementCreateSystemWide(), + applicationChecker: RunningApplicationCheckerStub(), + windowListProvider: WindowListProviderStub(ownerProcessIdentifier: 1234) + ) + + _ = try resolver.windowElement(for: .windowIdentifier(42)) + + XCTAssertEqual(createdApplicationProcessIdentifier, 1234) + } + func testWindowIdentifierTargetUsesOwnerProcessIdentifierFromMatchingWindowListEntry() throws { let appElement = AXUIElementCreateApplication(1234) let matchedWindow = AXUIElementCreateApplication(getpid()) var createdApplicationProcessIdentifier: pid_t? - AXAPI.createApplicationElement = { processIdentifier in - createdApplicationProcessIdentifier = processIdentifier - return appElement - } - AXAPI.copyAttributeValue = { element, attribute, value in - switch attribute as String { - case kAXWindowsAttribute: - XCTAssertEqual(CFEqual(element, appElement), true) - value.pointee = [matchedWindow] as CFArray - return .success - case AXWindowAttributeName.windowIdentifier: - XCTAssertEqual(CFEqual(element, matchedWindow), true) - value.pointee = NSNumber(value: UInt32(42)) - return .success - default: - value.pointee = nil - return .noValue + let accessibilityClient = AXClient.testing( + createApplicationElement: { processIdentifier in + createdApplicationProcessIdentifier = processIdentifier + return appElement + }, + copyAttributeValue: { element, attribute, value in + switch attribute as String { + case AXWindowAttribute.windows.accessibilityName: + XCTAssertEqual(CFEqual(element, appElement), true) + value.pointee = [matchedWindow] as CFArray + return .success + case AXWindowAttribute.windowIdentifier.accessibilityName: + XCTAssertEqual(CFEqual(element, matchedWindow), true) + value.pointee = NSNumber(value: UInt32(42)) + return .success + default: + value.pointee = nil + return .noValue + } } - } + ) let resolver = AXWindowElementResolver( + accessibilityClient: accessibilityClient, systemWideElement: AXUIElementCreateSystemWide(), applicationChecker: RunningApplicationCheckerStub(), - windowListInfoProvider: CGWindowListInfoProviderStub(ownerProcessIdentifier: 1234) + windowListProvider: WindowListProviderStub(entries: [ + WindowListEntry(ownerProcessIdentifier: 9999, windowIdentifier: 7), + WindowListEntry(ownerProcessIdentifier: 1234, windowIdentifier: 42), + ]) ) _ = try resolver.windowElement(for: .windowIdentifier(42)) @@ -312,18 +331,17 @@ final class AXWindowElementResolverTests: XCTestCase { } func testWindowIdentifierTargetThrowsWhenApplicationWindowsAreMissing() { - defer { AXAPI.reset() } - AXAPI.isProcessTrusted = { true } - AXAPI.copyAttributeValue = { _, attribute, value in - XCTAssertEqual(attribute as String, kAXWindowsAttribute as String) + let accessibilityClient = AXClient.testing(copyAttributeValue: { _, attribute, value in + XCTAssertEqual(attribute as String, AXWindowAttribute.windows.accessibilityName) value.pointee = nil return .noValue - } + }) let resolver = AXWindowElementResolver( + accessibilityClient: accessibilityClient, systemWideElement: AXUIElementCreateSystemWide(), applicationChecker: RunningApplicationCheckerStub(), - windowListInfoProvider: CGWindowListInfoProviderStub(ownerProcessIdentifier: 1234) + windowListProvider: WindowListProviderStub(ownerProcessIdentifier: 1234) ) XCTAssertThrowsError(try resolver.windowElement(for: .windowIdentifier(42))) { error in @@ -332,18 +350,17 @@ final class AXWindowElementResolverTests: XCTestCase { } func testWindowIdentifierTargetThrowsInvalidAXValueWhenApplicationWindowsHaveUnexpectedType() { - defer { AXAPI.reset() } - AXAPI.isProcessTrusted = { true } - AXAPI.copyAttributeValue = { _, attribute, value in - XCTAssertEqual(attribute as String, kAXWindowsAttribute as String) + let accessibilityClient = AXClient.testing(copyAttributeValue: { _, attribute, value in + XCTAssertEqual(attribute as String, AXWindowAttribute.windows.accessibilityName) value.pointee = "invalid" as CFString return .success - } + }) let resolver = AXWindowElementResolver( + accessibilityClient: accessibilityClient, systemWideElement: AXUIElementCreateSystemWide(), applicationChecker: RunningApplicationCheckerStub(), - windowListInfoProvider: CGWindowListInfoProviderStub(ownerProcessIdentifier: 1234) + windowListProvider: WindowListProviderStub(ownerProcessIdentifier: 1234) ) XCTAssertThrowsError(try resolver.windowElement(for: .windowIdentifier(42))) { error in @@ -352,27 +369,25 @@ final class AXWindowElementResolverTests: XCTestCase { } func testWindowIdentifierTargetThrowsWhenNoAccessibilityWindowMatches() { - defer { AXAPI.reset() } - AXAPI.isProcessTrusted = { true } - - AXAPI.copyAttributeValue = { _, attribute, value in + let accessibilityClient = AXClient.testing(copyAttributeValue: { _, attribute, value in switch attribute as String { - case kAXWindowsAttribute: + case AXWindowAttribute.windows.accessibilityName: value.pointee = [AXUIElementCreateApplication(getpid())] as CFArray return .success - case AXWindowAttributeName.windowIdentifier: + case AXWindowAttribute.windowIdentifier.accessibilityName: value.pointee = NSNumber(value: UInt32(7)) return .success default: value.pointee = nil return .noValue } - } + }) let resolver = AXWindowElementResolver( + accessibilityClient: accessibilityClient, systemWideElement: AXUIElementCreateSystemWide(), applicationChecker: RunningApplicationCheckerStub(), - windowListInfoProvider: CGWindowListInfoProviderStub(ownerProcessIdentifier: 1234) + windowListProvider: WindowListProviderStub(ownerProcessIdentifier: 1234) ) XCTAssertThrowsError(try resolver.windowElement(for: .windowIdentifier(42))) { error in @@ -381,27 +396,25 @@ final class AXWindowElementResolverTests: XCTestCase { } func testWindowIdentifierTargetThrowsInvalidAXValueWhenWindowIdentifierHasUnexpectedType() { - defer { AXAPI.reset() } - AXAPI.isProcessTrusted = { true } - - AXAPI.copyAttributeValue = { _, attribute, value in + let accessibilityClient = AXClient.testing(copyAttributeValue: { _, attribute, value in switch attribute as String { - case kAXWindowsAttribute: + case AXWindowAttribute.windows.accessibilityName: value.pointee = [AXUIElementCreateApplication(getpid())] as CFArray return .success - case AXWindowAttributeName.windowIdentifier: + case AXWindowAttribute.windowIdentifier.accessibilityName: value.pointee = "invalid" as CFString return .success default: value.pointee = nil return .noValue } - } + }) let resolver = AXWindowElementResolver( + accessibilityClient: accessibilityClient, systemWideElement: AXUIElementCreateSystemWide(), applicationChecker: RunningApplicationCheckerStub(), - windowListInfoProvider: CGWindowListInfoProviderStub(ownerProcessIdentifier: 1234) + windowListProvider: WindowListProviderStub(ownerProcessIdentifier: 1234) ) XCTAssertThrowsError(try resolver.windowElement(for: .windowIdentifier(42))) { error in @@ -411,35 +424,45 @@ final class AXWindowElementResolverTests: XCTestCase { } private final class RecordingRunningApplicationChecker: - RunningApplicationChecking, @unchecked Sendable + RunningApplicationChecking { - private(set) var requests: [pid_t] = [] + private let requestsStorage = OSAllocatedUnfairLock(initialState: [pid_t]()) let isRunning: Bool + var requests: [pid_t] { + requestsStorage.withLock { $0 } + } + init(isRunning: Bool) { self.isRunning = isRunning } func isRunningApplication(processIdentifier: pid_t) -> Bool { - requests.append(processIdentifier) + requestsStorage.withLock { + $0.append(processIdentifier) + } return isRunning } } -private final class RecordingCGWindowListInfoProvider: - CGWindowListInfoProviding, @unchecked Sendable +private final class RecordingWindowListProvider: + WindowListProviding { - private(set) var requests: [(options: CGWindowListOption, relativeToWindow: CGWindowID)] = [] - let entries: [CGWindowListInfoEntry]? + private let requestsStorage = OSAllocatedUnfairLock(initialState: [WindowListRequest]()) + let entries: [WindowListEntry]? - init(entries: [CGWindowListInfoEntry]?) { + var requests: [WindowListRequest] { + requestsStorage.withLock { $0 } + } + + init(entries: [WindowListEntry]?) { self.entries = entries } - func windowListInfo(options: CGWindowListOption, relativeToWindow: CGWindowID) - -> [CGWindowListInfoEntry]? - { - requests.append((options: options, relativeToWindow: relativeToWindow)) + func windowList(for request: WindowListRequest) -> [WindowListEntry]? { + requestsStorage.withLock { + $0.append(request) + } return entries } } diff --git a/Tests/WindowKitAppKitTests/Accessibility/AXWindowErrorTests.swift b/Tests/WindowKitAppKitTests/Accessibility/AXWindowErrorTests.swift index 5bf3ad9..ea94d85 100644 --- a/Tests/WindowKitAppKitTests/Accessibility/AXWindowErrorTests.swift +++ b/Tests/WindowKitAppKitTests/Accessibility/AXWindowErrorTests.swift @@ -1,22 +1,10 @@ import ApplicationServices +import WindowKit import XCTest -@testable import WindowKit @testable import WindowKitAppKit final class AXWindowErrorTests: XCTestCase { - func testAXWindowErrorIsSendable() { - for error in sampleErrors { - assertSendable(error) - } - } - - func testAXWindowErrorEquatableMatchesIdenticalValues() { - for error in sampleErrors { - XCTAssertEqual(error, error) - } - } - func testAXWindowErrorEquatableDistinguishesCasesAndAssociatedValues() { XCTAssertNotEqual( AXWindowError.applicationNotFound(.focused), @@ -30,26 +18,77 @@ final class AXWindowErrorTests: XCTestCase { AXWindowError.applicationNotFound(.focused), .windowNotFound(.focused) ) - XCTAssertNotEqual( - AXWindowError.unsupportedAction(AXWindowActionName.lower), - .unsupportedAction("AXRaise") - ) XCTAssertNotEqual( AXWindowError.underlyingAccessibilityError(.notImplemented), .underlyingAccessibilityError(.cannotComplete) ) } - private func assertSendable(_: Value) {} -} + func testAXWindowErrorMapsToPublicAccessibilityError() { + XCTAssertEqual( + AXWindowError.accessibilityNotTrusted.windowAccessibilityError(for: .readFrame), + .accessibilityNotTrusted + ) + XCTAssertEqual( + AXWindowError.applicationNotFound(.focused).windowAccessibilityError(for: .readFrame), + .applicationUnavailable(.focused) + ) + XCTAssertEqual( + AXWindowError.windowNotFound(.windowIdentifier(1)).windowAccessibilityError(for: .readFrame), + .windowUnavailable(.windowIdentifier(1)) + ) + XCTAssertEqual( + AXWindowError.invalidAXValue.windowAccessibilityError(for: .readFrame), + .invalidAccessibilityValue(.readFrame) + ) + XCTAssertEqual( + AXWindowError.unsupportedOperation.windowAccessibilityError(for: .raise), + .unsupportedAccessibilityOperation(.raise) + ) + XCTAssertEqual( + AXWindowError.underlyingAccessibilityError(.cannotComplete) + .windowAccessibilityError(for: .readFrame), + .accessibilityOperationFailed(operation: .readFrame, failure: .cannotComplete) + ) + } + + func testEveryKnownAXErrorMapsToStablePublicOperationFailure() { + let cases: [(AXError, WindowAccessibilityOperationFailure)] = [ + (.success, .unexpectedSuccess), + (.failure, .failure), + (.illegalArgument, .illegalArgument), + (.invalidUIElement, .invalidUIElement), + (.invalidUIElementObserver, .invalidUIElementObserver), + (.cannotComplete, .cannotComplete), + (.attributeUnsupported, .attributeUnsupported), + (.actionUnsupported, .actionUnsupported), + (.notificationUnsupported, .notificationUnsupported), + (.notImplemented, .notImplemented), + (.notificationAlreadyRegistered, .notificationAlreadyRegistered), + (.notificationNotRegistered, .notificationNotRegistered), + (.apiDisabled, .apiDisabled), + (.noValue, .noValue), + (.parameterizedAttributeUnsupported, .parameterizedAttributeUnsupported), + (.notEnoughPrecision, .notEnoughPrecision), + ] + + for (axError, expectedFailure) in cases { + XCTAssertEqual( + AXWindowError.underlyingAccessibilityError(axError) + .windowAccessibilityError(for: .readFrame), + .accessibilityOperationFailed(operation: .readFrame, failure: expectedFailure) + ) + } + } + + func testUnrecognizedAXErrorMapsToPublicUnrecognizedOperationFailure() throws { + let unknownAXError = try XCTUnwrap(AXError(rawValue: 9999)) + + XCTAssertEqual( + AXWindowError.underlyingAccessibilityError(unknownAXError) + .windowAccessibilityError(for: .readFrame), + .accessibilityOperationFailed(operation: .readFrame, failure: .unrecognizedAXError) + ) + } -private let sampleErrors: [AXWindowError] = [ - .accessibilityNotTrusted, - .applicationNotFound(.focused), - .applicationNotFound(.windowIdentifier(1)), - .windowNotFound(.focused), - .windowNotFound(.windowIdentifier(1)), - .invalidAXValue, - .unsupportedAction(AXWindowActionName.lower), - .underlyingAccessibilityError(.notImplemented), -] +} diff --git a/Tests/WindowKitAppKitTests/Accessibility/AXWindowFrameControllerTests.swift b/Tests/WindowKitAppKitTests/Accessibility/AXWindowFrameControllerTests.swift deleted file mode 100644 index 4bd3c8b..0000000 --- a/Tests/WindowKitAppKitTests/Accessibility/AXWindowFrameControllerTests.swift +++ /dev/null @@ -1,134 +0,0 @@ -import ApplicationServices -import CoreGraphics -import XCTest - -@testable import WindowKit -@testable import WindowKitAppKit - -@MainActor -final class AXWindowFrameControllerTests: XCTestCase { - func testFrameReadsPositionAndSizeAttributes() throws { - defer { AXAPI.reset() } - AXAPI.isProcessTrusted = { true } - - var position = CGPoint(x: 30, y: 40) - var size = CGSize(width: 500, height: 300) - guard let positionValue = AXValueCreate(.cgPoint, &position), - let sizeValue = AXValueCreate(.cgSize, &size) - else { - XCTFail("Failed to create AXValue") - return - } - - let positionAttribute = kAXPositionAttribute as String - let sizeAttribute = kAXSizeAttribute as String - - let fixture = FocusedWindowAXFixture() - fixture.installCopyAttributeValue { element, attribute, value in - XCTAssertEqual(CFEqual(element, fixture.windowElement), true) - switch attribute as String { - case positionAttribute: - value.pointee = positionValue - return .success - case sizeAttribute: - value.pointee = sizeValue - return .success - default: - value.pointee = nil - return .noValue - } - } - - let frame = try fixture.frameController().frame(for: .focused) - - XCTAssertEqual(frame.origin, CGPoint(x: 30, y: 40)) - XCTAssertEqual(frame.size, CGSize(width: 500, height: 300)) - } - - func testFrameThrowsUnderlyingAccessibilityErrorWhenPositionReadFails() { - defer { AXAPI.reset() } - AXAPI.isProcessTrusted = { true } - - let fixture = FocusedWindowAXFixture() - var readAttributes: [String] = [] - fixture.installCopyAttributeValue { element, attribute, value in - XCTAssertEqual(CFEqual(element, fixture.windowElement), true) - readAttributes.append(attribute as String) - value.pointee = nil - return .cannotComplete - } - - XCTAssertThrowsError(try fixture.frameController().frame(for: .focused)) { error in - XCTAssertEqual(error as? AXWindowError, .underlyingAccessibilityError(.cannotComplete)) - } - XCTAssertEqual(readAttributes, [kAXPositionAttribute as String]) - } - - func testSetFrameWritesPositionAndSizeAttributes() throws { - defer { AXAPI.reset() } - AXAPI.isProcessTrusted = { true } - - let fixture = FocusedWindowAXFixture() - fixture.installCopyAttributeValue() - - var writes: [FrameAttributeWrite] = [] - let positionAttribute = kAXPositionAttribute as String - let sizeAttribute = kAXSizeAttribute as String - AXAPI.setAttributeValue = { element, attribute, value in - XCTAssertEqual(CFEqual(element, fixture.windowElement), true) - guard CFGetTypeID(value) == AXValueGetTypeID() else { - XCTFail("Expected AXValue") - return .success - } - let axValue = unsafeDowncast(value, to: AXValue.self) - switch attribute as String { - case positionAttribute: - var point = CGPoint.zero - XCTAssertTrue(AXValueGetValue(axValue, .cgPoint, &point)) - writes.append(.position(point)) - case sizeAttribute: - var size = CGSize.zero - XCTAssertTrue(AXValueGetValue(axValue, .cgSize, &size)) - writes.append(.size(size)) - default: - XCTFail("Unexpected attribute \(attribute)") - } - return .success - } - - try fixture.frameController().setFrame(CGRect(x: 1, y: 2, width: 3, height: 4), for: .focused) - - XCTAssertEqual( - writes, - [ - .position(CGPoint(x: 1, y: 2)), - .size(CGSize(width: 3, height: 4)), - ]) - } - - func testSetFrameThrowsUnderlyingAccessibilityErrorWhenPositionWriteFails() { - defer { AXAPI.reset() } - AXAPI.isProcessTrusted = { true } - - let fixture = FocusedWindowAXFixture() - fixture.installCopyAttributeValue() - - var writtenAttributes: [String] = [] - AXAPI.setAttributeValue = { _, attribute, _ in - writtenAttributes.append(attribute as String) - return .cannotComplete - } - - XCTAssertThrowsError( - try fixture.frameController().setFrame(CGRect(x: 1, y: 2, width: 3, height: 4), for: .focused) - ) { error in - XCTAssertEqual(error as? AXWindowError, .underlyingAccessibilityError(.cannotComplete)) - } - XCTAssertEqual(writtenAttributes, [kAXPositionAttribute as String]) - } -} - -private enum FrameAttributeWrite: Equatable { - case position(CGPoint) - case size(CGSize) -} diff --git a/Tests/WindowKitAppKitTests/Accessibility/AXWindowNamesTests.swift b/Tests/WindowKitAppKitTests/Accessibility/AXWindowNamesTests.swift new file mode 100644 index 0000000..2e7fc66 --- /dev/null +++ b/Tests/WindowKitAppKitTests/Accessibility/AXWindowNamesTests.swift @@ -0,0 +1,30 @@ +import ApplicationServices +import XCTest + +@testable import WindowKitAppKit + +final class AXWindowNamesTests: XCTestCase { + func testWindowAttributesExposeAccessibilityNames() { + XCTAssertEqual( + AXWindowAttribute.focusedApplication.accessibilityName, + kAXFocusedApplicationAttribute as String + ) + XCTAssertEqual( + AXWindowAttribute.focusedWindow.accessibilityName, + kAXFocusedWindowAttribute as String + ) + XCTAssertEqual(AXWindowAttribute.fullScreen.accessibilityName, "AXFullScreen") + XCTAssertEqual(AXWindowAttribute.minimized.accessibilityName, kAXMinimizedAttribute as String) + XCTAssertEqual(AXWindowAttribute.position.accessibilityName, kAXPositionAttribute as String) + XCTAssertEqual(AXWindowAttribute.role.accessibilityName, kAXRoleAttribute as String) + XCTAssertEqual(AXWindowAttribute.size.accessibilityName, kAXSizeAttribute as String) + XCTAssertEqual(AXWindowAttribute.subrole.accessibilityName, kAXSubroleAttribute as String) + XCTAssertEqual(AXWindowAttribute.windowIdentifier.accessibilityName, "AXWindowID") + XCTAssertEqual(AXWindowAttribute.windows.accessibilityName, kAXWindowsAttribute as String) + } + + func testWindowActionsExposeAccessibilityNames() { + XCTAssertEqual(AXWindowAction.raise.accessibilityName, kAXRaiseAction as String) + XCTAssertEqual(AXWindowAction.lower.accessibilityName, "AXLower") + } +} diff --git a/Tests/WindowKitAppKitTests/Accessibility/WindowAccessibilityErrorTests.swift b/Tests/WindowKitAppKitTests/Accessibility/WindowAccessibilityErrorTests.swift new file mode 100644 index 0000000..e964cde --- /dev/null +++ b/Tests/WindowKitAppKitTests/Accessibility/WindowAccessibilityErrorTests.swift @@ -0,0 +1,48 @@ +import WindowKit +import WindowKitAppKit +import XCTest + +final class WindowAccessibilityErrorTests: XCTestCase { + func testWindowAccessibilityErrorIsHashable() { + for error in sampleErrors { + assertHashable(error) + } + } + + func testWindowAccessibilityErrorEquatableDistinguishesCasesAndAssociatedValues() { + XCTAssertNotEqual( + WindowAccessibilityError.accessibilityNotTrusted, + .applicationUnavailable(.focused) + ) + XCTAssertNotEqual( + WindowAccessibilityError.applicationUnavailable(.focused), + .applicationUnavailable(.windowIdentifier(1)) + ) + XCTAssertNotEqual( + WindowAccessibilityError.applicationUnavailable(.focused), + .windowUnavailable(.focused) + ) + XCTAssertNotEqual( + WindowAccessibilityError.unsupportedAccessibilityOperation(.lower), + .unsupportedAccessibilityOperation(.raise) + ) + XCTAssertNotEqual( + WindowAccessibilityError.accessibilityOperationFailed( + operation: .readFrame, + failure: .notImplemented + ), + .accessibilityOperationFailed(operation: .readFrame, failure: .cannotComplete) + ) + } + + private func assertHashable(_: Value) {} +} + +private let sampleErrors: [WindowAccessibilityError] = [ + .accessibilityNotTrusted, + .applicationUnavailable(.focused), + .windowUnavailable(.windowIdentifier(1)), + .invalidAccessibilityValue(.readState), + .unsupportedAccessibilityOperation(.lower), + .accessibilityOperationFailed(operation: .readRole, failure: .notImplemented), +] diff --git a/Tests/WindowKitAppKitTests/Factories/AppKitFactoriesTests.swift b/Tests/WindowKitAppKitTests/Factories/AppKitFactoriesTests.swift deleted file mode 100644 index 70ba31b..0000000 --- a/Tests/WindowKitAppKitTests/Factories/AppKitFactoriesTests.swift +++ /dev/null @@ -1,33 +0,0 @@ -import WindowKit -import WindowKitAppKit -import XCTest - -final class AppKitFactoriesTests: XCTestCase { - func testWindowHitTesterFactorySupportsDefaultAndConfiguredPublicAPI() { - let defaultHitTester: any WindowHitTesting = WindowHitTester.appKit() - let hitTester = WindowHitTester.appKit( - pointTransform: { $0 }, - filter: WindowSnapshotFilter(includedOwnerBundleIdentifiers: ["com.example.app"]) - ) - let publicValue: any WindowHitTesting = hitTester - - assertSendable(hitTester) - _ = defaultHitTester - _ = publicValue - } - - func testWindowSnapshotQueryFactorySupportsDefaultAndConfiguredPublicAPI() { - let defaultQuery: any WindowSnapshotProviding = WindowSnapshotQuery.appKit() - let query = WindowSnapshotQuery.appKit( - filter: WindowSnapshotFilter(includedOwnerBundleIdentifiers: ["com.example.app"]), - limitsToTopmostWindow: true - ) - let publicValue: any WindowSnapshotProviding = query - - assertSendable(query) - _ = defaultQuery - _ = publicValue - } - - private func assertSendable(_: Value) {} -} diff --git a/Tests/WindowKitTests/HitTesting/WindowListHitTestProviderTests.swift b/Tests/WindowKitAppKitTests/HitTesting/WindowHitTesterTests.swift similarity index 67% rename from Tests/WindowKitTests/HitTesting/WindowListHitTestProviderTests.swift rename to Tests/WindowKitAppKitTests/HitTesting/WindowHitTesterTests.swift index d9a6788..2626747 100644 --- a/Tests/WindowKitTests/HitTesting/WindowListHitTestProviderTests.swift +++ b/Tests/WindowKitAppKitTests/HitTesting/WindowHitTesterTests.swift @@ -1,54 +1,91 @@ +import os +import WindowKit import XCTest -@testable import WindowKit +@testable import WindowKitAppKit @MainActor -final class WindowListHitTestProviderTests: XCTestCase { - func testListHitTestProviderReturnsWindowListUnavailableWhenProviderReturnsNil() { +final class WindowHitTesterTests: XCTestCase { + func testHitTesterSkipsBundleLookupForWindowsOutsidePoint() { + let provider = WindowListProviderSpy(entries: [ + makeWindowEntry( + layer: 0, + frame: CGRect(x: 200, y: 200, width: 100, height: 100), + ownerProcessIdentifier: 111 + ) + ]) + let bundleProvider = CountingBundleIdentifierProvider() + let hitTester = WindowHitTester( + listProvider: provider, + ownerBundleIdentifierProvider: bundleProvider + ) + + XCTAssertEqual( + hitTester.hitTest(at: CGPoint(x: 10, y: 10)), + .miss(.noMatchingWindowAtPoint) + ) + XCTAssertEqual(bundleProvider.callCount, 0) + } + + func testHitTesterReturnsWindowListUnavailableWhenProviderReturnsNil() { + let provider = WindowListProviderSpy(entries: nil) + let hitTester = WindowHitTester( + listProvider: provider, + ownerBundleIdentifierProvider: makeBundleIdentifierProvider() + ) + XCTAssertEqual(hitTester.hitTest(at: CGPoint(x: 10, y: 10)), .miss(.sourceUnavailable)) + } + + func testHitTesterDoesNotTransformPointWhenWindowListUnavailable() { + let transformCount = OSAllocatedUnfairLock(initialState: 0) let provider = WindowListProviderSpy(entries: nil) - let hitTester = WindowListHitTestProvider( + let hitTester = WindowHitTester( listProvider: provider, + pointTransform: { point in + transformCount.withLock { $0 += 1 } + return point + }, ownerBundleIdentifierProvider: makeBundleIdentifierProvider() ) - XCTAssertEqual(hitTester.hitTest(at: CGPoint(x: 10, y: 10)), .miss(.listUnavailable)) + + XCTAssertEqual(hitTester.hitTest(at: CGPoint(x: 10, y: 10)), .miss(.sourceUnavailable)) + XCTAssertEqual(transformCount.withLock { $0 }, 0) } - func testListHitTestProviderReadsDefaultOnScreenWindowList() { + func testHitTesterReadsDefaultOnScreenWindowList() { let provider = WindowListProviderSpy(entries: []) - let hitTester = WindowListHitTestProvider(listProvider: provider) + let hitTester = WindowHitTester(listProvider: provider) _ = hitTester.hitTest(at: CGPoint(x: 10, y: 10)) - XCTAssertEqual(provider.requests.map(\.windowIdentifier), [kCGNullWindowID]) - XCTAssertEqual( - provider.requests.map(\.options), - [[.optionOnScreenOnly, .excludeDesktopElements]] - ) + XCTAssertEqual(provider.requests, [.onScreenWindows]) } - func testListHitTestProviderForwardsCustomWindowListOptions() { + func testHitTesterForwardsCustomWindowListOptions() { let provider = WindowListProviderSpy(entries: []) - let hitTester = WindowListHitTestProvider(options: [.optionAll], listProvider: provider) + let hitTester = WindowHitTester( + request: .windows(options: [.optionAll]), + listProvider: provider + ) _ = hitTester.hitTest(at: CGPoint(x: 10, y: 10)) - XCTAssertEqual(provider.requests.map(\.windowIdentifier), [kCGNullWindowID]) - XCTAssertEqual(provider.requests.map(\.options), [[.optionAll]]) + XCTAssertEqual(provider.requests, [.windows(options: [.optionAll])]) } - func testListHitTestProviderReturnsNoMatchingWindowAtPointWhenNoFrameContainsPoint() { + func testHitTesterReturnsNoMatchingWindowAtPointWhenNoFrameContainsPoint() { let provider = WindowListProviderSpy(entries: [ makeWindowEntry( layer: 0, frame: CGRect(x: 0, y: 0, width: 50, height: 50), ownerProcessIdentifier: 111) ]) - let hitTester = WindowListHitTestProvider( + let hitTester = WindowHitTester( listProvider: provider, ownerBundleIdentifierProvider: makeBundleIdentifierProvider() ) XCTAssertEqual(hitTester.hitTest(at: CGPoint(x: 100, y: 100)), .miss(.noMatchingWindowAtPoint)) } - func testListHitTestProviderStandardizesFrameBeforeHitTesting() { + func testHitTesterStandardizesFrameBeforeHitTesting() { let provider = WindowListProviderSpy(entries: [ makeWindowEntry( layer: 0, @@ -56,7 +93,7 @@ final class WindowListHitTestProviderTests: XCTestCase { ownerProcessIdentifier: 111 ) ]) - let hitTester = WindowListHitTestProvider(listProvider: provider) + let hitTester = WindowHitTester(listProvider: provider) XCTAssertEqual( hitTester.hitTest(at: CGPoint(x: 10, y: 10)).snapshot?.ownerProcessIdentifier, @@ -64,19 +101,19 @@ final class WindowListHitTestProviderTests: XCTestCase { ) } - func testListHitTestProviderReturnsNoWindowWhenOwnerProcessIdentifierMissing() { + func testHitTesterReturnsNoWindowWhenOwnerProcessIdentifierMissing() { let provider = WindowListProviderSpy(entries: [ makeWindowEntry( layer: 0, frame: CGRect(x: 0, y: 0, width: 100, height: 100), ownerProcessIdentifier: nil) ]) - let hitTester = WindowListHitTestProvider( + let hitTester = WindowHitTester( listProvider: provider, ownerBundleIdentifierProvider: makeBundleIdentifierProvider() ) XCTAssertEqual(hitTester.hitTest(at: CGPoint(x: 10, y: 10)), .miss(.noMatchingWindowAtPoint)) } - func testListHitTestProviderReturnsNoWindowWhenWindowIdentifierMissing() { + func testHitTesterReturnsNoWindowWhenWindowIdentifierMissing() { let provider = WindowListProviderSpy(entries: [ makeWindowEntry( layer: 0, @@ -85,38 +122,37 @@ final class WindowListHitTestProviderTests: XCTestCase { windowIdentifier: nil ) ]) - let hitTester = WindowListHitTestProvider( + let hitTester = WindowHitTester( listProvider: provider, ownerBundleIdentifierProvider: makeBundleIdentifierProvider() ) XCTAssertEqual(hitTester.hitTest(at: CGPoint(x: 10, y: 10)), .miss(.noMatchingWindowAtPoint)) } - func testListHitTestProviderFiltersByLayer() { + func testHitTesterFiltersByLayer() { let provider = WindowListProviderSpy(entries: [ makeWindowEntry( layer: 1, frame: CGRect(x: 0, y: 0, width: 100, height: 100), ownerProcessIdentifier: 111), makeWindowEntry( layer: 0, frame: CGRect(x: 0, y: 0, width: 100, height: 100), ownerProcessIdentifier: 222), ]) - let hitTester = WindowListHitTestProvider(allowedLayers: [0], listProvider: provider) + let hitTester = WindowHitTester(listProvider: provider) switch hitTester.hitTest(at: CGPoint(x: 10, y: 10)) { case let .hit(window): XCTAssertEqual(window.ownerProcessIdentifier, 222) - XCTAssertEqual(window.layer, 0) case let .miss(reason): XCTFail("Expected hit, got miss(\(reason))") } } - func testListHitTestProviderReturnsFirstMatchingWindow() { + func testHitTesterReturnsFirstMatchingWindow() { let provider = WindowListProviderSpy(entries: [ makeWindowEntry( layer: 0, frame: CGRect(x: 0, y: 0, width: 100, height: 100), ownerProcessIdentifier: 111), makeWindowEntry( layer: 0, frame: CGRect(x: 0, y: 0, width: 100, height: 100), ownerProcessIdentifier: 222), ]) - let hitTester = WindowListHitTestProvider(listProvider: provider) + let hitTester = WindowHitTester(listProvider: provider) switch hitTester.hitTest(at: CGPoint(x: 10, y: 10)) { case let .hit(window): @@ -126,12 +162,12 @@ final class WindowListHitTestProviderTests: XCTestCase { } } - func testListHitTestProviderAppliesPointTransform() { + func testHitTesterAppliesPointTransform() { let provider = WindowListProviderSpy(entries: [ makeWindowEntry( layer: 0, frame: CGRect(x: 0, y: 0, width: 100, height: 100), ownerProcessIdentifier: 222) ]) - let hitTester = WindowListHitTestProvider( + let hitTester = WindowHitTester( listProvider: provider, pointTransform: { CGPoint(x: $0.x - 20, y: $0.y - 20) }, ownerBundleIdentifierProvider: makeBundleIdentifierProvider() @@ -145,12 +181,12 @@ final class WindowListHitTestProviderTests: XCTestCase { } } - func testListHitTestProviderIncludesOwnerNameAndTitle() { + func testHitTesterIncludesOwnerNameAndTitle() { let provider = WindowListProviderSpy(entries: [ makeWindowEntry( layer: 0, frame: CGRect(x: 0, y: 0, width: 100, height: 100), ownerProcessIdentifier: 222) ]) - let hitTester = WindowListHitTestProvider( + let hitTester = WindowHitTester( listProvider: provider, ownerBundleIdentifierProvider: makeBundleIdentifierProvider() ) @@ -164,12 +200,12 @@ final class WindowListHitTestProviderTests: XCTestCase { } } - func testListHitTestProviderIncludesWindowIdentifierWhenPresent() { + func testHitTesterIncludesWindowIdentifierWhenPresent() { let provider = WindowListProviderSpy(entries: [ makeWindowEntry( layer: 0, frame: CGRect(x: 0, y: 0, width: 100, height: 100), ownerProcessIdentifier: 222) ]) - let hitTester = WindowListHitTestProvider( + let hitTester = WindowHitTester( listProvider: provider, ownerBundleIdentifierProvider: makeBundleIdentifierProvider() ) @@ -181,12 +217,12 @@ final class WindowListHitTestProviderTests: XCTestCase { } } - func testListHitTestProviderFiltersExcludedOwnerBundleIdentifiers() { + func testHitTesterFiltersExcludedOwnerBundleIdentifiers() { let provider = WindowListProviderSpy(entries: [ makeWindowEntry( layer: 0, frame: CGRect(x: 0, y: 0, width: 100, height: 100), ownerProcessIdentifier: 222) ]) - let hitTester = WindowListHitTestProvider( + let hitTester = WindowHitTester( listProvider: provider, ownerBundleIdentifierProvider: makeBundleIdentifierProvider( bundleIdentifier: "com.example.blocked"), @@ -196,12 +232,12 @@ final class WindowListHitTestProviderTests: XCTestCase { XCTAssertEqual(hitTester.hitTest(at: CGPoint(x: 10, y: 10)), .miss(.noMatchingWindowAtPoint)) } - func testListHitTestProviderFiltersIncludedOwnerBundleIdentifiers() { + func testHitTesterFiltersIncludedOwnerBundleIdentifiers() { let provider = WindowListProviderSpy(entries: [ makeWindowEntry( layer: 0, frame: CGRect(x: 0, y: 0, width: 100, height: 100), ownerProcessIdentifier: 222) ]) - let hitTester = WindowListHitTestProvider( + let hitTester = WindowHitTester( listProvider: provider, ownerBundleIdentifierProvider: makeBundleIdentifierProvider( bundleIdentifier: "com.example.allowed"), @@ -212,7 +248,7 @@ final class WindowListHitTestProviderTests: XCTestCase { hitTester.hitTest(at: CGPoint(x: 10, y: 10)).snapshot?.ownerProcessIdentifier, 222) } - func testListHitTestProviderFiltersIncludedWindowIdentifiers() { + func testHitTesterFiltersIncludedWindowIdentifiers() { let provider = WindowListProviderSpy(entries: [ makeWindowEntry( layer: 0, frame: CGRect(x: 0, y: 0, width: 100, height: 100), @@ -223,7 +259,7 @@ final class WindowListHitTestProviderTests: XCTestCase { ownerProcessIdentifier: 222, windowIdentifier: 222), ]) - let hitTester = WindowListHitTestProvider( + let hitTester = WindowHitTester( listProvider: provider, filter: WindowSnapshotFilter(includedWindowIdentifiers: [222]) ) @@ -231,46 +267,30 @@ final class WindowListHitTestProviderTests: XCTestCase { XCTAssertEqual(hitTester.hitTest(at: CGPoint(x: 10, y: 10)).snapshot?.windowIdentifier, 222) } - func testListHitTestProviderExcludesMinimizedWhenEnabled() { - let provider = WindowListProviderSpy(entries: [ - makeWindowEntry( - layer: 0, frame: CGRect(x: 0, y: 0, width: 100, height: 100), ownerProcessIdentifier: 222, - isMinimized: true) - ]) - let hitTester = WindowListHitTestProvider( - listProvider: provider, - ownerBundleIdentifierProvider: makeBundleIdentifierProvider(), - filter: WindowSnapshotFilter(excludesMinimizedWindows: true) - ) - - XCTAssertEqual(hitTester.hitTest(at: CGPoint(x: 10, y: 10)), .miss(.noMatchingWindowAtPoint)) - } - - func testListHitTestProviderIncludesMinimizedMetadata() { - let provider = WindowListProviderSpy(entries: [ - makeWindowEntry( - layer: 0, - frame: CGRect(x: 0, y: 0, width: 100, height: 100), - ownerProcessIdentifier: 222, - isMinimized: true - ) - ]) - let hitTester = WindowListHitTestProvider(listProvider: provider) - - XCTAssertEqual(hitTester.hitTest(at: CGPoint(x: 10, y: 10)).snapshot?.isMinimized, true) - } - - func testListHitTestProviderIsSendable() { - let hitTester = WindowListHitTestProvider(listProvider: WindowListProviderSpy(entries: [])) + func testHitTesterIsSendable() { + let hitTester = WindowHitTester(listProvider: WindowListProviderSpy(entries: [])) assertSendable(hitTester) } private func makeBundleIdentifierProvider( bundleIdentifier: String = "com.example.app" - ) -> WindowOwnerBundleIdentifierProviderStub { - WindowOwnerBundleIdentifierProviderStub(bundleIdentifier: bundleIdentifier) + ) -> WindowOwnerBundleIdentifierProvidingStub { + WindowOwnerBundleIdentifierProvidingStub(bundleIdentifier: bundleIdentifier) } private func assertSendable(_: Value) {} } + +private struct CountingBundleIdentifierProvider: WindowOwnerBundleIdentifierProviding { + private let callCountStorage = OSAllocatedUnfairLock(initialState: 0) + + var callCount: Int { + callCountStorage.withLock { $0 } + } + + func bundleIdentifier(forProcessIdentifier _: pid_t) -> String? { + callCountStorage.withLock { $0 += 1 } + return "com.example.app" + } +} diff --git a/Tests/WindowKitAppKitTests/Placement/WindowPlacerErrorTests.swift b/Tests/WindowKitAppKitTests/Placement/WindowPlacerErrorTests.swift index c8fa17b..93b1614 100644 --- a/Tests/WindowKitAppKitTests/Placement/WindowPlacerErrorTests.swift +++ b/Tests/WindowKitAppKitTests/Placement/WindowPlacerErrorTests.swift @@ -7,6 +7,10 @@ final class WindowPlacerErrorTests: XCTestCase { assertSendable(WindowPlacerError.screenNotFound(.displayIdentifier(1))) } + func testWindowPlacerErrorIsHashable() { + assertHashable(WindowPlacerError.screenNotFound(.displayIdentifier(1))) + } + func testWindowPlacerErrorEquatableMatchesIdenticalValues() { XCTAssertEqual( WindowPlacerError.screenNotFound(.displayIdentifier(1)), @@ -22,4 +26,6 @@ final class WindowPlacerErrorTests: XCTestCase { } private func assertSendable(_: Value) {} + + private func assertHashable(_: Value) {} } diff --git a/Tests/WindowKitAppKitTests/Placement/WindowPlacerTests.swift b/Tests/WindowKitAppKitTests/Placement/WindowPlacerTests.swift index db9c64a..bc1bd23 100644 --- a/Tests/WindowKitAppKitTests/Placement/WindowPlacerTests.swift +++ b/Tests/WindowKitAppKitTests/Placement/WindowPlacerTests.swift @@ -1,4 +1,5 @@ import CoreGraphics +import os import WindowKit import WindowKitAppKit import XCTest @@ -22,7 +23,8 @@ final class WindowPlacerTests: XCTestCase { screenProvider: screens ) - try controller.place(.rightHalf, for: target) + try controller.place( + .grid(columns: 2, rows: 1, column: 1, row: 0, columnSpan: 1, rowSpan: 1), for: target) XCTAssertEqual( try frameController.frame(for: target), CGRect(x: 400, y: 20, width: 400, height: 560)) } @@ -68,23 +70,56 @@ final class WindowPlacerTests: XCTestCase { ]) let controller = WindowPlacer(frameController: frameController, screenProvider: screens) - try controller.place(.leftHalf, for: target, area: .full) + try controller.place( + .grid(columns: 2, rows: 1, column: 0, row: 0, columnSpan: 1, rowSpan: 1), + for: target, + area: .full + ) XCTAssertEqual( try frameController.frame(for: target), CGRect(x: 0, y: 0, width: 400, height: 600)) } @MainActor - func testPlaceReturnsWithoutMutationWhenNoScreensAreAvailable() throws { - let target = WindowTarget.windowIdentifier(4) - let original = CGRect(x: 900, y: 900, width: 100, height: 100) - let frameController = WindowFrameControllerFake(frames: [(target, original)]) - let screens = WindowScreenProviderStub(screens: []) - let controller = WindowPlacer(frameController: frameController, screenProvider: screens) + func testPlaceThrowsBeforeReadingWindowFrameWhenNoScreensAreAvailable() throws { + let target = WindowTarget.windowIdentifier(42) + let frameController = WindowFrameControllerFake() + let controller = WindowPlacer( + frameController: frameController, + screenProvider: WindowScreenProviderStub(screens: []) + ) - try controller.place(.fill, for: target) + XCTAssertThrowsError( + try controller.place(.fill, for: target) + ) { error in + XCTAssertEqual(error as? WindowPlacerError, .screenNotFound(.containingWindow)) + } + XCTAssertEqual(frameController.frameReadCount, 0) + } - XCTAssertEqual(try frameController.frame(for: target), original) + @MainActor + func testPlaceDoesNotReadWindowFrameForExplicitScreenTargets() throws { + let target = WindowTarget.windowIdentifier(43) + let frameController = WindowFrameControllerFake(frames: [ + (target, CGRect(x: 0, y: 0, width: 10, height: 10)) + ]) + let controller = WindowPlacer( + frameController: frameController, + screenProvider: WindowScreenProviderStub(screens: [ + .init( + displayIdentifier: 1, + frame: CGRect(x: 0, y: 0, width: 800, height: 600), + visibleFrame: CGRect(x: 0, y: 0, width: 800, height: 600), + isMain: true + ) + ]) + ) + + try controller.place(.fill, for: target, screen: .displayIdentifier(1)) + + XCTAssertEqual(frameController.frameReadCount, 0) + XCTAssertEqual( + try frameController.frame(for: target), CGRect(x: 0, y: 0, width: 800, height: 600)) } @MainActor @@ -106,7 +141,7 @@ final class WindowPlacerTests: XCTestCase { .fill, for: target, screen: .displayIdentifier(99), - allowsScreenFallback: false + screenFallback: .none ) ) { error in XCTAssertEqual(error as? WindowPlacerError, .screenNotFound(.displayIdentifier(99))) @@ -152,7 +187,7 @@ final class WindowPlacerTests: XCTestCase { let controller = WindowPlacer(frameController: frameController, screenProvider: screens) XCTAssertThrowsError( - try controller.moveToAdjacentScreen(.next, for: target, allowsScreenFallback: false) + try controller.moveToAdjacentScreen(.next, for: target, screenFallback: .none) ) { error in XCTAssertEqual(error as? WindowPlacerError, .screenNotFound(.containingWindow)) } @@ -183,7 +218,7 @@ final class WindowPlacerTests: XCTestCase { } @MainActor - func testMoveToAdjacentScreenThrowsWhenNoScreensAreAvailableInStrictMode() throws { + func testMoveToAdjacentScreenThrowsWhenNoScreensAreAvailable() throws { let target = WindowTarget.windowIdentifier(8) let original = CGRect(x: 10, y: 10, width: 100, height: 100) let frameController = WindowFrameControllerFake(frames: [(target, original)]) @@ -193,7 +228,7 @@ final class WindowPlacerTests: XCTestCase { ) XCTAssertThrowsError( - try controller.moveToAdjacentScreen(.next, for: target, allowsScreenFallback: false) + try controller.moveToAdjacentScreen(.next, for: target) ) { error in XCTAssertEqual(error as? WindowPlacerError, .screenNotFound(.containingWindow)) } @@ -201,32 +236,46 @@ final class WindowPlacerTests: XCTestCase { } @MainActor - func testMoveToAdjacentScreenReturnsWithoutMutationWhenNoScreensAreAvailable() throws { - let target = WindowTarget.windowIdentifier(9) - let original = CGRect(x: 10, y: 10, width: 100, height: 100) - let frameController = WindowFrameControllerFake(frames: [(target, original)]) + func testMoveToAdjacentScreenThrowsBeforeReadingWindowFrameWhenNoScreensAreAvailable() + throws + { + let target = WindowTarget.windowIdentifier(81) + let frameController = WindowFrameControllerFake() let controller = WindowPlacer( frameController: frameController, screenProvider: WindowScreenProviderStub(screens: []) ) - try controller.moveToAdjacentScreen(.next, for: target) - - XCTAssertEqual(try frameController.frame(for: target), original) + XCTAssertThrowsError( + try controller.moveToAdjacentScreen(.next, for: target) + ) { error in + XCTAssertEqual(error as? WindowPlacerError, .screenNotFound(.containingWindow)) + } + XCTAssertEqual(frameController.frameReadCount, 0) } + } @MainActor -private final class WindowFrameControllerFake: WindowFrameControlling, @unchecked Sendable { - private var frames: [(WindowTarget, CGRect)] +private final class WindowFrameControllerFake: WindowFrameControlling { + private let framesStorage: OSAllocatedUnfairLock<[(WindowTarget, CGRect)]> + private let frameReadCountStorage = OSAllocatedUnfairLock(initialState: 0) + + var frameReadCount: Int { + frameReadCountStorage.withLock { $0 } + } init(frames: [(WindowTarget, CGRect)] = []) { - self.frames = frames + framesStorage = OSAllocatedUnfairLock(initialState: frames) } @MainActor func frame(for target: WindowTarget) throws -> CGRect { - guard let frame = frames.first(where: { $0.0 == target })?.1 else { + frameReadCountStorage.withLock { $0 += 1 } + let frame = framesStorage.withLock { frames in + frames.first(where: { $0.0 == target })?.1 + } + guard let frame else { throw WindowFrameControllerFakeError.missingFrame(target) } return frame @@ -234,10 +283,16 @@ private final class WindowFrameControllerFake: WindowFrameControlling, @unchecke @MainActor func setFrame(_ frame: CGRect, for target: WindowTarget) throws { - guard let index = frames.firstIndex(where: { $0.0 == target }) else { + let didUpdate = framesStorage.withLock { frames in + guard let index = frames.firstIndex(where: { $0.0 == target }) else { + return false + } + frames[index].1 = frame + return true + } + guard didUpdate else { throw WindowFrameControllerFakeError.missingFrame(target) } - frames[index].1 = frame } } diff --git a/Tests/WindowKitAppKitTests/Roles/AXWindowRoleProviderTests.swift b/Tests/WindowKitAppKitTests/Roles/AXWindowRoleProviderTests.swift deleted file mode 100644 index fe90d43..0000000 --- a/Tests/WindowKitAppKitTests/Roles/AXWindowRoleProviderTests.swift +++ /dev/null @@ -1,169 +0,0 @@ -import ApplicationServices -import WindowKit -import XCTest - -@testable import WindowKitAppKit - -@MainActor -final class AXWindowRoleProviderTests: XCTestCase { - func testRoleReturnsResolvedAccessibilityRole() throws { - defer { AXAPI.reset() } - AXAPI.isProcessTrusted = { true } - - let fixture = FocusedWindowAXFixture() - let roleAttribute = kAXRoleAttribute as String - let subroleAttribute = kAXSubroleAttribute as String - var readAttributes: [String] = [] - - fixture.installCopyAttributeValue { element, attribute, value in - XCTAssertEqual(CFEqual(element, fixture.windowElement), true) - switch attribute as String { - case roleAttribute: - readAttributes.append(attribute as String) - value.pointee = "AXSheet" as CFString - return .success - case subroleAttribute: - readAttributes.append(attribute as String) - value.pointee = nil - return .noValue - default: - value.pointee = nil - return .noValue - } - } - - let provider = fixture.roleProvider() - - XCTAssertEqual(try provider.role(for: .focused), .sheet) - XCTAssertEqual(readAttributes, [roleAttribute, subroleAttribute]) - } - - func testRoleReturnsResolvedAccessibilitySubrole() throws { - defer { AXAPI.reset() } - AXAPI.isProcessTrusted = { true } - - let fixture = FocusedWindowAXFixture() - let roleAttribute = kAXRoleAttribute as String - let subroleAttribute = kAXSubroleAttribute as String - - fixture.installCopyAttributeValue { element, attribute, value in - XCTAssertEqual(CFEqual(element, fixture.windowElement), true) - switch attribute as String { - case roleAttribute: - value.pointee = kAXWindowRole as CFString - return .success - case subroleAttribute: - value.pointee = kAXDialogSubrole as CFString - return .success - default: - value.pointee = nil - return .noValue - } - } - - let provider = fixture.roleProvider() - - XCTAssertEqual(try provider.role(for: .focused), .dialog) - } - - func testRoleReturnsUnknownWhenRoleMetadataIsUnavailable() throws { - defer { AXAPI.reset() } - AXAPI.isProcessTrusted = { true } - - let fixture = FocusedWindowAXFixture() - fixture.installCopyAttributeValue() - - let provider = fixture.roleProvider() - - XCTAssertEqual(try provider.role(for: .focused), .unknown(role: nil, subrole: nil)) - } - - func testRoleThrowsUnderlyingAccessibilityErrorWhenRoleReadFails() { - defer { AXAPI.reset() } - AXAPI.isProcessTrusted = { true } - - let fixture = FocusedWindowAXFixture() - let roleAttribute = kAXRoleAttribute as String - var readAttributes: [String] = [] - - fixture.installCopyAttributeValue { element, attribute, value in - XCTAssertEqual(CFEqual(element, fixture.windowElement), true) - switch attribute as String { - case roleAttribute: - readAttributes.append(attribute as String) - value.pointee = nil - return .cannotComplete - default: - value.pointee = nil - return .noValue - } - } - - let provider = fixture.roleProvider() - - XCTAssertThrowsError(try provider.role(for: .focused)) { error in - XCTAssertEqual(error as? AXWindowError, .underlyingAccessibilityError(.cannotComplete)) - } - XCTAssertEqual(readAttributes, [roleAttribute]) - } - - func testRoleThrowsUnderlyingAccessibilityErrorWhenSubroleReadFails() { - defer { AXAPI.reset() } - AXAPI.isProcessTrusted = { true } - - let fixture = FocusedWindowAXFixture() - let roleAttribute = kAXRoleAttribute as String - let subroleAttribute = kAXSubroleAttribute as String - var readAttributes: [String] = [] - - fixture.installCopyAttributeValue { element, attribute, value in - XCTAssertEqual(CFEqual(element, fixture.windowElement), true) - switch attribute as String { - case roleAttribute: - readAttributes.append(attribute as String) - value.pointee = kAXWindowRole as CFString - return .success - case subroleAttribute: - readAttributes.append(attribute as String) - value.pointee = nil - return .cannotComplete - default: - value.pointee = nil - return .noValue - } - } - - let provider = fixture.roleProvider() - - XCTAssertThrowsError(try provider.role(for: .focused)) { error in - XCTAssertEqual(error as? AXWindowError, .underlyingAccessibilityError(.cannotComplete)) - } - XCTAssertEqual(readAttributes, [roleAttribute, subroleAttribute]) - } - - func testRoleThrowsInvalidAXValueForUnexpectedRoleType() { - defer { AXAPI.reset() } - AXAPI.isProcessTrusted = { true } - - let fixture = FocusedWindowAXFixture() - let roleAttribute = kAXRoleAttribute as String - - fixture.installCopyAttributeValue { element, attribute, value in - XCTAssertEqual(CFEqual(element, fixture.windowElement), true) - switch attribute as String { - case roleAttribute: - value.pointee = NSNumber(value: 1) - return .success - default: - value.pointee = nil - return .noValue - } - } - - let provider = fixture.roleProvider() - - XCTAssertThrowsError(try provider.role(for: .focused)) { error in - XCTAssertEqual(error as? AXWindowError, .invalidAXValue) - } - } -} diff --git a/Tests/WindowKitAppKitTests/Roles/WindowRoleResolverTests.swift b/Tests/WindowKitAppKitTests/Roles/WindowRoleResolverTests.swift index 5e0dac2..872beba 100644 --- a/Tests/WindowKitAppKitTests/Roles/WindowRoleResolverTests.swift +++ b/Tests/WindowKitAppKitTests/Roles/WindowRoleResolverTests.swift @@ -1,4 +1,3 @@ -import ApplicationServices import XCTest @testable import WindowKitAppKit @@ -6,9 +5,15 @@ import XCTest final class WindowRoleResolverTests: XCTestCase { func testResolveClassifiesStandardWindow() { let resolver = WindowRoleResolver() - XCTAssertEqual(resolver.resolve(role: kAXWindowRole as String, subrole: nil), .standard) XCTAssertEqual( - resolver.resolve(role: kAXWindowRole as String, subrole: kAXStandardWindowSubrole as String), + resolver.resolve( + accessibilityRole: AXWindowRoleValue.window.accessibilityName, + accessibilitySubrole: nil), + .standard) + XCTAssertEqual( + resolver.resolve( + accessibilityRole: AXWindowRoleValue.window.accessibilityName, + accessibilitySubrole: AXWindowSubroleValue.standardWindow.accessibilityName), .standard ) } @@ -16,31 +21,45 @@ final class WindowRoleResolverTests: XCTestCase { func testResolveClassifiesDialogWindow() { let resolver = WindowRoleResolver() XCTAssertEqual( - resolver.resolve(role: kAXWindowRole as String, subrole: kAXDialogSubrole as String), + resolver.resolve( + accessibilityRole: AXWindowRoleValue.window.accessibilityName, + accessibilitySubrole: AXWindowSubroleValue.dialog.accessibilityName + ), .dialog ) } func testResolveClassifiesSheetWindow() { let resolver = WindowRoleResolver() - XCTAssertEqual(resolver.resolve(role: kAXSheetRole as String, subrole: nil), .sheet) XCTAssertEqual( - resolver.resolve(role: kAXSheetRole as String, subrole: kAXDialogSubrole as String), + resolver.resolve( + accessibilityRole: AXWindowRoleValue.sheet.accessibilityName, + accessibilitySubrole: nil), .sheet + ) + XCTAssertEqual( + resolver.resolve( + accessibilityRole: AXWindowRoleValue.sheet.accessibilityName, + accessibilitySubrole: AXWindowSubroleValue.dialog.accessibilityName), .sheet ) } func testResolveClassifiesPanelWindow() { let resolver = WindowRoleResolver() - XCTAssertEqual(resolver.resolve(role: "AXPanel", subrole: nil), .panel) XCTAssertEqual( - resolver.resolve(role: kAXWindowRole as String, subrole: kAXFloatingWindowSubrole as String), + resolver.resolve( + accessibilityRole: AXWindowRoleValue.panel.accessibilityName, + accessibilitySubrole: nil), .panel) + XCTAssertEqual( + resolver.resolve( + accessibilityRole: AXWindowRoleValue.window.accessibilityName, + accessibilitySubrole: AXWindowSubroleValue.floatingWindow.accessibilityName), .panel ) XCTAssertEqual( resolver.resolve( - role: kAXWindowRole as String, - subrole: kAXSystemFloatingWindowSubrole as String + accessibilityRole: AXWindowRoleValue.window.accessibilityName, + accessibilitySubrole: AXWindowSubroleValue.systemFloatingWindow.accessibilityName ), .panel ) @@ -48,9 +67,15 @@ final class WindowRoleResolverTests: XCTestCase { func testResolveClassifiesPopoverWindow() { let resolver = WindowRoleResolver() - XCTAssertEqual(resolver.resolve(role: kAXPopoverRole as String, subrole: nil), .popover) XCTAssertEqual( - resolver.resolve(role: kAXPopoverRole as String, subrole: kAXDialogSubrole as String), + resolver.resolve( + accessibilityRole: AXWindowRoleValue.popover.accessibilityName, + accessibilitySubrole: nil), + .popover) + XCTAssertEqual( + resolver.resolve( + accessibilityRole: AXWindowRoleValue.popover.accessibilityName, + accessibilitySubrole: AXWindowSubroleValue.dialog.accessibilityName), .popover ) } @@ -58,33 +83,69 @@ final class WindowRoleResolverTests: XCTestCase { func testResolveClassifiesSystemDialogWindow() { let resolver = WindowRoleResolver() XCTAssertEqual( - resolver.resolve(role: kAXWindowRole as String, subrole: kAXSystemDialogSubrole as String), + resolver.resolve( + accessibilityRole: AXWindowRoleValue.window.accessibilityName, + accessibilitySubrole: AXWindowSubroleValue.systemDialog.accessibilityName), .systemDialog ) } - func testResolveClassifiesWindowBySubroleWhenRoleIsUnavailable() { + func testResolvePreservesSubroleMetadataWhenRoleIsUnavailable() { let resolver = WindowRoleResolver() - XCTAssertEqual(resolver.resolve(role: nil, subrole: kAXDialogSubrole as String), .dialog) + XCTAssertEqual( + resolver.resolve( + accessibilityRole: nil, + accessibilitySubrole: AXWindowSubroleValue.dialog.accessibilityName), + .unclassified( + accessibilityRole: nil, + accessibilitySubrole: AXWindowSubroleValue.dialog.accessibilityName + ) + ) } - func testResolvePreservesUnknownRoleMetadata() { + func testResolvePreservesUnclassifiedRoleMetadata() { let resolver = WindowRoleResolver() XCTAssertEqual( - resolver.resolve(role: "Custom", subrole: "Other"), .unknown(role: "Custom", subrole: "Other") + resolver.resolve(accessibilityRole: "Custom", accessibilitySubrole: "Other"), + .unclassified(accessibilityRole: "Custom", accessibilitySubrole: "Other") ) XCTAssertEqual( - resolver.resolve(role: kAXDialogSubrole as String, subrole: nil), - .unknown(role: kAXDialogSubrole as String, subrole: nil) + resolver.resolve( + accessibilityRole: AXWindowSubroleValue.dialog.accessibilityName, + accessibilitySubrole: nil), + .unclassified( + accessibilityRole: AXWindowSubroleValue.dialog.accessibilityName, + accessibilitySubrole: nil) ) XCTAssertEqual( - resolver.resolve(role: kAXWindowRole as String, subrole: "AXUtilityWindow"), - .unknown(role: kAXWindowRole as String, subrole: "AXUtilityWindow") + resolver.resolve( + accessibilityRole: AXWindowRoleValue.window.accessibilityName, + accessibilitySubrole: "AXUtilityWindow"), + .unclassified( + accessibilityRole: AXWindowRoleValue.window.accessibilityName, + accessibilitySubrole: "AXUtilityWindow") ) } - func testResolveReturnsUnknownWhenRoleAndSubroleAreMissing() { + func testResolvePreservesUnknownRoleEvenWhenSubroleIsKnown() { let resolver = WindowRoleResolver() - XCTAssertEqual(resolver.resolve(role: nil, subrole: nil), .unknown(role: nil, subrole: nil)) + + XCTAssertEqual( + resolver.resolve( + accessibilityRole: "CustomRole", + accessibilitySubrole: AXWindowSubroleValue.dialog.accessibilityName + ), + .unclassified( + accessibilityRole: "CustomRole", + accessibilitySubrole: AXWindowSubroleValue.dialog.accessibilityName + ) + ) + } + + func testResolveReturnsUnclassifiedRoleWhenRoleAndSubroleAreMissing() { + let resolver = WindowRoleResolver() + XCTAssertEqual( + resolver.resolve(accessibilityRole: nil, accessibilitySubrole: nil), + .unclassified(accessibilityRole: nil, accessibilitySubrole: nil)) } } diff --git a/Tests/WindowKitAppKitTests/Roles/WindowRoleTests.swift b/Tests/WindowKitAppKitTests/Roles/WindowRoleTests.swift index 3d29027..47db9d3 100644 --- a/Tests/WindowKitAppKitTests/Roles/WindowRoleTests.swift +++ b/Tests/WindowKitAppKitTests/Roles/WindowRoleTests.swift @@ -10,13 +10,16 @@ final class WindowRoleTests: XCTestCase { try assertValueConformance(WindowRole.panel) try assertValueConformance(WindowRole.popover) try assertValueConformance(WindowRole.systemDialog) - try assertValueConformance(WindowRole.unknown(role: "AXWindow", subrole: "AXDialog")) - try assertValueConformance(WindowRole.unknown(role: nil, subrole: nil)) + try assertValueConformance( + WindowRole.unclassified(accessibilityRole: "AXWindow", accessibilitySubrole: "AXDialog")) + try assertValueConformance( + WindowRole.unclassified(accessibilityRole: nil, accessibilitySubrole: nil)) } func testWindowRoleIsSendable() { assertSendable(WindowRole.standard) - assertSendable(WindowRole.unknown(role: "AXWindow", subrole: "AXDialog")) + assertSendable( + WindowRole.unclassified(accessibilityRole: "AXWindow", accessibilitySubrole: "AXDialog")) } @MainActor diff --git a/Tests/WindowKitAppKitTests/RunningApplications/NSRunningApplicationAdaptersTests.swift b/Tests/WindowKitAppKitTests/RunningApplications/NSRunningApplicationBundleIdentifierProviderTests.swift similarity index 52% rename from Tests/WindowKitAppKitTests/RunningApplications/NSRunningApplicationAdaptersTests.swift rename to Tests/WindowKitAppKitTests/RunningApplications/NSRunningApplicationBundleIdentifierProviderTests.swift index 7f1a7e5..08c0a96 100644 --- a/Tests/WindowKitAppKitTests/RunningApplications/NSRunningApplicationAdaptersTests.swift +++ b/Tests/WindowKitAppKitTests/RunningApplications/NSRunningApplicationBundleIdentifierProviderTests.swift @@ -1,9 +1,7 @@ -import WindowKit +import WindowKitAppKit import XCTest -@testable import WindowKitAppKit - -final class NSRunningApplicationAdaptersTests: XCTestCase { +final class NSRunningApplicationBundleIdentifierProviderTests: XCTestCase { private let unknownProcessIdentifier = pid_t(-1) func testBundleIdentifierProviderCanBeUsedThroughOwnerBundleIdentifierProvidingProtocol() { @@ -18,20 +16,8 @@ final class NSRunningApplicationAdaptersTests: XCTestCase { XCTAssertNil(provider.bundleIdentifier(forProcessIdentifier: unknownProcessIdentifier)) } - func testAdaptersAreSendable() { + func testBundleIdentifierProviderIsSendable() { assertSendable(NSRunningApplicationBundleIdentifierProvider()) - assertSendable(NSRunningApplicationChecker()) - } - - func testRunningApplicationCheckerReturnsFalseForUnknownProcessIdentifier() { - let checker = NSRunningApplicationChecker() - XCTAssertFalse(checker.isRunningApplication(processIdentifier: unknownProcessIdentifier)) - } - - func testRunningApplicationCheckerCanBeUsedThroughCheckingProtocol() { - let checker: any RunningApplicationChecking = NSRunningApplicationChecker() - - XCTAssertFalse(checker.isRunningApplication(processIdentifier: unknownProcessIdentifier)) } private func assertSendable(_: Value) {} diff --git a/Tests/WindowKitAppKitTests/RunningApplications/NSRunningApplicationCheckerTests.swift b/Tests/WindowKitAppKitTests/RunningApplications/NSRunningApplicationCheckerTests.swift new file mode 100644 index 0000000..ab854dd --- /dev/null +++ b/Tests/WindowKitAppKitTests/RunningApplications/NSRunningApplicationCheckerTests.swift @@ -0,0 +1,24 @@ +import XCTest + +@testable import WindowKitAppKit + +final class NSRunningApplicationCheckerTests: XCTestCase { + private let unknownProcessIdentifier = pid_t(-1) + + func testRunningApplicationCheckerIsSendable() { + assertSendable(NSRunningApplicationChecker()) + } + + func testRunningApplicationCheckerReturnsFalseForUnknownProcessIdentifier() { + let checker = NSRunningApplicationChecker() + XCTAssertFalse(checker.isRunningApplication(processIdentifier: unknownProcessIdentifier)) + } + + func testRunningApplicationCheckerCanBeUsedThroughCheckingProtocol() { + let checker: any RunningApplicationChecking = NSRunningApplicationChecker() + + XCTAssertFalse(checker.isRunningApplication(processIdentifier: unknownProcessIdentifier)) + } + + private func assertSendable(_: Value) {} +} diff --git a/Tests/WindowKitTests/Snapshots/WindowSnapshotQueryTests.swift b/Tests/WindowKitAppKitTests/Snapshots/WindowSnapshotQueryTests.swift similarity index 73% rename from Tests/WindowKitTests/Snapshots/WindowSnapshotQueryTests.swift rename to Tests/WindowKitAppKitTests/Snapshots/WindowSnapshotQueryTests.swift index 797f97f..bf9599e 100644 --- a/Tests/WindowKitTests/Snapshots/WindowSnapshotQueryTests.swift +++ b/Tests/WindowKitAppKitTests/Snapshots/WindowSnapshotQueryTests.swift @@ -1,30 +1,41 @@ import CoreGraphics +import WindowKit import XCTest -@testable import WindowKit +@testable import WindowKitAppKit final class WindowSnapshotQueryTests: XCTestCase { + func testSnapshotQuerySkipsEntriesWithNonFiniteFrames() throws { + let provider = WindowListProviderSpy(entries: [ + makeWindowEntry( + layer: 0, + frame: CGRect(x: CGFloat.infinity, y: 0, width: 100, height: 100), + ownerProcessIdentifier: 123 + ) + ]) + + XCTAssertTrue(try WindowSnapshotQuery(listProvider: provider).snapshots().isEmpty) + } + func testSnapshotQueryReadsDefaultOnScreenWindowList() throws { let provider = WindowListProviderSpy(entries: []) let query = WindowSnapshotQuery(listProvider: provider) _ = try query.snapshots() - XCTAssertEqual(provider.requests.map(\.windowIdentifier), [kCGNullWindowID]) - XCTAssertEqual( - provider.requests.map(\.options), - [[.optionOnScreenOnly, .excludeDesktopElements]] - ) + XCTAssertEqual(provider.requests, [.onScreenWindows]) } func testSnapshotQueryForwardsCustomWindowListOptions() throws { let provider = WindowListProviderSpy(entries: []) - let query = WindowSnapshotQuery(options: [.optionAll], listProvider: provider) + let query = WindowSnapshotQuery( + request: .windows(options: [.optionAll]), + listProvider: provider + ) _ = try query.snapshots() - XCTAssertEqual(provider.requests.map(\.windowIdentifier), [kCGNullWindowID]) - XCTAssertEqual(provider.requests.map(\.options), [[.optionAll]]) + XCTAssertEqual(provider.requests, [.windows(options: [.optionAll])]) } func testSnapshotQueryIsSendable() { @@ -41,9 +52,8 @@ final class WindowSnapshotQueryTests: XCTestCase { ]) let query = WindowSnapshotQuery( - allowedLayers: [0], listProvider: provider, - ownerBundleIdentifierProvider: WindowOwnerBundleIdentifierProviderStub( + ownerBundleIdentifierProvider: WindowOwnerBundleIdentifierProvidingStub( bundleIdentifier: "com.example.app") ) @@ -60,7 +70,7 @@ final class WindowSnapshotQueryTests: XCTestCase { let query = WindowSnapshotQuery( listProvider: provider, - ownerBundleIdentifierProvider: WindowOwnerBundleIdentifierProviderStub( + ownerBundleIdentifierProvider: WindowOwnerBundleIdentifierProvidingStub( bundleIdentifier: "com.example.app") ) @@ -83,7 +93,6 @@ final class WindowSnapshotQueryTests: XCTestCase { XCTAssertEqual(snapshot?.ownerName, "ExampleApp") XCTAssertEqual(snapshot?.title, "Main") XCTAssertEqual(snapshot?.windowIdentifier, 456) - XCTAssertEqual(snapshot?.layer, 0) } func testSnapshotQueryStandardizesFrame() throws { @@ -107,7 +116,8 @@ final class WindowSnapshotQueryTests: XCTestCase { let provider = WindowListProviderSpy(entries: [ makeWindowEntry(layer: 0, frame: .zero, ownerProcessIdentifier: 123, windowIdentifier: 456) ]) - let query: any WindowSnapshotProviding = WindowSnapshotQuery(listProvider: provider) + let query: any WindowSnapshotProviding & WindowSnapshotResolving = + WindowSnapshotQuery(listProvider: provider) XCTAssertEqual(try query.snapshots().map(\.windowIdentifier), [456]) XCTAssertEqual(try query.snapshot(for: 456)?.windowIdentifier, 456) @@ -120,13 +130,55 @@ final class WindowSnapshotQueryTests: XCTestCase { let query = WindowSnapshotQuery( listProvider: provider, - ownerBundleIdentifierProvider: WindowOwnerBundleIdentifierProviderStub( + ownerBundleIdentifierProvider: WindowOwnerBundleIdentifierProvidingStub( bundleIdentifier: "com.example.app") ) XCTAssertTrue(try query.snapshots().isEmpty) } + func testSnapshotQuerySkipsCGWindowEntriesWithInvalidIdentifiers() throws { + let provider = WindowListProviderSpy(entries: [ + WindowListEntry( + cgWindowInfo: makeCGWindowInfo( + ownerProcessIdentifier: NSNumber(value: 123), + windowIdentifier: NSNumber(value: -1))), + WindowListEntry( + cgWindowInfo: makeCGWindowInfo( + ownerProcessIdentifier: NSNumber(value: -1), + windowIdentifier: NSNumber(value: 456))), + WindowListEntry( + cgWindowInfo: makeCGWindowInfo( + ownerProcessIdentifier: NSNumber(value: 123), + windowIdentifier: NSNumber(value: 1.5))), + WindowListEntry( + cgWindowInfo: makeCGWindowInfo( + ownerProcessIdentifier: NSNumber(value: true), + windowIdentifier: NSNumber(value: 456))), + ]) + let query = WindowSnapshotQuery(listProvider: provider) + + XCTAssertTrue(try query.snapshots().isEmpty) + } + + func testSnapshotQuerySkipsCGWindowEntriesWithInvalidLayers() throws { + let provider = WindowListProviderSpy(entries: [ + WindowListEntry( + cgWindowInfo: makeCGWindowInfo( + layer: NSNumber(value: true), + ownerProcessIdentifier: NSNumber(value: 123), + windowIdentifier: NSNumber(value: 456))), + WindowListEntry( + cgWindowInfo: makeCGWindowInfo( + layer: NSNumber(value: 0.5), + ownerProcessIdentifier: NSNumber(value: 123), + windowIdentifier: NSNumber(value: 456))), + ]) + let query = WindowSnapshotQuery(listProvider: provider) + + XCTAssertTrue(try query.snapshots().isEmpty) + } + func testSnapshotQueryFiltersExcludedOwnerBundleIdentifiers() throws { let provider = WindowListProviderSpy(entries: [ makeWindowEntry(layer: 0, frame: .zero, ownerProcessIdentifier: 123) @@ -134,7 +186,7 @@ final class WindowSnapshotQueryTests: XCTestCase { let query = WindowSnapshotQuery( listProvider: provider, - ownerBundleIdentifierProvider: WindowOwnerBundleIdentifierProviderStub( + ownerBundleIdentifierProvider: WindowOwnerBundleIdentifierProvidingStub( bundleIdentifier: "com.example.blocked"), filter: WindowSnapshotFilter(excludedOwnerBundleIdentifiers: ["com.example.blocked"]) ) @@ -142,12 +194,30 @@ final class WindowSnapshotQueryTests: XCTestCase { XCTAssertTrue(try query.snapshots().isEmpty) } + func testSnapshotQueryAppliesExcludedOwnerBundleIdentifiersAfterIncludedOnes() throws { + let provider = WindowListProviderSpy(entries: [ + makeWindowEntry(layer: 0, frame: .zero, ownerProcessIdentifier: 123) + ]) + + let query = WindowSnapshotQuery( + listProvider: provider, + ownerBundleIdentifierProvider: WindowOwnerBundleIdentifierProvidingStub( + bundleIdentifier: "com.example.app"), + filter: WindowSnapshotFilter( + includedOwnerBundleIdentifiers: ["com.example.app"], + excludedOwnerBundleIdentifiers: ["com.example.app"] + ) + ) + + XCTAssertTrue(try query.snapshots().isEmpty) + } + func testSnapshotQueryFiltersExcludedOwnerProcessIdentifiers() throws { let provider = WindowListProviderSpy(entries: [ makeWindowEntry(layer: 0, frame: .zero, ownerProcessIdentifier: 123), makeWindowEntry(layer: 0, frame: .zero, ownerProcessIdentifier: 456), ]) - let bundleIdentifierProvider = WindowOwnerBundleIdentifierProviderSpy( + let bundleIdentifierProvider = WindowOwnerBundleIdentifierProvidingStub( bundleIdentifier: "com.example.app") let query = WindowSnapshotQuery( @@ -158,7 +228,6 @@ final class WindowSnapshotQueryTests: XCTestCase { let snapshots = try query.snapshots() XCTAssertEqual(snapshots.map(\.ownerProcessIdentifier), [456]) - XCTAssertEqual(bundleIdentifierProvider.processIdentifiers, [456]) } func testSnapshotQueryFiltersExcludedWindowIdentifiers() throws { @@ -166,7 +235,7 @@ final class WindowSnapshotQueryTests: XCTestCase { makeWindowEntry(layer: 0, frame: .zero, ownerProcessIdentifier: 123, windowIdentifier: 111), makeWindowEntry(layer: 0, frame: .zero, ownerProcessIdentifier: 456, windowIdentifier: 222), ]) - let bundleIdentifierProvider = WindowOwnerBundleIdentifierProviderSpy( + let bundleIdentifierProvider = WindowOwnerBundleIdentifierProvidingStub( bundleIdentifier: "com.example.app") let query = WindowSnapshotQuery( @@ -177,7 +246,6 @@ final class WindowSnapshotQueryTests: XCTestCase { let snapshots = try query.snapshots() XCTAssertEqual(snapshots.map(\.windowIdentifier), [222]) - XCTAssertEqual(bundleIdentifierProvider.processIdentifiers, [456]) } func testSnapshotQueryFiltersIncludedOwnerBundleIdentifiers() throws { @@ -187,7 +255,7 @@ final class WindowSnapshotQueryTests: XCTestCase { let query = WindowSnapshotQuery( listProvider: provider, - ownerBundleIdentifierProvider: WindowOwnerBundleIdentifierProviderStub( + ownerBundleIdentifierProvider: WindowOwnerBundleIdentifierProvidingStub( bundleIdentifier: "com.example.allowed"), filter: WindowSnapshotFilter(includedOwnerBundleIdentifiers: ["com.example.allowed"]) ) @@ -202,7 +270,8 @@ final class WindowSnapshotQueryTests: XCTestCase { let query = WindowSnapshotQuery( listProvider: provider, - ownerBundleIdentifierProvider: WindowOwnerBundleIdentifierProviderStub(bundleIdentifier: nil), + ownerBundleIdentifierProvider: WindowOwnerBundleIdentifierProvidingStub( + bundleIdentifier: nil), filter: WindowSnapshotFilter(includedOwnerBundleIdentifiers: ["com.example.allowed"]) ) @@ -246,22 +315,19 @@ final class WindowSnapshotQueryTests: XCTestCase { let snapshot = try query.snapshot(for: 222) XCTAssertEqual(snapshot?.windowIdentifier, 222) - XCTAssertEqual(provider.requests.map(\.windowIdentifier), [222]) - XCTAssertEqual( - provider.requests.map(\.options), [[.optionIncludingWindow, .excludeDesktopElements]]) + XCTAssertEqual(provider.requests, [.window(222)]) } func testSnapshotForWindowIdentifierForwardsCustomSingleWindowListOptions() throws { let provider = WindowListProviderSpy(entries: []) let query = WindowSnapshotQuery( - singleWindowOptions: [.optionAll], + makeSingleWindowRequest: { .window($0, options: [.optionAll]) }, listProvider: provider ) _ = try query.snapshot(for: 222) - XCTAssertEqual(provider.requests.map(\.windowIdentifier), [222]) - XCTAssertEqual(provider.requests.map(\.options), [[.optionAll]]) + XCTAssertEqual(provider.requests, [.window(222, options: [.optionAll])]) } func testSnapshotForWindowIdentifierReturnsNilWhenFilteredOut() throws { @@ -281,7 +347,7 @@ final class WindowSnapshotQueryTests: XCTestCase { let query = WindowSnapshotQuery(listProvider: WindowListProviderSpy(entries: nil)) XCTAssertThrowsError(try query.snapshot(for: 222)) { error in - XCTAssertEqual(error as? WindowSnapshotQueryError, .listUnavailable) + XCTAssertEqual(error as? WindowSnapshotQueryError, .windowListUnavailable) } } @@ -295,53 +361,25 @@ final class WindowSnapshotQueryTests: XCTestCase { XCTAssertNil(try query.snapshot(for: 222)) } - func testSnapshotQueryExcludesMinimizedWhenEnabled() throws { - let provider = WindowListProviderSpy(entries: [ - makeWindowEntry(layer: 0, frame: .zero, ownerProcessIdentifier: 123, isMinimized: true) - ]) - let bundleIdentifierProvider = WindowOwnerBundleIdentifierProviderSpy( - bundleIdentifier: "com.example.app") - - let query = WindowSnapshotQuery( - listProvider: provider, - ownerBundleIdentifierProvider: bundleIdentifierProvider, - filter: WindowSnapshotFilter(excludesMinimizedWindows: true) - ) - - XCTAssertTrue(try query.snapshots().isEmpty) - XCTAssertTrue(bundleIdentifierProvider.processIdentifiers.isEmpty) - } - - func testSnapshotQueryIncludesMinimizedMetadata() throws { - let provider = WindowListProviderSpy(entries: [ - makeWindowEntry(layer: 0, frame: .zero, ownerProcessIdentifier: 123, isMinimized: true) - ]) - - let query = WindowSnapshotQuery(listProvider: provider) - - XCTAssertEqual(try query.snapshots().first?.isMinimized, true) - } - - func testSnapshotQueryLimitsToTopmostWindowWhenEnabled() throws { + func testSnapshotQueryReturnsTopmostMatchingWindowWhenLimited() throws { let provider = WindowListProviderSpy(entries: [ makeWindowEntry(layer: 0, frame: .zero, ownerProcessIdentifier: 1), makeWindowEntry(layer: 0, frame: .zero, ownerProcessIdentifier: 2), ]) - let bundleIdentifierProvider = WindowOwnerBundleIdentifierProviderSpy( + let bundleIdentifierProvider = WindowOwnerBundleIdentifierProvidingStub( bundleIdentifier: "com.example.app") let query = WindowSnapshotQuery( listProvider: provider, ownerBundleIdentifierProvider: bundleIdentifierProvider, - limitsToTopmostWindow: true + limit: .topmostMatchingWindow ) let snapshots = try query.snapshots() XCTAssertEqual(snapshots.map(\.ownerProcessIdentifier), [1]) - XCTAssertEqual(bundleIdentifierProvider.processIdentifiers, [1]) } - func testSnapshotQueryLimitsToFirstMatchingTopmostWindowWhenEnabled() throws { + func testSnapshotQueryReturnsFirstMatchingTopmostWindowWhenLimited() throws { let provider = WindowListProviderSpy(entries: [ makeWindowEntry(layer: 1, frame: .zero, ownerProcessIdentifier: 1), makeWindowEntry(layer: 0, frame: .zero, ownerProcessIdentifier: 2), @@ -349,9 +387,8 @@ final class WindowSnapshotQueryTests: XCTestCase { ]) let query = WindowSnapshotQuery( - allowedLayers: [0], listProvider: provider, - limitsToTopmostWindow: true + limit: .topmostMatchingWindow ) XCTAssertEqual(try query.snapshots().map(\.ownerProcessIdentifier), [2]) @@ -361,14 +398,28 @@ final class WindowSnapshotQueryTests: XCTestCase { let query = WindowSnapshotQuery(listProvider: WindowListProviderSpy(entries: nil)) XCTAssertThrowsError(try query.snapshots()) { error in - XCTAssertEqual(error as? WindowSnapshotQueryError, .listUnavailable) + XCTAssertEqual(error as? WindowSnapshotQueryError, .windowListUnavailable) } } func testSnapshotQueryErrorIsEquatableAndSendable() { - XCTAssertEqual(WindowSnapshotQueryError.listUnavailable, .listUnavailable) - assertSendable(WindowSnapshotQueryError.listUnavailable) + XCTAssertEqual(WindowSnapshotQueryError.windowListUnavailable, .windowListUnavailable) + assertSendable(WindowSnapshotQueryError.windowListUnavailable) } } +private func makeCGWindowInfo( + layer: NSNumber = NSNumber(value: 0), + ownerProcessIdentifier: NSNumber, + windowIdentifier: NSNumber +) -> NSDictionary { + [ + kCGWindowBounds as String: CGRect(x: 0, y: 0, width: 100, height: 100) + .dictionaryRepresentation, + kCGWindowLayer as String: layer, + kCGWindowOwnerPID as String: ownerProcessIdentifier, + kCGWindowNumber as String: windowIdentifier, + ] +} + private func assertSendable(_: Value) {} diff --git a/Tests/WindowKitAppKitTests/Support/AXAPI+Reset.swift b/Tests/WindowKitAppKitTests/Support/AXAPI+Reset.swift deleted file mode 100644 index 38f74d1..0000000 --- a/Tests/WindowKitAppKitTests/Support/AXAPI+Reset.swift +++ /dev/null @@ -1,16 +0,0 @@ -import ApplicationServices - -@testable import WindowKitAppKit - -extension AXAPI { - static func reset() { - isProcessTrusted = AXIsProcessTrusted - createSystemWideElement = AXUIElementCreateSystemWide - createApplicationElement = AXUIElementCreateApplication - copyAttributeValue = AXUIElementCopyAttributeValue - setAttributeValue = AXUIElementSetAttributeValue - valueGet = AXValueGetValue - valueCreate = AXValueCreate - performAction = AXUIElementPerformAction - } -} diff --git a/Tests/WindowKitAppKitTests/Support/AXClient+Testing.swift b/Tests/WindowKitAppKitTests/Support/AXClient+Testing.swift new file mode 100644 index 0000000..a069c0e --- /dev/null +++ b/Tests/WindowKitAppKitTests/Support/AXClient+Testing.swift @@ -0,0 +1,37 @@ +import ApplicationServices + +@testable import WindowKitAppKit + +extension AXClient { + static func testing( + isProcessTrusted: @escaping () -> Bool = { true }, + createSystemWideElement: @escaping () -> AXUIElement = AXUIElementCreateSystemWide, + createApplicationElement: @escaping (pid_t) -> AXUIElement = AXUIElementCreateApplication, + copyAttributeValue: + @escaping ( + AXUIElement, + CFString, + UnsafeMutablePointer + ) -> AXError = { _, _, value in + value.pointee = nil + return .noValue + }, + setAttributeValue: @escaping (AXUIElement, CFString, CFTypeRef) -> AXError = { _, _, _ in + .success + }, + valueGet: @escaping (AXValue, AXValueType, UnsafeMutableRawPointer) -> Bool = AXValueGetValue, + valueCreate: @escaping (AXValueType, UnsafeRawPointer) -> AXValue? = AXValueCreate, + performAction: @escaping (AXUIElement, CFString) -> AXError = { _, _ in .success } + ) -> AXClient { + AXClient( + isProcessTrusted: isProcessTrusted, + createSystemWideElement: createSystemWideElement, + createApplicationElement: createApplicationElement, + copyAttributeValue: copyAttributeValue, + setAttributeValue: setAttributeValue, + valueGet: valueGet, + valueCreate: valueCreate, + performAction: performAction + ) + } +} diff --git a/Tests/WindowKitAppKitTests/Support/AccessibilityTestSupport.swift b/Tests/WindowKitAppKitTests/Support/AccessibilityTestSupport.swift index 2ca4144..7ff6c3f 100644 --- a/Tests/WindowKitAppKitTests/Support/AccessibilityTestSupport.swift +++ b/Tests/WindowKitAppKitTests/Support/AccessibilityTestSupport.swift @@ -16,20 +16,23 @@ struct RunningApplicationCheckerStub: RunningApplicationChecking { } } -struct CGWindowListInfoProviderStub: CGWindowListInfoProviding, @unchecked Sendable { - let entries: [CGWindowListInfoEntry]? +struct WindowListProviderStub: WindowListProviding { + let entries: [WindowListEntry]? - init(entries: [CGWindowListInfoEntry]?) { + init(entries: [WindowListEntry]?) { self.entries = entries } - init(ownerProcessIdentifier: pid_t) { - entries = [[kCGWindowOwnerPID as String: NSNumber(value: ownerProcessIdentifier)]] + init(ownerProcessIdentifier: pid_t, windowIdentifier: CGWindowID = 42) { + entries = [ + WindowListEntry( + ownerProcessIdentifier: ownerProcessIdentifier, + windowIdentifier: windowIdentifier + ) + ] } - func windowListInfo(options _: CGWindowListOption, relativeToWindow _: CGWindowID) - -> [CGWindowListInfoEntry]? - { + func windowList(for _: WindowListRequest) -> [WindowListEntry]? { entries } } @@ -49,7 +52,7 @@ struct FocusedWindowAXFixture { windowElement = AXUIElementCreateApplication(windowProcessIdentifier ?? processIdentifier + 1) } - func installCopyAttributeValue( + func accessibilityClient( additionalHandler: @escaping ( AXUIElement, @@ -58,29 +61,41 @@ struct FocusedWindowAXFixture { ) -> AXError = { _, _, value in value.pointee = nil return .noValue - } - ) { - let focusedAppAttribute = kAXFocusedApplicationAttribute as String - let focusedWindowAttribute = kAXFocusedWindowAttribute as String - - AXAPI.copyAttributeValue = { element, attribute, value in - switch attribute as String { - case focusedAppAttribute where CFEqual(element, systemWideElement): - value.pointee = appElement - return .success - case focusedWindowAttribute where CFEqual(element, appElement): - value.pointee = windowElement - return .success - default: - return additionalHandler(element, attribute, value) - } - } + }, + setAttributeValue: @escaping (AXUIElement, CFString, CFTypeRef) -> AXError = { _, _, _ in + .success + }, + valueCreate: @escaping (AXValueType, UnsafeRawPointer) -> AXValue? = AXValueCreate, + performAction: @escaping (AXUIElement, CFString) -> AXError = { _, _ in .success } + ) -> AXClient { + let focusedAppAttribute = AXWindowAttribute.focusedApplication.accessibilityName + let focusedWindowAttribute = AXWindowAttribute.focusedWindow.accessibilityName + + return .testing( + copyAttributeValue: { element, attribute, value in + switch attribute as String { + case focusedAppAttribute where CFEqual(element, systemWideElement): + value.pointee = appElement + return .success + case focusedWindowAttribute where CFEqual(element, appElement): + value.pointee = windowElement + return .success + default: + return additionalHandler(element, attribute, value) + } + }, + setAttributeValue: setAttributeValue, + valueCreate: valueCreate, + performAction: performAction + ) } func commandController( + accessibilityClient: AXClient? = nil, isRunningApplication: Bool = false - ) -> AXWindowCommandController { - AXWindowCommandController( + ) -> AXWindowController { + AXWindowController( + accessibilityClient: accessibilityClient ?? self.accessibilityClient(), systemWideElement: systemWideElement, applicationChecker: RunningApplicationCheckerStub( isRunning: isRunningApplication) @@ -88,9 +103,11 @@ struct FocusedWindowAXFixture { } func frameController( + accessibilityClient: AXClient? = nil, isRunningApplication: Bool = false - ) -> AXWindowFrameController { - AXWindowFrameController( + ) -> AXWindowController { + AXWindowController( + accessibilityClient: accessibilityClient ?? self.accessibilityClient(), systemWideElement: systemWideElement, applicationChecker: RunningApplicationCheckerStub( isRunning: isRunningApplication) @@ -98,9 +115,11 @@ struct FocusedWindowAXFixture { } func stateProvider( + accessibilityClient: AXClient? = nil, isRunningApplication: Bool = false - ) -> AXWindowStateProvider { - AXWindowStateProvider( + ) -> AXWindowController { + AXWindowController( + accessibilityClient: accessibilityClient ?? self.accessibilityClient(), systemWideElement: systemWideElement, applicationChecker: RunningApplicationCheckerStub( isRunning: isRunningApplication) @@ -108,9 +127,11 @@ struct FocusedWindowAXFixture { } func roleProvider( + accessibilityClient: AXClient? = nil, isRunningApplication: Bool = false - ) -> AXWindowRoleProvider { - AXWindowRoleProvider( + ) -> AXWindowController { + AXWindowController( + accessibilityClient: accessibilityClient ?? self.accessibilityClient(), systemWideElement: systemWideElement, applicationChecker: RunningApplicationCheckerStub( isRunning: isRunningApplication) diff --git a/Tests/WindowKitAppKitTests/Support/ValueConformanceAssertions.swift b/Tests/WindowKitAppKitTests/Support/ValueConformanceAssertions.swift index 499a0c7..e0594f5 100644 --- a/Tests/WindowKitAppKitTests/Support/ValueConformanceAssertions.swift +++ b/Tests/WindowKitAppKitTests/Support/ValueConformanceAssertions.swift @@ -11,3 +11,15 @@ func assertValueConformance( XCTAssertEqual(decoded, value, file: file, line: line) XCTAssertEqual(Set([value, decoded]).count, 1, file: file, line: line) } + +func assertValueConformance( + _: Value.Type, + _ value: Value, + file: StaticString = #filePath, + line: UInt = #line +) throws { + try assertValueConformance(value, file: file, line: line) + assertSendable(Value.self) +} + +func assertSendable(_: Value.Type) {} diff --git a/Tests/WindowKitAppKitTests/Support/WindowListTestSupport.swift b/Tests/WindowKitAppKitTests/Support/WindowListTestSupport.swift new file mode 100644 index 0000000..f5a0f7a --- /dev/null +++ b/Tests/WindowKitAppKitTests/Support/WindowListTestSupport.swift @@ -0,0 +1,53 @@ +import CoreGraphics +import Foundation +import os +import WindowKit + +@testable import WindowKitAppKit + +final class WindowListProviderSpy: WindowListProviding { + let entries: [WindowListEntry]? + private let requestsStorage = OSAllocatedUnfairLock(initialState: [WindowListRequest]()) + + var requests: [WindowListRequest] { + requestsStorage.withLock { $0 } + } + + init(entries: [WindowListEntry]?) { + self.entries = entries + } + + func windowList(for request: WindowListRequest) -> [WindowListEntry]? { + requestsStorage.withLock { + $0.append(request) + } + return entries + } +} + +struct WindowOwnerBundleIdentifierProvidingStub: WindowOwnerBundleIdentifierProviding { + let bundleIdentifier: String? + + func bundleIdentifier(forProcessIdentifier _: pid_t) -> String? { + bundleIdentifier + } +} + +func makeWindowEntry( + layer: Int, + frame: CGRect, + ownerProcessIdentifier: Int32?, + windowIdentifier: UInt32? = 12345 +) -> WindowListEntry { + let processIdentifier = ownerProcessIdentifier.map { pid_t($0) } + let cgWindowIdentifier = windowIdentifier.map { CGWindowID($0) } + + return WindowListEntry( + layer: layer, + frame: frame, + ownerProcessIdentifier: processIdentifier, + windowIdentifier: cgWindowIdentifier, + ownerName: "ExampleApp", + title: "Main" + ) +} diff --git a/Tests/WindowKitAppKitTests/WindowKitAppKitPublicAPITests.swift b/Tests/WindowKitAppKitTests/WindowKitAppKitPublicAPITests.swift new file mode 100644 index 0000000..361c960 --- /dev/null +++ b/Tests/WindowKitAppKitTests/WindowKitAppKitPublicAPITests.swift @@ -0,0 +1,218 @@ +import CoreGraphics +import WindowKit +import WindowKitAppKit +import XCTest + +@MainActor +final class WindowKitAppKitPublicAPITests: XCTestCase { + func testPlacementAndRoleAPIsAreUsableFromPublicImport() throws { + let frameController = PublicFrameController( + frame: CGRect(x: 0, y: 0, width: 100, height: 100) + ) + let frameProvider: any WindowFrameProviding = frameController + let commandController = PublicCommandController() + let stateProvider: any WindowStateProviding = PublicStateProvider( + state: WindowState(isMinimized: false, isFullScreen: false) + ) + let screenProvider = PublicScreenProvider(screens: [publicScreen()]) + let placer = WindowPlacer(frameController: frameController, screenProvider: screenProvider) + let defaultPlacer = WindowPlacer() + let roleProvider: any WindowRoleProviding = PublicRoleProvider(role: WindowRole.standard) + + try placer.place(WindowPlacement.fill, for: WindowTarget.windowIdentifier(1)) + XCTAssertEqual( + try frameProvider.frame(for: .focused), CGRect(x: 0, y: 0, width: 100, height: 100)) + XCTAssertEqual(try stateProvider.state(for: .focused).isMinimized, false) + try commandController.raise(.focused) + XCTAssertEqual(commandController.raisedTarget, .focused) + XCTAssertEqual(try roleProvider.role(for: .focused), .standard) + XCTAssertEqual(WindowPlacerError.screenNotFound(.main), .screenNotFound(.main)) + XCTAssertEqual(WindowRole.dialog, .dialog) + XCTAssertEqual( + WindowRole.unclassified(accessibilityRole: "AXWindow", accessibilitySubrole: nil), + .unclassified(accessibilityRole: "AXWindow", accessibilitySubrole: nil) + ) + withExtendedLifetime(defaultPlacer) {} + } + + func testAppKitHitTestingAndSnapshotQueriesAreUsableFromPublicImport() { + let defaultHitTester: any WindowHitTesting = WindowHitTester() + let configuredHitTester: any WindowHitTesting = WindowHitTester( + pointTransform: { $0 }, + filter: WindowSnapshotFilter(includedOwnerBundleIdentifiers: ["com.example.app"]) + ) + let ownerBundleIdentifierProvider: any WindowOwnerBundleIdentifierProviding = + PublicOwnerBundleIdentifierProvider(bundleIdentifier: "com.example.app") + let query = WindowSnapshotQuery( + ownerBundleIdentifierProvider: ownerBundleIdentifierProvider, + filter: WindowSnapshotFilter(includedOwnerBundleIdentifiers: ["com.example.app"]), + limit: WindowSnapshotQueryLimit.topmostMatchingWindow + ) + let snapshots: () throws -> [WindowSnapshot] = query.snapshots + let snapshot: (CGWindowID) throws -> WindowSnapshot? = query.snapshot(for:) + + XCTAssertNotNil(defaultHitTester) + XCTAssertNotNil(configuredHitTester) + assertPublicSendableValue(WindowHitTester()) + assertPublicSendableValue(query) + withExtendedLifetime(snapshots) {} + withExtendedLifetime(snapshot) {} + XCTAssertEqual(WindowSnapshotQueryError.windowListUnavailable, .windowListUnavailable) + XCTAssertNotNil(NSRunningApplicationBundleIdentifierProvider()) + XCTAssertNotNil(NSScreenProvider()) + } + + func testAccessibilityImplementationsAreUsableFromPublicImport() { + let controller = AXWindowController() + let windowController: any WindowControlling = controller + let frameController: any WindowFrameControlling = controller + let commandController: any WindowCommandControlling = controller + let stateProvider: any WindowStateProviding = controller + let roleProvider: any WindowRoleProviding = controller + + assertPublicSendableValue(controller) + withExtendedLifetime(windowController) {} + withExtendedLifetime(frameController) {} + withExtendedLifetime(commandController) {} + withExtendedLifetime(stateProvider) {} + withExtendedLifetime(roleProvider) {} + } + + func testDocumentationExamplesAreUsableWithoutTestableImport() throws { + let query = WindowSnapshotQuery() + let snapshots: () throws -> [WindowSnapshot] = query.snapshots + let snapshot: (CGWindowID) throws -> WindowSnapshot? = query.snapshot(for:) + let hitTester = WindowHitTester() + let topmost: (CGPoint) -> WindowSnapshot? = hitTester.topmost(at:) + let placer = WindowPlacer() + let place: (WindowPlacement, WindowTarget) throws -> Void = { + try placer.place($0, for: $1) + } + + withExtendedLifetime(snapshots) {} + withExtendedLifetime(snapshot) {} + withExtendedLifetime(topmost) {} + withExtendedLifetime(place) {} + } + + func testPublicValueConformancesAreUsableFromPublicImport() throws { + try assertValueConformance(WindowRole.self, WindowRole.standard) + try assertValueConformance( + WindowRole.self, + WindowRole.unclassified(accessibilityRole: "AXWindow", accessibilitySubrole: nil) + ) + try assertValueConformance( + WindowAccessibilityOperation.self, + WindowAccessibilityOperation.raise + ) + try assertValueConformance( + WindowAccessibilityOperationFailure.self, + WindowAccessibilityOperationFailure.cannotComplete + ) + try assertValueConformance( + WindowSnapshotQueryLimit.self, + WindowSnapshotQueryLimit.allMatchingWindows + ) + + assertPublicSendableValue(WindowAccessibilityError.accessibilityNotTrusted) + assertPublicSendableValue(WindowPlacer()) + assertPublicSendableValue(WindowPlacerError.screenNotFound(.main)) + assertPublicSendableValue(WindowSnapshotQuery()) + assertPublicSendableValue(WindowSnapshotQueryError.windowListUnavailable) + assertSendable((any WindowRoleProviding).self) + assertSendable((any WindowOwnerBundleIdentifierProviding).self) + } +} + +private func publicScreen() -> WindowScreen { + WindowScreen( + displayIdentifier: 1, + frame: CGRect(x: 0, y: 0, width: 800, height: 600), + visibleFrame: CGRect(x: 0, y: 0, width: 800, height: 560), + isMain: true + ) +} + +@MainActor +private final class PublicFrameController: WindowFrameControlling { + private let frame: CGRect + private(set) var assignedFrame: CGRect? + + init(frame: CGRect) { + self.frame = frame + } + + func frame(for _: WindowTarget) throws -> CGRect { + frame + } + + func setFrame(_ frame: CGRect, for _: WindowTarget) throws { + assignedFrame = frame + } +} + +private struct PublicScreenProvider: WindowScreenProviding { + let screensValue: [WindowScreen] + + init(screens: [WindowScreen]) { + screensValue = screens + } + + func screens() -> [WindowScreen] { + screensValue + } +} + +private struct PublicOwnerBundleIdentifierProvider: WindowOwnerBundleIdentifierProviding { + let bundleIdentifierValue: String? + + init(bundleIdentifier: String?) { + bundleIdentifierValue = bundleIdentifier + } + + func bundleIdentifier(forProcessIdentifier _: pid_t) -> String? { + bundleIdentifierValue + } +} + +@MainActor +private final class PublicCommandController: WindowCommandControlling { + private(set) var raisedTarget: WindowTarget? + + func setMinimized(_: WindowTarget, isMinimized _: Bool) throws {} + func setFullScreen(_: WindowTarget, isFullScreen _: Bool) throws {} + + func raise(_ target: WindowTarget) throws { + raisedTarget = target + } + + func lower(_: WindowTarget) throws {} +} + +@MainActor +private struct PublicRoleProvider: WindowRoleProviding { + let roleValue: WindowRole + + init(role: WindowRole) { + roleValue = role + } + + func role(for _: WindowTarget) throws -> WindowRole { + roleValue + } +} + +@MainActor +private struct PublicStateProvider: WindowStateProviding { + let stateValue: WindowState + + init(state: WindowState) { + stateValue = state + } + + func state(for _: WindowTarget) throws -> WindowState { + stateValue + } +} + +private func assertPublicSendableValue(_: Value) {} diff --git a/Tests/WindowKitTests/Control/WindowControlProtocolTests.swift b/Tests/WindowKitTests/Control/WindowControlProtocolTests.swift new file mode 100644 index 0000000..1d87f5d --- /dev/null +++ b/Tests/WindowKitTests/Control/WindowControlProtocolTests.swift @@ -0,0 +1,109 @@ +import WindowKit +import XCTest + +@MainActor +final class WindowControlProtocolTests: XCTestCase { + func testWindowControllingComposesEveryControlCapability() throws { + let controller: any WindowControlling = CompleteWindowControllerStub() + + XCTAssertEqual(try controller.frame(for: .focused), .zero) + XCTAssertEqual(try controller.state(for: .focused), WindowState()) + try controller.setFrame(.zero, for: .focused) + try controller.setMinimized(.focused, isMinimized: false) + try controller.setFullScreen(.focused, isFullScreen: false) + try controller.raise(.focused) + try controller.lower(.focused) + } + + func testFrameControllingRefinesFrameProviding() throws { + let frame = CGRect(x: 10, y: 20, width: 300, height: 200) + let controller = WindowFrameControllerSpy(frame: frame) + let provider: any WindowFrameProviding = controller + let controlling: any WindowFrameControlling = controller + + XCTAssertEqual(try provider.frame(for: .focused), frame) + try controlling.setFrame(frame, for: .windowIdentifier(42)) + XCTAssertEqual(controller.assignedFrames, [.init(frame: frame, target: .windowIdentifier(42))]) + } + + func testCommandControllingKeepsStateAndOrderingCommandsSeparate() throws { + let controller = WindowCommandControllerSpy() + + try controller.setMinimized(.focused, isMinimized: true) + try controller.setFullScreen(.windowIdentifier(42), isFullScreen: false) + try controller.raise(.focused) + try controller.lower(.windowIdentifier(42)) + + XCTAssertEqual( + controller.commands, + [ + .setMinimized(.focused, true), + .setFullScreen(.windowIdentifier(42), false), + .raise(.focused), + .lower(.windowIdentifier(42)), + ] + ) + } +} + +@MainActor +private struct CompleteWindowControllerStub: WindowControlling { + func frame(for _: WindowTarget) throws -> CGRect { .zero } + func setFrame(_: CGRect, for _: WindowTarget) throws {} + func state(for _: WindowTarget) throws -> WindowState { WindowState() } + func setMinimized(_: WindowTarget, isMinimized _: Bool) throws {} + func setFullScreen(_: WindowTarget, isFullScreen _: Bool) throws {} + func raise(_: WindowTarget) throws {} + func lower(_: WindowTarget) throws {} +} + +@MainActor +private final class WindowFrameControllerSpy: WindowFrameControlling { + struct Assignment: Equatable { + let frame: CGRect + let target: WindowTarget + } + + private let frameValue: CGRect + private(set) var assignedFrames: [Assignment] = [] + + init(frame: CGRect) { + frameValue = frame + } + + func frame(for _: WindowTarget) throws -> CGRect { + frameValue + } + + func setFrame(_ frame: CGRect, for target: WindowTarget) throws { + assignedFrames.append(.init(frame: frame, target: target)) + } +} + +@MainActor +private final class WindowCommandControllerSpy: WindowCommandControlling { + enum Command: Equatable { + case setMinimized(WindowTarget, Bool) + case setFullScreen(WindowTarget, Bool) + case raise(WindowTarget) + case lower(WindowTarget) + } + + private(set) var commands: [Command] = [] + + func setMinimized(_ target: WindowTarget, isMinimized: Bool) throws { + commands.append(.setMinimized(target, isMinimized)) + } + + func setFullScreen(_ target: WindowTarget, isFullScreen: Bool) throws { + commands.append(.setFullScreen(target, isFullScreen)) + } + + func raise(_ target: WindowTarget) throws { + commands.append(.raise(target)) + } + + func lower(_ target: WindowTarget) throws { + commands.append(.lower(target)) + } +} diff --git a/Tests/WindowKitTests/Control/WindowStateTests.swift b/Tests/WindowKitTests/Control/WindowStateTests.swift index 54786b9..c3eb39a 100644 --- a/Tests/WindowKitTests/Control/WindowStateTests.swift +++ b/Tests/WindowKitTests/Control/WindowStateTests.swift @@ -1,7 +1,6 @@ +import WindowKit import XCTest -@testable import WindowKit - final class WindowStateTests: XCTestCase { func testInitStoresStateFields() { let state = WindowState(isMinimized: false, isFullScreen: true) @@ -10,6 +9,13 @@ final class WindowStateTests: XCTestCase { XCTAssertEqual(state.isFullScreen, true) } + func testInitDefaultsToUnknownState() { + let state = WindowState() + + XCTAssertNil(state.isMinimized) + XCTAssertNil(state.isFullScreen) + } + func testWindowStateValueConformance() throws { try assertValueConformance(WindowState(isMinimized: false, isFullScreen: true)) try assertValueConformance(WindowState(isMinimized: nil, isFullScreen: nil)) diff --git a/Tests/WindowKitTests/Control/WindowTargetTests.swift b/Tests/WindowKitTests/Control/WindowTargetTests.swift index 1e71749..476430b 100644 --- a/Tests/WindowKitTests/Control/WindowTargetTests.swift +++ b/Tests/WindowKitTests/Control/WindowTargetTests.swift @@ -1,7 +1,6 @@ +import WindowKit import XCTest -@testable import WindowKit - final class WindowTargetTests: XCTestCase { func testWindowIdentifierStoresIdentifier() { let target = WindowTarget.windowIdentifier(456) diff --git a/Tests/WindowKitTests/HitTesting/WindowHitTestResultTests.swift b/Tests/WindowKitTests/HitTesting/WindowHitTestResultTests.swift index fa62355..0e3326e 100644 --- a/Tests/WindowKitTests/HitTesting/WindowHitTestResultTests.swift +++ b/Tests/WindowKitTests/HitTesting/WindowHitTestResultTests.swift @@ -1,7 +1,6 @@ +import WindowKit import XCTest -@testable import WindowKit - final class WindowHitTestResultTests: XCTestCase { func testHitResultExposesSnapshot() { let snapshot = makeSnapshot(windowIdentifier: 1) @@ -21,16 +20,16 @@ final class WindowHitTestResultTests: XCTestCase { } func testHitTestResultAndMissReasonValueConformance() throws { - try assertValueConformance(WindowHitTestMissReason.listUnavailable) + try assertValueConformance(WindowHitTestMissReason.sourceUnavailable) try assertValueConformance(WindowHitTestMissReason.noMatchingWindowAtPoint) - try assertValueConformance(WindowHitTestResult.miss(.listUnavailable)) + try assertValueConformance(WindowHitTestResult.miss(.sourceUnavailable)) try assertValueConformance( WindowHitTestResult.hit(makeSnapshot()) ) } func testHitTestResultAndMissReasonAreSendable() { - assertSendable(WindowHitTestMissReason.listUnavailable) + assertSendable(WindowHitTestMissReason.sourceUnavailable) assertSendable(WindowHitTestResult.miss(.noMatchingWindowAtPoint)) assertSendable( WindowHitTestResult.hit(makeSnapshot()) @@ -42,7 +41,6 @@ final class WindowHitTestResultTests: XCTestCase { ownerProcessIdentifier: 123, windowIdentifier: windowIdentifier, frame: .zero, - layer: 0 ) } diff --git a/Tests/WindowKitTests/HitTesting/WindowHitTesterTests.swift b/Tests/WindowKitTests/HitTesting/WindowHitTesterTests.swift deleted file mode 100644 index 93719e3..0000000 --- a/Tests/WindowKitTests/HitTesting/WindowHitTesterTests.swift +++ /dev/null @@ -1,90 +0,0 @@ -import XCTest - -@testable import WindowKit - -@MainActor -final class WindowHitTesterTests: XCTestCase { - func testHitTestReturnsProviderResult() { - let snapshot = WindowSnapshot( - ownerProcessIdentifier: 10, - windowIdentifier: 42, - frame: CGRect(x: 0, y: 0, width: 200, height: 100), - layer: 0 - ) - let provider = WindowHitTestProviderStub( - result: .hit(snapshot), - expectedPoint: CGPoint(x: 10, y: 10) - ) - let tester = WindowHitTester(provider: provider) - - XCTAssertEqual(tester.hitTest(at: CGPoint(x: 10, y: 10)), .hit(snapshot)) - } - - func testTopmostReturnsSnapshot() { - let snapshot = WindowSnapshot( - ownerProcessIdentifier: 10, - windowIdentifier: 42, - ownerName: "Demo", - ownerBundleIdentifier: "com.example.demo", - title: "Front", - frame: CGRect(x: 0, y: 0, width: 200, height: 100), - layer: 0 - ) - let provider = WindowHitTestProviderStub(result: .hit(snapshot)) - let tester = WindowHitTester(provider: provider) - - let result = tester.topmost(at: CGPoint(x: 10, y: 10)) - - XCTAssertEqual(result?.windowIdentifier, 42) - } - - func testHitTesterCanBeUsedThroughHitTestingProtocol() { - let snapshot = WindowSnapshot( - ownerProcessIdentifier: 10, - windowIdentifier: 42, - frame: CGRect(x: 0, y: 0, width: 200, height: 100), - layer: 0 - ) - let provider = WindowHitTestProviderStub(result: .hit(snapshot)) - let tester: any WindowHitTesting = WindowHitTester(provider: provider) - - XCTAssertEqual(tester.hitTest(at: CGPoint(x: 10, y: 10)).snapshot?.windowIdentifier, 42) - XCTAssertEqual(tester.topmost(at: CGPoint(x: 10, y: 10))?.windowIdentifier, 42) - } - - func testTopmostReturnsNilForMiss() { - let provider = WindowHitTestProviderStub(result: .miss(.noMatchingWindowAtPoint)) - let tester = WindowHitTester(provider: provider) - - let result = tester.topmost(at: CGPoint(x: 10, y: 10)) - - XCTAssertNil(result) - } - - func testWindowHitTesterIsSendable() { - let tester = WindowHitTester( - provider: WindowHitTestProviderStub(result: .miss(.noMatchingWindowAtPoint)) - ) - - assertSendable(tester) - } - - private func assertSendable(_: Value) {} -} - -private struct WindowHitTestProviderStub: WindowHitTestProviding { - let result: WindowHitTestResult - var expectedPoint: CGPoint? - - init(result: WindowHitTestResult, expectedPoint: CGPoint? = nil) { - self.result = result - self.expectedPoint = expectedPoint - } - - func hitTest(at point: CGPoint) -> WindowHitTestResult { - if let expectedPoint { - XCTAssertEqual(point, expectedPoint) - } - return result - } -} diff --git a/Tests/WindowKitTests/Placement/WindowPlacementCalculatorTests.swift b/Tests/WindowKitTests/Placement/WindowPlacementCalculatorTests.swift index 285852b..002ce06 100644 --- a/Tests/WindowKitTests/Placement/WindowPlacementCalculatorTests.swift +++ b/Tests/WindowKitTests/Placement/WindowPlacementCalculatorTests.swift @@ -1,142 +1,20 @@ import CoreGraphics +import WindowKit import XCTest -@testable import WindowKit - final class WindowPlacementCalculatorTests: XCTestCase { - private let calculator = WindowPlacementCalculator() - func testFillReturnsInsetScreenArea() { let screenArea = CGRect(x: 10, y: 20, width: 300, height: 200) XCTAssertEqual( - calculator.frame(for: .fill, in: screenArea, inset: 10), + WindowPlacementCalculator.frame(for: .fill, in: screenArea, inset: 10), CGRect(x: 20, y: 30, width: 280, height: 180) ) } - func testLeftHalfSplitsWidth() { - let screenArea = CGRect(x: 0, y: 0, width: 200, height: 100) - XCTAssertEqual( - calculator.frame(for: .leftHalf, in: screenArea), - CGRect(x: 0, y: 0, width: 100, height: 100) - ) - } - - func testRightHalfSplitsWidth() { - let screenArea = CGRect(x: 0, y: 0, width: 200, height: 100) - XCTAssertEqual( - calculator.frame(for: .rightHalf, in: screenArea), - CGRect(x: 100, y: 0, width: 100, height: 100) - ) - } - - func testTopHalfSplitsHeight() { - let screenArea = CGRect(x: 0, y: 10, width: 200, height: 100) - XCTAssertEqual( - calculator.frame(for: .topHalf, in: screenArea), - CGRect(x: 0, y: 60, width: 200, height: 50) - ) - } - - func testBottomHalfSplitsHeight() { - let screenArea = CGRect(x: 0, y: 10, width: 200, height: 100) - XCTAssertEqual( - calculator.frame(for: .bottomHalf, in: screenArea), - CGRect(x: 0, y: 10, width: 200, height: 50) - ) - } - - func testTopLeftQuarter() { - let screenArea = CGRect(x: 0, y: 0, width: 200, height: 100) - XCTAssertEqual( - calculator.frame(for: .topLeftQuarter, in: screenArea), - CGRect(x: 0, y: 50, width: 100, height: 50) - ) - } - - func testRemainingQuarterPlacements() { - let screenArea = CGRect(x: 0, y: 0, width: 200, height: 100) - - XCTAssertEqual( - calculator.frame(for: .topRightQuarter, in: screenArea), - CGRect(x: 100, y: 50, width: 100, height: 50) - ) - XCTAssertEqual( - calculator.frame(for: .bottomLeftQuarter, in: screenArea), - CGRect(x: 0, y: 0, width: 100, height: 50) - ) - XCTAssertEqual( - calculator.frame(for: .bottomRightQuarter, in: screenArea), - CGRect(x: 100, y: 0, width: 100, height: 50) - ) - } - - func testLeftThirdSplitsWidthIntoThirds() { - let screenArea = CGRect(x: 0, y: 0, width: 300, height: 90) - XCTAssertEqual( - calculator.frame(for: .leftThird, in: screenArea), - CGRect(x: 0, y: 0, width: 100, height: 90) - ) - } - - func testTopThirdSplitsHeightIntoThirds() { - let screenArea = CGRect(x: 0, y: 0, width: 300, height: 90) - XCTAssertEqual( - calculator.frame(for: .topThird, in: screenArea), - CGRect(x: 0, y: 60, width: 300, height: 30) - ) - } - - func testRemainingThirdPlacements() { - let screenArea = CGRect(x: 0, y: 0, width: 300, height: 90) - - XCTAssertEqual( - calculator.frame(for: .centerThird, in: screenArea), - CGRect(x: 100, y: 0, width: 100, height: 90) - ) - XCTAssertEqual( - calculator.frame(for: .rightThird, in: screenArea), - CGRect(x: 200, y: 0, width: 100, height: 90) - ) - XCTAssertEqual( - calculator.frame(for: .middleThird, in: screenArea), - CGRect(x: 0, y: 30, width: 300, height: 30) - ) - XCTAssertEqual( - calculator.frame(for: .bottomThird, in: screenArea), - CGRect(x: 0, y: 0, width: 300, height: 30) - ) - } - - func testLeftTwoThirdsSplitsWidth() { - let screenArea = CGRect(x: 0, y: 0, width: 300, height: 90) - XCTAssertEqual( - calculator.frame(for: .leftTwoThirds, in: screenArea), - CGRect(x: 0, y: 0, width: 200, height: 90) - ) - } - - func testRemainingTwoThirdPlacements() { - let screenArea = CGRect(x: 0, y: 0, width: 300, height: 90) - - XCTAssertEqual( - calculator.frame(for: .rightTwoThirds, in: screenArea), - CGRect(x: 100, y: 0, width: 200, height: 90) - ) - XCTAssertEqual( - calculator.frame(for: .topTwoThirds, in: screenArea), - CGRect(x: 0, y: 30, width: 300, height: 60) - ) - XCTAssertEqual( - calculator.frame(for: .bottomTwoThirds, in: screenArea), - CGRect(x: 0, y: 0, width: 300, height: 60) - ) - } - func testGridPlacementCalculatesBottomRowCell() { let screenArea = CGRect(x: 0, y: 0, width: 300, height: 200) XCTAssertEqual( - calculator.frame( + WindowPlacementCalculator.frame( for: .grid(columns: 3, rows: 2, column: 1, row: 0, columnSpan: 2, rowSpan: 1), in: screenArea ), @@ -147,7 +25,7 @@ final class WindowPlacementCalculatorTests: XCTestCase { func testGridPlacementCalculatesTopRowCell() { let screenArea = CGRect(x: 0, y: 0, width: 300, height: 200) XCTAssertEqual( - calculator.frame( + WindowPlacementCalculator.frame( for: .grid(columns: 3, rows: 2, column: 1, row: 1, columnSpan: 1, rowSpan: 1), in: screenArea ), @@ -159,7 +37,7 @@ final class WindowPlacementCalculatorTests: XCTestCase { let screenArea = CGRect(x: 0, y: 0, width: 300, height: 200) XCTAssertEqual( - calculator.frame( + WindowPlacementCalculator.frame( for: .grid(columns: 0, rows: -1, column: -10, row: 99, columnSpan: 99, rowSpan: 99), in: screenArea ), @@ -171,7 +49,7 @@ final class WindowPlacementCalculatorTests: XCTestCase { let screenArea = CGRect(x: 10, y: 20, width: 300, height: 200) XCTAssertEqual( - calculator.frame(for: .fill, in: screenArea, inset: -50), + WindowPlacementCalculator.frame(for: .fill, in: screenArea, inset: -50), screenArea ) } @@ -180,7 +58,7 @@ final class WindowPlacementCalculatorTests: XCTestCase { let screenArea = CGRect(x: 10, y: 20, width: 300, height: 200) XCTAssertEqual( - calculator.frame(for: .fill, in: screenArea, inset: 500), + WindowPlacementCalculator.frame(for: .fill, in: screenArea, inset: 500), CGRect(x: 110, y: 120, width: 100, height: 0) ) } @@ -189,12 +67,12 @@ final class WindowPlacementCalculatorTests: XCTestCase { let screenArea = CGRect(x: 10, y: 20, width: 300, height: 200) XCTAssertEqual( - calculator.frame(for: .fill, in: screenArea, inset: .infinity), + WindowPlacementCalculator.frame(for: .fill, in: screenArea, inset: .infinity), CGRect(x: 110, y: 120, width: 100, height: 0) ) XCTAssertEqual( - calculator.frame(for: .fill, in: screenArea, inset: .nan), + WindowPlacementCalculator.frame(for: .fill, in: screenArea, inset: .nan), screenArea ) } @@ -203,15 +81,35 @@ final class WindowPlacementCalculatorTests: XCTestCase { let screenArea = CGRect(x: 310, y: 220, width: -300, height: -200) XCTAssertEqual( - calculator.frame(for: .fill, in: screenArea, inset: 10), + WindowPlacementCalculator.frame(for: .fill, in: screenArea, inset: 10), CGRect(x: 20, y: 30, width: 280, height: 180) ) } + func testFrameSanitizesNonFiniteScreenAreaBeforePlacement() { + let screenArea = CGRect( + x: CGFloat.infinity, + y: CGFloat.nan, + width: CGFloat.infinity, + height: -CGFloat.infinity + ) + + XCTAssertEqual( + WindowPlacementCalculator.frame(for: .fill, in: screenArea), + CGRect(x: 0, y: 0, width: 0, height: 0) + ) + XCTAssertEqual( + WindowPlacementCalculator.frame( + for: .centered(widthRatio: 0.5, heightRatio: 0.5), in: screenArea), + CGRect(x: 0, y: 0, width: 0, height: 0) + ) + } + func testCenteredClampsRatiosToZeroToOne() { let screenArea = CGRect(x: 0, y: 0, width: 200, height: 100) XCTAssertEqual( - calculator.frame(for: .centered(widthRatio: 2, heightRatio: -1), in: screenArea), + WindowPlacementCalculator.frame( + for: .centered(widthRatio: 2, heightRatio: -1), in: screenArea), CGRect(x: 0, y: 50, width: 200, height: 0) ) } @@ -220,7 +118,8 @@ final class WindowPlacementCalculatorTests: XCTestCase { let screenArea = CGRect(x: 0, y: 0, width: 200, height: 100) XCTAssertEqual( - calculator.frame(for: .centered(widthRatio: .infinity, heightRatio: .nan), in: screenArea), + WindowPlacementCalculator.frame( + for: .centered(widthRatio: .infinity, heightRatio: .nan), in: screenArea), CGRect(x: 0, y: 50, width: 200, height: 0) ) } @@ -229,14 +128,10 @@ final class WindowPlacementCalculatorTests: XCTestCase { let screenArea = CGRect(x: 0, y: 0, width: 200, height: 100) XCTAssertEqual( - calculator.frame(for: .centered(widthRatio: 0.5, heightRatio: 0.4), in: screenArea), + WindowPlacementCalculator.frame( + for: .centered(widthRatio: 0.5, heightRatio: 0.4), in: screenArea), CGRect(x: 50, y: 30, width: 100, height: 40) ) } - func testWindowPlacementCalculatorIsSendable() { - assertSendable(WindowPlacementCalculator()) - } - - private func assertSendable(_: Value) {} } diff --git a/Tests/WindowKitTests/Placement/WindowPlacementTests.swift b/Tests/WindowKitTests/Placement/WindowPlacementTests.swift index ad42872..55052ae 100644 --- a/Tests/WindowKitTests/Placement/WindowPlacementTests.swift +++ b/Tests/WindowKitTests/Placement/WindowPlacementTests.swift @@ -1,30 +1,11 @@ import CoreGraphics +import WindowKit import XCTest -@testable import WindowKit - final class WindowPlacementTests: XCTestCase { func testWindowPlacementValueConformance() throws { let placements: [WindowPlacement] = [ .fill, - .leftHalf, - .rightHalf, - .topHalf, - .bottomHalf, - .topLeftQuarter, - .topRightQuarter, - .bottomLeftQuarter, - .bottomRightQuarter, - .leftThird, - .centerThird, - .rightThird, - .topThird, - .middleThird, - .bottomThird, - .leftTwoThirds, - .rightTwoThirds, - .topTwoThirds, - .bottomTwoThirds, .grid(columns: 3, rows: 2, column: 1, row: 0, columnSpan: 2, rowSpan: 1), .centered(widthRatio: 0.5, heightRatio: 0.5), ] diff --git a/Tests/WindowKitTests/Screens/WindowScreenAreaTests.swift b/Tests/WindowKitTests/Screens/WindowScreenAreaTests.swift index ca1cd75..88a2aef 100644 --- a/Tests/WindowKitTests/Screens/WindowScreenAreaTests.swift +++ b/Tests/WindowKitTests/Screens/WindowScreenAreaTests.swift @@ -1,7 +1,6 @@ +import WindowKit import XCTest -@testable import WindowKit - final class WindowScreenAreaTests: XCTestCase { func testWindowScreenAreaValueConformance() throws { try assertValueConformance(WindowScreenArea.full) diff --git a/Tests/WindowKitTests/Screens/WindowScreenCycleDirectionTests.swift b/Tests/WindowKitTests/Screens/WindowScreenCycleDirectionTests.swift index 0f02934..d3222c8 100644 --- a/Tests/WindowKitTests/Screens/WindowScreenCycleDirectionTests.swift +++ b/Tests/WindowKitTests/Screens/WindowScreenCycleDirectionTests.swift @@ -1,7 +1,6 @@ +import WindowKit import XCTest -@testable import WindowKit - final class WindowScreenCycleDirectionTests: XCTestCase { func testWindowScreenCycleDirectionValueConformance() throws { try assertValueConformance(WindowScreenCycleDirection.next) diff --git a/Tests/WindowKitTests/Screens/WindowScreenFallbackTests.swift b/Tests/WindowKitTests/Screens/WindowScreenFallbackTests.swift new file mode 100644 index 0000000..7a2eafb --- /dev/null +++ b/Tests/WindowKitTests/Screens/WindowScreenFallbackTests.swift @@ -0,0 +1,16 @@ +import WindowKit +import XCTest + +final class WindowScreenFallbackTests: XCTestCase { + func testWindowScreenFallbackValueConformance() throws { + try assertValueConformance(WindowScreenFallback.firstScreen) + try assertValueConformance(WindowScreenFallback.none) + } + + func testWindowScreenFallbackIsSendable() { + assertSendable(WindowScreenFallback.firstScreen) + assertSendable(WindowScreenFallback.none) + } + + private func assertSendable(_: Value) {} +} diff --git a/Tests/WindowKitTests/Screens/WindowScreenMovementTests.swift b/Tests/WindowKitTests/Screens/WindowScreenMovementTests.swift index 3e9cc77..2887028 100644 --- a/Tests/WindowKitTests/Screens/WindowScreenMovementTests.swift +++ b/Tests/WindowKitTests/Screens/WindowScreenMovementTests.swift @@ -1,9 +1,18 @@ import CoreGraphics +import WindowKit import XCTest -@testable import WindowKit - final class WindowScreenMovementTests: XCTestCase { + func testMovePreservingRelativePositionCentersWhenSourceHasNoMovementRange() { + let result = WindowScreenMovement.preservingRelativePosition( + windowFrame: CGRect(x: 0, y: 0, width: 100, height: 100), + from: CGRect(x: 0, y: 0, width: 100, height: 100), + to: CGRect(x: 200, y: 300, width: 300, height: 500) + ) + + XCTAssertEqual(result, CGRect(x: 300, y: 500, width: 100, height: 100)) + } + func testMoveCenterPreservingSizeKeepsFittingSizeCentered() { let windowFrame = CGRect(x: 0, y: 0, width: 120, height: 80) let visibleFrame = CGRect(x: 100, y: 200, width: 300, height: 200) @@ -37,6 +46,29 @@ final class WindowScreenMovementTests: XCTestCase { XCTAssertEqual(moved, CGRect(x: 100, y: 100, width: 300, height: 200)) } + func testMoveCenterPreservingSizeClampsNonFiniteSize() { + let moved = WindowScreenMovement.centerPreservingSize( + windowFrame: CGRect(x: 0, y: 0, width: CGFloat.infinity, height: CGFloat.nan), + in: CGRect(x: 100, y: 200, width: 300, height: 200) + ) + + XCTAssertEqual(moved, CGRect(x: 250, y: 300, width: 0, height: 0)) + } + + func testMoveCenterPreservingSizeSanitizesNonFiniteVisibleFrame() { + let moved = WindowScreenMovement.centerPreservingSize( + windowFrame: CGRect(x: 0, y: 0, width: 120, height: 80), + in: CGRect( + x: CGFloat.infinity, + y: CGFloat.nan, + width: CGFloat.infinity, + height: -CGFloat.infinity + ) + ) + + XCTAssertEqual(moved, .zero) + } + func testMovePreservingRelativePositionKeepsRelativeOrigin() { let windowFrame = CGRect(x: 50, y: 100, width: 100, height: 100) let source = CGRect(x: 0, y: 0, width: 200, height: 200) @@ -85,6 +117,21 @@ final class WindowScreenMovementTests: XCTestCase { from: CGRect(x: 0, y: 0, width: 200, height: 200), to: CGRect(x: 300, y: 0, width: 100, height: 100) ) - XCTAssertEqual(moved, CGRect(x: 320, y: 0, width: 80, height: 60)) + XCTAssertEqual(moved, CGRect(x: 300, y: 0, width: 80, height: 60)) + } + + func testMovePreservingRelativePositionSanitizesNonFiniteVisibleFrames() { + let moved = WindowScreenMovement.preservingRelativePosition( + windowFrame: CGRect(x: 10, y: 20, width: 80, height: 60), + from: CGRect(x: CGFloat.infinity, y: 0, width: 200, height: 200), + to: CGRect( + x: CGFloat.infinity, + y: CGFloat.nan, + width: CGFloat.infinity, + height: -CGFloat.infinity + ) + ) + + XCTAssertEqual(moved, .zero) } } diff --git a/Tests/WindowKitTests/Screens/WindowScreenProvidingTests.swift b/Tests/WindowKitTests/Screens/WindowScreenProvidingTests.swift new file mode 100644 index 0000000..78a14a2 --- /dev/null +++ b/Tests/WindowKitTests/Screens/WindowScreenProvidingTests.swift @@ -0,0 +1,24 @@ +import WindowKit +import XCTest + +@MainActor +final class WindowScreenProvidingTests: XCTestCase { + func testScreensCanBeReadThroughProvidingProtocol() { + let screen = WindowScreen(frame: .zero, visibleFrame: .zero) + let provider: any WindowScreenProviding = WindowScreenProviderStub(screens: [screen]) + + XCTAssertEqual(provider.screens(), [screen]) + } +} + +private struct WindowScreenProviderStub: WindowScreenProviding { + let screenValues: [WindowScreen] + + init(screens: [WindowScreen]) { + screenValues = screens + } + + func screens() -> [WindowScreen] { + screenValues + } +} diff --git a/Tests/WindowKitTests/Screens/WindowScreenSelectorTests.swift b/Tests/WindowKitTests/Screens/WindowScreenSelectorTests.swift index 2dd2566..00bff96 100644 --- a/Tests/WindowKitTests/Screens/WindowScreenSelectorTests.swift +++ b/Tests/WindowKitTests/Screens/WindowScreenSelectorTests.swift @@ -1,9 +1,38 @@ import CoreGraphics +import WindowKit import XCTest -@testable import WindowKit - final class WindowScreenSelectorTests: XCTestCase { + func testOrderedScreensPreservesInputOrderWhenSortKeysAreEqual() { + let first = WindowScreen( + frame: CGRect(x: 0, y: 0, width: 100, height: 100), + visibleFrame: CGRect(x: 0, y: 10, width: 100, height: 90), + isMain: true + ) + let second = WindowScreen( + frame: CGRect(x: 0, y: 0, width: 100, height: 100), + visibleFrame: CGRect(x: 0, y: 20, width: 100, height: 80) + ) + + XCTAssertEqual(WindowScreenSelector.orderedScreens(from: [second, first]), [second, first]) + } + + func testOrderedScreensPreservesInputOrderForDuplicateDisplayIdentifiers() { + let first = WindowScreen( + displayIdentifier: 7, + frame: CGRect(x: 0, y: 0, width: 100, height: 100), + visibleFrame: CGRect(x: 0, y: 10, width: 100, height: 90), + isMain: true + ) + let second = WindowScreen( + displayIdentifier: 7, + frame: CGRect(x: 0, y: 0, width: 100, height: 100), + visibleFrame: CGRect(x: 0, y: 20, width: 100, height: 80) + ) + + XCTAssertEqual(WindowScreenSelector.orderedScreens(from: [second, first]), [second, first]) + } + func testOrderedScreensSortsByMinXThenMinY() { let screens = [ makeScreen(CGRect(x: 100, y: 10, width: 100, height: 100)), @@ -83,7 +112,7 @@ final class WindowScreenSelectorTests: XCTestCase { for: .main, windowFrame: primary.frame, in: screens, - fallsBackToFirstScreen: false + fallback: .none ) ) XCTAssertNil( @@ -91,7 +120,7 @@ final class WindowScreenSelectorTests: XCTestCase { for: .containingWindow, windowFrame: CGRect(x: 500, y: 500, width: 100, height: 100), in: screens, - fallsBackToFirstScreen: false + fallback: .none ) ) XCTAssertNil( @@ -99,7 +128,7 @@ final class WindowScreenSelectorTests: XCTestCase { for: .point(CGPoint(x: 500, y: 500)), windowFrame: primary.frame, in: screens, - fallsBackToFirstScreen: false + fallback: .none ) ) XCTAssertNil( @@ -107,7 +136,7 @@ final class WindowScreenSelectorTests: XCTestCase { for: .displayIdentifier(9), windowFrame: primary.frame, in: screens, - fallsBackToFirstScreen: false + fallback: .none ) ) XCTAssertNil( @@ -115,7 +144,7 @@ final class WindowScreenSelectorTests: XCTestCase { for: .orderedIndex(9), windowFrame: primary.frame, in: screens, - fallsBackToFirstScreen: false + fallback: .none ) ) } @@ -165,6 +194,29 @@ final class WindowScreenSelectorTests: XCTestCase { ) } + func testScreenSelectionOrdersUnorderedInputBeforeIndexAndFallbackSelection() { + let right = makeScreen(CGRect(x: 300, y: 0, width: 200, height: 200)) + let left = makeScreen(CGRect(x: 0, y: 0, width: 200, height: 200)) + + XCTAssertEqual( + WindowScreenSelector.screen( + for: .orderedIndex(0), + windowFrame: right.frame, + in: [right, left], + fallback: .none + ), + left + ) + XCTAssertEqual( + WindowScreenSelector.screen( + for: .displayIdentifier(99), + windowFrame: right.frame, + in: [right, left] + ), + left + ) + } + func testScreenReturnsNilForEmptyScreens() { XCTAssertNil( WindowScreenSelector.screen( @@ -185,7 +237,17 @@ final class WindowScreenSelectorTests: XCTestCase { XCTAssertNil(WindowScreenSelector.index(containing: CGPoint(x: 500, y: 500), in: screens)) } - func testOrderedScreensStandardizesFramesBeforeSorting() { + func testIndexContainingPointReturnsOrderedScreenIndex() { + let right = makeScreen(CGRect(x: 300, y: 0, width: 200, height: 200)) + let left = makeScreen(CGRect(x: 0, y: 0, width: 200, height: 200)) + + XCTAssertEqual( + WindowScreenSelector.index(containing: CGPoint(x: 350, y: 10), in: [right, left]), + 1 + ) + } + + func testOrderedScreensUsesStandardizedScreenFrames() { let screens = [ makeScreen( CGRect(x: 200, y: 0, width: -100, height: 100), @@ -198,7 +260,7 @@ final class WindowScreenSelectorTests: XCTestCase { WindowScreenSelector.orderedScreens(from: screens).map(\.frame.origin), [ CGPoint(x: 0, y: 0), - CGPoint(x: 200, y: 0), + CGPoint(x: 100, y: 0), ] ) } @@ -236,7 +298,7 @@ final class WindowScreenSelectorTests: XCTestCase { for: .containingWindow, windowFrame: CGRect(x: 40, y: 40, width: 20, height: 20), in: [screen], - fallsBackToFirstScreen: false + fallback: .none ), screen ) @@ -265,6 +327,25 @@ final class WindowScreenSelectorTests: XCTestCase { XCTAssertNil(WindowScreenSelector.adjacentIndex(from: 0, direction: .next, screenCount: -1)) } + func testAdjacentIndexAvoidsOverflowAtIntegerLimits() { + XCTAssertEqual( + WindowScreenSelector.adjacentIndex( + from: Int.max - 1, + direction: .next, + screenCount: Int.max + ), + 0 + ) + XCTAssertEqual( + WindowScreenSelector.adjacentIndex( + from: Int.min, + direction: .previous, + screenCount: Int.max + ), + Int.max - 2 + ) + } + private func makeScreen( _ frame: CGRect, displayIdentifier: CGDirectDisplayID? = nil, diff --git a/Tests/WindowKitTests/Screens/WindowScreenTargetTests.swift b/Tests/WindowKitTests/Screens/WindowScreenTargetTests.swift index e1e8e05..07b321b 100644 --- a/Tests/WindowKitTests/Screens/WindowScreenTargetTests.swift +++ b/Tests/WindowKitTests/Screens/WindowScreenTargetTests.swift @@ -1,8 +1,7 @@ import CoreGraphics +import WindowKit import XCTest -@testable import WindowKit - final class WindowScreenTargetTests: XCTestCase { func testWindowScreenTargetValueConformance() throws { try assertValueConformance(WindowScreenTarget.main) diff --git a/Tests/WindowKitTests/Screens/WindowScreenTests.swift b/Tests/WindowKitTests/Screens/WindowScreenTests.swift index 349a7fe..e5624d1 100644 --- a/Tests/WindowKitTests/Screens/WindowScreenTests.swift +++ b/Tests/WindowKitTests/Screens/WindowScreenTests.swift @@ -1,9 +1,18 @@ import CoreGraphics +import WindowKit import XCTest -@testable import WindowKit - final class WindowScreenTests: XCTestCase { + func testInitSanitizesNonFiniteFrames() { + let screen = WindowScreen( + frame: CGRect(x: .infinity, y: .nan, width: .infinity, height: -.infinity), + visibleFrame: CGRect(x: .nan, y: .infinity, width: -.infinity, height: .nan) + ) + + XCTAssertEqual(screen.frame, .zero) + XCTAssertEqual(screen.visibleFrame, .zero) + } + private let fullFrame = CGRect(x: 0, y: 0, width: 800, height: 600) private let visibleFrame = CGRect(x: 0, y: 24, width: 800, height: 576) @@ -28,6 +37,20 @@ final class WindowScreenTests: XCTestCase { XCTAssertEqual(screen.frame(for: .visible), visibleFrame) } + func testInitStandardizesFrames() { + let screen = WindowScreen( + frame: CGRect(x: 800, y: 600, width: -800, height: -600), + visibleFrame: CGRect(x: 800, y: 600, width: -780, height: -560) + ) + + XCTAssertEqual(screen.frame.origin, CGPoint(x: 0, y: 0)) + XCTAssertEqual(screen.frame.size, CGSize(width: 800, height: 600)) + XCTAssertEqual(screen.visibleFrame.origin, CGPoint(x: 20, y: 40)) + XCTAssertEqual(screen.visibleFrame.size, CGSize(width: 780, height: 560)) + XCTAssertEqual(screen.frame(for: .full).origin, CGPoint(x: 0, y: 0)) + XCTAssertEqual(screen.frame(for: .visible).origin, CGPoint(x: 20, y: 40)) + } + func testWindowScreenValueConformance() throws { try assertValueConformance(makeScreen()) try assertValueConformance(makeScreen(displayIdentifier: 123, isMain: true)) diff --git a/Tests/WindowKitTests/Snapshots/WindowSnapshotFilterTests.swift b/Tests/WindowKitTests/Snapshots/WindowSnapshotFilterTests.swift index ce4ca17..9dbc012 100644 --- a/Tests/WindowKitTests/Snapshots/WindowSnapshotFilterTests.swift +++ b/Tests/WindowKitTests/Snapshots/WindowSnapshotFilterTests.swift @@ -1,7 +1,6 @@ +import WindowKit import XCTest -@testable import WindowKit - final class WindowSnapshotFilterTests: XCTestCase { func testInitUsesDefaultFilterOptions() { let filter = WindowSnapshotFilter() @@ -12,7 +11,6 @@ final class WindowSnapshotFilterTests: XCTestCase { XCTAssertTrue(filter.excludedOwnerBundleIdentifiers.isEmpty) XCTAssertTrue(filter.excludedOwnerProcessIdentifiers.isEmpty) XCTAssertTrue(filter.excludedWindowIdentifiers.isEmpty) - XCTAssertFalse(filter.excludesMinimizedWindows) } func testInitStoresFilterOptions() { @@ -24,7 +22,6 @@ final class WindowSnapshotFilterTests: XCTestCase { XCTAssertEqual(filter.excludedOwnerBundleIdentifiers, ["com.example.blocked"]) XCTAssertEqual(filter.excludedOwnerProcessIdentifiers, [333]) XCTAssertEqual(filter.excludedWindowIdentifiers, [444]) - XCTAssertTrue(filter.excludesMinimizedWindows) } func testWindowSnapshotFilterValueConformance() throws { @@ -36,6 +33,57 @@ final class WindowSnapshotFilterTests: XCTestCase { assertSendable(makeFilter()) } + func testIncludesAcceptsSnapshotsMatchingAllFilterOptions() { + let filter = makeFilter() + let snapshot = WindowSnapshot( + ownerProcessIdentifier: 111, + windowIdentifier: 222, + ownerName: "Allowed", + ownerBundleIdentifier: "com.example.allowed", + title: "Main", + frame: .zero, + ) + + XCTAssertTrue(filter.includes(snapshot)) + } + + func testIncludesRejectsSnapshotsOutsideFilterOptions() { + let filter = makeFilter() + + XCTAssertFalse(filter.includes(makeSnapshot(ownerBundleIdentifier: "com.example.other"))) + XCTAssertFalse(filter.includes(makeSnapshot(ownerProcessIdentifier: 333))) + XCTAssertFalse(filter.includes(makeSnapshot(windowIdentifier: 444))) + } + + func testIncludesRejectsMissingBundleIdentifierWhenBundleAllowListIsSet() { + let filter = WindowSnapshotFilter(includedOwnerBundleIdentifiers: ["com.example.allowed"]) + + XCTAssertFalse(filter.includes(makeSnapshot(ownerBundleIdentifier: nil))) + } + + func testIncludesAppliesExcludeFiltersAfterIncludeFilters() { + let snapshot = makeSnapshot() + + XCTAssertFalse( + WindowSnapshotFilter( + includedOwnerBundleIdentifiers: ["com.example.allowed"], + excludedOwnerBundleIdentifiers: ["com.example.allowed"] + ).includes(snapshot) + ) + XCTAssertFalse( + WindowSnapshotFilter( + includedOwnerProcessIdentifiers: [111], + excludedOwnerProcessIdentifiers: [111] + ).includes(snapshot) + ) + XCTAssertFalse( + WindowSnapshotFilter( + includedWindowIdentifiers: [222], + excludedWindowIdentifiers: [222] + ).includes(snapshot) + ) + } + private func makeFilter() -> WindowSnapshotFilter { WindowSnapshotFilter( includedOwnerBundleIdentifiers: ["com.example.allowed"], @@ -43,8 +91,22 @@ final class WindowSnapshotFilterTests: XCTestCase { includedWindowIdentifiers: [222], excludedOwnerBundleIdentifiers: ["com.example.blocked"], excludedOwnerProcessIdentifiers: [333], - excludedWindowIdentifiers: [444], - excludesMinimizedWindows: true + excludedWindowIdentifiers: [444] + ) + } + + private func makeSnapshot( + ownerProcessIdentifier: pid_t = 111, + windowIdentifier: CGWindowID = 222, + ownerBundleIdentifier: String? = "com.example.allowed" + ) -> WindowSnapshot { + WindowSnapshot( + ownerProcessIdentifier: ownerProcessIdentifier, + windowIdentifier: windowIdentifier, + ownerName: "Allowed", + ownerBundleIdentifier: ownerBundleIdentifier, + title: "Main", + frame: .zero, ) } diff --git a/Tests/WindowKitTests/Snapshots/WindowSnapshotTests.swift b/Tests/WindowKitTests/Snapshots/WindowSnapshotTests.swift index 9b56bee..768d5d8 100644 --- a/Tests/WindowKitTests/Snapshots/WindowSnapshotTests.swift +++ b/Tests/WindowKitTests/Snapshots/WindowSnapshotTests.swift @@ -1,8 +1,7 @@ import CoreGraphics +import WindowKit import XCTest -@testable import WindowKit - final class WindowSnapshotTests: XCTestCase { private let frame = CGRect(x: 10, y: 20, width: 300, height: 200) @@ -12,15 +11,13 @@ final class WindowSnapshotTests: XCTestCase { XCTAssertNil(snapshot.ownerName) XCTAssertNil(snapshot.ownerBundleIdentifier) XCTAssertNil(snapshot.title) - XCTAssertNil(snapshot.isMinimized) } func testInitStoresSnapshotFields() { let snapshot = makeSnapshot( ownerName: "ExampleApp", ownerBundleIdentifier: "com.example.app", - title: "Main", - isMinimized: true + title: "Main" ) XCTAssertEqual(snapshot.ownerProcessIdentifier, 123) @@ -29,8 +26,32 @@ final class WindowSnapshotTests: XCTestCase { XCTAssertEqual(snapshot.ownerBundleIdentifier, "com.example.app") XCTAssertEqual(snapshot.title, "Main") XCTAssertEqual(snapshot.frame, frame) - XCTAssertEqual(snapshot.layer, 0) - XCTAssertEqual(snapshot.isMinimized, true) + } + + func testInitStandardizesFrame() { + let snapshot = WindowSnapshot( + ownerProcessIdentifier: 123, + windowIdentifier: 456, + frame: CGRect(x: 100, y: 100, width: -80, height: -60), + ) + + XCTAssertEqual(snapshot.frame.origin, CGPoint(x: 20, y: 40)) + XCTAssertEqual(snapshot.frame.size, CGSize(width: 80, height: 60)) + } + + func testInitSanitizesNonFiniteFrame() { + let snapshot = WindowSnapshot( + ownerProcessIdentifier: 123, + windowIdentifier: 456, + frame: CGRect( + x: CGFloat.infinity, + y: CGFloat.nan, + width: CGFloat.infinity, + height: -CGFloat.infinity + ), + ) + + XCTAssertEqual(snapshot.frame, CGRect(x: 0, y: 0, width: 0, height: 0)) } func testWindowSnapshotValueConformance() throws { @@ -39,11 +60,9 @@ final class WindowSnapshotTests: XCTestCase { makeSnapshot( ownerName: "ExampleApp", ownerBundleIdentifier: "com.example.app", - title: "Main", - isMinimized: false + title: "Main" ) ) - try assertValueConformance(makeSnapshot(isMinimized: true)) } func testWindowSnapshotIsSendable() { @@ -51,8 +70,7 @@ final class WindowSnapshotTests: XCTestCase { makeSnapshot( ownerName: "ExampleApp", ownerBundleIdentifier: "com.example.app", - title: "Main", - isMinimized: true + title: "Main" ) ) } @@ -60,8 +78,7 @@ final class WindowSnapshotTests: XCTestCase { private func makeSnapshot( ownerName: String? = nil, ownerBundleIdentifier: String? = nil, - title: String? = nil, - isMinimized: Bool? = nil + title: String? = nil ) -> WindowSnapshot { WindowSnapshot( ownerProcessIdentifier: 123, @@ -70,8 +87,6 @@ final class WindowSnapshotTests: XCTestCase { ownerBundleIdentifier: ownerBundleIdentifier, title: title, frame: frame, - layer: 0, - isMinimized: isMinimized ) } diff --git a/Tests/WindowKitTests/Support/SourceBoundarySupport.swift b/Tests/WindowKitTests/Support/SourceBoundarySupport.swift new file mode 100644 index 0000000..9d14636 --- /dev/null +++ b/Tests/WindowKitTests/Support/SourceBoundarySupport.swift @@ -0,0 +1,28 @@ +import Foundation +import XCTest + +enum SourceBoundarySupport { + static func repositoryRoot() -> URL { + URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + } + + static func sourceFilesText(relativeRoot: String) throws -> String { + let sourcesURL = repositoryRoot().appendingPathComponent(relativeRoot) + if sourcesURL.pathExtension == "swift" { + return try String(contentsOf: sourcesURL, encoding: .utf8) + } + + let fileURLs = + FileManager.default + .enumerator(at: sourcesURL, includingPropertiesForKeys: nil)? + .compactMap { $0 as? URL } + .filter { $0.pathExtension == "swift" } ?? [] + + XCTAssertFalse(fileURLs.isEmpty, "\(relativeRoot) should contain source files.") + return try fileURLs.map { try String(contentsOf: $0, encoding: .utf8) }.joined(separator: "\n") + } +} diff --git a/Tests/WindowKitTests/Support/ValueConformanceAssertions.swift b/Tests/WindowKitTests/Support/ValueConformanceAssertions.swift index 499a0c7..e0594f5 100644 --- a/Tests/WindowKitTests/Support/ValueConformanceAssertions.swift +++ b/Tests/WindowKitTests/Support/ValueConformanceAssertions.swift @@ -11,3 +11,15 @@ func assertValueConformance( XCTAssertEqual(decoded, value, file: file, line: line) XCTAssertEqual(Set([value, decoded]).count, 1, file: file, line: line) } + +func assertValueConformance( + _: Value.Type, + _ value: Value, + file: StaticString = #filePath, + line: UInt = #line +) throws { + try assertValueConformance(value, file: file, line: line) + assertSendable(Value.self) +} + +func assertSendable(_: Value.Type) {} diff --git a/Tests/WindowKitTests/Support/WindowListTestSupport.swift b/Tests/WindowKitTests/Support/WindowListTestSupport.swift deleted file mode 100644 index 776ac2c..0000000 --- a/Tests/WindowKitTests/Support/WindowListTestSupport.swift +++ /dev/null @@ -1,65 +0,0 @@ -import CoreGraphics -import Foundation - -@testable import WindowKit - -final class WindowListProviderSpy: WindowListProviding, @unchecked Sendable { - let entries: [WindowListEntry]? - private(set) var requests: [(options: CGWindowListOption, windowIdentifier: CGWindowID)] = - [] - - init(entries: [WindowListEntry]?) { - self.entries = entries - } - - func windowList(options: CGWindowListOption, windowIdentifier: CGWindowID) -> [WindowListEntry]? { - requests.append((options: options, windowIdentifier: windowIdentifier)) - return entries - } -} - -struct WindowOwnerBundleIdentifierProviderStub: WindowOwnerBundleIdentifierProviding { - let bundleIdentifier: String? - - func bundleIdentifier(forProcessIdentifier _: pid_t) -> String? { - bundleIdentifier - } -} - -final class WindowOwnerBundleIdentifierProviderSpy: - WindowOwnerBundleIdentifierProviding, @unchecked Sendable -{ - let bundleIdentifier: String? - private(set) var processIdentifiers: [pid_t] = [] - - init(bundleIdentifier: String? = nil) { - self.bundleIdentifier = bundleIdentifier - } - - func bundleIdentifier(forProcessIdentifier processIdentifier: pid_t) -> String? { - processIdentifiers.append(processIdentifier) - return bundleIdentifier - } -} - -func makeWindowEntry( - layer: Int, - frame: CGRect, - ownerProcessIdentifier: Int32?, - isMinimized: Bool = false, - windowIdentifier: UInt32? = 12345 -) -> WindowListEntry { - var entry: WindowListEntry = [:] - entry[kCGWindowLayer as String] = NSNumber(value: layer) - entry[kCGWindowBounds as String] = frame.dictionaryRepresentation as NSDictionary - if let ownerProcessIdentifier { - entry[kCGWindowOwnerPID as String] = NSNumber(value: ownerProcessIdentifier) - } - entry[WindowListEntryKeys.isMinimized] = NSNumber(value: isMinimized) - if let windowIdentifier { - entry[kCGWindowNumber as String] = NSNumber(value: windowIdentifier) - } - entry[kCGWindowOwnerName as String] = "ExampleApp" - entry[kCGWindowName as String] = "Main" - return entry -} diff --git a/Tests/WindowKitTests/WindowKitPublicAPITests.swift b/Tests/WindowKitTests/WindowKitPublicAPITests.swift new file mode 100644 index 0000000..8806bb6 --- /dev/null +++ b/Tests/WindowKitTests/WindowKitPublicAPITests.swift @@ -0,0 +1,248 @@ +import CoreGraphics +import WindowKit +import XCTest + +final class WindowKitPublicAPITests: XCTestCase { + func testCoreValueAndGeometryAPIsAreUsableFromPublicImport() { + let snapshot = publicSnapshot() + let filter = WindowSnapshotFilter( + includedOwnerBundleIdentifiers: ["com.example.app"], + includedOwnerProcessIdentifiers: [10], + includedWindowIdentifiers: [20] + ) + let screen = publicScreen() + let placement = WindowPlacement.grid( + columns: 2, + rows: 1, + column: 0, + row: 0, + columnSpan: 1, + rowSpan: 1 + ) + + XCTAssertEqual(snapshot.ownerBundleIdentifier, "com.example.app") + XCTAssertTrue(filter.includes(snapshot)) + XCTAssertEqual(screen.frame(for: WindowScreenArea.visible), screen.visibleFrame) + XCTAssertEqual( + WindowPlacementCalculator.frame(for: placement, in: screen.frame), + CGRect(x: 0, y: 0, width: 500, height: 800) + ) + XCTAssertEqual( + WindowScreenSelector.screen( + for: WindowScreenTarget.main, + windowFrame: snapshot.frame, + in: [screen], + fallback: WindowScreenFallback.none + ), + screen + ) + XCTAssertEqual(WindowScreenSelector.index(containing: CGPoint(x: 1, y: 1), in: [screen]), 0) + XCTAssertEqual( + WindowScreenSelector.adjacentIndex( + from: 0, + direction: WindowScreenCycleDirection.next, + screenCount: 1 + ), + 0 + ) + XCTAssertEqual( + WindowScreenMovement.centerPreservingSize( + windowFrame: snapshot.frame, + in: screen.visibleFrame + ).size, + snapshot.frame.size + ) + } + + func testPublicValueConformancesAreUsableFromPublicImport() throws { + let snapshot = publicSnapshot() + let screen = publicScreen() + + try assertValueConformance( + WindowState.self, WindowState(isMinimized: false, isFullScreen: true)) + try assertValueConformance(WindowTarget.self, WindowTarget.windowIdentifier(20)) + try assertValueConformance( + WindowHitTestMissReason.self, + WindowHitTestMissReason.noMatchingWindowAtPoint + ) + try assertValueConformance(WindowHitTestResult.self, WindowHitTestResult.hit(snapshot)) + try assertValueConformance(WindowPlacement.self, WindowPlacement.fill) + try assertValueConformance(WindowScreenArea.self, WindowScreenArea.visible) + try assertValueConformance(WindowScreenFallback.self, WindowScreenFallback.firstScreen) + try assertValueConformance( + WindowScreenCycleDirection.self, + WindowScreenCycleDirection.next + ) + try assertValueConformance(WindowScreenTarget.self, WindowScreenTarget.displayIdentifier(1)) + try assertValueConformance(WindowSnapshot.self, snapshot) + try assertValueConformance( + WindowSnapshotFilter.self, + WindowSnapshotFilter(includedOwnerBundleIdentifiers: ["com.example.app"]) + ) + try assertValueConformance(WindowScreen.self, screen) + + assertSendable((any WindowCommandControlling).self) + assertSendable((any WindowControlling).self) + assertSendable((any WindowFrameControlling).self) + assertSendable((any WindowFrameProviding).self) + assertSendable((any WindowStateProviding).self) + assertSendable((any WindowHitTesting).self) + assertSendable((any WindowScreenProviding).self) + assertSendable((any WindowSnapshotProviding).self) + assertSendable((any WindowSnapshotResolving).self) + } + + func testControlHitTestingAndProviderContractsAreUsableFromPublicImport() throws { + let target = WindowTarget.windowIdentifier(20) + let state = WindowState(isMinimized: false, isFullScreen: true) + let hitSnapshot = publicSnapshot() + let hit = WindowHitTestResult.hit(hitSnapshot) + let miss = WindowHitTestResult.miss(WindowHitTestMissReason.noMatchingWindowAtPoint) + let hitTesting: any WindowHitTesting = PublicHitTesting(result: hit) + let emptySnapshotProvider: any WindowSnapshotProviding = PublicSnapshotProvider() + let emptySnapshotResolver: any WindowSnapshotResolving = PublicSnapshotProvider() + + XCTAssertEqual(WindowTarget.focused, .focused) + XCTAssertEqual(target, .windowIdentifier(20)) + XCTAssertEqual(state.isFullScreen, true) + XCTAssertEqual(hit.snapshot, hitSnapshot) + XCTAssertEqual(miss.missReason, .noMatchingWindowAtPoint) + XCTAssertEqual(hitTesting.topmost(at: CGPoint(x: 10, y: 10)), hitSnapshot) + XCTAssertEqual(try emptySnapshotProvider.snapshots(), []) + XCTAssertNil(try emptySnapshotResolver.snapshot(for: 20)) + } + + @MainActor + func testControlAndScreenProviderContractsAreUsableFromPublicImport() throws { + let target = WindowTarget.focused + let frame = CGRect(x: 10, y: 20, width: 300, height: 200) + let state = WindowState(isMinimized: true, isFullScreen: false) + let screen = publicScreen() + let frameController = PublicFrameController(frame: frame) + let frameProvider: any WindowFrameProviding = frameController + let frameControlling: any WindowFrameControlling = frameController + let commandControlling: any WindowCommandControlling = PublicCommandController() + let stateProviding: any WindowStateProviding = PublicStateProvider(state: state) + let screenProviding: any WindowScreenProviding = PublicScreenProvider(screens: [screen]) + + XCTAssertEqual(try frameProvider.frame(for: target), frame) + try frameControlling.setFrame(frame, for: target) + try commandControlling.setMinimized(target, isMinimized: false) + try commandControlling.setFullScreen(target, isFullScreen: true) + try commandControlling.raise(target) + try commandControlling.lower(target) + XCTAssertEqual(try stateProviding.state(for: target), state) + XCTAssertEqual(screenProviding.screens(), [screen]) + } + + func testDocumentationExamplesAreUsableWithoutTestableImport() { + let screen = WindowScreen( + frame: CGRect(x: 0, y: 0, width: 1440, height: 900), + visibleFrame: CGRect(x: 0, y: 44, width: 1440, height: 812) + ) + let frame = WindowPlacementCalculator.frame( + for: .grid(columns: 2, rows: 1, column: 0, row: 0, columnSpan: 1, rowSpan: 1), + in: screen.visibleFrame, + inset: 8 + ) + let snapshot = WindowSnapshot( + ownerProcessIdentifier: 42, + windowIdentifier: 1001, + ownerName: "Editor", + ownerBundleIdentifier: "com.example.editor", + title: "Document", + frame: CGRect(x: 120, y: 80, width: 900, height: 640), + ) + + XCTAssertEqual(frame, CGRect(x: 8, y: 52, width: 712, height: 796)) + XCTAssertEqual(snapshot.ownerBundleIdentifier, "com.example.editor") + } +} + +private func publicSnapshot() -> WindowSnapshot { + WindowSnapshot( + ownerProcessIdentifier: 10, + windowIdentifier: 20, + ownerName: "Example", + ownerBundleIdentifier: "com.example.app", + title: "Main", + frame: CGRect(x: 10, y: 20, width: 300, height: 200), + ) +} + +private func publicScreen() -> WindowScreen { + WindowScreen( + displayIdentifier: 1, + frame: CGRect(x: 0, y: 0, width: 1000, height: 800), + visibleFrame: CGRect(x: 0, y: 40, width: 1000, height: 720), + isMain: true + ) +} + +private struct PublicHitTesting: WindowHitTesting { + let result: WindowHitTestResult + + func hitTest(at _: CGPoint) -> WindowHitTestResult { + result + } +} + +private struct PublicSnapshotProvider: WindowSnapshotProviding, WindowSnapshotResolving { + func snapshots() throws -> [WindowSnapshot] { + [] + } + + func snapshot(for _: CGWindowID) throws -> WindowSnapshot? { + nil + } +} + +private final class PublicFrameController: WindowFrameControlling { + private let frameValue: CGRect + + init(frame: CGRect) { + frameValue = frame + } + + func frame(for _: WindowTarget) throws -> CGRect { + frameValue + } + + func setFrame(_: CGRect, for _: WindowTarget) throws {} +} + +private struct PublicCommandController: WindowCommandControlling { + func setMinimized(_: WindowTarget, isMinimized _: Bool) throws {} + + func setFullScreen(_: WindowTarget, isFullScreen _: Bool) throws {} + + func raise(_: WindowTarget) throws {} + + func lower(_: WindowTarget) throws {} +} + +private struct PublicStateProvider: WindowStateProviding { + let stateValue: WindowState + + init(state: WindowState) { + stateValue = state + } + + func state(for _: WindowTarget) throws -> WindowState { + stateValue + } +} + +private struct PublicScreenProvider: WindowScreenProviding { + let screenValues: [WindowScreen] + + init(screens: [WindowScreen]) { + screenValues = screens + } + + func screens() -> [WindowScreen] { + screenValues + } +} + +private func assertPublicSendableValue(_: Value) {} diff --git a/Tests/WindowKitTests/WindowKitSourceBoundaryTests.swift b/Tests/WindowKitTests/WindowKitSourceBoundaryTests.swift new file mode 100644 index 0000000..11170eb --- /dev/null +++ b/Tests/WindowKitTests/WindowKitSourceBoundaryTests.swift @@ -0,0 +1,86 @@ +import Foundation +import XCTest + +final class WindowKitSourceBoundaryTests: XCTestCase { + func testLiveWindowQueryingStaysOutOfCoreWindowKitSources() throws { + let sourceText = try SourceBoundarySupport.sourceFilesText(relativeRoot: "Sources/WindowKit") + for platformImport in ["AppKit", "ApplicationServices", "Cocoa"] { + XCTAssertFalse( + sourceText.containsImport(platformImport), + "\(platformImport) belongs to WindowKitAppKit or host code, not the platform-neutral WindowKit core." + ) + } + XCTAssertFalse( + sourceText.contains("CGWindowList"), + "Live window querying belongs to WindowKitAppKit or host code, not the platform-neutral WindowKit core." + ) + } + + func testRunningApplicationAdaptersDoNotImportCoreWindowKit() throws { + let sourceText = try SourceBoundarySupport.sourceFilesText( + relativeRoot: "Sources/WindowKitAppKit/RunningApplications" + ) + + XCTAssertFalse( + sourceText.containsImport("WindowKit"), + "Running application adapters should stay at the AppKit/Darwin process boundary and avoid importing core WindowKit types they do not use." + ) + } + + func testAppKitImplementationSeamsStayOutOfPublicSurface() throws { + let sourceText = try SourceBoundarySupport.sourceFilesText( + relativeRoot: "Sources/WindowKitAppKit" + ) + let internalTypes = [ + "AXClient", + "AXWindowAccess", + "AXWindowAction", + "AXWindowElementResolver", + "AXWindowError", + "RunningApplicationChecking", + "NSRunningApplicationChecker", + "SystemWindowListProvider", + "WindowListEntry", + "WindowListProviding", + "WindowListRequest", + "WindowListSnapshotMapper", + "WindowRoleResolver", + ] + + for internalType in internalTypes { + XCTAssertFalse( + containsPublicDeclaration(named: internalType, in: sourceText), + "\(internalType) is an AppKit implementation detail and should not become public API." + ) + } + XCTAssertFalse( + sourceText.contains("@usableFromInline"), + "WindowKitAppKit should not create semi-public inlinable seams." + ) + XCTAssertFalse( + sourceText.contains("@_spi"), + "WindowKitAppKit should not expose SPI as a compatibility seam." + ) + } + + private func containsPublicDeclaration(named typeName: String, in source: String) -> Bool { + let pattern = + #"^\s*(?:@[\w.]+(?:\([^)]*\))?\s*)*(?:public|open)\s+(?:actor|class|enum|final\s+class|protocol|struct|typealias)\s+"# + + NSRegularExpression.escapedPattern(for: typeName) + + #"\b"# + + return source.split(separator: "\n").contains { line in + line.range(of: pattern, options: .regularExpression) != nil + } + } + +} + +private extension String { + func containsImport(_ moduleName: String) -> Bool { + range( + of: #"(?m)^\s*import\s+"# + NSRegularExpression.escapedPattern(for: moduleName) + #"\b"#, + options: .regularExpression + ) != nil + } +} diff --git a/justfile b/justfile deleted file mode 100644 index 6eeb466..0000000 --- a/justfile +++ /dev/null @@ -1,19 +0,0 @@ -format_paths := "Sources Tests Package.swift" -swift_test_flags := "-Xswiftc -strict-concurrency=complete -Xswiftc -warnings-as-errors" - -default: - just --list - -format: - swift format format --recursive --in-place {{format_paths}} - -lint: - swift format lint --recursive --strict {{format_paths}} - -build: - swift build - -test: - swift test {{swift_test_flags}} - -check: lint test