From 0738e0fb1b945d3d9279def95936c2f07786cca6 Mon Sep 17 00:00:00 2001 From: Haruki Fukui Date: Sun, 12 Jul 2026 05:48:28 +0900 Subject: [PATCH] Prepare AppRuntimeKit 0.2.0 --- .editorconfig | 14 +- .github/ISSUE_TEMPLATE/bug_report.yml | 68 --- .github/ISSUE_TEMPLATE/config.yml | 5 - .github/ISSUE_TEMPLATE/feature_request.yml | 53 -- .github/pull_request_template.md | 19 +- .github/workflows/ci.yml | 2 +- .gitignore | 11 + .swift-format | 55 +- CHANGELOG.md | 52 +- CONTRIBUTING.md | 46 +- Makefile | 38 +- README.md | 76 ++- SECURITY.md | 7 +- .../AppRuntimeKit.docc/AppRuntimeKit.md | 103 ++-- .../ApplicationProcessSnapshot.swift | 12 +- .../Runtime/ApplicationActivationResult.swift | 17 - .../Runtime/ApplicationOpenResult.swift | 25 - .../Runtime/ApplicationOpeningError.swift | 19 + .../Runtime/ApplicationRequestResult.swift | 13 + .../Runtime/ApplicationRuntimeEvent.swift | 12 +- .../ApplicationRuntimeRequestError.swift | 17 + .../Runtime/ApplicationTerminationMode.swift | 7 + .../ApplicationTerminationResult.swift | 25 - .../AppRuntimeKitAppKit.md | 244 ++------- .../ApplicationProcessSnapshot+AppKit.swift | 1 - ...ationDuplicateActivationCoordinator.swift} | 114 ++-- ...ateActivationCoordinatorDependencies.swift | 128 +++++ ...onProcessSnapshot+RunningApplication.swift | 13 + ...cationProcessSnapshot+RuntimeProcess.swift | 13 - .../ApplicationRuntime+Activation.swift | 84 +-- ...pplicationRuntime+EventNotifications.swift | 61 --- .../ApplicationRuntime+EventWaiting.swift | 109 ---- .../Runtime/ApplicationRuntime+Events.swift | 181 +++++-- .../Runtime/ApplicationRuntime+Lookup.swift | 154 ++++++ .../Runtime/ApplicationRuntime+Opening.swift | 155 +++--- .../Runtime/ApplicationRuntime+Queries.swift | 45 ++ .../ApplicationRuntime+Termination.swift | 72 +-- .../Runtime/ApplicationRuntime.swift | 145 +---- .../ApplicationRuntimeDependencies.swift | 161 ++++-- .../ApplicationRuntimeEventSource.swift | 116 ++++ .../Runtime/ApplicationRuntimeProcess.swift | 11 - ...ApplicationRuntimeRunningApplication.swift | 8 +- ...ingleInstanceCoordinatorDependencies.swift | 79 --- .../AppRuntimeKitAppKitPublicAPITests.swift | 321 ++++++----- ...pRuntimeKitAppKitSourceBoundaryTests.swift | 80 +++ ...pplicationProcessSnapshotAppKitTests.swift | 18 - ...nDuplicateActivationCoordinatorTests.swift | 390 ++++++++++++++ .../ApplicationRuntimeActivationTests.swift | 464 +++++++++------- .../ApplicationRuntimeDependenciesTests.swift | 189 ++++--- .../ApplicationRuntimeEventWaitTests.swift | 203 +++++++ .../ApplicationRuntimeEventWaitingTests.swift | 244 --------- .../ApplicationRuntimeEventsTests.swift | 156 +++--- .../ApplicationRuntimeOpeningTests.swift | 407 ++++---------- ...icationRuntimeOpeningValidationTests.swift | 226 +------- ...ift => ApplicationRuntimeQueryTests.swift} | 203 +------ .../ApplicationRuntimeTerminationTests.swift | 460 ++++++++-------- ...cationSingleInstanceCoordinatorTests.swift | 503 ------------------ ...cationDuplicateActivationTestSupport.swift | 194 +++++++ ...nRuntimeEventNotificationTestSupport.swift | 230 -------- ...icationRuntimeEventSourceTestSupport.swift | 223 ++++++++ ...ApplicationRuntimeFactoryTestSupport.swift | 205 +++++++ .../ApplicationRuntimeOpeningAssertions.swift | 128 +++++ .../ApplicationRuntimeOpeningFactories.swift | 66 +++ ...cationRuntimeOpeningRequestRecorders.swift | 202 +++++++ ...tionRuntimeOpeningTemporaryFixtures.swift} | 38 +- ...nRuntimeRunningApplicationAssertions.swift | 67 +++ ...pplicationRuntimeSnapshotTestSupport.swift | 17 + .../Support/AsyncStreamTestSupport.swift | 10 + .../Support/ConformanceAssertions.swift | 37 ++ .../FakeRuntimeRunningApplication.swift | 24 +- ...eRuntimeRunningApplicationAssertions.swift | 34 ++ .../Support/PackageFileTestSupport.swift | 29 + .../AppRuntimeKitPublicAPITests.swift | 107 ++-- .../ApplicationProcessSnapshotTests.swift | 45 +- .../ApplicationActivationResultTests.swift | 57 -- .../Runtime/ApplicationOpenResultTests.swift | 81 --- .../ApplicationRequestResultTests.swift | 116 ++++ .../ApplicationRuntimeEventTests.swift | 52 +- .../ApplicationTerminationResultTests.swift | 68 --- ...pplicationProcessSnapshotTestSupport.swift | 17 + .../Support/ConformanceAssertions.swift | 37 ++ 81 files changed, 4453 insertions(+), 4085 deletions(-) delete mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml delete mode 100644 .github/ISSUE_TEMPLATE/config.yml delete mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml delete mode 100644 Sources/AppRuntimeKit/Runtime/ApplicationActivationResult.swift delete mode 100644 Sources/AppRuntimeKit/Runtime/ApplicationOpenResult.swift create mode 100644 Sources/AppRuntimeKit/Runtime/ApplicationOpeningError.swift create mode 100644 Sources/AppRuntimeKit/Runtime/ApplicationRequestResult.swift create mode 100644 Sources/AppRuntimeKit/Runtime/ApplicationRuntimeRequestError.swift create mode 100644 Sources/AppRuntimeKit/Runtime/ApplicationTerminationMode.swift delete mode 100644 Sources/AppRuntimeKit/Runtime/ApplicationTerminationResult.swift rename Sources/AppRuntimeKitAppKit/{SingleInstance/ApplicationSingleInstanceCoordinator.swift => DuplicateActivation/ApplicationDuplicateActivationCoordinator.swift} (55%) create mode 100644 Sources/AppRuntimeKitAppKit/DuplicateActivation/ApplicationDuplicateActivationCoordinatorDependencies.swift create mode 100644 Sources/AppRuntimeKitAppKit/Runtime/ApplicationProcessSnapshot+RunningApplication.swift delete mode 100644 Sources/AppRuntimeKitAppKit/Runtime/ApplicationProcessSnapshot+RuntimeProcess.swift delete mode 100644 Sources/AppRuntimeKitAppKit/Runtime/ApplicationRuntime+EventNotifications.swift delete mode 100644 Sources/AppRuntimeKitAppKit/Runtime/ApplicationRuntime+EventWaiting.swift create mode 100644 Sources/AppRuntimeKitAppKit/Runtime/ApplicationRuntime+Lookup.swift create mode 100644 Sources/AppRuntimeKitAppKit/Runtime/ApplicationRuntime+Queries.swift create mode 100644 Sources/AppRuntimeKitAppKit/Runtime/ApplicationRuntimeEventSource.swift delete mode 100644 Sources/AppRuntimeKitAppKit/Runtime/ApplicationRuntimeProcess.swift delete mode 100644 Sources/AppRuntimeKitAppKit/SingleInstance/ApplicationSingleInstanceCoordinatorDependencies.swift create mode 100644 Tests/AppRuntimeKitAppKitTests/AppRuntimeKitAppKitSourceBoundaryTests.swift delete mode 100644 Tests/AppRuntimeKitAppKitTests/Application/ApplicationProcessSnapshotAppKitTests.swift create mode 100644 Tests/AppRuntimeKitAppKitTests/DuplicateActivation/ApplicationDuplicateActivationCoordinatorTests.swift create mode 100644 Tests/AppRuntimeKitAppKitTests/Runtime/ApplicationRuntimeEventWaitTests.swift delete mode 100644 Tests/AppRuntimeKitAppKitTests/Runtime/ApplicationRuntimeEventWaitingTests.swift rename Tests/AppRuntimeKitAppKitTests/Runtime/{ApplicationRuntimeTests.swift => ApplicationRuntimeQueryTests.swift} (51%) delete mode 100644 Tests/AppRuntimeKitAppKitTests/SingleInstance/ApplicationSingleInstanceCoordinatorTests.swift create mode 100644 Tests/AppRuntimeKitAppKitTests/Support/ApplicationDuplicateActivationTestSupport.swift delete mode 100644 Tests/AppRuntimeKitAppKitTests/Support/ApplicationRuntimeEventNotificationTestSupport.swift create mode 100644 Tests/AppRuntimeKitAppKitTests/Support/ApplicationRuntimeEventSourceTestSupport.swift create mode 100644 Tests/AppRuntimeKitAppKitTests/Support/ApplicationRuntimeFactoryTestSupport.swift create mode 100644 Tests/AppRuntimeKitAppKitTests/Support/ApplicationRuntimeOpeningAssertions.swift create mode 100644 Tests/AppRuntimeKitAppKitTests/Support/ApplicationRuntimeOpeningFactories.swift create mode 100644 Tests/AppRuntimeKitAppKitTests/Support/ApplicationRuntimeOpeningRequestRecorders.swift rename Tests/AppRuntimeKitAppKitTests/Support/{ApplicationRuntimeOpeningTestSupport.swift => ApplicationRuntimeOpeningTemporaryFixtures.swift} (73%) create mode 100644 Tests/AppRuntimeKitAppKitTests/Support/ApplicationRuntimeRunningApplicationAssertions.swift create mode 100644 Tests/AppRuntimeKitAppKitTests/Support/ApplicationRuntimeSnapshotTestSupport.swift create mode 100644 Tests/AppRuntimeKitAppKitTests/Support/AsyncStreamTestSupport.swift create mode 100644 Tests/AppRuntimeKitAppKitTests/Support/ConformanceAssertions.swift create mode 100644 Tests/AppRuntimeKitAppKitTests/Support/FakeRuntimeRunningApplicationAssertions.swift create mode 100644 Tests/AppRuntimeKitAppKitTests/Support/PackageFileTestSupport.swift delete mode 100644 Tests/AppRuntimeKitTests/Runtime/ApplicationActivationResultTests.swift delete mode 100644 Tests/AppRuntimeKitTests/Runtime/ApplicationOpenResultTests.swift create mode 100644 Tests/AppRuntimeKitTests/Runtime/ApplicationRequestResultTests.swift delete mode 100644 Tests/AppRuntimeKitTests/Runtime/ApplicationTerminationResultTests.swift create mode 100644 Tests/AppRuntimeKitTests/Support/ApplicationProcessSnapshotTestSupport.swift create mode 100644 Tests/AppRuntimeKitTests/Support/ConformanceAssertions.swift diff --git a/.editorconfig b/.editorconfig index fe52efb..2aab0a2 100644 --- a/.editorconfig +++ b/.editorconfig @@ -3,20 +3,10 @@ root = true [*] charset = utf-8 end_of_line = lf -insert_final_newline = true -trim_trailing_whitespace = true - -[*.swift] -indent_style = space -indent_size = 2 - -[*.{md,yml,yaml,json}] -indent_style = space -indent_size = 2 - -[.swift-format] 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.yml b/.github/ISSUE_TEMPLATE/bug_report.yml deleted file mode 100644 index 85b8b11..0000000 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ /dev/null @@ -1,68 +0,0 @@ -name: Bug report -description: Report a reproducible AppRuntimeKit issue. -title: "[Bug]: " -labels: ["bug"] -body: - - type: markdown - attributes: - value: >- - Do not include vulnerability details, secrets, tokens, personal data, private logs, - local paths, private app metadata, or app-specific internal references. Use SECURITY.md - for security reports. - - type: textarea - id: summary - attributes: - label: Summary - description: Describe the issue in one or two sentences. - validations: - required: true - - type: textarea - id: environment - attributes: - label: Environment - description: >- - Include AppRuntimeKit version or commit, macOS version, Swift or Xcode version, product - used, and host-app context. - placeholder: | - - AppRuntimeKit version or commit: - - macOS version: - - Swift or Xcode version: - - Product: AppRuntimeKit / AppRuntimeKitAppKit - - Host app sandboxed: yes/no - - Reproducibility: always / sometimes / once - validations: - required: true - - type: textarea - id: reproduction - attributes: - label: Reproduction - description: >- - Provide the smallest code sample, test case, or focused steps that reproduce the issue. - placeholder: | - 1. - 2. - 3. - validations: - required: true - - type: textarea - id: expected - attributes: - label: Expected behavior - description: >- - Describe the runtime result, application snapshot, event, or public API value you - expected. - validations: - required: true - - type: textarea - id: actual - attributes: - label: Actual behavior - description: >- - Include the exact public error, result, assertion, or behavior. Redact sensitive values. - validations: - required: true - - type: textarea - id: additional_context - attributes: - label: Additional context - description: Add any other public, non-sensitive context that helps explain the issue. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml deleted file mode 100644 index 566a379..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/app-runtime-kit/security/policy - about: Report vulnerabilities privately instead of opening a public issue. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml deleted file mode 100644 index eb7b51b..0000000 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ /dev/null @@ -1,53 +0,0 @@ -name: Feature request -description: Propose a focused AppRuntimeKit improvement. -title: "[Feature]: " -labels: ["enhancement"] -body: - - type: markdown - attributes: - value: >- - Do not include vulnerability details, secrets, tokens, personal data, private logs, - local paths, private app metadata, or app-specific internal references. - - type: textarea - id: summary - attributes: - label: Summary - description: Describe the requested improvement in one or two sentences. - validations: - required: true - - type: textarea - id: use_case - attributes: - label: Use case - description: Describe the host-app or library integration problem this would solve. - validations: - required: true - - type: textarea - id: scope - attributes: - label: Scope - description: >- - Explain why this belongs in AppRuntimeKit's runtime application operations, - lifecycle events, duplicate-instance activation request coordination, or documentation - rather than the host app. - validations: - required: true - - type: textarea - id: proposal - attributes: - label: Proposal - description: Describe the API, behavior, documentation, tests, or packaging change you want. - validations: - required: true - - type: textarea - id: alternatives - attributes: - label: Alternatives considered - description: >- - Describe host-app workarounds, separate package designs, or existing APIs you - considered. - - type: textarea - id: additional_context - attributes: - label: Additional context - description: Add any other public, non-sensitive context that helps explain the request. diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index f180c7e..520fdeb 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -2,24 +2,13 @@ - -## Scope - -- - ## Validation - [ ] `make check` -- [ ] Tests added or updated for behavior changes, or not needed -- [ ] Documentation or `CHANGELOG.md` updated if needed +- [ ] Documentation or `CHANGELOG.md` updated if public behavior changed ## Checklist -- [ ] Public API changes are intentional and documented. -- [ ] AppRuntimeKit scope and host-app responsibility boundaries are respected. -- [ ] No generated build output, dependency caches, local state, secrets, tokens, private keys, - personal data, local paths, private app metadata, or app-specific internal references are - included. - -## 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 2cbbb4d..21dd31d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,7 @@ jobs: steps: - name: Check out repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: persist-credentials: false diff --git a/.gitignore b/.gitignore index 268946b..031f0e0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,15 @@ .build/ .swiftpm/ .DS_Store +.envrc +.env +.env.* +!.env.example Package.resolved +*.doccarchive +DerivedData/ +xcuserdata/ +*.xcodeproj +*.xcworkspace +*.xcresult +*.log diff --git a/.swift-format b/.swift-format index d7c1122..0f54008 100644 --- a/.swift-format +++ b/.swift-format @@ -1,11 +1,54 @@ { + "indentation": { + "spaces": 2 + }, + "lineLength": 100, + "maximumBlankLines": 1, + "respectsExistingLineBreaks": true, "rules": { - "AllPublicDeclarationsHaveDocumentation": true, - "BeginDocumentationCommentWithOneLineSummary": true, - "NeverForceUnwrap": true, - "NeverUseForceTry": true, - "NeverUseImplicitlyUnwrappedOptionals": true, - "ValidateDocumentationComments": 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, + "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 1500d8a..7e04ae1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,14 +1,60 @@ # Changelog -All notable user-facing changes to AppRuntimeKit are documented in this file. +All notable user-facing changes to AppRuntimeKit will be documented in this file. Released versions follow semantic versioning. -## [0.1.0] +## [Unreleased] + +No changes yet. + +## [0.2.0] - 2026-07-12 + +### Changed + +- Updated installation guidance to use the 0.2.0 release line. +- Standardized runtime events on `ApplicationRuntimeEvent.Lifecycle` so event filtering and + documentation use the same lifecycle vocabulary. +- Introduced `ApplicationDuplicateActivationCoordinator` because the package only coordinates duplicate-process + activation requests; ownership locks and duplicate-process exit policy remain host-app + responsibilities. +- Added `ApplicationTerminationMode` as the explicit public termination mode contract, and + standardized rejected activation, opening, and termination requests on `requestRejected`. +- Merged duplicate `ApplicationActivationError` and `ApplicationTerminationError` public contracts + into `ApplicationRuntimeRequestError` because activation and termination use the same runtime + request failure taxonomy. +- Split termination lookup validation into `applicationTerminated`, + `applicationBundleIdentifierUnavailable`, and `applicationBundleIdentifierMismatch` instead of + collapsing those request failures into `applicationNotFound`. +- Added `invalidProcessIdentifier` so invalid caller input is no longer reported as a missing + application. +- Added process-identifier termination with expected bundle-identifier validation, matching the + activation API and protecting callers from acting on a reused process identifier. +- Standardized activation, opening, and termination public result contracts on generic + `ApplicationRequestResult` with `requestAccepted` and `failed` cases so all + request outcomes share one public result vocabulary. +- Renamed `ApplicationOpenError` to `ApplicationOpeningError` so opening request errors use noun + terminology matching activation and termination contracts. +- Consolidated runtime event waiting APIs and clarified their public documentation examples. +- Moved application lifecycle event observation behind an internal event-source seam so tests no + longer depend on live `NSWorkspace` notifications or running applications. +- Split runtime facade, query, and live-lookup implementation into focused source files, and + consolidated identical synchronous `NSWorkspace.open` dependency seams. +- Moved duplicate-process activation request distributed notifications behind an internal + notification-center adapter and covered the adapter contract with fake-center tests. +- Clarified that AppRuntimeKitAppKit owns URL, file, and application opening request validation. +- Removed unintended serialization and raw-value contracts, limited `CaseIterable` to lifecycle + and mode enumerations, and retained `Equatable` and `Hashable` only where they support result + handling. +- Expanded public API tests for AppRuntimeKit and AppRuntimeKitAppKit. +- Added README links to release notes. +- Clarified that security updates target the latest released version. + +## [0.1.0] - 2026-06-01 ### Added - Initial public release of the `AppRuntimeKit` product. - Added application process snapshots, runtime query/open/activate/terminate/event APIs, and - duplicate-instance activation request coordination. + duplicate-process activation request coordination. - Added README and DocC documentation for the public API. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0bea539..c554b0d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,51 +1,13 @@ # Contributing -Thank you for your interest in improving AppRuntimeKit. - -## Scope - -AppRuntimeKit focuses on small macOS application process snapshots, AppKit-backed -current/running/frontmost application queries, application bundle URL resolution, -application activation and termination, application opening, application lifecycle -events, and duplicate-instance activation request coordination. Host-app -concerns such as UI, persistence, permission onboarding, analytics, telemetry, -release packaging, and product policy should stay outside this package. - -Keep changes focused. Avoid bundling unrelated refactors, formatting-only -rewrites, and behavior changes in the same pull request. - -## Development - -Local development commands use `make`. - -Run the full local check before opening a pull request: +Run the package check before submitting changes: ```sh make check ``` -For focused local work, run checks separately: - -```sh -make format -make lint -make test -make build -make docc -``` - -## Pull Requests - -Before submitting a pull request: - -- Keep the public API surface minimal and documented. -- Add or update tests for behavior changes. -- Update README, DocC, or `CHANGELOG.md` when user-facing behavior changes. -- Do not commit generated build output, dependency caches, editor state, local - tool state, secrets, tokens, private keys, personal data, local paths, private - app metadata, 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, pull requests, or discussions. -Follow [SECURITY.md](SECURITY.md) instead. +Report vulnerabilities privately. See SECURITY.md. diff --git a/Makefile b/Makefile index b607397..dc0b800 100644 --- a/Makefile +++ b/Makefile @@ -1,19 +1,17 @@ SWIFT_FORMAT_PATHS := Sources Tests Package.swift -SWIFT_TEST_FLAGS := -Xswiftc -strict-concurrency=complete -Xswiftc -warnings-as-errors -SWIFT_BUILD_FLAGS := $(SWIFT_TEST_FLAGS) -SWIFT_DOCC_MODULES := AppRuntimeKit AppRuntimeKitAppKit -SWIFT_DOCC_OUTPUT_DIR := .build/docc -SWIFT_DOCC_BUNDLE_VERSION := 0.1.0 -SWIFT_SYMBOL_GRAPH_DIR := .build/$(shell swift -print-target-info | awk -F\" '/"unversionedTriple"/ { print $$4; exit }')/symbolgraph +SWIFT_STRICT_FLAGS := -Xswiftc -strict-concurrency=complete -Xswiftc -warnings-as-errors -.DEFAULT_GOAL := help +.DEFAULT_GOAL := check -.PHONY: help check format lint test build docc +.PHONY: check build build-release format lint test -help: - @awk -F: '/^[a-zA-Z0-9_-]+:/ { print $$1 }' $(MAKEFILE_LIST) +check: lint build build-release test -check: lint test build docc +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) @@ -22,20 +20,4 @@ lint: swift format lint --recursive --strict $(SWIFT_FORMAT_PATHS) test: - swift test $(SWIFT_TEST_FLAGS) - -build: - swift build $(SWIFT_BUILD_FLAGS) - -docc: build - @mkdir -p $(SWIFT_DOCC_OUTPUT_DIR) - swift package dump-symbol-graph --minimum-access-level public - @for module in $(SWIFT_DOCC_MODULES); do \ - xcrun docc convert Sources/$$module/$$module.docc \ - --additional-symbol-graph-dir $(SWIFT_SYMBOL_GRAPH_DIR) \ - --output-dir $(SWIFT_DOCC_OUTPUT_DIR)/$$module.doccarchive \ - --fallback-display-name $$module \ - --fallback-bundle-identifier com.naviapps.$$module \ - --fallback-bundle-version $(SWIFT_DOCC_BUNDLE_VERSION) \ - --warnings-as-errors || exit $$?; \ - done + swift test $(SWIFT_STRICT_FLAGS) diff --git a/README.md b/README.md index 7c78392..6726ea4 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,20 @@ # AppRuntimeKit -[![CI](https://github.com/naviapps/app-runtime-kit/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/naviapps/app-runtime-kit/actions/workflows/ci.yml) +[![CI](https://github.com/naviapps/app-runtime-kit/actions/workflows/ci.yml/badge.svg)](https://github.com/naviapps/app-runtime-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%2Fapp-runtime-kit%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/naviapps/app-runtime-kit) [![Supported platforms](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fnaviapps%2Fapp-runtime-kit%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/naviapps/app-runtime-kit) AppRuntimeKit is a Swift package for building launcher apps, menu bar utilities, -automation tools, companion apps, and single-instance macOS apps with a clean -split between provider-neutral runtime values and live AppKit integration. +automation tools, companion apps, and duplicate-process activation requests with a +clean split between provider-neutral runtime values and live AppKit integration. The package provides two products: - `AppRuntimeKit` for provider-neutral process snapshots, runtime events, and runtime request result values. -- `AppRuntimeKitAppKit` for `NSWorkspace`, `NSRunningApplication`, and - distributed-notification backed runtime operations. +- `AppRuntimeKitAppKit` for `NSWorkspace`, `NSRunningApplication`, URL/file/application + opening, and distributed-notification backed runtime operations. ## Common Use Cases @@ -22,7 +22,7 @@ The package provides two products: - menu bar utilities - automation tools - companion applications -- single-instance desktop apps +- duplicate-process activation requests - runtime-aware diagnostics and support tools ## Core Types @@ -32,12 +32,23 @@ The package provides two products: Store immutable process identifiers, bundle identifiers, display names, and termination state without keeping a live process handle. +### `ApplicationRuntimeEvent` + +Represent launch, termination, activation, and deactivation notifications with +provider-neutral snapshot values. + +### Runtime Result Values + +Use `ApplicationRequestResult` for compact request outcomes, specialized with +`ApplicationOpeningError` or `ApplicationRuntimeRequestError` for validated error cases. +Use `ApplicationTerminationMode` to choose graceful or forced termination requests. + ### `ApplicationRuntime` Query, open, activate, terminate, observe, and wait for macOS applications through the `AppRuntimeKitAppKit` product. -### `ApplicationSingleInstanceCoordinator` +### `ApplicationDuplicateActivationCoordinator` Coordinate activation requests from duplicate processes with distributed notifications. @@ -49,11 +60,20 @@ call sites do not become ambiguous with the synchronous open methods. `NSWorkspace` and `NSRunningApplication` backed APIs live in `AppRuntimeKitAppKit` and are main-actor isolated. -Host apps remain responsible for product-specific lifecycle policy, UI, persistence, -permissions onboarding, analytics, telemetry, privacy disclosures, and release packaging. +## Responsibility Boundary + +AppRuntimeKit owns reusable process snapshots, lifecycle events, typed request outcomes, and +validation errors. AppRuntimeKitAppKit owns the AppKit-backed runtime adapter and +duplicate-process activation request coordination. + +Host apps remain responsible for product-specific lifecycle policy, URL/file/application +exposure policy, UI, persistence, permissions onboarding, analytics, telemetry, privacy +disclosures, and release packaging. Inputs are validated before use, and result types distinguish invalid input, missing -applications, and rejected runtime requests. See the API documentation for detailed behavior. +applications, stale process identity, and rejected runtime requests. Process-targeted activation +and termination overloads can validate an expected bundle identifier before sending a request. +See the API documentation for detailed behavior. ## Requirements @@ -65,20 +85,20 @@ applications, and rejected runtime requests. See the API documentation for detai Add this package to your Swift Package dependencies: ```swift -.package(url: "https://github.com/naviapps/app-runtime-kit.git", from: "0.1.0") +.package(url: "https://github.com/naviapps/app-runtime-kit.git", from: "0.2.0") ``` Then add the product that matches the boundary you need: ```swift -.product(name: "AppRuntimeKit", package: "app-runtime-kit") +.product(name: "AppRuntimeKit", package: "app-runtime-kit"), .product(name: "AppRuntimeKitAppKit", package: "app-runtime-kit") ``` ## Documentation -- [AppRuntimeKit API reference](https://swiftpackageindex.com/naviapps/app-runtime-kit/documentation/appruntimekit) -- [AppRuntimeKitAppKit API reference](https://swiftpackageindex.com/naviapps/app-runtime-kit/documentation/appruntimekitappkit) +- [AppRuntimeKit API reference](https://swiftpackageindex.com/naviapps/app-runtime-kit/documentation/applicationruntimekit) +- [AppRuntimeKitAppKit API reference](https://swiftpackageindex.com/naviapps/app-runtime-kit/documentation/applicationruntimekitappkit) ## Practical Example @@ -89,7 +109,7 @@ import AppRuntimeKit import AppRuntimeKitAppKit @MainActor -func openSafariIfNeeded() -> ApplicationOpenResult? { +func openSafariIfNeeded() -> ApplicationRequestResult? { let runtime = ApplicationRuntime() guard runtime.runningApplications(bundleIdentifier: "com.apple.Safari").isEmpty else { return nil @@ -101,6 +121,8 @@ func openSafariIfNeeded() -> ApplicationOpenResult? { ## Basic Usage Use `ApplicationProcessSnapshot` for explicit process snapshot values: +the explicit initializer records the values you pass and leaves live-process +validation to runtime adapters. ```swift import AppKit @@ -132,7 +154,7 @@ func runningExampleApplications() -> [ApplicationProcessSnapshot] { } @MainActor -func openExampleApplication() -> ApplicationOpenResult { +func openExampleApplication() -> ApplicationRequestResult { let runtime = ApplicationRuntime() return runtime.openApplication(bundleIdentifier: "com.example.app") } @@ -177,21 +199,20 @@ final class RuntimeEventObserver { } ``` -Keep `ApplicationSingleInstanceCoordinator` alive while the app should receive +Keep `ApplicationDuplicateActivationCoordinator` alive while the app should receive activation requests from duplicate processes. Calling `stop()` or releasing the coordinator removes the observer. ```swift -import AppRuntimeKit import AppRuntimeKitAppKit @MainActor -final class SingleInstanceCoordinatorStore { - private var coordinator: ApplicationSingleInstanceCoordinator? +final class DuplicateActivationCoordinatorStore { + private var coordinator: ApplicationDuplicateActivationCoordinator? - func start() throws -> ApplicationSingleInstanceCoordinator.CoordinationResult { + func start() throws -> ApplicationDuplicateActivationCoordinator.CoordinationResult { stop() - let coordinator = try ApplicationSingleInstanceCoordinator( + let coordinator = try ApplicationDuplicateActivationCoordinator( bundleIdentifier: "com.example.app" ) let result = coordinator.coordinateActivatingCurrentApplication() @@ -210,16 +231,17 @@ final class SingleInstanceCoordinatorStore { ## Development -Local development commands use `make`. - -Run all local checks: +Run the package check with: ```sh make check ``` -For focused local work, run `make format`, `make lint`, `make test`, `make build`, or `make docc`. -GitHub Actions runs `make check` on pull requests and pushes to `main`. +GitHub Actions runs the same check on pull requests and pushes to `main`. + +## Contributing + +See [CONTRIBUTING.md](CONTRIBUTING.md). Release notes are in [CHANGELOG.md](CHANGELOG.md). ## Security diff --git a/SECURITY.md b/SECURITY.md index 2d52f6c..b61c493 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,8 +2,7 @@ ## Supported Versions -Before the first public release, security fixes target the default branch. After -a public release, security fixes target the latest released version. +Security updates are provided for the latest released version. ## Reporting a Vulnerability @@ -34,8 +33,6 @@ 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: @@ -44,7 +41,7 @@ Security-sensitive areas include: - Activating running applications supplied by host apps - Terminating running applications supplied by host apps - Bundle identifiers, process identifiers, app names, file URLs, and local paths -- Distributed notifications used for single-instance activation requests +- Distributed notifications used for duplicate-process activation requests - Application launch, termination, activation, and deactivation snapshots - Logging or diagnostics that include private app metadata or local paths diff --git a/Sources/AppRuntimeKit/AppRuntimeKit.docc/AppRuntimeKit.md b/Sources/AppRuntimeKit/AppRuntimeKit.docc/AppRuntimeKit.md index 85324c1..77b83b8 100644 --- a/Sources/AppRuntimeKit/AppRuntimeKit.docc/AppRuntimeKit.md +++ b/Sources/AppRuntimeKit/AppRuntimeKit.docc/AppRuntimeKit.md @@ -5,15 +5,15 @@ Provider-neutral application runtime value types. ## Overview `AppRuntimeKit` contains the small value types shared by application runtime -adapters. Import it when code needs stable process snapshots, runtime event +adapters. Import it when code needs process snapshots, runtime event values, or result enums without depending on live AppKit APIs. The package includes: - ``ApplicationProcessSnapshot`` for immutable application process snapshots. - ``ApplicationRuntimeEvent`` for launch, termination, activation, and deactivation events. -- ``ApplicationOpenResult``, ``ApplicationActivationResult``, and - ``ApplicationTerminationResult`` for validated runtime request outcomes. +- ``ApplicationRequestResult`` for validated runtime request outcomes. +Use ``ApplicationRuntimeEvent/Lifecycle`` when filtering event streams by lifecycle. Live `NSWorkspace`, `NSRunningApplication`, and distributed notification behavior lives in the `AppRuntimeKitAppKit` product. @@ -22,7 +22,7 @@ behavior lives in the `AppRuntimeKitAppKit` product. AppRuntimeKit owns provider-neutral runtime values and validation-result vocabulary. It does not own live application lookup, opening, activation, -termination, event observation, duplicate-instance notification transport, +termination, event observation, duplicate-process activation notification transport, host UI, persistence, permissions onboarding, telemetry, privacy disclosures, or release packaging. @@ -30,6 +30,9 @@ or release packaging. Use ``ApplicationProcessSnapshot`` when a caller needs immutable process metadata without retaining a live process object. +The explicit initializer records the values the caller supplies; runtime +adapters are responsible for validating live process state before acting on a +snapshot. ```swift import AppRuntimeKit @@ -49,65 +52,59 @@ lifecycle events without exposing provider-specific notification objects. ```swift import AppRuntimeKit -let event = ApplicationRuntimeEvent(kind: .activated, application: snapshot) +let snapshot = ApplicationProcessSnapshot( + processIdentifier: 123, + bundleIdentifier: "com.example.app", + displayName: "Example" +) +let event = ApplicationRuntimeEvent(lifecycle: .activated, application: snapshot) ``` ## Topics -### Process Snapshots +### Process Snapshot Types - ``ApplicationProcessSnapshot`` - ``ApplicationProcessSnapshot/init(processIdentifier:bundleIdentifier:displayName:isTerminated:)`` -- ``ApplicationProcessSnapshot/processIdentifier`` -- ``ApplicationProcessSnapshot/bundleIdentifier`` -- ``ApplicationProcessSnapshot/displayName`` -- ``ApplicationProcessSnapshot/isTerminated`` -### Runtime Events +### Runtime Event Types - ``ApplicationRuntimeEvent`` -- ``ApplicationRuntimeEvent/Kind`` -- ``ApplicationRuntimeEvent/init(kind:application:)`` -- ``ApplicationRuntimeEvent/kind`` -- ``ApplicationRuntimeEvent/application`` -- ``ApplicationRuntimeEvent/Kind/launched`` -- ``ApplicationRuntimeEvent/Kind/terminated`` -- ``ApplicationRuntimeEvent/Kind/activated`` -- ``ApplicationRuntimeEvent/Kind/deactivated`` - -### Opening Results - -- ``ApplicationOpenResult`` -- ``ApplicationOpenResult/opened`` -- ``ApplicationOpenResult/failed(_:)`` -- ``ApplicationOpenError`` -- ``ApplicationOpenError/invalidURL`` -- ``ApplicationOpenError/invalidFileURL`` -- ``ApplicationOpenError/invalidApplicationURL`` -- ``ApplicationOpenError/invalidBundleIdentifier`` -- ``ApplicationOpenError/fileNotFound`` -- ``ApplicationOpenError/applicationNotFound`` -- ``ApplicationOpenError/requestFailed`` - -### Activation Results - -- ``ApplicationActivationResult`` -- ``ApplicationActivationResult/activated`` -- ``ApplicationActivationResult/failed(_:)`` -- ``ApplicationActivationError`` -- ``ApplicationActivationError/invalidBundleIdentifier`` -- ``ApplicationActivationError/applicationNotFound`` -- ``ApplicationActivationError/requestFailed`` - -### Termination Results - -- ``ApplicationTerminationResult`` -- ``ApplicationTerminationResult/requested`` -- ``ApplicationTerminationResult/failed(_:)`` +- ``ApplicationRuntimeEvent/init(lifecycle:application:)`` +- ``ApplicationRuntimeEvent/Lifecycle`` +- ``ApplicationRuntimeEvent/Lifecycle/launched`` +- ``ApplicationRuntimeEvent/Lifecycle/terminated`` +- ``ApplicationRuntimeEvent/Lifecycle/activated`` +- ``ApplicationRuntimeEvent/Lifecycle/deactivated`` + +### Request Results + +- ``ApplicationRequestResult`` +- ``ApplicationRequestResult/requestAccepted`` +- ``ApplicationRequestResult/failed(_:)`` +- ``ApplicationOpeningError`` +- ``ApplicationOpeningError/missingURLScheme`` +- ``ApplicationOpeningError/nonFileURL`` +- ``ApplicationOpeningError/fileSystemItemNotFound`` +- ``ApplicationOpeningError/invalidApplicationBundleURL`` +- ``ApplicationOpeningError/invalidBundleIdentifier`` +- ``ApplicationOpeningError/installedApplicationNotFound`` +- ``ApplicationOpeningError/applicationBundleNotFound`` +- ``ApplicationOpeningError/requestRejected`` + +### Termination Modes + - ``ApplicationTerminationMode`` - ``ApplicationTerminationMode/graceful`` - ``ApplicationTerminationMode/forced`` -- ``ApplicationTerminationError`` -- ``ApplicationTerminationError/invalidBundleIdentifier`` -- ``ApplicationTerminationError/applicationNotFound`` -- ``ApplicationTerminationError/requestFailed`` + +### Runtime Request Errors + +- ``ApplicationRuntimeRequestError`` +- ``ApplicationRuntimeRequestError/invalidProcessIdentifier`` +- ``ApplicationRuntimeRequestError/invalidBundleIdentifier`` +- ``ApplicationRuntimeRequestError/applicationNotFound`` +- ``ApplicationRuntimeRequestError/applicationTerminated`` +- ``ApplicationRuntimeRequestError/applicationBundleIdentifierUnavailable`` +- ``ApplicationRuntimeRequestError/applicationBundleIdentifierMismatch`` +- ``ApplicationRuntimeRequestError/requestRejected`` diff --git a/Sources/AppRuntimeKit/Application/ApplicationProcessSnapshot.swift b/Sources/AppRuntimeKit/Application/ApplicationProcessSnapshot.swift index 8e802c2..5214dfd 100644 --- a/Sources/AppRuntimeKit/Application/ApplicationProcessSnapshot.swift +++ b/Sources/AppRuntimeKit/Application/ApplicationProcessSnapshot.swift @@ -1,17 +1,17 @@ import Darwin -/// Immutable recorded state for an application process. +/// Immutable recorded metadata for an application process. /// -/// A snapshot is not a live process handle; runtime operations re-check the live process before -/// acting on one. +/// A snapshot is not a live process handle. Runtime adapters re-check live process state before +/// acting on a snapshot. public struct ApplicationProcessSnapshot: Sendable, Equatable, Hashable { /// The process identifier of the application process. public let processIdentifier: pid_t - /// The bundle identifier of the application, if available. + /// The recorded bundle identifier of the application, if available. public let bundleIdentifier: String? - /// The display name of the application, if available. + /// The recorded display name of the application, if available. public let displayName: String? - /// Indicates whether the application process was terminated when this snapshot was created. + /// Indicates whether the application process was recorded as terminated. public let isTerminated: Bool /// Creates an application process snapshot from explicit values. diff --git a/Sources/AppRuntimeKit/Runtime/ApplicationActivationResult.swift b/Sources/AppRuntimeKit/Runtime/ApplicationActivationResult.swift deleted file mode 100644 index 83cb01b..0000000 --- a/Sources/AppRuntimeKit/Runtime/ApplicationActivationResult.swift +++ /dev/null @@ -1,17 +0,0 @@ -/// Describes why an application activation request failed. -public enum ApplicationActivationError: Error, Sendable, CaseIterable, Equatable, Hashable { - /// The bundle identifier is blank after trimming whitespace. - case invalidBundleIdentifier - /// No running application exists for the request. - case applicationNotFound - /// The running application rejected the activation request. - case requestFailed -} - -/// Represents the result of an application activation request. -public enum ApplicationActivationResult: Sendable, Equatable, Hashable { - /// The running application was activated. - case activated - /// The activation request failed before or while requesting activation. - case failed(ApplicationActivationError) -} diff --git a/Sources/AppRuntimeKit/Runtime/ApplicationOpenResult.swift b/Sources/AppRuntimeKit/Runtime/ApplicationOpenResult.swift deleted file mode 100644 index fd493d4..0000000 --- a/Sources/AppRuntimeKit/Runtime/ApplicationOpenResult.swift +++ /dev/null @@ -1,25 +0,0 @@ -/// Describes why an application runtime open request failed. -public enum ApplicationOpenError: Error, Sendable, CaseIterable, Equatable, Hashable { - /// The URL cannot be opened as a generic URL. - case invalidURL - /// The URL is not a file URL. - case invalidFileURL - /// No file system item exists at the file URL. - case fileNotFound - /// The URL is not a valid application bundle URL. - case invalidApplicationURL - /// The bundle identifier is blank after trimming whitespace. - case invalidBundleIdentifier - /// No installed application or bundle exists for the request. - case applicationNotFound - /// The underlying open request was rejected. - case requestFailed -} - -/// Represents the result of an application runtime open request. -public enum ApplicationOpenResult: Sendable, Equatable, Hashable { - /// The underlying open request was accepted. - case opened - /// The open request failed before or during runtime handling. - case failed(ApplicationOpenError) -} diff --git a/Sources/AppRuntimeKit/Runtime/ApplicationOpeningError.swift b/Sources/AppRuntimeKit/Runtime/ApplicationOpeningError.swift new file mode 100644 index 0000000..a1d24b2 --- /dev/null +++ b/Sources/AppRuntimeKit/Runtime/ApplicationOpeningError.swift @@ -0,0 +1,19 @@ +/// Describes why opening a URL, file, or application failed. +public enum ApplicationOpeningError: Error, Sendable, Equatable, Hashable { + /// The URL cannot be opened because it has no scheme. + case missingURLScheme + /// The file opening request received a non-file URL. + case nonFileURL + /// No file system item exists at the file URL. + case fileSystemItemNotFound + /// The application opening request received a URL that is not a valid application bundle. + case invalidApplicationBundleURL + /// The bundle identifier is blank after trimming whitespace. + case invalidBundleIdentifier + /// No installed application matches the bundle identifier. + case installedApplicationNotFound + /// No application bundle exists at the requested URL. + case applicationBundleNotFound + /// The underlying opening request was rejected. + case requestRejected +} diff --git a/Sources/AppRuntimeKit/Runtime/ApplicationRequestResult.swift b/Sources/AppRuntimeKit/Runtime/ApplicationRequestResult.swift new file mode 100644 index 0000000..78db0cd --- /dev/null +++ b/Sources/AppRuntimeKit/Runtime/ApplicationRequestResult.swift @@ -0,0 +1,13 @@ +/// Represents the outcome of an application request with a typed failure reason. +public enum ApplicationRequestResult { + /// The request was accepted. + case requestAccepted + /// The request was rejected or could not be attempted. + case failed(Failure) +} + +extension ApplicationRequestResult: Sendable where Failure: Sendable {} + +extension ApplicationRequestResult: Equatable where Failure: Equatable {} + +extension ApplicationRequestResult: Hashable where Failure: Hashable {} diff --git a/Sources/AppRuntimeKit/Runtime/ApplicationRuntimeEvent.swift b/Sources/AppRuntimeKit/Runtime/ApplicationRuntimeEvent.swift index de365b6..782806e 100644 --- a/Sources/AppRuntimeKit/Runtime/ApplicationRuntimeEvent.swift +++ b/Sources/AppRuntimeKit/Runtime/ApplicationRuntimeEvent.swift @@ -1,7 +1,7 @@ /// Describes an application runtime lifecycle event. public struct ApplicationRuntimeEvent: Sendable, Equatable, Hashable { - /// Identifies an application runtime lifecycle event. - public enum Kind: Sendable, CaseIterable, Equatable, Hashable { + /// Identifies an application runtime lifecycle transition. + public enum Lifecycle: Sendable, CaseIterable, Equatable, Hashable { /// An application finished launching. case launched /// An application terminated. @@ -13,13 +13,13 @@ public struct ApplicationRuntimeEvent: Sendable, Equatable, Hashable { } /// The lifecycle transition that occurred. - public let kind: Kind - /// The application snapshot captured from the workspace notification. + public let lifecycle: Lifecycle + /// The application snapshot captured when the lifecycle event occurred. public let application: ApplicationProcessSnapshot /// Creates an application runtime event. - public init(kind: Kind, application: ApplicationProcessSnapshot) { - self.kind = kind + public init(lifecycle: Lifecycle, application: ApplicationProcessSnapshot) { + self.lifecycle = lifecycle self.application = application } } diff --git a/Sources/AppRuntimeKit/Runtime/ApplicationRuntimeRequestError.swift b/Sources/AppRuntimeKit/Runtime/ApplicationRuntimeRequestError.swift new file mode 100644 index 0000000..27bab59 --- /dev/null +++ b/Sources/AppRuntimeKit/Runtime/ApplicationRuntimeRequestError.swift @@ -0,0 +1,17 @@ +/// Describes why an application runtime request failed. +public enum ApplicationRuntimeRequestError: Error, Sendable, Equatable, Hashable { + /// The caller supplied a process identifier that cannot identify an application process. + case invalidProcessIdentifier + /// The caller supplied a bundle identifier that is blank after trimming whitespace. + case invalidBundleIdentifier + /// No running application matches the request. + case applicationNotFound + /// The requested application is already terminated. + case applicationTerminated + /// The matched application cannot expose the bundle identifier required for validation. + case applicationBundleIdentifierUnavailable + /// The matched application's bundle identifier does not equal the expected bundle identifier. + case applicationBundleIdentifierMismatch + /// The matched application rejected the request. + case requestRejected +} diff --git a/Sources/AppRuntimeKit/Runtime/ApplicationTerminationMode.swift b/Sources/AppRuntimeKit/Runtime/ApplicationTerminationMode.swift new file mode 100644 index 0000000..5a29d35 --- /dev/null +++ b/Sources/AppRuntimeKit/Runtime/ApplicationTerminationMode.swift @@ -0,0 +1,7 @@ +/// Describes how an application termination request should be performed. +public enum ApplicationTerminationMode: Sendable, CaseIterable, Equatable, Hashable { + /// Requests graceful application termination. + case graceful + /// Requests forced application termination. + case forced +} diff --git a/Sources/AppRuntimeKit/Runtime/ApplicationTerminationResult.swift b/Sources/AppRuntimeKit/Runtime/ApplicationTerminationResult.swift deleted file mode 100644 index 0ac2bcb..0000000 --- a/Sources/AppRuntimeKit/Runtime/ApplicationTerminationResult.swift +++ /dev/null @@ -1,25 +0,0 @@ -/// Describes how an application termination request should be performed. -public enum ApplicationTerminationMode: Sendable, CaseIterable, Equatable, Hashable { - /// Requests graceful application termination. - case graceful - /// Requests forced application termination. - case forced -} - -/// Describes why an application termination request failed. -public enum ApplicationTerminationError: Error, Sendable, CaseIterable, Equatable, Hashable { - /// The bundle identifier is blank after trimming whitespace. - case invalidBundleIdentifier - /// No running application exists for the request. - case applicationNotFound - /// The running application rejected the termination request. - case requestFailed -} - -/// Represents the result of an application termination request. -public enum ApplicationTerminationResult: Sendable, Equatable, Hashable { - /// The running application accepted the termination request. - case requested - /// The termination request failed before or during runtime handling. - case failed(ApplicationTerminationError) -} diff --git a/Sources/AppRuntimeKitAppKit/AppRuntimeKitAppKit.docc/AppRuntimeKitAppKit.md b/Sources/AppRuntimeKitAppKit/AppRuntimeKitAppKit.docc/AppRuntimeKitAppKit.md index dcac90d..7a78524 100644 --- a/Sources/AppRuntimeKitAppKit/AppRuntimeKitAppKit.docc/AppRuntimeKitAppKit.md +++ b/Sources/AppRuntimeKitAppKit/AppRuntimeKitAppKit.docc/AppRuntimeKitAppKit.md @@ -7,15 +7,16 @@ Reusable macOS application runtime helpers. `AppRuntimeKitAppKit` provides AppKit-backed adapters for common macOS application runtime tasks. Import `AppRuntimeKitAppKit` when a host app needs to inspect application process snapshots, observe application lifecycle events, resolve application bundle -URLs, open applications, request application termination, or coordinate duplicate-instance -activation requests. +URLs, open URLs, open files, open applications, request application termination, or coordinate +duplicate-process activation requests. The package includes: -- `ApplicationProcessSnapshot` for immutable application process snapshots. +- `ApplicationProcessSnapshot` for immutable application process snapshots, including + `ApplicationProcessSnapshot(runningApplication:)` for AppKit process snapshots. - ``ApplicationRuntime`` for querying, opening, activating, terminating, observing, and waiting for application lifecycle events. -- ``ApplicationSingleInstanceCoordinator`` for duplicate-instance activation requests. +- ``ApplicationDuplicateActivationCoordinator`` for duplicate-process activation requests. All AppKit-backed APIs are main-actor isolated. Host apps remain responsible for UI decisions, permission onboarding, persistence, analytics, telemetry, privacy disclosures, release packaging, @@ -25,18 +26,18 @@ and deciding when to expose these operations. AppRuntimeKitAppKit owns the AppKit-backed bridge from neutral process snapshots to current/running/frontmost application process queries, application bundle URL resolution by bundle -identifier, running application activation and termination requests, application open request -validation, application lifecycle event streams and event waiting helpers, and lightweight -duplicate-instance activation request coordination. +identifier, URL/file/application opening request validation, running application activation and +termination requests, application lifecycle event streams and event waiting helpers, and +lightweight duplicate-process activation request coordination. AppRuntimeKitAppKit does not own product-specific lifecycle policy, window activation policy, user interface, permission onboarding, persistence, analytics, telemetry, privacy disclosures, release -packaging, or deciding which runtime operations a host app exposes. +packaging, or deciding which URLs, files, applications, or runtime operations a host app exposes. -## Querying Applications +## Usage -Use ``ApplicationRuntime`` when the host app needs snapshots for the current, running, or -frontmost application, or an installed application URL for a bundle identifier. +Create ``ApplicationRuntime`` on the main actor when a host app needs AppKit-backed process queries +or runtime requests: ```swift import AppKit @@ -44,199 +45,42 @@ import AppRuntimeKit import AppRuntimeKitAppKit @MainActor -func exampleAppSnapshots() -> [ApplicationProcessSnapshot] { +func exampleRuntimeOperations() -> [ApplicationProcessSnapshot] { let runtime = ApplicationRuntime() return runtime.runningApplications(bundleIdentifier: "com.example.app") } - -@MainActor -func exampleAppURL() -> URL? { - let runtime = ApplicationRuntime() - return runtime.installedApplicationURL(bundleIdentifier: "com.example.app") -} - -@MainActor -func currentApplicationSnapshot() -> ApplicationProcessSnapshot { - ApplicationProcessSnapshot(runningApplication: NSRunningApplication.current) -} -``` - -Blank bundle identifiers return no results for running application queries and `nil` for installed -application URL queries. Nonpositive process identifiers return `nil` for process identifier -queries, and current or frontmost application queries return `nil` when the live application is -unavailable, terminated, or has a nonpositive process identifier. - -## Opening Applications - -Use ``ApplicationRuntime`` when the host app wants validated `NSWorkspace` open calls and a compact -`ApplicationOpenResult` instead of a raw boolean. - -```swift -import AppRuntimeKit -import AppRuntimeKitAppKit - -@MainActor -func openExampleApp() -> ApplicationOpenResult { - let runtime = ApplicationRuntime() - return runtime.openApplication(bundleIdentifier: "com.example.app") -} -``` - -Blank bundle identifiers return `ApplicationOpenError/invalidBundleIdentifier`. Missing installed -applications return `ApplicationOpenError/applicationNotFound`. Missing file URLs return -`ApplicationOpenError/fileNotFound` before calling `NSWorkspace`. Invalid application bundle URLs -return `ApplicationOpenError/invalidApplicationURL`; application bundle URLs must resolve to an -`APPL` bundle with an executable file. Rejected open requests return -`ApplicationOpenError/requestFailed`. -Asynchronous open helpers require an explicit `NSWorkspace.OpenConfiguration` so call sites remain -unambiguous with the synchronous helpers. - -## Activating Applications - -Use ``ApplicationRuntime`` when the host app wants to bring a running application to the -foreground without owning direct `NSRunningApplication` lookup code. - -```swift -import AppRuntimeKit -import AppRuntimeKitAppKit - -@MainActor -func activateExampleApp(_ app: ApplicationProcessSnapshot) -> ApplicationActivationResult { - let runtime = ApplicationRuntime() - return runtime.activateApplication(app) -} ``` -Activation helpers do not choose window policy for the host app. Pass explicit -`NSApplication.ActivationOptions` when the default empty options are not the right fit. -When activating from `ApplicationProcessSnapshot`, AppRuntimeKit verifies the live process still -matches the snapshot process identifier and, when available, bundle identifier. -Blank bundle identifiers return `ApplicationActivationError/invalidBundleIdentifier`. Missing, -terminated, or mismatched running applications return -`ApplicationActivationError/applicationNotFound`. Rejected activation requests return -`ApplicationActivationError/requestFailed`. - -## Terminating Applications - -Use ``ApplicationRuntime`` when the host app wants to request termination of a running -application without owning direct `NSRunningApplication` lookup code. - -```swift -import AppRuntimeKit -import AppRuntimeKitAppKit - -@MainActor -func terminateExampleApp(_ app: ApplicationProcessSnapshot) -> ApplicationTerminationResult { - let runtime = ApplicationRuntime() - return runtime.terminateApplication(app) -} -``` - -Termination helpers do not decide when an app should be closed. Host apps own that policy. -Pass `ApplicationTerminationMode/forced` only when the caller intentionally wants a forced -termination request. When terminating from `ApplicationProcessSnapshot`, AppRuntimeKit verifies -that the live process still has the same process identifier and matching bundle identifier when one -is available. -Blank bundle identifiers return `ApplicationTerminationError/invalidBundleIdentifier`. Missing, -terminated, or mismatched running applications return -`ApplicationTerminationError/applicationNotFound`. Rejected termination requests return -`ApplicationTerminationError/requestFailed`. - -## Observing Application Events - -Use ``ApplicationRuntime`` when the host app needs `AsyncStream` values for application lifecycle -notifications. +Use `ApplicationProcessSnapshot(runningApplication:)` when adapter code already has an +`NSRunningApplication` and needs a neutral snapshot. Runtime request methods return +``AppRuntimeKit/ApplicationRequestResult`` values so call sites can distinguish accepted requests +from validation or lookup failures. ```swift +import AppKit import AppRuntimeKit import AppRuntimeKitAppKit @MainActor -final class RuntimeEventObserver { - private let runtime = ApplicationRuntime() - private let handleEvent: @MainActor (ApplicationRuntimeEvent) -> Void - private var task: Task? - - init(handleEvent: @escaping @MainActor (ApplicationRuntimeEvent) -> Void) { - self.handleEvent = handleEvent - } - - func start() { - stop() - let runtime = runtime - let handleEvent = handleEvent - task = Task { @MainActor in - for await event in runtime.events() { - handleEvent(event) - } - } - } - - func stop() { - task?.cancel() - task = nil - } - - deinit { - task?.cancel() - } +func currentApplicationSnapshot() -> AppRuntimeKit.ApplicationProcessSnapshot { + AppRuntimeKit.ApplicationProcessSnapshot(runningApplication: NSRunningApplication.current) } ``` -Use ``ApplicationRuntime/events()`` when the caller wants all supported lifecycle events in one -stream. Use ``ApplicationRuntime/launchEvents()``, -``ApplicationRuntime/terminationEvents()``, ``ApplicationRuntime/activationEvents()``, or -``ApplicationRuntime/deactivationEvents()`` when the caller only needs one event kind. - -The wait helpers cover launch, termination, activation, and deactivation. Use -``ApplicationRuntime/waitForApplicationEvent(_:bundleIdentifier:timeout:)`` when the caller needs the -matching event kind and snapshot together. They return `nil` for blank bundle identifiers and when a -timeout expires. - -Keep observation tasks alive while the host app should receive events. Cancel the task when the -observation is no longer needed. - -## Duplicate Instance Activation Requests - -Use ``ApplicationSingleInstanceCoordinator`` when a macOS app wants a lightweight distributed -notification handshake for activation requests from duplicate processes. +Use ``ApplicationDuplicateActivationCoordinator`` when a macOS app wants a lightweight distributed +notification handshake for activation requests from duplicate processes: ```swift -import AppRuntimeKit import AppRuntimeKitAppKit -@MainActor -final class SingleInstanceCoordinatorStore { - private var coordinator: ApplicationSingleInstanceCoordinator? - - func start() throws -> ApplicationSingleInstanceCoordinator.CoordinationResult { - stop() - let coordinator = try ApplicationSingleInstanceCoordinator( - bundleIdentifier: "com.example.app" - ) - let result = coordinator.coordinateActivatingCurrentApplication() - if result == .unique { - self.coordinator = coordinator - } - return result - } - - func stop() { - coordinator?.stop() - coordinator = nil - } -} +let coordinator = try ApplicationDuplicateActivationCoordinator( + bundleIdentifier: "com.example.app" +) +let result = coordinator.coordinateActivatingCurrentApplication() ``` -Duplicate-instance activation request coordination does not own window activation policy. The -callback lets the host app choose how to bring its existing UI forward, or the convenience method can -activate the current application with caller-provided `NSApplication.ActivationOptions`. - -Keep ``ApplicationSingleInstanceCoordinator`` alive while duplicate activation requests should be -observed. Calling ``ApplicationSingleInstanceCoordinator/stop()`` or releasing the coordinator -removes the observer. A duplicate process is only notified; the host app decides whether and when -that duplicate process exits. Custom distributed notification names are trimmed and blank names are -rejected. +Runtime event streams are `AsyncStream` values. Keep observation tasks or duplicate-activation +coordinators alive only while the host app should receive those events. ## Topics @@ -247,6 +91,7 @@ rejected. ### Runtime Queries +- ``AppRuntimeKit/ApplicationProcessSnapshot/init(runningApplication:)`` - ``ApplicationRuntime/currentApplication()`` - ``ApplicationRuntime/runningApplication(processIdentifier:)`` - ``ApplicationRuntime/runningApplications(bundleIdentifier:)`` @@ -266,14 +111,17 @@ rejected. ### Runtime Activation +- ``ApplicationRuntime/activateCurrentApplication(options:)`` - ``ApplicationRuntime/activateApplication(_:options:)`` - ``ApplicationRuntime/activateApplication(processIdentifier:options:)`` +- ``ApplicationRuntime/activateApplication(processIdentifier:expectedBundleIdentifier:options:)`` - ``ApplicationRuntime/activateApplication(bundleIdentifier:options:)`` ### Runtime Termination - ``ApplicationRuntime/terminateApplication(_:mode:)`` - ``ApplicationRuntime/terminateApplication(processIdentifier:mode:)`` +- ``ApplicationRuntime/terminateApplication(processIdentifier:expectedBundleIdentifier:mode:)`` - ``ApplicationRuntime/terminateApplication(bundleIdentifier:mode:)`` ### Runtime Events @@ -289,17 +137,17 @@ rejected. - ``ApplicationRuntime/waitForApplicationDeactivation(bundleIdentifier:timeout:)`` - ``ApplicationRuntime/waitForApplicationEvent(_:bundleIdentifier:timeout:)`` -### Duplicate Instance Activation Requests - -- ``ApplicationSingleInstanceCoordinator`` -- ``ApplicationSingleInstanceCoordinator/init(bundleIdentifier:)`` -- ``ApplicationSingleInstanceCoordinator/init(bundleIdentifier:notificationName:)`` -- ``ApplicationSingleInstanceCoordinator/coordinate(onDuplicateActivationRequest:)`` -- ``ApplicationSingleInstanceCoordinator/coordinateActivatingCurrentApplication(options:)`` -- ``ApplicationSingleInstanceCoordinator/stop()`` -- ``ApplicationSingleInstanceCoordinator/ConfigurationError`` -- ``ApplicationSingleInstanceCoordinator/ConfigurationError/invalidBundleIdentifier`` -- ``ApplicationSingleInstanceCoordinator/ConfigurationError/invalidNotificationName`` -- ``ApplicationSingleInstanceCoordinator/CoordinationResult`` -- ``ApplicationSingleInstanceCoordinator/CoordinationResult/unique`` -- ``ApplicationSingleInstanceCoordinator/CoordinationResult/duplicate`` +### Duplicate-Process Activation Request APIs + +- ``ApplicationDuplicateActivationCoordinator`` +- ``ApplicationDuplicateActivationCoordinator/init(bundleIdentifier:)`` +- ``ApplicationDuplicateActivationCoordinator/init(bundleIdentifier:notificationName:)`` +- ``ApplicationDuplicateActivationCoordinator/coordinate(onDuplicateActivationRequest:)`` +- ``ApplicationDuplicateActivationCoordinator/coordinateActivatingCurrentApplication(options:)`` +- ``ApplicationDuplicateActivationCoordinator/stop()`` +- ``ApplicationDuplicateActivationCoordinator/ConfigurationError`` +- ``ApplicationDuplicateActivationCoordinator/ConfigurationError/invalidBundleIdentifier`` +- ``ApplicationDuplicateActivationCoordinator/ConfigurationError/invalidNotificationName`` +- ``ApplicationDuplicateActivationCoordinator/CoordinationResult`` +- ``ApplicationDuplicateActivationCoordinator/CoordinationResult/unique`` +- ``ApplicationDuplicateActivationCoordinator/CoordinationResult/duplicate`` diff --git a/Sources/AppRuntimeKitAppKit/Application/ApplicationProcessSnapshot+AppKit.swift b/Sources/AppRuntimeKitAppKit/Application/ApplicationProcessSnapshot+AppKit.swift index 5ee141a..6545150 100644 --- a/Sources/AppRuntimeKitAppKit/Application/ApplicationProcessSnapshot+AppKit.swift +++ b/Sources/AppRuntimeKitAppKit/Application/ApplicationProcessSnapshot+AppKit.swift @@ -3,7 +3,6 @@ import AppRuntimeKit extension ApplicationProcessSnapshot { /// Creates an application process snapshot from an AppKit running application. - @MainActor public init(runningApplication: NSRunningApplication) { self.init( processIdentifier: runningApplication.processIdentifier, diff --git a/Sources/AppRuntimeKitAppKit/SingleInstance/ApplicationSingleInstanceCoordinator.swift b/Sources/AppRuntimeKitAppKit/DuplicateActivation/ApplicationDuplicateActivationCoordinator.swift similarity index 55% rename from Sources/AppRuntimeKitAppKit/SingleInstance/ApplicationSingleInstanceCoordinator.swift rename to Sources/AppRuntimeKitAppKit/DuplicateActivation/ApplicationDuplicateActivationCoordinator.swift index 66c0514..b89c218 100644 --- a/Sources/AppRuntimeKitAppKit/SingleInstance/ApplicationSingleInstanceCoordinator.swift +++ b/Sources/AppRuntimeKitAppKit/DuplicateActivation/ApplicationDuplicateActivationCoordinator.swift @@ -1,34 +1,36 @@ import AppKit -import AppRuntimeKit import Foundation /// Coordinates activation requests from duplicate processes using distributed notifications. /// /// The host app provides its bundle identifier, and may override the distributed /// notification name when coordinating with an existing app-specific convention. -/// Keep the coordinator alive for as long as duplicate activation requests should be observed. +/// Keep the coordinator alive for as long as duplicate-process activation requests should be +/// observed. +/// This coordinator does not acquire a process ownership lock; the host app decides whether a +/// duplicate process exits after posting the activation request. @MainActor -public final class ApplicationSingleInstanceCoordinator { - /// Describes why duplicate-instance activation request coordination could not be configured. - public enum ConfigurationError: Error, Sendable, CaseIterable, Equatable, Hashable { +public final class ApplicationDuplicateActivationCoordinator { + /// Describes why duplicate-process activation request coordination could not be configured. + public enum ConfigurationError: Error, Sendable, Equatable, Hashable { /// The bundle identifier is blank after trimming whitespace. case invalidBundleIdentifier /// The distributed notification name is blank after trimming whitespace. case invalidNotificationName } - /// Represents a duplicate-instance activation request coordination result. - public enum CoordinationResult: Sendable, CaseIterable, Equatable, Hashable { - /// No other running instance was found, and duplicate activation requests are being observed. + /// Represents a duplicate-process activation request coordination result. + public enum CoordinationResult: Sendable, Equatable, Hashable { + /// No other currently running process was found, and activation requests are being observed. case unique - /// Another running instance was found, and an activation request was posted. + /// Another running process was found, and an activation request was posted. case duplicate } private let bundleIdentifier: String private let notificationName: Notification.Name - private let dependencies: ApplicationSingleInstanceCoordinatorDependencies - private var activationObservation: ApplicationSingleInstanceObservation? + private let dependencies: ApplicationDuplicateActivationCoordinatorDependencies + private var activationObserverToken: ApplicationDuplicateActivationObserverToken? /// Creates a coordinator with a default notification name derived from the bundle identifier. /// @@ -61,54 +63,39 @@ public final class ApplicationSingleInstanceCoordinator { init( bundleIdentifier: String, notificationName: Notification.Name, - dependencies: ApplicationSingleInstanceCoordinatorDependencies + dependencies: ApplicationDuplicateActivationCoordinatorDependencies ) throws { self.bundleIdentifier = try Self.trimmedBundleIdentifier(bundleIdentifier) self.notificationName = try Self.validatedNotificationName(notificationName) self.dependencies = dependencies } - /// Coordinates duplicate-instance activation requests. + /// Coordinates duplicate-process activation requests. /// - /// - Returns: `.unique` when no other running instance is found, or `.duplicate` - /// after notifying an existing instance. + /// - Returns: `.unique` when no other running process is found, or `.duplicate` + /// after posting an activation request to a duplicate process. /// Keep this coordinator alive after `.unique`; `stop()` or deinitialization removes the observer. public func coordinate( onDuplicateActivationRequest: @escaping @MainActor @Sendable () -> Void ) -> CoordinationResult { stop() - let currentProcessIdentifier = dependencies.currentProcessIdentifier() - let runningInstances = - dependencies - .runningProcessIdentifiers(bundleIdentifier) - .filter { $0 > 0 && $0 != currentProcessIdentifier } - - if !runningInstances.isEmpty { - dependencies.activationRequestTransport.postActivationRequest( - name: notificationName, - bundleIdentifier: bundleIdentifier - ) + if !runningDuplicateProcessIdentifiers().isEmpty { + postActivationRequestToDuplicateProcess() return .duplicate } - let observer = dependencies.activationRequestTransport.observeActivationRequests( - name: notificationName, - bundleIdentifier: bundleIdentifier, - handler: onDuplicateActivationRequest - ) - activationObservation = ApplicationSingleInstanceObservation( - activationRequestTransport: dependencies.activationRequestTransport, - observer: observer + activationObserverToken = observeActivationRequests( + onDuplicateActivationRequest: onDuplicateActivationRequest ) return .unique } - /// Coordinates duplicate-instance activation requests and activates this process when requested. + /// Coordinates duplicate-process activation requests and activates this process when requested. /// - /// - Returns: `.unique` when no other running instance is found, or `.duplicate` - /// after notifying an existing instance. + /// - Returns: `.unique` when no other running process is found, or `.duplicate` + /// after posting an activation request to a duplicate process. /// Keep this coordinator alive after `.unique`; `stop()` or deinitialization removes the observer. public func coordinateActivatingCurrentApplication( options: NSApplication.ActivationOptions = [] @@ -119,16 +106,16 @@ public final class ApplicationSingleInstanceCoordinator { } } - /// Stops observing activation requests from future duplicate instances. + /// Stops observing activation requests from future duplicate processes. public func stop() { - activationObservation?.cancel() - activationObservation = nil + activationObserverToken?.cancel() + activationObserverToken = nil } - static func defaultNotificationName(trimmedBundleIdentifier bundleIdentifier: String) + private static func defaultNotificationName(trimmedBundleIdentifier bundleIdentifier: String) -> Notification.Name { - Notification.Name("\(bundleIdentifier).single-instance.activate") + Notification.Name("\(bundleIdentifier).duplicate-process-activation-request.activate") } private static func trimmedBundleIdentifier(_ bundleIdentifier: String) throws -> String { @@ -148,15 +135,46 @@ public final class ApplicationSingleInstanceCoordinator { } return Notification.Name(trimmedName) } + + private func runningDuplicateProcessIdentifiers() -> [pid_t] { + let currentProcessIdentifier = dependencies.currentProcessIdentifier() + let processIdentifiers = + dependencies + .runningProcessIdentifiers(bundleIdentifier) + .filter { $0 > 0 && $0 != currentProcessIdentifier } + return processIdentifiers + } + + private func postActivationRequestToDuplicateProcess() { + dependencies.activationRequestTransport.postActivationRequest( + name: notificationName, + bundleIdentifier: bundleIdentifier + ) + } + + private func observeActivationRequests( + onDuplicateActivationRequest: @escaping @MainActor @Sendable () -> Void + ) -> ApplicationDuplicateActivationObserverToken { + let activationRequestTransport = dependencies.activationRequestTransport + let activationRequestObserver = activationRequestTransport.observeActivationRequests( + name: notificationName, + bundleIdentifier: bundleIdentifier, + handler: onDuplicateActivationRequest + ) + return ApplicationDuplicateActivationObserverToken( + activationRequestTransport: activationRequestTransport, + observer: activationRequestObserver + ) + } } -private final class ApplicationSingleInstanceObservation { - private let activationRequestTransport: ApplicationSingleInstanceActivationRequestTransport - private var observer: Any? +private final class ApplicationDuplicateActivationObserverToken { + private let activationRequestTransport: ApplicationDuplicateActivationRequestTransport + private var observer: NSObjectProtocol? init( - activationRequestTransport: ApplicationSingleInstanceActivationRequestTransport, - observer: Any + activationRequestTransport: ApplicationDuplicateActivationRequestTransport, + observer: NSObjectProtocol ) { self.activationRequestTransport = activationRequestTransport self.observer = observer @@ -164,7 +182,7 @@ private final class ApplicationSingleInstanceObservation { func cancel() { guard let observer else { return } - activationRequestTransport.removeObservation(observer) + activationRequestTransport.removeActivationRequestObserver(observer) self.observer = nil } diff --git a/Sources/AppRuntimeKitAppKit/DuplicateActivation/ApplicationDuplicateActivationCoordinatorDependencies.swift b/Sources/AppRuntimeKitAppKit/DuplicateActivation/ApplicationDuplicateActivationCoordinatorDependencies.swift new file mode 100644 index 0000000..a8851ac --- /dev/null +++ b/Sources/AppRuntimeKitAppKit/DuplicateActivation/ApplicationDuplicateActivationCoordinatorDependencies.swift @@ -0,0 +1,128 @@ +import AppKit +import Foundation + +protocol ApplicationDuplicateActivationRequestTransport { + func postActivationRequest(name: Notification.Name, bundleIdentifier: String) + func observeActivationRequests( + name: Notification.Name, + bundleIdentifier: String, + handler: @escaping @MainActor @Sendable () -> Void + ) -> NSObjectProtocol + func removeActivationRequestObserver(_ observer: NSObjectProtocol) +} + +protocol ApplicationDuplicateActivationNotificationCenter { + func post(name: Notification.Name, object: String?) + func addObserver( + forName name: Notification.Name?, + object: String?, + queue: OperationQueue?, + using block: @escaping @Sendable (Notification) -> Void + ) -> NSObjectProtocol + func removeObserver(_ observer: NSObjectProtocol) +} + +struct LiveDuplicateActivationNotificationCenter: ApplicationDuplicateActivationNotificationCenter { + private let notificationCenter: DistributedNotificationCenter + + init(notificationCenter: DistributedNotificationCenter = .default()) { + self.notificationCenter = notificationCenter + } + + func post(name: Notification.Name, object: String?) { + notificationCenter.post(name: name, object: object) + } + + func addObserver( + forName name: Notification.Name?, + object: String?, + queue: OperationQueue?, + using block: @escaping @Sendable (Notification) -> Void + ) -> NSObjectProtocol { + notificationCenter.addObserver( + forName: name, + object: object, + queue: queue, + using: block + ) + } + + func removeObserver(_ observer: NSObjectProtocol) { + notificationCenter.removeObserver(observer) + } +} + +struct ApplicationDuplicateActivationCoordinatorDependencies { + let activateCurrentApplication: + @MainActor @Sendable (_ options: NSApplication.ActivationOptions) -> Bool + let currentProcessIdentifier: @MainActor @Sendable () -> pid_t + let runningProcessIdentifiers: @MainActor @Sendable (_ bundleIdentifier: String) -> [pid_t] + let activationRequestTransport: ApplicationDuplicateActivationRequestTransport + + init( + activateCurrentApplication: + @escaping @MainActor @Sendable ( + _ options: NSApplication.ActivationOptions + ) -> Bool = { _ in false }, + currentProcessIdentifier: @escaping @MainActor @Sendable () -> pid_t = { 0 }, + runningProcessIdentifiers: + @escaping @MainActor @Sendable ( + _ bundleIdentifier: String + ) -> [pid_t] = { _ in [] }, + activationRequestTransport: ApplicationDuplicateActivationRequestTransport + ) { + self.activateCurrentApplication = activateCurrentApplication + self.currentProcessIdentifier = currentProcessIdentifier + self.runningProcessIdentifiers = runningProcessIdentifiers + self.activationRequestTransport = activationRequestTransport + } + + static func live() -> Self { + Self( + activateCurrentApplication: { options in + NSRunningApplication.current.activate(options: options) + }, + currentProcessIdentifier: { ProcessInfo.processInfo.processIdentifier }, + runningProcessIdentifiers: { bundleIdentifier in + NSRunningApplication + .runningApplications(withBundleIdentifier: bundleIdentifier) + .map(\.processIdentifier) + }, + activationRequestTransport: DistributedNotificationActivationRequestTransport() + ) + } +} + +struct DistributedNotificationActivationRequestTransport: + ApplicationDuplicateActivationRequestTransport +{ + private let notificationCenter: ApplicationDuplicateActivationNotificationCenter + + init( + notificationCenter: ApplicationDuplicateActivationNotificationCenter = + LiveDuplicateActivationNotificationCenter() + ) { + self.notificationCenter = notificationCenter + } + + func postActivationRequest(name: Notification.Name, bundleIdentifier: String) { + notificationCenter.post(name: name, object: bundleIdentifier) + } + + func observeActivationRequests( + name: Notification.Name, + bundleIdentifier: String, + handler: @escaping @MainActor @Sendable () -> Void + ) -> NSObjectProtocol { + notificationCenter + .addObserver(forName: name, object: bundleIdentifier, queue: .main) { _ in + Task { @MainActor in + handler() + } + } + } + + func removeActivationRequestObserver(_ observer: NSObjectProtocol) { + notificationCenter.removeObserver(observer) + } +} diff --git a/Sources/AppRuntimeKitAppKit/Runtime/ApplicationProcessSnapshot+RunningApplication.swift b/Sources/AppRuntimeKitAppKit/Runtime/ApplicationProcessSnapshot+RunningApplication.swift new file mode 100644 index 0000000..0961f95 --- /dev/null +++ b/Sources/AppRuntimeKitAppKit/Runtime/ApplicationProcessSnapshot+RunningApplication.swift @@ -0,0 +1,13 @@ +import AppRuntimeKit + +extension ApplicationProcessSnapshot { + @MainActor + init(runningApplication: any ApplicationRuntimeRunningApplication) { + self.init( + processIdentifier: runningApplication.processIdentifier, + bundleIdentifier: runningApplication.bundleIdentifier, + displayName: runningApplication.displayName, + isTerminated: runningApplication.isTerminated + ) + } +} diff --git a/Sources/AppRuntimeKitAppKit/Runtime/ApplicationProcessSnapshot+RuntimeProcess.swift b/Sources/AppRuntimeKitAppKit/Runtime/ApplicationProcessSnapshot+RuntimeProcess.swift deleted file mode 100644 index 2420e08..0000000 --- a/Sources/AppRuntimeKitAppKit/Runtime/ApplicationProcessSnapshot+RuntimeProcess.swift +++ /dev/null @@ -1,13 +0,0 @@ -import AppRuntimeKit - -extension ApplicationProcessSnapshot { - @MainActor - init(process: any ApplicationRuntimeProcess) { - self.init( - processIdentifier: process.processIdentifier, - bundleIdentifier: process.bundleIdentifier, - displayName: process.displayName, - isTerminated: process.isTerminated - ) - } -} diff --git a/Sources/AppRuntimeKitAppKit/Runtime/ApplicationRuntime+Activation.swift b/Sources/AppRuntimeKitAppKit/Runtime/ApplicationRuntime+Activation.swift index fc79fcb..43e3c34 100644 --- a/Sources/AppRuntimeKitAppKit/Runtime/ApplicationRuntime+Activation.swift +++ b/Sources/AppRuntimeKitAppKit/Runtime/ApplicationRuntime+Activation.swift @@ -2,21 +2,56 @@ import AppKit import AppRuntimeKit extension ApplicationRuntime { + /// Activates the current application. + public func activateCurrentApplication( + options: NSApplication.ActivationOptions = [] + ) -> ApplicationRequestResult { + Self.applicationRequestResult( + fromApplicationLookupResult: currentApplicationLookupResult(), + request: { Self.requestActivation(for: $0, options: options) } + ) + } + /// Activates a running application represented by a process snapshot. public func activateApplication( _ snapshot: ApplicationProcessSnapshot, options: NSApplication.ActivationOptions = [] - ) -> ApplicationActivationResult { - Self.activationResult(for: runningApplication(matching: snapshot), options: options) + ) -> ApplicationRequestResult { + Self.applicationRequestResult( + fromApplicationLookupResult: runningApplication(matching: snapshot), + request: { Self.requestActivation(for: $0, options: options) } + ) } /// Activates a running application matching the process identifier. public func activateApplication( processIdentifier: pid_t, options: NSApplication.ActivationOptions = [] - ) -> ApplicationActivationResult { - Self.activationResult( - for: runningApplication(matchingProcessIdentifier: processIdentifier), + ) -> ApplicationRequestResult { + Self.applicationRequestResult( + fromApplicationLookupResult: runningApplication(matchingProcessIdentifier: processIdentifier), + request: { Self.requestActivation(for: $0, options: options) } + ) + } + + /// Activates a running application matching the process identifier and expected bundle identifier. + public func activateApplication( + processIdentifier: pid_t, + expectedBundleIdentifier: String, + options: NSApplication.ActivationOptions = [] + ) -> ApplicationRequestResult { + guard + let expectedBundleIdentifier = ApplicationBundleIdentifierInput.trimmedNonEmpty( + expectedBundleIdentifier + ) + else { + return .failed(.invalidBundleIdentifier) + } + return activateApplication( + ApplicationProcessSnapshot( + processIdentifier: processIdentifier, + bundleIdentifier: expectedBundleIdentifier + ), options: options ) } @@ -25,40 +60,21 @@ extension ApplicationRuntime { public func activateApplication( bundleIdentifier: String, options: NSApplication.ActivationOptions = [] - ) -> ApplicationActivationResult { - Self.activationResult( - for: runningApplication(matchingBundleIdentifier: bundleIdentifier), - options: options + ) -> ApplicationRequestResult { + Self.applicationRequestResult( + fromApplicationLookupResult: runningApplication(matchingBundleIdentifier: bundleIdentifier), + request: { Self.requestActivation(for: $0, options: options) } ) } - private static func activationResult( + private static func requestActivation( for application: any ApplicationRuntimeRunningApplication, options: NSApplication.ActivationOptions - ) -> ApplicationActivationResult { - application.activate(options: options) ? .activated : .failed(.requestFailed) - } - - private static func activationResult( - for lookupResult: LookupResult, - options: NSApplication.ActivationOptions - ) -> ApplicationActivationResult { - switch lookupResult { - case let .success(application): - activationResult(for: application, options: options) - case let .failure(error): - activationResult(for: error) - } + ) -> ApplicationRequestResult { + requestResult( + isAccepted: application.activate(options: options), + rejectedError: .requestRejected + ) } - private static func activationResult( - for error: LookupError - ) -> ApplicationActivationResult { - switch error { - case .invalidBundleIdentifier: - .failed(.invalidBundleIdentifier) - case .applicationNotFound: - .failed(.applicationNotFound) - } - } } diff --git a/Sources/AppRuntimeKitAppKit/Runtime/ApplicationRuntime+EventNotifications.swift b/Sources/AppRuntimeKitAppKit/Runtime/ApplicationRuntime+EventNotifications.swift deleted file mode 100644 index 3acdfe6..0000000 --- a/Sources/AppRuntimeKitAppKit/Runtime/ApplicationRuntime+EventNotifications.swift +++ /dev/null @@ -1,61 +0,0 @@ -import AppKit -import AppRuntimeKit -import Foundation - -extension ApplicationRuntime { - nonisolated static func applicationSnapshot( - from notification: Notification - ) -> ApplicationProcessSnapshot? { - guard - let app = notification.userInfo?[NSWorkspace.applicationUserInfoKey] - as? NSRunningApplication - else { return nil } - return ApplicationProcessSnapshot( - processIdentifier: app.processIdentifier, - bundleIdentifier: app.bundleIdentifier, - displayName: app.localizedName, - isTerminated: app.isTerminated - ) - } - - nonisolated static func eventNotificationName( - for kind: ApplicationRuntimeEvent.Kind - ) -> Notification.Name { - switch kind { - case .launched: - NSWorkspace.didLaunchApplicationNotification - case .terminated: - NSWorkspace.didTerminateApplicationNotification - case .activated: - NSWorkspace.didActivateApplicationNotification - case .deactivated: - NSWorkspace.didDeactivateApplicationNotification - } - } - - final class EventNotificationObservation: @unchecked Sendable { - private let center: NotificationCenter - private let lock = NSLock() - private var tokens: [NSObjectProtocol] - - init(center: NotificationCenter, tokens: [NSObjectProtocol]) { - self.center = center - self.tokens = tokens - } - - func cancel() { - lock.lock() - let tokens = self.tokens - self.tokens = [] - lock.unlock() - - for token in tokens { - center.removeObserver(token) - } - } - - deinit { - cancel() - } - } -} diff --git a/Sources/AppRuntimeKitAppKit/Runtime/ApplicationRuntime+EventWaiting.swift b/Sources/AppRuntimeKitAppKit/Runtime/ApplicationRuntime+EventWaiting.swift deleted file mode 100644 index 41d6c3c..0000000 --- a/Sources/AppRuntimeKitAppKit/Runtime/ApplicationRuntime+EventWaiting.swift +++ /dev/null @@ -1,109 +0,0 @@ -import AppRuntimeKit - -extension ApplicationRuntime { - /// Waits for an application launch event matching the bundle identifier. - public func waitForApplicationLaunch( - bundleIdentifier: String, - timeout: Duration? = nil - ) async -> ApplicationProcessSnapshot? { - await waitForApplicationSnapshotEvent( - .launched, - bundleIdentifier: bundleIdentifier, - timeout: timeout - ) - } - - /// Waits for an application termination event matching the bundle identifier. - public func waitForApplicationTermination( - bundleIdentifier: String, - timeout: Duration? = nil - ) async -> ApplicationProcessSnapshot? { - await waitForApplicationSnapshotEvent( - .terminated, - bundleIdentifier: bundleIdentifier, - timeout: timeout - ) - } - - /// Waits for an application activation event matching the bundle identifier. - public func waitForApplicationActivation( - bundleIdentifier: String, - timeout: Duration? = nil - ) async -> ApplicationProcessSnapshot? { - await waitForApplicationSnapshotEvent( - .activated, - bundleIdentifier: bundleIdentifier, - timeout: timeout - ) - } - - /// Waits for an application deactivation event matching the bundle identifier. - public func waitForApplicationDeactivation( - bundleIdentifier: String, - timeout: Duration? = nil - ) async -> ApplicationProcessSnapshot? { - await waitForApplicationSnapshotEvent( - .deactivated, - bundleIdentifier: bundleIdentifier, - timeout: timeout - ) - } - - /// Waits for an application lifecycle event matching the event kind and bundle identifier. - public func waitForApplicationEvent( - _ kind: ApplicationRuntimeEvent.Kind, - bundleIdentifier: String, - timeout: Duration? = nil - ) async -> ApplicationRuntimeEvent? { - guard let bundleIdentifier = ApplicationBundleIdentifierInput.trimmedNonEmpty(bundleIdentifier) - else { - return nil - } - return await waitForMatchingApplicationEvent( - kind: kind, - bundleIdentifier: bundleIdentifier, - stream: eventStream(kinds: [kind]), - timeout: timeout - ) - } -} - -private extension ApplicationRuntime { - func waitForApplicationSnapshotEvent( - _ kind: ApplicationRuntimeEvent.Kind, - bundleIdentifier: String, - timeout: Duration? - ) async -> ApplicationProcessSnapshot? { - await waitForApplicationEvent( - kind, - bundleIdentifier: bundleIdentifier, - timeout: timeout - )?.application - } -} - -private func waitForMatchingApplicationEvent( - kind: ApplicationRuntimeEvent.Kind, - bundleIdentifier: String, - stream: AsyncStream, - timeout: Duration? -) async -> ApplicationRuntimeEvent? { - await withTaskGroup(of: ApplicationRuntimeEvent?.self) { group in - group.addTask { - for await event in stream - where event.kind == kind && event.application.bundleIdentifier == bundleIdentifier { - return event - } - return nil - } - if let timeout { - group.addTask { - try? await Task.sleep(for: timeout) - return nil - } - } - let result = await group.next() ?? nil - group.cancelAll() - return result - } -} diff --git a/Sources/AppRuntimeKitAppKit/Runtime/ApplicationRuntime+Events.swift b/Sources/AppRuntimeKitAppKit/Runtime/ApplicationRuntime+Events.swift index 769679e..0098265 100644 --- a/Sources/AppRuntimeKitAppKit/Runtime/ApplicationRuntime+Events.swift +++ b/Sources/AppRuntimeKitAppKit/Runtime/ApplicationRuntime+Events.swift @@ -1,94 +1,161 @@ -import AppKit import AppRuntimeKit -import Foundation extension ApplicationRuntime { /// Returns an async stream of application lifecycle events. public func events() -> AsyncStream { - eventStream(kinds: ApplicationRuntimeEvent.Kind.allCases) + applicationEvents(lifecycles: Set(ApplicationRuntimeEvent.Lifecycle.allCases)) } /// Returns an async stream of application launch events. public func launchEvents() -> AsyncStream { - snapshotStream( - name: NSWorkspace.didLaunchApplicationNotification - ) + applicationSnapshots(lifecycle: .launched) } /// Returns an async stream of application termination events. public func terminationEvents() -> AsyncStream { - snapshotStream( - name: NSWorkspace.didTerminateApplicationNotification - ) + applicationSnapshots(lifecycle: .terminated) } /// Returns an async stream of application activation events. public func activationEvents() -> AsyncStream { - snapshotStream( - name: NSWorkspace.didActivateApplicationNotification - ) + applicationSnapshots(lifecycle: .activated) } /// Returns an async stream of application deactivation events. public func deactivationEvents() -> AsyncStream { - snapshotStream( - name: NSWorkspace.didDeactivateApplicationNotification + applicationSnapshots(lifecycle: .deactivated) + } + + /// Waits for an application launch event matching the bundle identifier. + public func waitForApplicationLaunch( + bundleIdentifier: String, + timeout: Duration? = nil + ) async -> ApplicationProcessSnapshot? { + await waitForApplicationSnapshotEvent( + .launched, + bundleIdentifier: bundleIdentifier, + timeout: timeout + ) + } + + /// Waits for an application termination event matching the bundle identifier. + public func waitForApplicationTermination( + bundleIdentifier: String, + timeout: Duration? = nil + ) async -> ApplicationProcessSnapshot? { + await waitForApplicationSnapshotEvent( + .terminated, + bundleIdentifier: bundleIdentifier, + timeout: timeout + ) + } + + /// Waits for an application activation event matching the bundle identifier. + public func waitForApplicationActivation( + bundleIdentifier: String, + timeout: Duration? = nil + ) async -> ApplicationProcessSnapshot? { + await waitForApplicationSnapshotEvent( + .activated, + bundleIdentifier: bundleIdentifier, + timeout: timeout + ) + } + + /// Waits for an application deactivation event matching the bundle identifier. + public func waitForApplicationDeactivation( + bundleIdentifier: String, + timeout: Duration? = nil + ) async -> ApplicationProcessSnapshot? { + await waitForApplicationSnapshotEvent( + .deactivated, + bundleIdentifier: bundleIdentifier, + timeout: timeout ) } - private func snapshotStream(name: Notification.Name) -> AsyncStream< - ApplicationProcessSnapshot - > { - AsyncStream { continuation in - let token = workspace.notificationCenter.addObserver( - forName: name, - object: nil, - queue: .main - ) { notification in - guard let snapshot = Self.applicationSnapshot(from: notification) else { - return + /// Waits for an application lifecycle event matching the lifecycle and bundle identifier. + public func waitForApplicationEvent( + _ lifecycle: ApplicationRuntimeEvent.Lifecycle, + bundleIdentifier: String, + timeout: Duration? = nil + ) async -> ApplicationRuntimeEvent? { + guard let bundleIdentifier = ApplicationBundleIdentifierInput.trimmedNonEmpty(bundleIdentifier) + else { + return nil + } + return await waitForMatchingApplicationEvent( + lifecycle: lifecycle, + bundleIdentifier: bundleIdentifier, + timeout: timeout + ) + } +} + +private extension ApplicationRuntime { + func waitForApplicationSnapshotEvent( + _ lifecycle: ApplicationRuntimeEvent.Lifecycle, + bundleIdentifier: String, + timeout: Duration? + ) async -> ApplicationProcessSnapshot? { + await waitForApplicationEvent( + lifecycle, + bundleIdentifier: bundleIdentifier, + timeout: timeout + )?.application + } + + var eventSource: any ApplicationRuntimeEventSource { + dependencies.eventSource(workspace) + } + + func applicationSnapshots( + lifecycle: ApplicationRuntimeEvent.Lifecycle + ) -> AsyncStream { + AsyncStream { continuation in + let task = Task { + for await event in applicationEvents(lifecycles: Set([lifecycle])) { + continuation.yield(event.application) } - continuation.yield(snapshot) + continuation.finish() } - - let observer = EventNotificationObservation( - center: workspace.notificationCenter, - tokens: [token] - ) continuation.onTermination = { _ in - observer.cancel() + task.cancel() } } } - func eventStream(kinds: [ApplicationRuntimeEvent.Kind]) -> AsyncStream { - AsyncStream { continuation in - var tokens: [NSObjectProtocol] = [] - for kind in kinds { - let token = workspace.notificationCenter.addObserver( - forName: Self.eventNotificationName(for: kind), - object: nil, - queue: .main - ) { notification in - guard let snapshot = Self.applicationSnapshot(from: notification) else { - return - } - continuation.yield( - ApplicationRuntimeEvent( - kind: kind, - application: snapshot - ) - ) + func applicationEvents( + lifecycles: Set + ) -> AsyncStream { + eventSource.eventStream(lifecycles: lifecycles) + } + + func waitForMatchingApplicationEvent( + lifecycle: ApplicationRuntimeEvent.Lifecycle, + bundleIdentifier: String, + timeout: Duration? + ) async -> ApplicationRuntimeEvent? { + let stream = applicationEvents(lifecycles: Set([lifecycle])) + return await withTaskGroup(of: ApplicationRuntimeEvent?.self) { group in + group.addTask { + for await event in stream + where event.lifecycle == lifecycle + && event.application.bundleIdentifier == bundleIdentifier + { + return event } - tokens.append(token) + return nil } - let observer = EventNotificationObservation( - center: workspace.notificationCenter, - tokens: tokens - ) - continuation.onTermination = { _ in - observer.cancel() + if let timeout { + group.addTask { + try? await Task.sleep(for: timeout) + return nil + } } + let result = await group.next() ?? nil + group.cancelAll() + return result } } } diff --git a/Sources/AppRuntimeKitAppKit/Runtime/ApplicationRuntime+Lookup.swift b/Sources/AppRuntimeKitAppKit/Runtime/ApplicationRuntime+Lookup.swift new file mode 100644 index 0000000..66a216e --- /dev/null +++ b/Sources/AppRuntimeKitAppKit/Runtime/ApplicationRuntime+Lookup.swift @@ -0,0 +1,154 @@ +import AppRuntimeKit +import Darwin + +extension ApplicationRuntime { + enum ApplicationLookupError: Error { + case invalidProcessIdentifier + case invalidBundleIdentifier + case applicationNotFound + case applicationTerminated + case applicationBundleIdentifierUnavailable + case applicationBundleIdentifierMismatch + + var requestError: ApplicationRuntimeRequestError { + switch self { + case .invalidProcessIdentifier: + .invalidProcessIdentifier + case .invalidBundleIdentifier: + .invalidBundleIdentifier + case .applicationNotFound: + .applicationNotFound + case .applicationTerminated: + .applicationTerminated + case .applicationBundleIdentifierUnavailable: + .applicationBundleIdentifierUnavailable + case .applicationBundleIdentifierMismatch: + .applicationBundleIdentifierMismatch + } + } + } + + static func applicationRequestResult( + fromApplicationLookupResult applicationLookupResult: Result< + any ApplicationRuntimeRunningApplication, + ApplicationLookupError + >, + request: (any ApplicationRuntimeRunningApplication) -> ApplicationRequestResult< + ApplicationRuntimeRequestError + > + ) -> ApplicationRequestResult { + switch applicationLookupResult { + case let .success(application): + request(application) + case let .failure(error): + .failed(error.requestError) + } + } + + func currentApplicationLookupResult() + -> Result + { + guard let application = dependencies.currentApplication() else { + return .failure(.applicationNotFound) + } + guard application.processIdentifier > 0 else { + return .failure(.invalidProcessIdentifier) + } + guard !application.isTerminated else { + return .failure(.applicationTerminated) + } + return .success(application) + } + + func runningApplication( + matchingProcessIdentifier processIdentifier: pid_t + ) -> Result { + guard processIdentifier > 0 else { + return .failure(.invalidProcessIdentifier) + } + guard let application = dependencies.runningApplication(processIdentifier), + application.processIdentifier == processIdentifier + else { + return .failure(.applicationNotFound) + } + guard !application.isTerminated else { + return .failure(.applicationTerminated) + } + return .success(application) + } + + func runningApplication( + matching snapshot: ApplicationProcessSnapshot + ) -> Result { + guard snapshot.processIdentifier > 0 else { + return .failure(.invalidProcessIdentifier) + } + guard !snapshot.isTerminated else { + return .failure(.applicationTerminated) + } + let bundleIdentifier: String? + if let snapshotBundleIdentifier = snapshot.bundleIdentifier { + guard + let trimmedBundleIdentifier = ApplicationBundleIdentifierInput.trimmedNonEmpty( + snapshotBundleIdentifier + ) + else { + return .failure(.invalidBundleIdentifier) + } + bundleIdentifier = trimmedBundleIdentifier + } else { + bundleIdentifier = nil + } + let application: any ApplicationRuntimeRunningApplication + switch runningApplication(matchingProcessIdentifier: snapshot.processIdentifier) { + case let .success(runningApplication): + application = runningApplication + case let .failure(error): + return .failure(error) + } + if let bundleIdentifier, + application.bundleIdentifier != bundleIdentifier + { + guard application.bundleIdentifier != nil else { + return .failure(.applicationBundleIdentifierUnavailable) + } + return .failure(.applicationBundleIdentifierMismatch) + } + return .success(application) + } + + func runningApplication( + matchingBundleIdentifier bundleIdentifier: String + ) -> Result { + guard let bundleIdentifier = ApplicationBundleIdentifierInput.trimmedNonEmpty(bundleIdentifier) + else { + return .failure(.invalidBundleIdentifier) + } + guard let application = runningApplications(matchingBundleIdentifier: bundleIdentifier).first + else { + return .failure(.applicationNotFound) + } + return .success(application) + } + + static func snapshot( + for application: any ApplicationRuntimeRunningApplication + ) -> ApplicationProcessSnapshot? { + guard application.processIdentifier > 0, !application.isTerminated else { + return nil + } + return ApplicationProcessSnapshot(runningApplication: application) + } + + func runningApplications( + matchingBundleIdentifier bundleIdentifier: String + ) -> [any ApplicationRuntimeRunningApplication] { + dependencies + .runningApplications(bundleIdentifier) + .filter { + $0.processIdentifier > 0 + && !$0.isTerminated + && $0.bundleIdentifier == bundleIdentifier + } + } +} diff --git a/Sources/AppRuntimeKitAppKit/Runtime/ApplicationRuntime+Opening.swift b/Sources/AppRuntimeKitAppKit/Runtime/ApplicationRuntime+Opening.swift index 43dbfa7..c1fc664 100644 --- a/Sources/AppRuntimeKitAppKit/Runtime/ApplicationRuntime+Opening.swift +++ b/Sources/AppRuntimeKitAppKit/Runtime/ApplicationRuntime+Opening.swift @@ -4,61 +4,74 @@ import Foundation extension ApplicationRuntime { /// Validates and opens the URL through `NSWorkspace`. - public func openURL(_ url: URL) -> ApplicationOpenResult { - if let failure = Self.urlValidationFailure(for: url) { return .failed(failure) } - return Self.openResult(isAccepted: dependencies.openURL(workspace, url)) + public func openURL(_ url: URL) -> ApplicationRequestResult { + if let validationError = Self.urlValidationError(for: url) { + return .failed(validationError) + } + return requestOpening(url) } /// Validates and opens the file URL through `NSWorkspace`. - public func openFile(_ url: URL) -> ApplicationOpenResult { - if let failure = Self.fileURLValidationFailure(for: url) { return .failed(failure) } - return Self.openResult(isAccepted: dependencies.openURL(workspace, url)) + public func openFile(_ url: URL) -> ApplicationRequestResult { + if let validationError = fileURLValidationError(for: url) { + return .failed(validationError) + } + return requestOpening(url) } /// Validates and opens an installed application matching the bundle identifier. - public func openApplication(bundleIdentifier: String) -> ApplicationOpenResult { + public func openApplication(bundleIdentifier: String) -> ApplicationRequestResult< + ApplicationOpeningError + > { switch validatedInstalledApplicationURL(bundleIdentifier: bundleIdentifier) { case let .success(appURL): - return Self.openResult(isAccepted: dependencies.openApplicationURL(workspace, appURL)) + return requestOpening(appURL) case let .failure(error): return .failed(error) } } /// Validates and opens the application bundle at the file URL. - public func openApplication(at url: URL) -> ApplicationOpenResult { - if let failure = Self.applicationBundleURLValidationFailure(for: url) { - return .failed(failure) + public func openApplication(at url: URL) -> ApplicationRequestResult { + if let validationError = applicationBundleURLValidationError(for: url) { + return .failed(validationError) } - return Self.openResult(isAccepted: dependencies.openApplicationURL(workspace, url)) + return requestOpening(url) } /// Validates and opens the URL asynchronously through `NSWorkspace`. public func openURL( _ url: URL, configuration: NSWorkspace.OpenConfiguration - ) async -> ApplicationOpenResult { - if let failure = Self.urlValidationFailure(for: url) { return .failed(failure) } - return await openURLAsync(url, configuration: configuration) + ) async -> ApplicationRequestResult { + if let validationError = Self.urlValidationError(for: url) { + return .failed(validationError) + } + return await requestConfiguredURLOpening(url, configuration: configuration) } /// Validates and opens the file URL asynchronously through `NSWorkspace`. public func openFile( _ url: URL, configuration: NSWorkspace.OpenConfiguration - ) async -> ApplicationOpenResult { - if let failure = Self.fileURLValidationFailure(for: url) { return .failed(failure) } - return await openURLAsync(url, configuration: configuration) + ) async -> ApplicationRequestResult { + if let validationError = fileURLValidationError(for: url) { + return .failed(validationError) + } + return await requestConfiguredURLOpening(url, configuration: configuration) } /// Validates and opens an installed application asynchronously. public func openApplication( bundleIdentifier: String, configuration: NSWorkspace.OpenConfiguration - ) async -> ApplicationOpenResult { + ) async -> ApplicationRequestResult { switch validatedInstalledApplicationURL(bundleIdentifier: bundleIdentifier) { case let .success(appURL): - return await openApplicationAsync(at: appURL, configuration: configuration) + return await requestConfiguredApplicationOpening( + at: appURL, + configuration: configuration + ) case let .failure(error): return .failed(error) } @@ -68,58 +81,45 @@ extension ApplicationRuntime { public func openApplication( at url: URL, configuration: NSWorkspace.OpenConfiguration - ) async -> ApplicationOpenResult { - if let failure = Self.applicationBundleURLValidationFailure(for: url) { - return .failed(failure) + ) async -> ApplicationRequestResult { + if let validationError = applicationBundleURLValidationError(for: url) { + return .failed(validationError) } - return await openApplicationAsync(at: url, configuration: configuration) + return await requestConfiguredApplicationOpening(at: url, configuration: configuration) } - private static func urlValidationFailure(for url: URL) -> ApplicationOpenError? { - url.scheme == nil ? .invalidURL : nil + private static func urlValidationError(for url: URL) -> ApplicationOpeningError? { + url.scheme == nil ? .missingURLScheme : nil } - private static func fileURLValidationFailure(for url: URL) -> ApplicationOpenError? { - guard url.isFileURL else { return .invalidFileURL } - return FileManager.default.fileExists(atPath: url.path) ? nil : .fileNotFound + private func fileURLValidationError(for url: URL) -> ApplicationOpeningError? { + guard url.isFileURL else { return .nonFileURL } + return dependencies.fileExists(url) ? nil : .fileSystemItemNotFound } - private static func applicationBundleURLValidationFailure(for url: URL) - -> ApplicationOpenError? + private func applicationBundleURLValidationError(for url: URL) + -> ApplicationOpeningError? { - guard url.isFileURL else { return .invalidApplicationURL } - guard url.pathExtension.lowercased() == "app" else { return .invalidApplicationURL } - - var isDirectory: ObjCBool = false - guard FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory) else { - return .applicationNotFound + guard url.isFileURL else { return .invalidApplicationBundleURL } + guard url.pathExtension.lowercased() == "app" else { return .invalidApplicationBundleURL } + guard dependencies.fileExists(url) else { + return .applicationBundleNotFound } - guard isDirectory.boolValue else { return .invalidApplicationURL } + guard dependencies.directoryExists(url) else { return .invalidApplicationBundleURL } guard - let bundle = Bundle(url: url), - bundle.object(forInfoDictionaryKey: "CFBundlePackageType") as? String == "APPL", - let executableURL = bundle.executableURL, - Self.isExecutableFile(at: executableURL) + let bundleInfo = dependencies.applicationBundleInfo(url), + bundleInfo.packageType == "APPL", + let executableURL = bundleInfo.executableURL, + dependencies.executableFileExists(executableURL) else { - return .invalidApplicationURL + return .invalidApplicationBundleURL } return nil } - private static func isExecutableFile(at url: URL) -> Bool { - guard url.isFileURL else { return false } - - var isDirectory: ObjCBool = false - guard FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory) else { - return false - } - guard !isDirectory.boolValue else { return false } - return FileManager.default.isExecutableFile(atPath: url.path) - } - private func validatedInstalledApplicationURL( bundleIdentifier: String - ) -> Result { + ) -> Result { guard let bundleIdentifier = ApplicationBundleIdentifierInput.trimmedNonEmpty(bundleIdentifier) else { return .failure(.invalidBundleIdentifier) @@ -127,31 +127,48 @@ extension ApplicationRuntime { guard let appURL = dependencies.installedApplicationURL(workspace, bundleIdentifier) else { - return .failure(.applicationNotFound) + return .failure(.installedApplicationNotFound) } - if let failure = Self.applicationBundleURLValidationFailure(for: appURL) { - return .failure(failure) + if let validationError = applicationBundleURLValidationError(for: appURL) { + return .failure(validationError) } return .success(appURL) } - private func openURLAsync(_ url: URL, configuration: NSWorkspace.OpenConfiguration) async - -> ApplicationOpenResult - { - await Self.openResult( - isAccepted: dependencies.openURLWithConfiguration(workspace, url, configuration) + private func requestOpening(_ url: URL) -> ApplicationRequestResult { + Self.requestResult( + isAccepted: dependencies.requestOpening(workspace, url), + rejectedError: .requestRejected ) } - private func openApplicationAsync(at url: URL, configuration: NSWorkspace.OpenConfiguration) async - -> ApplicationOpenResult + private func requestConfiguredURLOpening( + _ url: URL, + configuration: NSWorkspace.OpenConfiguration + ) async + -> ApplicationRequestResult { - await Self.openResult( - isAccepted: dependencies.openApplicationURLWithConfiguration(workspace, url, configuration) + let isAccepted = await dependencies.requestConfiguredURLOpening(workspace, url, configuration) + return Self.requestResult( + isAccepted: isAccepted, + rejectedError: .requestRejected ) } - private static func openResult(isAccepted: Bool) -> ApplicationOpenResult { - isAccepted ? .opened : .failed(.requestFailed) + private func requestConfiguredApplicationOpening( + at url: URL, + configuration: NSWorkspace.OpenConfiguration + ) async + -> ApplicationRequestResult + { + let isAccepted = await dependencies.requestConfiguredApplicationOpening( + workspace, + url, + configuration + ) + return Self.requestResult( + isAccepted: isAccepted, + rejectedError: .requestRejected + ) } } diff --git a/Sources/AppRuntimeKitAppKit/Runtime/ApplicationRuntime+Queries.swift b/Sources/AppRuntimeKitAppKit/Runtime/ApplicationRuntime+Queries.swift new file mode 100644 index 0000000..26c7d01 --- /dev/null +++ b/Sources/AppRuntimeKitAppKit/Runtime/ApplicationRuntime+Queries.swift @@ -0,0 +1,45 @@ +import AppRuntimeKit +import Darwin +import Foundation + +extension ApplicationRuntime { + /// Returns a snapshot for the current application, if it is available. + public func currentApplication() -> ApplicationProcessSnapshot? { + dependencies.currentApplication().flatMap(Self.snapshot) + } + + /// Returns a snapshot for a running process identifier. + public func runningApplication(processIdentifier: pid_t) -> ApplicationProcessSnapshot? { + switch runningApplication(matchingProcessIdentifier: processIdentifier) { + case let .success(application): + return Self.snapshot(for: application) + case .failure: + return nil + } + } + + /// Returns running applications matching the bundle identifier. + public func runningApplications(bundleIdentifier: String) -> [ApplicationProcessSnapshot] { + guard let bundleIdentifier = ApplicationBundleIdentifierInput.trimmedNonEmpty(bundleIdentifier) + else { + return [] + } + return + runningApplications(matchingBundleIdentifier: bundleIdentifier) + .compactMap(Self.snapshot) + } + + /// Returns the current frontmost application, if one is available. + public func frontmostApplication() -> ApplicationProcessSnapshot? { + dependencies.frontmostApplication(workspace).flatMap(Self.snapshot) + } + + /// Returns the installed application URL matching the bundle identifier. + public func installedApplicationURL(bundleIdentifier: String) -> URL? { + guard let bundleIdentifier = ApplicationBundleIdentifierInput.trimmedNonEmpty(bundleIdentifier) + else { + return nil + } + return dependencies.installedApplicationURL(workspace, bundleIdentifier) + } +} diff --git a/Sources/AppRuntimeKitAppKit/Runtime/ApplicationRuntime+Termination.swift b/Sources/AppRuntimeKitAppKit/Runtime/ApplicationRuntime+Termination.swift index da270ef..6b9564d 100644 --- a/Sources/AppRuntimeKitAppKit/Runtime/ApplicationRuntime+Termination.swift +++ b/Sources/AppRuntimeKitAppKit/Runtime/ApplicationRuntime+Termination.swift @@ -6,17 +6,42 @@ extension ApplicationRuntime { public func terminateApplication( _ snapshot: ApplicationProcessSnapshot, mode: ApplicationTerminationMode = .graceful - ) -> ApplicationTerminationResult { - Self.terminationResult(for: runningApplication(matching: snapshot), mode: mode) + ) -> ApplicationRequestResult { + Self.applicationRequestResult( + fromApplicationLookupResult: runningApplication(matching: snapshot), + request: { Self.requestTermination(for: $0, mode: mode) } + ) } /// Requests termination of a running application matching the process identifier. public func terminateApplication( processIdentifier: pid_t, mode: ApplicationTerminationMode = .graceful - ) -> ApplicationTerminationResult { - Self.terminationResult( - for: runningApplication(matchingProcessIdentifier: processIdentifier), + ) -> ApplicationRequestResult { + Self.applicationRequestResult( + fromApplicationLookupResult: runningApplication(matchingProcessIdentifier: processIdentifier), + request: { Self.requestTermination(for: $0, mode: mode) } + ) + } + + /// Requests termination of a running application after validating its bundle identifier. + public func terminateApplication( + processIdentifier: pid_t, + expectedBundleIdentifier: String, + mode: ApplicationTerminationMode = .graceful + ) -> ApplicationRequestResult { + guard + let expectedBundleIdentifier = ApplicationBundleIdentifierInput.trimmedNonEmpty( + expectedBundleIdentifier + ) + else { + return .failed(.invalidBundleIdentifier) + } + return terminateApplication( + ApplicationProcessSnapshot( + processIdentifier: processIdentifier, + bundleIdentifier: expectedBundleIdentifier + ), mode: mode ) } @@ -25,17 +50,17 @@ extension ApplicationRuntime { public func terminateApplication( bundleIdentifier: String, mode: ApplicationTerminationMode = .graceful - ) -> ApplicationTerminationResult { - Self.terminationResult( - for: runningApplication(matchingBundleIdentifier: bundleIdentifier), - mode: mode + ) -> ApplicationRequestResult { + Self.applicationRequestResult( + fromApplicationLookupResult: runningApplication(matchingBundleIdentifier: bundleIdentifier), + request: { Self.requestTermination(for: $0, mode: mode) } ) } - private static func terminationResult( + private static func requestTermination( for application: any ApplicationRuntimeRunningApplication, mode: ApplicationTerminationMode - ) -> ApplicationTerminationResult { + ) -> ApplicationRequestResult { let isAccepted = switch mode { case .graceful: @@ -43,29 +68,6 @@ extension ApplicationRuntime { case .forced: application.requestForcedTermination() } - return isAccepted ? .requested : .failed(.requestFailed) - } - - private static func terminationResult( - for lookupResult: LookupResult, - mode: ApplicationTerminationMode - ) -> ApplicationTerminationResult { - switch lookupResult { - case let .success(application): - terminationResult(for: application, mode: mode) - case let .failure(error): - terminationResult(for: error) - } - } - - private static func terminationResult( - for error: LookupError - ) -> ApplicationTerminationResult { - switch error { - case .invalidBundleIdentifier: - .failed(.invalidBundleIdentifier) - case .applicationNotFound: - .failed(.applicationNotFound) - } + return requestResult(isAccepted: isAccepted, rejectedError: .requestRejected) } } diff --git a/Sources/AppRuntimeKitAppKit/Runtime/ApplicationRuntime.swift b/Sources/AppRuntimeKitAppKit/Runtime/ApplicationRuntime.swift index 63a662f..8b025e2 100644 --- a/Sources/AppRuntimeKitAppKit/Runtime/ApplicationRuntime.swift +++ b/Sources/AppRuntimeKitAppKit/Runtime/ApplicationRuntime.swift @@ -1,17 +1,9 @@ import AppKit import AppRuntimeKit -import Foundation /// Provides AppKit-backed application runtime operations. @MainActor public struct ApplicationRuntime { - enum LookupError: Error { - case invalidBundleIdentifier - case applicationNotFound - } - - typealias LookupResult = Result - let workspace: NSWorkspace let dependencies: ApplicationRuntimeDependencies @@ -28,137 +20,10 @@ public struct ApplicationRuntime { self.dependencies = dependencies } - /// Returns a snapshot for the current application, if it is available. - public func currentApplication() -> ApplicationProcessSnapshot? { - guard - let application = dependencies.currentApplication(), - application.processIdentifier > 0, - !application.isTerminated - else { - return nil - } - return ApplicationProcessSnapshot(process: application) - } - - /// Returns a snapshot for a running process identifier. - public func runningApplication(processIdentifier: pid_t) -> ApplicationProcessSnapshot? { - switch runningApplication(matchingProcessIdentifier: processIdentifier) { - case let .success(application): - return ApplicationProcessSnapshot(process: application) - case .failure: - return nil - } - } - - /// Returns running applications matching the bundle identifier. - public func runningApplications(bundleIdentifier: String) -> [ApplicationProcessSnapshot] { - guard let bundleIdentifier = ApplicationBundleIdentifierInput.trimmedNonEmpty(bundleIdentifier) - else { - return [] - } - return - runningApplications(matchingBundleIdentifier: bundleIdentifier) - .map { ApplicationProcessSnapshot(process: $0) } - } - - /// Returns the current frontmost application, if one is available. - public func frontmostApplication() -> ApplicationProcessSnapshot? { - guard - let application = dependencies.frontmostApplication(workspace), - application.processIdentifier > 0, - !application.isTerminated - else { - return nil - } - return ApplicationProcessSnapshot(process: application) - } - - /// Returns the installed application URL matching the bundle identifier. - public func installedApplicationURL(bundleIdentifier: String) -> URL? { - guard let bundleIdentifier = ApplicationBundleIdentifierInput.trimmedNonEmpty(bundleIdentifier) - else { - return nil - } - return dependencies.installedApplicationURL(workspace, bundleIdentifier) - } - - func runningApplication( - matchingProcessIdentifier processIdentifier: pid_t - ) -> LookupResult { - guard - processIdentifier > 0, - let application = dependencies.runningApplication(processIdentifier), - application.processIdentifier == processIdentifier, - !application.isTerminated - else { - return .failure(.applicationNotFound) - } - return .success(application) - } - - func runningApplication( - matching snapshot: ApplicationProcessSnapshot - ) -> LookupResult { - guard snapshot.processIdentifier > 0, !snapshot.isTerminated else { - return .failure(.applicationNotFound) - } - let bundleIdentifier: String? - if let snapshotBundleIdentifier = snapshot.bundleIdentifier { - guard - let trimmedBundleIdentifier = ApplicationBundleIdentifierInput.trimmedNonEmpty( - snapshotBundleIdentifier - ) - else { - return .failure(.invalidBundleIdentifier) - } - bundleIdentifier = trimmedBundleIdentifier - } else { - bundleIdentifier = nil - } - let application: any ApplicationRuntimeRunningApplication - switch runningApplication(matchingProcessIdentifier: snapshot.processIdentifier) { - case let .success(runningApplication): - application = runningApplication - case let .failure(error): - return .failure(error) - } - if let bundleIdentifier, - application.bundleIdentifier != bundleIdentifier - { - return .failure(.applicationNotFound) - } - return .success(application) - } - - func runningApplications( - matchingBundleIdentifier bundleIdentifier: String - ) -> [any ApplicationRuntimeRunningApplication] { - dependencies - .runningApplications(bundleIdentifier) - .filter { - $0.processIdentifier > 0 - && !$0.isTerminated - && $0.bundleIdentifier == bundleIdentifier - } - } - - func firstRunningApplication( - matchingBundleIdentifier bundleIdentifier: String - ) -> (any ApplicationRuntimeRunningApplication)? { - runningApplications(matchingBundleIdentifier: bundleIdentifier).first - } - - func runningApplication( - matchingBundleIdentifier bundleIdentifier: String - ) -> LookupResult { - guard let bundleIdentifier = ApplicationBundleIdentifierInput.trimmedNonEmpty(bundleIdentifier) - else { - return .failure(.invalidBundleIdentifier) - } - guard let application = firstRunningApplication(matchingBundleIdentifier: bundleIdentifier) - else { - return .failure(.applicationNotFound) - } - return .success(application) + static func requestResult( + isAccepted: Bool, + rejectedError: Failure + ) -> ApplicationRequestResult { + isAccepted ? .requestAccepted : .failed(rejectedError) } } diff --git a/Sources/AppRuntimeKitAppKit/Runtime/ApplicationRuntimeDependencies.swift b/Sources/AppRuntimeKitAppKit/Runtime/ApplicationRuntimeDependencies.swift index 34103ea..dad30ab 100644 --- a/Sources/AppRuntimeKitAppKit/Runtime/ApplicationRuntimeDependencies.swift +++ b/Sources/AppRuntimeKitAppKit/Runtime/ApplicationRuntimeDependencies.swift @@ -1,55 +1,108 @@ import AppKit -import AppRuntimeKit import Foundation +struct ApplicationBundleInfo: Sendable, Equatable { + let packageType: String? + let executableURL: URL? +} + struct ApplicationRuntimeDependencies { - typealias CurrentApplicationProvider = @MainActor @Sendable () -> (any ApplicationRuntimeProcess)? - typealias RunningApplicationProvider = - @MainActor @Sendable (_ processIdentifier: pid_t) -> (any ApplicationRuntimeRunningApplication)? - typealias RunningApplicationsProvider = + let currentApplication: @MainActor @Sendable () -> (any ApplicationRuntimeRunningApplication)? + let runningApplication: + @MainActor @Sendable (_ processIdentifier: pid_t) -> ( + any ApplicationRuntimeRunningApplication + )? + let runningApplications: @MainActor @Sendable (_ bundleIdentifier: String) -> [any ApplicationRuntimeRunningApplication] - typealias FrontmostApplicationProvider = - @MainActor @Sendable (_ workspace: NSWorkspace) -> (any ApplicationRuntimeProcess)? - typealias InstalledApplicationURLProvider = + let frontmostApplication: + @MainActor @Sendable (_ workspace: NSWorkspace) -> (any ApplicationRuntimeRunningApplication)? + let installedApplicationURL: @MainActor @Sendable (_ workspace: NSWorkspace, _ bundleIdentifier: String) -> URL? - typealias URLOpener = @MainActor @Sendable (_ workspace: NSWorkspace, _ url: URL) -> Bool - typealias ConfiguredURLOpener = + let requestOpening: @MainActor @Sendable (_ workspace: NSWorkspace, _ url: URL) -> Bool + let requestConfiguredURLOpening: @MainActor @Sendable ( _ workspace: NSWorkspace, _ url: URL, _ configuration: NSWorkspace.OpenConfiguration ) async -> Bool - - var currentApplication: CurrentApplicationProvider - var runningApplication: RunningApplicationProvider - var runningApplications: RunningApplicationsProvider - var frontmostApplication: FrontmostApplicationProvider - var installedApplicationURL: InstalledApplicationURLProvider - var openURL: URLOpener - var openApplicationURL: URLOpener - var openURLWithConfiguration: ConfiguredURLOpener - var openApplicationURLWithConfiguration: ConfiguredURLOpener + let requestConfiguredApplicationOpening: + @MainActor @Sendable ( + _ workspace: NSWorkspace, + _ url: URL, + _ configuration: NSWorkspace.OpenConfiguration + ) async -> Bool + let fileExists: @MainActor @Sendable (_ url: URL) -> Bool + let directoryExists: @MainActor @Sendable (_ url: URL) -> Bool + let executableFileExists: @MainActor @Sendable (_ url: URL) -> Bool + let applicationBundleInfo: @MainActor @Sendable (_ url: URL) -> ApplicationBundleInfo? + let eventSource: + @MainActor @Sendable (_ workspace: NSWorkspace) -> any ApplicationRuntimeEventSource init( - currentApplication: @escaping CurrentApplicationProvider = { nil }, - runningApplication: @escaping RunningApplicationProvider = { _ in nil }, - runningApplications: @escaping RunningApplicationsProvider = { _ in [] }, - frontmostApplication: @escaping FrontmostApplicationProvider = { _ in nil }, - installedApplicationURL: @escaping InstalledApplicationURLProvider = { _, _ in nil }, - openURL: @escaping URLOpener = { _, _ in false }, - openApplicationURL: @escaping URLOpener = { _, _ in false }, - openURLWithConfiguration: @escaping ConfiguredURLOpener = { _, _, _ in false }, - openApplicationURLWithConfiguration: @escaping ConfiguredURLOpener = { _, _, _ in false } + currentApplication: + @escaping @MainActor @Sendable () -> ( + any ApplicationRuntimeRunningApplication + )? = { nil }, + runningApplication: + @escaping @MainActor @Sendable (_ processIdentifier: pid_t) -> ( + any ApplicationRuntimeRunningApplication + )? = { _ in nil }, + runningApplications: + @escaping @MainActor @Sendable (_ bundleIdentifier: String) -> + [any ApplicationRuntimeRunningApplication] = { _ in [] }, + frontmostApplication: + @escaping @MainActor @Sendable (_ workspace: NSWorkspace) -> ( + any ApplicationRuntimeRunningApplication + )? = { _ in nil }, + installedApplicationURL: + @escaping @MainActor @Sendable ( + _ workspace: NSWorkspace, + _ bundleIdentifier: String + ) -> URL? = { _, _ in nil }, + requestOpening: + @escaping @MainActor @Sendable ( + _ workspace: NSWorkspace, + _ url: URL + ) -> Bool = { _, _ in false }, + requestConfiguredURLOpening: + @escaping @MainActor @Sendable ( + _ workspace: NSWorkspace, + _ url: URL, + _ configuration: NSWorkspace.OpenConfiguration + ) async -> Bool = { _, _, _ in false }, + requestConfiguredApplicationOpening: + @escaping @MainActor @Sendable ( + _ workspace: NSWorkspace, + _ url: URL, + _ configuration: NSWorkspace.OpenConfiguration + ) async -> Bool = { _, _, _ in false }, + fileExists: + @escaping @MainActor @Sendable (_ url: URL) -> Bool = Self.defaultFileExists, + directoryExists: + @escaping @MainActor @Sendable (_ url: URL) -> Bool = Self.defaultDirectoryExists, + executableFileExists: + @escaping @MainActor @Sendable (_ url: URL) -> Bool = Self.defaultExecutableFileExists, + applicationBundleInfo: + @escaping @MainActor @Sendable (_ url: URL) -> ApplicationBundleInfo? = + Self.defaultApplicationBundleInfo, + eventSource: + @escaping @MainActor @Sendable ( + _ workspace: NSWorkspace + ) -> any ApplicationRuntimeEventSource = { _ in InertApplicationRuntimeEventSource() } ) { self.currentApplication = currentApplication self.runningApplication = runningApplication self.runningApplications = runningApplications self.frontmostApplication = frontmostApplication self.installedApplicationURL = installedApplicationURL - self.openURL = openURL - self.openApplicationURL = openApplicationURL - self.openURLWithConfiguration = openURLWithConfiguration - self.openApplicationURLWithConfiguration = openApplicationURLWithConfiguration + self.requestOpening = requestOpening + self.requestConfiguredURLOpening = requestConfiguredURLOpening + self.requestConfiguredApplicationOpening = requestConfiguredApplicationOpening + self.fileExists = fileExists + self.directoryExists = directoryExists + self.executableFileExists = executableFileExists + self.applicationBundleInfo = applicationBundleInfo + self.eventSource = eventSource } static func live() -> Self { @@ -69,26 +122,56 @@ struct ApplicationRuntimeDependencies { installedApplicationURL: { workspace, bundleIdentifier in workspace.urlForApplication(withBundleIdentifier: bundleIdentifier) }, - openURL: { workspace, url in - workspace.open(url) - }, - openApplicationURL: { workspace, url in + requestOpening: { workspace, url in workspace.open(url) }, - openURLWithConfiguration: { workspace, url, configuration in + requestConfiguredURLOpening: { workspace, url, configuration in await withCheckedContinuation { continuation in workspace.open(url, configuration: configuration) { application, error in continuation.resume(returning: application != nil && error == nil) } } }, - openApplicationURLWithConfiguration: { workspace, url, configuration in + requestConfiguredApplicationOpening: { workspace, url, configuration in await withCheckedContinuation { continuation in workspace.openApplication(at: url, configuration: configuration) { application, error in continuation.resume(returning: application != nil && error == nil) } } + }, + eventSource: { workspace in + WorkspaceApplicationRuntimeEventSource(workspace: workspace) } ) } + + private static func defaultFileExists(_ url: URL) -> Bool { + FileManager.default.fileExists(atPath: url.path) + } + + private static func defaultDirectoryExists(_ url: URL) -> Bool { + var isDirectory: ObjCBool = false + guard FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory) else { + return false + } + return isDirectory.boolValue + } + + private static func defaultExecutableFileExists(_ url: URL) -> Bool { + guard url.isFileURL else { return false } + var isDirectory: ObjCBool = false + guard FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory) else { + return false + } + guard !isDirectory.boolValue else { return false } + return FileManager.default.isExecutableFile(atPath: url.path) + } + + private static func defaultApplicationBundleInfo(_ url: URL) -> ApplicationBundleInfo? { + guard let bundle = Bundle(url: url) else { return nil } + return ApplicationBundleInfo( + packageType: bundle.object(forInfoDictionaryKey: "CFBundlePackageType") as? String, + executableURL: bundle.executableURL + ) + } } diff --git a/Sources/AppRuntimeKitAppKit/Runtime/ApplicationRuntimeEventSource.swift b/Sources/AppRuntimeKitAppKit/Runtime/ApplicationRuntimeEventSource.swift new file mode 100644 index 0000000..10e8ab9 --- /dev/null +++ b/Sources/AppRuntimeKitAppKit/Runtime/ApplicationRuntimeEventSource.swift @@ -0,0 +1,116 @@ +import AppKit +import AppRuntimeKit +import Foundation + +@MainActor +protocol ApplicationRuntimeEventSource { + func eventStream(lifecycles: Set) -> AsyncStream< + ApplicationRuntimeEvent + > +} + +struct InertApplicationRuntimeEventSource: ApplicationRuntimeEventSource { + func eventStream(lifecycles _: Set) -> AsyncStream< + ApplicationRuntimeEvent + > { + AsyncStream { continuation in + continuation.finish() + } + } +} + +struct WorkspaceApplicationRuntimeEventSource: ApplicationRuntimeEventSource { + private let notificationCenter: NotificationCenter + + init(workspace: NSWorkspace) { + notificationCenter = workspace.notificationCenter + } + + func eventStream(lifecycles: Set) -> AsyncStream< + ApplicationRuntimeEvent + > { + AsyncStream { continuation in + guard !lifecycles.isEmpty else { + continuation.finish() + return + } + var tokens: [NSObjectProtocol] = [] + for lifecycle in lifecycles { + let token = notificationCenter.addObserver( + forName: Self.eventNotificationName(for: lifecycle), + object: nil, + queue: .main + ) { notification in + guard let snapshot = Self.applicationSnapshot(from: notification) else { + return + } + continuation.yield( + ApplicationRuntimeEvent( + lifecycle: lifecycle, + application: snapshot + ) + ) + } + tokens.append(token) + } + let observerTokens = RuntimeEventObserverTokens( + center: notificationCenter, + tokens: tokens + ) + continuation.onTermination = { _ in + observerTokens.cancel() + } + } + } + + nonisolated private static func applicationSnapshot( + from notification: Notification + ) -> ApplicationProcessSnapshot? { + guard + let app = notification.userInfo?[NSWorkspace.applicationUserInfoKey] + as? NSRunningApplication + else { return nil } + return ApplicationProcessSnapshot(runningApplication: app) + } + + nonisolated private static func eventNotificationName( + for lifecycle: ApplicationRuntimeEvent.Lifecycle + ) -> Notification.Name { + switch lifecycle { + case .launched: + NSWorkspace.didLaunchApplicationNotification + case .terminated: + NSWorkspace.didTerminateApplicationNotification + case .activated: + NSWorkspace.didActivateApplicationNotification + case .deactivated: + NSWorkspace.didDeactivateApplicationNotification + } + } +} + +private final class RuntimeEventObserverTokens: @unchecked Sendable { + private let center: NotificationCenter + private let lock = NSLock() + private var tokens: [NSObjectProtocol] + + init(center: NotificationCenter, tokens: [NSObjectProtocol]) { + self.center = center + self.tokens = tokens + } + + func cancel() { + lock.lock() + let tokens = self.tokens + self.tokens = [] + lock.unlock() + + for token in tokens { + center.removeObserver(token) + } + } + + deinit { + cancel() + } +} diff --git a/Sources/AppRuntimeKitAppKit/Runtime/ApplicationRuntimeProcess.swift b/Sources/AppRuntimeKitAppKit/Runtime/ApplicationRuntimeProcess.swift deleted file mode 100644 index 6651278..0000000 --- a/Sources/AppRuntimeKitAppKit/Runtime/ApplicationRuntimeProcess.swift +++ /dev/null @@ -1,11 +0,0 @@ -import AppRuntimeKit -import Darwin - -/// Provides the process fields required to create an application runtime snapshot. -@MainActor -protocol ApplicationRuntimeProcess { - var processIdentifier: pid_t { get } - var bundleIdentifier: String? { get } - var displayName: String? { get } - var isTerminated: Bool { get } -} diff --git a/Sources/AppRuntimeKitAppKit/Runtime/ApplicationRuntimeRunningApplication.swift b/Sources/AppRuntimeKitAppKit/Runtime/ApplicationRuntimeRunningApplication.swift index 03efdec..a73e538 100644 --- a/Sources/AppRuntimeKitAppKit/Runtime/ApplicationRuntimeRunningApplication.swift +++ b/Sources/AppRuntimeKitAppKit/Runtime/ApplicationRuntimeRunningApplication.swift @@ -1,9 +1,13 @@ import AppKit -import AppRuntimeKit /// Provides the live operations AppRuntimeKit can request from a running application. @MainActor -protocol ApplicationRuntimeRunningApplication: ApplicationRuntimeProcess { +protocol ApplicationRuntimeRunningApplication { + var processIdentifier: pid_t { get } + var bundleIdentifier: String? { get } + var displayName: String? { get } + var isTerminated: Bool { get } + func activate(options: NSApplication.ActivationOptions) -> Bool func requestGracefulTermination() -> Bool func requestForcedTermination() -> Bool diff --git a/Sources/AppRuntimeKitAppKit/SingleInstance/ApplicationSingleInstanceCoordinatorDependencies.swift b/Sources/AppRuntimeKitAppKit/SingleInstance/ApplicationSingleInstanceCoordinatorDependencies.swift deleted file mode 100644 index 490b518..0000000 --- a/Sources/AppRuntimeKitAppKit/SingleInstance/ApplicationSingleInstanceCoordinatorDependencies.swift +++ /dev/null @@ -1,79 +0,0 @@ -import AppKit -import AppRuntimeKit -import Foundation - -protocol ApplicationSingleInstanceActivationRequestTransport { - func postActivationRequest(name: Notification.Name, bundleIdentifier: String) - func observeActivationRequests( - name: Notification.Name, - bundleIdentifier: String, - handler: @escaping @MainActor @Sendable () -> Void - ) -> Any - func removeObservation(_ observation: Any) -} - -struct ApplicationSingleInstanceCoordinatorDependencies { - typealias CurrentApplicationActivator = - @MainActor @Sendable (_ options: NSApplication.ActivationOptions) - -> Bool - typealias CurrentProcessIdentifierProvider = @MainActor @Sendable () -> pid_t - typealias RunningProcessIdentifiersProvider = - @MainActor @Sendable (_ bundleIdentifier: String) -> [pid_t] - - var activateCurrentApplication: CurrentApplicationActivator - var currentProcessIdentifier: CurrentProcessIdentifierProvider - var runningProcessIdentifiers: RunningProcessIdentifiersProvider - var activationRequestTransport: ApplicationSingleInstanceActivationRequestTransport - - init( - activateCurrentApplication: @escaping CurrentApplicationActivator = { _ in false }, - currentProcessIdentifier: @escaping CurrentProcessIdentifierProvider = { 0 }, - runningProcessIdentifiers: @escaping RunningProcessIdentifiersProvider = { _ in [] }, - activationRequestTransport: ApplicationSingleInstanceActivationRequestTransport - ) { - self.activateCurrentApplication = activateCurrentApplication - self.currentProcessIdentifier = currentProcessIdentifier - self.runningProcessIdentifiers = runningProcessIdentifiers - self.activationRequestTransport = activationRequestTransport - } - - static func live() -> Self { - Self( - activateCurrentApplication: { options in - NSRunningApplication.current.activate(options: options) - }, - currentProcessIdentifier: { ProcessInfo.processInfo.processIdentifier }, - runningProcessIdentifiers: { bundleIdentifier in - NSRunningApplication - .runningApplications(withBundleIdentifier: bundleIdentifier) - .map(\.processIdentifier) - }, - activationRequestTransport: DistributedNotificationActivationRequestTransport() - ) - } -} - -private struct DistributedNotificationActivationRequestTransport: - ApplicationSingleInstanceActivationRequestTransport -{ - func postActivationRequest(name: Notification.Name, bundleIdentifier: String) { - DistributedNotificationCenter.default().post(name: name, object: bundleIdentifier) - } - - func observeActivationRequests( - name: Notification.Name, - bundleIdentifier: String, - handler: @escaping @MainActor @Sendable () -> Void - ) -> Any { - DistributedNotificationCenter.default() - .addObserver(forName: name, object: bundleIdentifier, queue: .main) { _ in - Task { @MainActor in - handler() - } - } - } - - func removeObservation(_ observation: Any) { - DistributedNotificationCenter.default().removeObserver(observation) - } -} diff --git a/Tests/AppRuntimeKitAppKitTests/AppRuntimeKitAppKitPublicAPITests.swift b/Tests/AppRuntimeKitAppKitTests/AppRuntimeKitAppKitPublicAPITests.swift index ed8c9f5..bd6fa09 100644 --- a/Tests/AppRuntimeKitAppKitTests/AppRuntimeKitAppKitPublicAPITests.swift +++ b/Tests/AppRuntimeKitAppKitTests/AppRuntimeKitAppKitPublicAPITests.swift @@ -1,4 +1,5 @@ import AppKit +import Foundation import XCTest import AppRuntimeKit @@ -16,190 +17,222 @@ final class AppRuntimeKitAppKitPublicAPITests: XCTestCase { XCTAssertEqual(snapshot.isTerminated, application.isTerminated) } - func testPublicRuntimeQueryMethodsAreUsableWithoutTestableImport() { + func testPublicRuntimeMethodsAreUsableWithoutTestableImport() async throws { let runtime = ApplicationRuntime() let sharedRuntime = ApplicationRuntime(workspace: .shared) - - _ = runtime.currentApplication() - XCTAssertNil(runtime.runningApplication(processIdentifier: -1)) - XCTAssertTrue(runtime.runningApplications(bundleIdentifier: " ").isEmpty) - _ = sharedRuntime.frontmostApplication() - XCTAssertNil(sharedRuntime.installedApplicationURL(bundleIdentifier: " ")) - } - - func testPublicRuntimeOpeningMethodsAreUsableWithoutTestableImport() throws { - let runtime = ApplicationRuntime() - let invalidURL = try XCTUnwrap(URL(string: "invalid")) + let configuration = NSWorkspace.OpenConfiguration() + let missingURLScheme = try XCTUnwrap(URL(string: "invalid")) let webURL = try XCTUnwrap(URL(string: "https://example.com")) - - XCTAssertEqual(runtime.openURL(invalidURL), .failed(.invalidURL)) - XCTAssertEqual(runtime.openFile(webURL), .failed(.invalidFileURL)) - XCTAssertEqual( - runtime.openApplication(bundleIdentifier: " "), - .failed(.invalidBundleIdentifier) - ) - XCTAssertEqual(runtime.openApplication(at: webURL), .failed(.invalidApplicationURL)) - } - - func testPublicRuntimeActivationAndTerminationMethodsAreUsableWithoutTestableImport() { - let runtime = ApplicationRuntime() let missingSnapshot = ApplicationProcessSnapshot( processIdentifier: -1, bundleIdentifier: "com.example.missing", displayName: "Missing" ) - - XCTAssertEqual( - runtime.activateApplication(bundleIdentifier: " ", options: []), - .failed(.invalidBundleIdentifier) - ) - XCTAssertEqual( - runtime.activateApplication(processIdentifier: -1, options: []), - .failed(.applicationNotFound) - ) - XCTAssertEqual( - runtime.activateApplication(missingSnapshot, options: []), - .failed(.applicationNotFound) - ) - XCTAssertEqual( - runtime.terminateApplication(bundleIdentifier: " ", mode: .forced), - .failed(.invalidBundleIdentifier) - ) - XCTAssertEqual( - runtime.terminateApplication(processIdentifier: -1, mode: .forced), - .failed(.applicationNotFound) - ) - XCTAssertEqual( - runtime.terminateApplication(missingSnapshot, mode: .forced), - .failed(.applicationNotFound) - ) - } - - func testPublicRuntimeAsyncOpeningAndEventWaitingMethodsAreUsableWithoutTestableImport() - async throws - { - let runtime = ApplicationRuntime() - let configuration = NSWorkspace.OpenConfiguration() - let invalidURL = try XCTUnwrap(URL(string: "invalid")) - let webURL = try XCTUnwrap(URL(string: "https://example.com")) - - let invalidAsyncOpenResult = await runtime.openURL(invalidURL, configuration: configuration) - let invalidAsyncFileOpenResult = await runtime.openFile(webURL, configuration: configuration) - let invalidAsyncBundleOpenResult = await runtime.openApplication( - bundleIdentifier: " ", - configuration: configuration - ) - let invalidAsyncApplicationOpenResult = await runtime.openApplication( - at: webURL, - configuration: configuration - ) - let invalidWaitResult = await runtime.waitForApplicationEvent( - .activated, - bundleIdentifier: " ", - timeout: .milliseconds(1) - ) - - XCTAssertEqual(invalidAsyncOpenResult, .failed(.invalidURL)) - XCTAssertEqual(invalidAsyncFileOpenResult, .failed(.invalidFileURL)) - XCTAssertEqual(invalidAsyncBundleOpenResult, .failed(.invalidBundleIdentifier)) - XCTAssertEqual(invalidAsyncApplicationOpenResult, .failed(.invalidApplicationURL)) - XCTAssertNil(invalidWaitResult) - } - - func testPublicRuntimeEventStreamsAreUsableWithoutTestableImport() async { - let runtime = ApplicationRuntime() + let activateCurrent: + (NSApplication.ActivationOptions) -> ApplicationRequestResult< + ApplicationRuntimeRequestError + > = + runtime.activateCurrentApplication(options:) let events: AsyncStream = runtime.events() let launches: AsyncStream = runtime.launchEvents() let terminations: AsyncStream = runtime.terminationEvents() let activations: AsyncStream = runtime.activationEvents() let deactivations: AsyncStream = runtime.deactivationEvents() - - let invalidLaunch = await runtime.waitForApplicationLaunch( - bundleIdentifier: " ", - timeout: .milliseconds(1) + let invalidAsyncOpenURL = await runtime.openURL( + missingURLScheme, + configuration: configuration ) - let invalidTermination = await runtime.waitForApplicationTermination( + let invalidEventWait = await runtime.waitForApplicationEvent( + .activated, bundleIdentifier: " ", timeout: .milliseconds(1) ) - let invalidActivation = await runtime.waitForApplicationActivation( - bundleIdentifier: " ", - timeout: .milliseconds(1) + + assertSendable(runtime) + _ = runtime.currentApplication() + XCTAssertNil(runtime.runningApplication(processIdentifier: -1)) + XCTAssertTrue(runtime.runningApplications(bundleIdentifier: " ").isEmpty) + _ = sharedRuntime.frontmostApplication() + XCTAssertNil(sharedRuntime.installedApplicationURL(bundleIdentifier: " ")) + XCTAssertEqual(runtime.openURL(missingURLScheme), .failed(.missingURLScheme)) + XCTAssertEqual(runtime.openFile(webURL), .failed(.nonFileURL)) + XCTAssertEqual( + runtime.openApplication(bundleIdentifier: " "), + .failed(.invalidBundleIdentifier) ) - let invalidDeactivation = await runtime.waitForApplicationDeactivation( - bundleIdentifier: " ", - timeout: .milliseconds(1) + XCTAssertEqual(runtime.openApplication(at: webURL), .failed(.invalidApplicationBundleURL)) + XCTAssertEqual( + runtime.activateApplication(missingSnapshot, options: []), + .failed(.invalidProcessIdentifier) ) - - withExtendedLifetime((events, launches, terminations, activations, deactivations)) {} - XCTAssertNil(invalidLaunch) - XCTAssertNil(invalidTermination) - XCTAssertNil(invalidActivation) - XCTAssertNil(invalidDeactivation) + XCTAssertEqual( + runtime.terminateApplication(missingSnapshot, mode: ApplicationTerminationMode.forced), + .failed(.invalidProcessIdentifier) + ) + XCTAssertEqual( + runtime.terminateApplication( + processIdentifier: -1, + expectedBundleIdentifier: "com.example.missing" + ), + .failed(.invalidProcessIdentifier) + ) + XCTAssertEqual(invalidAsyncOpenURL, .failed(.missingURLScheme)) + XCTAssertNil(invalidEventWait) + withExtendedLifetime( + (activateCurrent, events, launches, terminations, activations, deactivations) + ) {} } - func testPublicSingleInstanceCoordinatorIsUsableWithoutTestableImport() throws { - let invalidBundleIdentifierError: ApplicationSingleInstanceCoordinator.ConfigurationError = + func testPublicDuplicateActivationCoordinatorIsUsableWithoutTestableImport() throws { + let configurationError: ApplicationDuplicateActivationCoordinator.ConfigurationError = .invalidBundleIdentifier - let invalidNotificationNameError: ApplicationSingleInstanceCoordinator.ConfigurationError = - .invalidNotificationName - let duplicateResult: ApplicationSingleInstanceCoordinator.CoordinationResult = .duplicate + let coordinationResult: ApplicationDuplicateActivationCoordinator.CoordinationResult = + .duplicate - assertError(invalidBundleIdentifierError) - assertSendable(invalidNotificationNameError) - assertSendable(duplicateResult) - XCTAssertEqual( - ApplicationSingleInstanceCoordinator.ConfigurationError.allCases, - [.invalidBundleIdentifier, .invalidNotificationName] + assertError(configurationError) + assertHashableSendable( + ApplicationDuplicateActivationCoordinator.ConfigurationError.self, + configurationError ) - XCTAssertEqual( - ApplicationSingleInstanceCoordinator.CoordinationResult.allCases, - [.unique, .duplicate] + assertHashableSendable( + ApplicationDuplicateActivationCoordinator.CoordinationResult.self, + coordinationResult ) - XCTAssertThrowsError(try ApplicationSingleInstanceCoordinator(bundleIdentifier: " ")) { + XCTAssertThrowsError(try ApplicationDuplicateActivationCoordinator(bundleIdentifier: " ")) { error in XCTAssertEqual( - error as? ApplicationSingleInstanceCoordinator.ConfigurationError, + error as? ApplicationDuplicateActivationCoordinator.ConfigurationError, .invalidBundleIdentifier ) } - XCTAssertThrowsError( - try ApplicationSingleInstanceCoordinator( - bundleIdentifier: "com.example.app", - notificationName: Notification.Name(" ") - ) - ) { error in - XCTAssertEqual( - error as? ApplicationSingleInstanceCoordinator.ConfigurationError, - .invalidNotificationName - ) - } let bundleIdentifier = "com.example.app-runtime-kit.public-api.\(UUID().uuidString)" - let coordinator = try ApplicationSingleInstanceCoordinator( - bundleIdentifier: bundleIdentifier, - notificationName: Notification.Name("\(bundleIdentifier).activate") + let coordinator = try ApplicationDuplicateActivationCoordinator( + bundleIdentifier: " \(bundleIdentifier)\n", + notificationName: Notification.Name(" \(bundleIdentifier).activate\n") ) defer { coordinator.stop() } - let result: ApplicationSingleInstanceCoordinator.CoordinationResult = - coordinator.coordinate(onDuplicateActivationRequest: {}) - let activatingResult = coordinator.coordinateActivatingCurrentApplication(options: []) + assertSendable(coordinator) + XCTAssertEqual( + coordinator.coordinate(onDuplicateActivationRequest: {}), + ApplicationDuplicateActivationCoordinator.CoordinationResult.unique + ) + } + + func testDocumentationExamplesAreUsableWithoutTestableImport() { + let openIfNeeded: @MainActor () -> ApplicationRequestResult? = + documentationOpenSafariIfNeeded + let runningApplications: @MainActor () -> [ApplicationProcessSnapshot] = + documentationRunningExampleApplications + let applicationURL: @MainActor () -> URL? = + documentationExampleAppURL + let currentSnapshot: @MainActor () -> ApplicationProcessSnapshot = + documentationCurrentApplicationSnapshot + let openApplication: @MainActor () -> ApplicationRequestResult = + documentationOpenExampleApplication + let observer = DocumentationRuntimeEventObserver { _ in } + let coordinatorStore = DocumentationDuplicateActivationCoordinatorStore() + + withExtendedLifetime( + ( + openIfNeeded, + runningApplications, + applicationURL, + currentSnapshot, + openApplication, + observer, + coordinatorStore + ) + ) {} + } +} - XCTAssertEqual(result, .unique) - XCTAssertEqual(activatingResult, .unique) +@MainActor +private func documentationOpenSafariIfNeeded() -> ApplicationRequestResult? +{ + let runtime = ApplicationRuntime() + guard runtime.runningApplications(bundleIdentifier: "com.apple.Safari").isEmpty else { + return nil } + return runtime.openApplication(bundleIdentifier: "com.apple.Safari") +} + +@MainActor +private func documentationRunningExampleApplications() -> [ApplicationProcessSnapshot] { + let runtime = ApplicationRuntime() + return runtime.runningApplications(bundleIdentifier: "com.example.app") } -private func assertError(_ error: E, file: StaticString = #filePath, line: UInt = #line) { - XCTAssertNotNil(error, file: file, line: line) +@MainActor +private func documentationExampleAppURL() -> URL? { + let runtime = ApplicationRuntime() + return runtime.installedApplicationURL(bundleIdentifier: "com.example.app") +} + +@MainActor +private func documentationCurrentApplicationSnapshot() -> ApplicationProcessSnapshot { + ApplicationProcessSnapshot(runningApplication: NSRunningApplication.current) +} + +@MainActor +private func documentationOpenExampleApplication() -> ApplicationRequestResult< + ApplicationOpeningError +> { + let runtime = ApplicationRuntime() + return runtime.openApplication(bundleIdentifier: "com.example.app") +} + +@MainActor +private final class DocumentationRuntimeEventObserver { + private let runtime = ApplicationRuntime() + private let handleEvent: @MainActor (ApplicationRuntimeEvent) -> Void + private var task: Task? + + init(handleEvent: @escaping @MainActor (ApplicationRuntimeEvent) -> Void) { + self.handleEvent = handleEvent + } + + func start() { + stop() + let runtime = runtime + let handleEvent = handleEvent + task = Task { @MainActor in + for await event in runtime.events() { + handleEvent(event) + } + } + } + + func stop() { + task?.cancel() + task = nil + } + + deinit { + task?.cancel() + } } -private func assertSendable( - _ value: T, - file: StaticString = #filePath, - line: UInt = #line -) { - XCTAssertNotNil(value, file: file, line: line) +@MainActor +private final class DocumentationDuplicateActivationCoordinatorStore { + private var coordinator: ApplicationDuplicateActivationCoordinator? + + func start() throws -> ApplicationDuplicateActivationCoordinator.CoordinationResult { + stop() + let coordinator = try ApplicationDuplicateActivationCoordinator( + bundleIdentifier: "com.example.app" + ) + let result = coordinator.coordinateActivatingCurrentApplication() + if result == .unique { + self.coordinator = coordinator + } + return result + } + + func stop() { + coordinator?.stop() + coordinator = nil + } } diff --git a/Tests/AppRuntimeKitAppKitTests/AppRuntimeKitAppKitSourceBoundaryTests.swift b/Tests/AppRuntimeKitAppKitTests/AppRuntimeKitAppKitSourceBoundaryTests.swift new file mode 100644 index 0000000..3bec20b --- /dev/null +++ b/Tests/AppRuntimeKitAppKitTests/AppRuntimeKitAppKitSourceBoundaryTests.swift @@ -0,0 +1,80 @@ +import Foundation +import XCTest + +final class AppRuntimeKitAppKitSourceBoundaryTests: XCTestCase { + func testRuntimeDependencyInjectionStaysOutOfPublicSurface() throws { + let source = try packageFileText( + relativePath: "Sources/AppRuntimeKitAppKit/Runtime/ApplicationRuntime.swift") + + XCTAssertFalse( + containsPublicInitializer(withParameterLabels: ["workspace", "dependencies"], in: source), + "ApplicationRuntime should expose AppKit workspace selection, but keep dependency injection internal to the package." + ) + } + + func testDuplicateActivationDependencyInjectionStaysOutOfPublicSurface() throws { + let source = try packageFileText( + relativePath: + "Sources/AppRuntimeKitAppKit/DuplicateActivation/ApplicationDuplicateActivationCoordinator.swift" + ) + + let failureMessage = + "ApplicationDuplicateActivationCoordinator should expose only bundle identifier and " + + "notification-name configuration; dependency injection stays internal." + + XCTAssertFalse( + containsPublicInitializer( + withParameterLabels: ["bundleIdentifier", "notificationName", "dependencies"], + in: source + ), + failureMessage + ) + } + + func testAppKitSupportTypesStayInternal() throws { + let sourceText = try packageSourceFilesText(relativeRoot: "Sources/AppRuntimeKitAppKit") + let internalSupportTypes = [ + "ApplicationRuntimeDependencies", + "ApplicationRuntimeEventSource", + "WorkspaceApplicationRuntimeEventSource", + "ApplicationDuplicateActivationCoordinatorDependencies", + "ApplicationDuplicateActivationRequestTransport", + "ApplicationDuplicateActivationNotificationCenter", + "DistributedNotificationActivationRequestTransport", + "LiveDuplicateActivationNotificationCenter", + ] + + for internalSupportType in internalSupportTypes { + XCTAssertFalse( + containsPublicTypeDeclaration(named: internalSupportType, in: sourceText), + "\(internalSupportType) is an AppKit implementation detail and should stay out of the public API." + ) + } + } + + private func containsPublicTypeDeclaration(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 func containsPublicInitializer( + withParameterLabels labels: [String], + in source: String + ) -> Bool { + let labelPattern = + labels + .map { #"\b"# + NSRegularExpression.escapedPattern(for: $0) + #"\s*:"# } + .joined(separator: #"[^{}]*"#) + let pattern = + #"(?ms)^\s*(?:@[\w.]+(?:\([^)]*\))?\s*)*public\s+(?:convenience\s+)?init\b[^{}]*"# + + labelPattern + + return source.range(of: pattern, options: .regularExpression) != nil + } +} diff --git a/Tests/AppRuntimeKitAppKitTests/Application/ApplicationProcessSnapshotAppKitTests.swift b/Tests/AppRuntimeKitAppKitTests/Application/ApplicationProcessSnapshotAppKitTests.swift deleted file mode 100644 index 8708c6f..0000000 --- a/Tests/AppRuntimeKitAppKitTests/Application/ApplicationProcessSnapshotAppKitTests.swift +++ /dev/null @@ -1,18 +0,0 @@ -import AppKit -import XCTest - -import AppRuntimeKit -@testable import AppRuntimeKitAppKit - -final class ApplicationProcessSnapshotAppKitTests: XCTestCase { - @MainActor - func testAppKitInitializerCapturesRunningApplicationSnapshot() { - let application = NSRunningApplication.current - let snapshot = ApplicationProcessSnapshot(runningApplication: application) - - XCTAssertEqual(snapshot.processIdentifier, application.processIdentifier) - XCTAssertEqual(snapshot.bundleIdentifier, application.bundleIdentifier) - XCTAssertEqual(snapshot.displayName, application.localizedName) - XCTAssertEqual(snapshot.isTerminated, application.isTerminated) - } -} diff --git a/Tests/AppRuntimeKitAppKitTests/DuplicateActivation/ApplicationDuplicateActivationCoordinatorTests.swift b/Tests/AppRuntimeKitAppKitTests/DuplicateActivation/ApplicationDuplicateActivationCoordinatorTests.swift new file mode 100644 index 0000000..680e396 --- /dev/null +++ b/Tests/AppRuntimeKitAppKitTests/DuplicateActivation/ApplicationDuplicateActivationCoordinatorTests.swift @@ -0,0 +1,390 @@ +import AppKit +import Foundation +import XCTest + +import AppRuntimeKit +@testable import AppRuntimeKitAppKit + +@MainActor +final class ApplicationDuplicateActivationCoordinatorTests: XCTestCase { + func testCoordinateTrimsNotificationName() throws { + let transport = FakeDuplicateActivationRequestTransport() + let coordinator = try makeDuplicateActivationCoordinator( + notificationName: Notification.Name(" test.duplicate-process-activation-request\n"), + runningProcessIdentifiers: [ + duplicateActivationCurrentProcessIdentifier, duplicateActivationProcessIdentifier, + ], + transport: transport + ) + + let result = coordinator.coordinate(onDuplicateActivationRequest: {}) + + XCTAssertEqual(result, .duplicate) + XCTAssertEqual( + transport.posts, + [ + .init( + name: duplicateActivationNotificationName, + bundleIdentifier: duplicateActivationBundleIdentifier + ) + ] + ) + } + + func testCoordinateTrimsBundleIdentifier() throws { + let transport = FakeDuplicateActivationRequestTransport() + let coordinator = try makeDuplicateActivationCoordinator( + bundleIdentifier: " \(duplicateActivationBundleIdentifier)\n", + runningProcessIdentifiers: [ + duplicateActivationCurrentProcessIdentifier, duplicateActivationProcessIdentifier, + ], + transport: transport + ) + + let result = coordinator.coordinate(onDuplicateActivationRequest: {}) + + XCTAssertEqual(result, .duplicate) + XCTAssertEqual(transport.posts, [duplicateActivationPost()]) + } + + func testCoordinateWhenDuplicatePostsAndReturnsDuplicate() throws { + let transport = FakeDuplicateActivationRequestTransport() + let coordinator = try makeDuplicateActivationCoordinator( + runningProcessIdentifiers: [ + duplicateActivationCurrentProcessIdentifier, duplicateActivationProcessIdentifier, + ], + transport: transport + ) + + let result = coordinator.coordinate(onDuplicateActivationRequest: {}) + + XCTAssertEqual(result, .duplicate) + XCTAssertEqual(transport.posts, [duplicateActivationPost()]) + } + + func testCoordinateIgnoresInvalidProcessIdentifiers() throws { + let transport = FakeDuplicateActivationRequestTransport() + let coordinator = try makeDuplicateActivationCoordinator( + runningProcessIdentifiers: [0, -1, duplicateActivationCurrentProcessIdentifier], + transport: transport + ) + + let result = coordinator.coordinate(onDuplicateActivationRequest: {}) + + XCTAssertEqual(result, .unique) + XCTAssertTrue(transport.posts.isEmpty) + } + + func testCoordinateWithInvalidCurrentProcessIdentifierDetectsDuplicate() + throws + { + let transport = FakeDuplicateActivationRequestTransport() + let coordinator = try makeDuplicateActivationCoordinator( + currentProcessIdentifier: 0, + runningProcessIdentifiers: [duplicateActivationProcessIdentifier], + transport: transport + ) + + let result = coordinator.coordinate(onDuplicateActivationRequest: {}) + + XCTAssertEqual(result, .duplicate) + XCTAssertEqual(transport.posts, [duplicateActivationPost()]) + } + + func testCoordinateWithInvalidCurrentProcessIdentifierIgnoresInvalidCandidates() + throws + { + let transport = FakeDuplicateActivationRequestTransport() + let coordinator = try makeDuplicateActivationCoordinator( + currentProcessIdentifier: 0, + runningProcessIdentifiers: [0, -1], + transport: transport + ) + + let result = coordinator.coordinate(onDuplicateActivationRequest: {}) + + XCTAssertEqual(result, .unique) + XCTAssertTrue(transport.posts.isEmpty) + } + + func testCoordinateWhenUniqueRegistersObserverAndCallbackRuns() async throws { + let transport = FakeDuplicateActivationRequestTransport() + let expectation = expectation( + description: "duplicate-process activation request callback runs" + ) + let coordinator = try makeDuplicateActivationCoordinator( + runningProcessIdentifiers: [duplicateActivationCurrentProcessIdentifier], + transport: transport + ) + + let result = coordinator.coordinate { + expectation.fulfill() + } + XCTAssertEqual(result, .unique) + + transport.trigger( + name: duplicateActivationNotificationName, + bundleIdentifier: duplicateActivationBundleIdentifier) + await fulfillment(of: [expectation], timeout: 1.0) + } + + func testCoordinateActivatingCurrentApplicationRegistersActivationCallback() async throws { + let transport = FakeDuplicateActivationRequestTransport() + let expectation = expectation(description: "current application is activated") + var activationOptions: [NSApplication.ActivationOptions] = [] + let coordinator = try makeDuplicateActivationCoordinator( + runningProcessIdentifiers: [duplicateActivationCurrentProcessIdentifier], + transport: transport, + activateCurrentApplication: { options in + activationOptions.append(options) + expectation.fulfill() + return true + } + ) + + let result = coordinator.coordinateActivatingCurrentApplication(options: [.activateAllWindows]) + XCTAssertEqual(result, .unique) + + transport.trigger( + name: duplicateActivationNotificationName, + bundleIdentifier: duplicateActivationBundleIdentifier) + await fulfillment(of: [expectation], timeout: 1.0) + + XCTAssertEqual(activationOptions, [.activateAllWindows]) + } + + func testCoordinateActivatingCurrentApplicationDoesNotActivateDuplicateProcess() throws { + let transport = FakeDuplicateActivationRequestTransport() + var activationRequestCount = 0 + let coordinator = try makeDuplicateActivationCoordinator( + runningProcessIdentifiers: [ + duplicateActivationCurrentProcessIdentifier, duplicateActivationProcessIdentifier, + ], + transport: transport, + activateCurrentApplication: { _ in + activationRequestCount += 1 + return true + } + ) + + let result = coordinator.coordinateActivatingCurrentApplication() + + XCTAssertEqual(result, .duplicate) + XCTAssertEqual(activationRequestCount, 0) + XCTAssertEqual(transport.posts, [duplicateActivationPost()]) + } + + func testCoordinateActivatingCurrentApplicationDoesNotRetainCoordinator() throws { + let transport = FakeDuplicateActivationRequestTransport() + weak var weakCoordinator: ApplicationDuplicateActivationCoordinator? + + do { + let coordinator = try makeDuplicateActivationCoordinator( + runningProcessIdentifiers: [duplicateActivationCurrentProcessIdentifier], + transport: transport + ) + weakCoordinator = coordinator + + XCTAssertEqual(coordinator.coordinateActivatingCurrentApplication(), .unique) + } + + XCTAssertNil(weakCoordinator) + XCTAssertEqual(transport.removedObservers.count, 1) + } + + func testStopRemovesObserver() throws { + let transport = FakeDuplicateActivationRequestTransport() + let coordinator = try makeDuplicateActivationCoordinator( + runningProcessIdentifiers: [], + transport: transport + ) + + _ = coordinator.coordinate(onDuplicateActivationRequest: {}) + coordinator.stop() + + XCTAssertEqual(transport.removedObservers.count, 1) + } + + func testStopPreventsFutureActivationRequests() throws { + let transport = FakeDuplicateActivationRequestTransport() + let coordinator = try makeDuplicateActivationCoordinator( + runningProcessIdentifiers: [], + transport: transport + ) + + var activationCount = 0 + _ = coordinator.coordinate { + activationCount += 1 + } + coordinator.stop() + + transport.trigger( + name: duplicateActivationNotificationName, + bundleIdentifier: duplicateActivationBundleIdentifier) + + XCTAssertEqual(activationCount, 0) + XCTAssertEqual(transport.removedObservers.count, 1) + } + + func testRepeatedCoordinateReplacesObserver() throws { + let transport = FakeDuplicateActivationRequestTransport() + let coordinator = try makeDuplicateActivationCoordinator( + runningProcessIdentifiers: [], + transport: transport + ) + + XCTAssertEqual(coordinator.coordinate(onDuplicateActivationRequest: {}), .unique) + XCTAssertEqual(coordinator.coordinate(onDuplicateActivationRequest: {}), .unique) + + XCTAssertEqual(transport.removedObservers.count, 1) + } + + func testRepeatedCoordinateDoesNotRunReplacedObserver() throws { + let transport = FakeDuplicateActivationRequestTransport() + let coordinator = try makeDuplicateActivationCoordinator( + runningProcessIdentifiers: [], + transport: transport + ) + + var firstActivationCount = 0 + var secondActivationCount = 0 + + XCTAssertEqual( + coordinator.coordinate { + firstActivationCount += 1 + }, + .unique + ) + XCTAssertEqual( + coordinator.coordinate { + secondActivationCount += 1 + }, + .unique + ) + + transport.trigger( + name: duplicateActivationNotificationName, + bundleIdentifier: duplicateActivationBundleIdentifier) + + XCTAssertEqual(firstActivationCount, 0) + XCTAssertEqual(secondActivationCount, 1) + XCTAssertEqual(transport.removedObservers.count, 1) + } + + func testDuplicateCoordinateRemovesExistingObserver() throws { + let transport = FakeDuplicateActivationRequestTransport() + let runningProcessIdentifiers = FakeRunningProcessIdentifiers([ + duplicateActivationCurrentProcessIdentifier + ]) + let coordinator = try makeDuplicateActivationCoordinator( + runningProcessIdentifiers: { _ in runningProcessIdentifiers.identifiers }, + transport: transport + ) + + XCTAssertEqual(coordinator.coordinate(onDuplicateActivationRequest: {}), .unique) + + runningProcessIdentifiers.identifiers = [ + duplicateActivationCurrentProcessIdentifier, + duplicateActivationProcessIdentifier, + ] + XCTAssertEqual(coordinator.coordinate(onDuplicateActivationRequest: {}), .duplicate) + + XCTAssertEqual(transport.removedObservers.count, 1) + XCTAssertEqual(transport.posts, [duplicateActivationPost()]) + } + + func testStopIsIdempotentWithoutObserver() throws { + let transport = FakeDuplicateActivationRequestTransport() + let coordinator = try makeDuplicateActivationCoordinator( + runningProcessIdentifiers: [], + transport: transport + ) + + coordinator.stop() + coordinator.stop() + + XCTAssertTrue(transport.removedObservers.isEmpty) + } + + func testObserverIgnoresDifferentBundleIdentifier() throws { + let transport = FakeDuplicateActivationRequestTransport() + let coordinator = try makeDuplicateActivationCoordinator( + runningProcessIdentifiers: [duplicateActivationCurrentProcessIdentifier], + transport: transport + ) + + var activationCount = 0 + let result = coordinator.coordinate { + activationCount += 1 + } + XCTAssertEqual(result, .unique) + + transport.trigger( + name: duplicateActivationNotificationName, bundleIdentifier: "com.example.other") + + XCTAssertEqual(activationCount, 0) + } + + func testDistributedNotificationTransportPostsBundleIdentifierObject() { + let notificationCenter = FakeDuplicateActivationNotificationCenter() + let transport = DistributedNotificationActivationRequestTransport( + notificationCenter: notificationCenter + ) + + transport.postActivationRequest( + name: duplicateActivationNotificationName, + bundleIdentifier: duplicateActivationBundleIdentifier + ) + + XCTAssertEqual( + notificationCenter.posts, + [ + .init( + name: duplicateActivationNotificationName, object: duplicateActivationBundleIdentifier) + ] + ) + } + + func testDistributedNotificationTransportObservesMainQueueWithBundleIdentifierObject() + async + { + let notificationCenter = FakeDuplicateActivationNotificationCenter() + let transport = DistributedNotificationActivationRequestTransport( + notificationCenter: notificationCenter + ) + var activationCount = 0 + + _ = transport.observeActivationRequests( + name: duplicateActivationNotificationName, + bundleIdentifier: duplicateActivationBundleIdentifier + ) { + activationCount += 1 + } + + let observerRegistration = try? XCTUnwrap(notificationCenter.observerRegistrations.first) + XCTAssertEqual(observerRegistration?.name, duplicateActivationNotificationName) + XCTAssertEqual(observerRegistration?.object, duplicateActivationBundleIdentifier) + XCTAssertTrue(observerRegistration?.queue === OperationQueue.main) + + observerRegistration?.block(Notification(name: duplicateActivationNotificationName)) + await Task.yield() + + XCTAssertEqual(activationCount, 1) + } + + func testDistributedNotificationTransportRemovesActivationRequestObserver() throws { + let notificationCenter = FakeDuplicateActivationNotificationCenter() + let transport = DistributedNotificationActivationRequestTransport( + notificationCenter: notificationCenter + ) + let observer = transport.observeActivationRequests( + name: duplicateActivationNotificationName, + bundleIdentifier: duplicateActivationBundleIdentifier + ) {} + + transport.removeActivationRequestObserver(observer) + + XCTAssertEqual( + notificationCenter.removedObservers, [ObjectIdentifier(observer as AnyObject)]) + } +} diff --git a/Tests/AppRuntimeKitAppKitTests/Runtime/ApplicationRuntimeActivationTests.swift b/Tests/AppRuntimeKitAppKitTests/Runtime/ApplicationRuntimeActivationTests.swift index 1b78a8e..1f42e55 100644 --- a/Tests/AppRuntimeKitAppKitTests/Runtime/ApplicationRuntimeActivationTests.swift +++ b/Tests/AppRuntimeKitAppKitTests/Runtime/ApplicationRuntimeActivationTests.swift @@ -6,70 +6,142 @@ import AppRuntimeKit @MainActor final class ApplicationRuntimeActivationTests: XCTestCase { + func testActivateCurrentApplicationActivatesCurrentApplication() { + let application = FakeRuntimeRunningApplication() + let runtime = applicationRuntimeReturningCurrentApplication( + application, + unexpectedProcessLookupMessage: + "Current application activation should not perform a process lookup." + ) + + let result = runtime.activateCurrentApplication(options: [.activateAllWindows]) + + XCTAssertEqual(result, .requestAccepted) + assertActivationOptions(application, [.activateAllWindows]) + } + + func testActivateCurrentApplicationRejectsMissingApplication() { + let runtime = applicationRuntimeReturningCurrentApplication( + nil, + unexpectedProcessLookupMessage: + "Current application activation should not perform a process lookup." + ) + + XCTAssertEqual( + runtime.activateCurrentApplication(), + .failed(.applicationNotFound) + ) + } + + func testActivateCurrentApplicationRejectsInvalidProcessIdentifier() { + let application = FakeRuntimeRunningApplication(processIdentifier: 0) + let runtime = applicationRuntimeReturningCurrentApplication( + application, + unexpectedProcessLookupMessage: + "Current application activation should not perform a process lookup." + ) + + XCTAssertEqual( + runtime.activateCurrentApplication(), + .failed(.invalidProcessIdentifier) + ) + assertActivationOptions(application, []) + } + + func testActivateCurrentApplicationRejectsTerminatedApplication() { + let application = FakeRuntimeRunningApplication(isTerminated: true) + let runtime = applicationRuntimeReturningCurrentApplication( + application, + unexpectedProcessLookupMessage: + "Current application activation should not perform a process lookup." + ) + + XCTAssertEqual( + runtime.activateCurrentApplication(), + .failed(.applicationTerminated) + ) + assertActivationOptions(application, []) + } + + func testActivateCurrentApplicationReportsActivationRequestRejection() { + let application = FakeRuntimeRunningApplication(activationRequestAccepted: false) + let runtime = applicationRuntimeReturningCurrentApplication( + application, + unexpectedProcessLookupMessage: + "Current application activation should not perform a process lookup." + ) + + XCTAssertEqual( + runtime.activateCurrentApplication(), + .failed(.requestRejected) + ) + assertActivationOptions(application, [[]]) + } + func testActivateApplicationByProcessIdentifierActivatesMatchingApplication() { let application = FakeRuntimeRunningApplication() - let runtime = Self.runtimeReturningRunningApplication(application, for: 123) + let runtime = applicationRuntimeReturningRunningApplication(application, for: 123) let result = runtime.activateApplication( processIdentifier: 123, options: [.activateAllWindows] ) - XCTAssertEqual(result, .activated) - Self.assertActivationOptions(application, [.activateAllWindows]) + XCTAssertEqual(result, .requestAccepted) + assertActivationOptions(application, [.activateAllWindows]) } func testActivateApplicationBySnapshotUsesProcessIdentifier() { let application = FakeRuntimeRunningApplication(processIdentifier: 456) - let runtime = Self.runtimeReturningRunningApplication(application, for: 456) - let snapshot = Self.snapshot() + let runtime = applicationRuntimeReturningRunningApplication(application, for: 456) + let snapshot = applicationRuntimeSnapshot() let result = runtime.activateApplication(snapshot) - XCTAssertEqual(result, .activated) - Self.assertActivationOptions(application, [[]]) + XCTAssertEqual(result, .requestAccepted) + assertActivationOptions(application, [[]]) } func testActivateApplicationBySnapshotForwardsOptions() { let application = FakeRuntimeRunningApplication(processIdentifier: 456) - let runtime = Self.runtimeReturningRunningApplication(application, for: 456) - let snapshot = Self.snapshot() + let runtime = applicationRuntimeReturningRunningApplication(application, for: 456) + let snapshot = applicationRuntimeSnapshot() let result = runtime.activateApplication(snapshot, options: [.activateAllWindows]) - XCTAssertEqual(result, .activated) - Self.assertActivationOptions(application, [.activateAllWindows]) + XCTAssertEqual(result, .requestAccepted) + assertActivationOptions(application, [.activateAllWindows]) } - func testActivateApplicationBySnapshotReportsActivationRequestFailure() { + func testActivateApplicationBySnapshotReportsActivationRequestRejection() { let application = FakeRuntimeRunningApplication( processIdentifier: 456, - activationResult: false + activationRequestAccepted: false ) - let runtime = Self.runtimeReturningRunningApplication(application, for: 456) - let snapshot = Self.snapshot() + let runtime = applicationRuntimeReturningRunningApplication(application, for: 456) + let snapshot = applicationRuntimeSnapshot() XCTAssertEqual( runtime.activateApplication(snapshot), - .failed(.requestFailed) + .failed(.requestRejected) ) - Self.assertActivationOptions(application, [[]]) + assertActivationOptions(application, [[]]) } func testActivateApplicationBySnapshotTrimsBundleIdentifier() { let application = FakeRuntimeRunningApplication(processIdentifier: 456) - let runtime = Self.runtimeReturningRunningApplication(application, for: 456) - let snapshot = Self.snapshot(bundleIdentifier: " com.example.app\n") + let runtime = applicationRuntimeReturningRunningApplication(application, for: 456) + let snapshot = applicationRuntimeSnapshot(bundleIdentifier: " com.example.app\n") - XCTAssertEqual(runtime.activateApplication(snapshot), .activated) - Self.assertActivationOptions(application, [[]]) + XCTAssertEqual(runtime.activateApplication(snapshot), .requestAccepted) + assertActivationOptions(application, [[]]) } func testActivateApplicationBySnapshotRejectsBlankBundleIdentifier() { - let runtime = Self.runtimeRejectingRunningApplicationLookup( + let runtime = applicationRuntimeRejectingRunningApplicationLookup( "Blank bundle identifier should not perform a live lookup." ) - let snapshot = Self.snapshot(bundleIdentifier: " ") + let snapshot = applicationRuntimeSnapshot(bundleIdentifier: " ") XCTAssertEqual( runtime.activateApplication(snapshot), @@ -78,39 +150,39 @@ final class ApplicationRuntimeActivationTests: XCTestCase { } func testActivateApplicationBySnapshotRejectsInvalidProcessIdentifier() { - let runtime = Self.runtimeRejectingRunningApplicationLookup( + let runtime = applicationRuntimeRejectingRunningApplicationLookup( "Invalid process identifier should not perform a live lookup." ) - let snapshot = Self.snapshot(processIdentifier: 0) + let snapshot = applicationRuntimeSnapshot(processIdentifier: 0) XCTAssertEqual( runtime.activateApplication(snapshot), - .failed(.applicationNotFound) + .failed(.invalidProcessIdentifier) ) } func testActivateApplicationBySnapshotRejectsNegativeProcessIdentifier() { - let runtime = Self.runtimeRejectingRunningApplicationLookup( + let runtime = applicationRuntimeRejectingRunningApplicationLookup( "Negative process identifier should not perform a live lookup." ) - let snapshot = Self.snapshot(processIdentifier: -1) + let snapshot = applicationRuntimeSnapshot(processIdentifier: -1) XCTAssertEqual( runtime.activateApplication(snapshot), - .failed(.applicationNotFound) + .failed(.invalidProcessIdentifier) ) } func testActivateApplicationBySnapshotRejectsMismatchedLiveProcessIdentifier() { let application = FakeRuntimeRunningApplication(processIdentifier: 999) - let runtime = Self.runtimeReturningRunningApplication(application, for: 456) - let snapshot = Self.snapshot() + let runtime = applicationRuntimeReturningRunningApplication(application, for: 456) + let snapshot = applicationRuntimeSnapshot() XCTAssertEqual( runtime.activateApplication(snapshot), .failed(.applicationNotFound) ) - Self.assertActivationOptions(application, []) + assertActivationOptions(application, []) } func testActivateApplicationBySnapshotRejectsBundleIdentifierMismatch() { @@ -118,14 +190,14 @@ final class ApplicationRuntimeActivationTests: XCTestCase { processIdentifier: 456, bundleIdentifier: "com.example.other" ) - let runtime = Self.runtimeReturningRunningApplication(application, for: 456) - let snapshot = Self.snapshot() + let runtime = applicationRuntimeReturningRunningApplication(application, for: 456) + let snapshot = applicationRuntimeSnapshot() XCTAssertEqual( runtime.activateApplication(snapshot), - .failed(.applicationNotFound) + .failed(.applicationBundleIdentifierMismatch) ) - Self.assertActivationOptions(application, []) + assertActivationOptions(application, []) } func testActivateApplicationBySnapshotRejectsMissingRunningApplicationBundleIdentifier() { @@ -133,14 +205,14 @@ final class ApplicationRuntimeActivationTests: XCTestCase { processIdentifier: 456, bundleIdentifier: nil ) - let runtime = Self.runtimeReturningRunningApplication(application, for: 456) - let snapshot = Self.snapshot() + let runtime = applicationRuntimeReturningRunningApplication(application, for: 456) + let snapshot = applicationRuntimeSnapshot() XCTAssertEqual( runtime.activateApplication(snapshot), - .failed(.applicationNotFound) + .failed(.applicationBundleIdentifierUnavailable) ) - Self.assertActivationOptions(application, []) + assertActivationOptions(application, []) } func testActivateApplicationBySnapshotAllowsMissingBundleIdentifier() { @@ -148,27 +220,27 @@ final class ApplicationRuntimeActivationTests: XCTestCase { processIdentifier: 456, bundleIdentifier: "com.example.app" ) - let runtime = Self.runtimeReturningRunningApplication(application, for: 456) - let snapshot = Self.snapshot(bundleIdentifier: nil) + let runtime = applicationRuntimeReturningRunningApplication(application, for: 456) + let snapshot = applicationRuntimeSnapshot(bundleIdentifier: nil) - XCTAssertEqual(runtime.activateApplication(snapshot), .activated) - Self.assertActivationOptions(application, [[]]) + XCTAssertEqual(runtime.activateApplication(snapshot), .requestAccepted) + assertActivationOptions(application, [[]]) } func testActivateApplicationBySnapshotRejectsTerminatedSnapshot() { - let runtime = Self.runtimeRejectingRunningApplicationLookup( + let runtime = applicationRuntimeRejectingRunningApplicationLookup( "Terminated snapshot should not perform a live lookup." ) - let snapshot = Self.snapshot(isTerminated: true) + let snapshot = applicationRuntimeSnapshot(isTerminated: true) XCTAssertEqual( runtime.activateApplication(snapshot), - .failed(.applicationNotFound) + .failed(.applicationTerminated) ) } func testActivateApplicationByProcessIdentifierRejectsMissingApplication() { - let runtime = Self.runtimeReturningRunningApplication(nil, for: 123) + let runtime = applicationRuntimeReturningRunningApplication(nil, for: 123) XCTAssertEqual( runtime.activateApplication(processIdentifier: 123), @@ -177,110 +249,199 @@ final class ApplicationRuntimeActivationTests: XCTestCase { } func testActivateApplicationByProcessIdentifierRejectsInvalidProcessIdentifier() { - let runtime = Self.runtimeRejectingRunningApplicationLookup( + let runtime = applicationRuntimeRejectingRunningApplicationLookup( "Invalid process identifier should not perform a live lookup." ) XCTAssertEqual( runtime.activateApplication(processIdentifier: 0), - .failed(.applicationNotFound) + .failed(.invalidProcessIdentifier) ) } func testActivateApplicationByProcessIdentifierRejectsNegativeProcessIdentifier() { - let runtime = Self.runtimeRejectingRunningApplicationLookup( + let runtime = applicationRuntimeRejectingRunningApplicationLookup( "Negative process identifier should not perform a live lookup." ) XCTAssertEqual( runtime.activateApplication(processIdentifier: -1), - .failed(.applicationNotFound) + .failed(.invalidProcessIdentifier) ) } func testActivateApplicationByProcessIdentifierRejectsMismatchedLiveProcessIdentifier() { let application = FakeRuntimeRunningApplication(processIdentifier: 999) - let runtime = Self.runtimeReturningRunningApplication(application, for: 123) + let runtime = applicationRuntimeReturningRunningApplication(application, for: 123) XCTAssertEqual( runtime.activateApplication(processIdentifier: 123), .failed(.applicationNotFound) ) - Self.assertActivationOptions(application, []) + assertActivationOptions(application, []) } func testActivateApplicationByProcessIdentifierRejectsTerminatedApplication() { let application = FakeRuntimeRunningApplication(isTerminated: true) - let runtime = Self.runtimeReturningRunningApplication(application, for: 123) + let runtime = applicationRuntimeReturningRunningApplication(application, for: 123) XCTAssertEqual( runtime.activateApplication(processIdentifier: 123), - .failed(.applicationNotFound) + .failed(.applicationTerminated) ) - Self.assertActivationOptions(application, []) + assertActivationOptions(application, []) } - func testActivateApplicationByProcessIdentifierReportsActivationRequestFailure() { - let application = FakeRuntimeRunningApplication(activationResult: false) - let runtime = Self.runtimeReturningRunningApplication(application, for: 123) + func testActivateApplicationByProcessIdentifierReportsActivationRequestRejection() { + let application = FakeRuntimeRunningApplication(activationRequestAccepted: false) + let runtime = applicationRuntimeReturningRunningApplication(application, for: 123) XCTAssertEqual( runtime.activateApplication(processIdentifier: 123), - .failed(.requestFailed) + .failed(.requestRejected) ) - Self.assertActivationOptions(application, [[]]) + assertActivationOptions(application, [[]]) + } + + func testActivateApplicationByProcessIdentifierAndExpectedBundleIdentifierActivatesMatch() { + let application = FakeRuntimeRunningApplication( + processIdentifier: 123, + bundleIdentifier: "com.example.app" + ) + let runtime = applicationRuntimeReturningRunningApplication(application, for: 123) + + let result = runtime.activateApplication( + processIdentifier: 123, + expectedBundleIdentifier: " com.example.app\n", + options: [.activateAllWindows] + ) + + XCTAssertEqual(result, .requestAccepted) + assertActivationOptions(application, [.activateAllWindows]) + } + + func testActivateApplicationByProcessIdentifierAndExpectedBundleIdentifierRejectsBlankBundle() { + let runtime = applicationRuntimeRejectingRunningApplicationLookup( + "Blank expected bundle identifier should not perform a live lookup." + ) + + XCTAssertEqual( + runtime.activateApplication(processIdentifier: 123, expectedBundleIdentifier: " "), + .failed(.invalidBundleIdentifier) + ) + } + + func testActivateApplicationByProcessIdentifierAndExpectedBundleIdentifierRejectsMismatch() { + let application = FakeRuntimeRunningApplication( + processIdentifier: 123, + bundleIdentifier: "com.example.other" + ) + let runtime = applicationRuntimeReturningRunningApplication(application, for: 123) + + XCTAssertEqual( + runtime.activateApplication( + processIdentifier: 123, + expectedBundleIdentifier: "com.example.app" + ), + .failed(.applicationBundleIdentifierMismatch) + ) + assertActivationOptions(application, []) + } + + func testActivateApplicationByProcessIdentifierAndExpectedBundleIdentifierRejectsMissingBundle() { + let application = FakeRuntimeRunningApplication(processIdentifier: 123, bundleIdentifier: nil) + let runtime = applicationRuntimeReturningRunningApplication(application, for: 123) + + XCTAssertEqual( + runtime.activateApplication( + processIdentifier: 123, + expectedBundleIdentifier: "com.example.app" + ), + .failed(.applicationBundleIdentifierUnavailable) + ) + assertActivationOptions(application, []) + } + + func testActivateApplicationByProcessIdentifierAndExpectedBundleIdentifierRejectsTerminated() { + let application = FakeRuntimeRunningApplication( + processIdentifier: 123, + bundleIdentifier: "com.example.app", + isTerminated: true + ) + let runtime = applicationRuntimeReturningRunningApplication(application, for: 123) + + XCTAssertEqual( + runtime.activateApplication( + processIdentifier: 123, + expectedBundleIdentifier: "com.example.app" + ), + .failed(.applicationTerminated) + ) + assertActivationOptions(application, []) } func testActivateApplicationByBundleIdentifierTrimsInput() { let invalidApplication = FakeRuntimeRunningApplication(processIdentifier: 0) let terminatedApplication = FakeRuntimeRunningApplication(isTerminated: true) - let missingBundleIdentifierApplication = FakeRuntimeRunningApplication(bundleIdentifier: nil) + let applicationBundleIdentifierUnavailableApplication = FakeRuntimeRunningApplication( + bundleIdentifier: nil) let mismatchedApplication = FakeRuntimeRunningApplication(bundleIdentifier: "com.example.other") let application = FakeRuntimeRunningApplication() - let runtime = Self.runtimeReturningRunningApplications([ - invalidApplication, - terminatedApplication, - missingBundleIdentifierApplication, - mismatchedApplication, - application, - ]) + let runtime = applicationRuntimeReturningRunningApplications( + [ + invalidApplication, + terminatedApplication, + applicationBundleIdentifierUnavailableApplication, + mismatchedApplication, + application, + ], + unexpectedProcessLookupMessage: + "Bundle identifier activation should not perform a process lookup.") let result = runtime.activateApplication(bundleIdentifier: " com.example.app\n") - XCTAssertEqual(result, .activated) - Self.assertActivationOptions(invalidApplication, []) - Self.assertActivationOptions(terminatedApplication, []) - Self.assertActivationOptions(missingBundleIdentifierApplication, []) - Self.assertActivationOptions(mismatchedApplication, []) - Self.assertActivationOptions(application, [[]]) + XCTAssertEqual(result, .requestAccepted) + assertActivationOptions(invalidApplication, []) + assertActivationOptions(terminatedApplication, []) + assertActivationOptions(applicationBundleIdentifierUnavailableApplication, []) + assertActivationOptions(mismatchedApplication, []) + assertActivationOptions(application, [[]]) } func testActivateApplicationByBundleIdentifierForwardsOptions() { let application = FakeRuntimeRunningApplication() - let runtime = Self.runtimeReturningRunningApplications([application]) + let runtime = applicationRuntimeReturningRunningApplications( + [application], + unexpectedProcessLookupMessage: + "Bundle identifier activation should not perform a process lookup." + ) let result = runtime.activateApplication( bundleIdentifier: "com.example.app", options: [.activateAllWindows] ) - XCTAssertEqual(result, .activated) - Self.assertActivationOptions(application, [.activateAllWindows]) + XCTAssertEqual(result, .requestAccepted) + assertActivationOptions(application, [.activateAllWindows]) } - func testActivateApplicationByBundleIdentifierReportsActivationRequestFailure() { - let application = FakeRuntimeRunningApplication(activationResult: false) - let runtime = Self.runtimeReturningRunningApplications([application]) + func testActivateApplicationByBundleIdentifierReportsActivationRequestRejection() { + let application = FakeRuntimeRunningApplication(activationRequestAccepted: false) + let runtime = applicationRuntimeReturningRunningApplications( + [application], + unexpectedProcessLookupMessage: + "Bundle identifier activation should not perform a process lookup." + ) XCTAssertEqual( runtime.activateApplication(bundleIdentifier: "com.example.app"), - .failed(.requestFailed) + .failed(.requestRejected) ) - Self.assertActivationOptions(application, [[]]) + assertActivationOptions(application, [[]]) } func testActivateApplicationByBundleIdentifierRejectsBlankInput() { - let runtime = Self.runtimeRejectingBundleIdentifierLookups() + let runtime = applicationRuntimeRejectingBundleIdentifierLookups() XCTAssertEqual( runtime.activateApplication(bundleIdentifier: " "), @@ -292,131 +453,22 @@ final class ApplicationRuntimeActivationTests: XCTestCase { let invalidProcessIdentifierApplication = FakeRuntimeRunningApplication(processIdentifier: 0) let terminatedApplication = FakeRuntimeRunningApplication(isTerminated: true) let mismatchedApplication = FakeRuntimeRunningApplication(bundleIdentifier: "com.example.other") - let runtime = Self.runtimeReturningRunningApplications([ - invalidProcessIdentifierApplication, - terminatedApplication, - mismatchedApplication, - ]) + let runtime = applicationRuntimeReturningRunningApplications( + [ + invalidProcessIdentifierApplication, + terminatedApplication, + mismatchedApplication, + ], + unexpectedProcessLookupMessage: + "Bundle identifier activation should not perform a process lookup.") XCTAssertEqual( runtime.activateApplication(bundleIdentifier: "com.example.app"), .failed(.applicationNotFound) ) - Self.assertActivationOptions(invalidProcessIdentifierApplication, []) - Self.assertActivationOptions(terminatedApplication, []) - Self.assertActivationOptions(mismatchedApplication, []) - } - - private static func assertActivationOptions( - _ application: FakeRuntimeRunningApplication, - _ expectedOptions: [NSApplication.ActivationOptions], - file: StaticString = #filePath, - line: UInt = #line - ) { - XCTAssertEqual(application.activationOptions, expectedOptions, file: file, line: line) - } - - private static func runtimeReturningRunningApplication( - _ application: FakeRuntimeRunningApplication?, - for expectedProcessIdentifier: pid_t, - file: StaticString = #filePath, - line: UInt = #line - ) -> ApplicationRuntime { - ApplicationRuntime( - dependencies: ApplicationRuntimeDependencies( - runningApplication: { processIdentifier in - XCTAssertEqual( - processIdentifier, - expectedProcessIdentifier, - file: file, - line: line - ) - return application - } - ) - ) - } - - private static func runtimeRejectingRunningApplicationLookup( - _ message: String, - file: StaticString = #filePath, - line: UInt = #line - ) -> ApplicationRuntime { - ApplicationRuntime( - dependencies: ApplicationRuntimeDependencies( - runningApplication: { _ in - XCTFail(message, file: file, line: line) - return FakeRuntimeRunningApplication() - } - ) - ) - } - - private static func runtimeReturningRunningApplications( - _ applications: [FakeRuntimeRunningApplication], - for expectedBundleIdentifier: String = "com.example.app", - file: StaticString = #filePath, - line: UInt = #line - ) -> ApplicationRuntime { - ApplicationRuntime( - dependencies: ApplicationRuntimeDependencies( - runningApplication: { _ in - XCTFail( - "Bundle identifier activation should not perform a process lookup.", - file: file, - line: line - ) - return nil - }, - runningApplications: { bundleIdentifier in - XCTAssertEqual( - bundleIdentifier, - expectedBundleIdentifier, - file: file, - line: line - ) - return applications - } - ) - ) - } - - private static func runtimeRejectingBundleIdentifierLookups( - file: StaticString = #filePath, - line: UInt = #line - ) -> ApplicationRuntime { - ApplicationRuntime( - dependencies: ApplicationRuntimeDependencies( - runningApplication: { _ in - XCTFail( - "Blank bundle identifier should not perform a process lookup.", - file: file, - line: line - ) - return nil - }, - runningApplications: { _ in - XCTFail( - "Blank bundle identifier should not perform a running application lookup.", - file: file, - line: line - ) - return [FakeRuntimeRunningApplication()] - } - ) - ) - } - - private static func snapshot( - processIdentifier: pid_t = 456, - bundleIdentifier: String? = "com.example.app", - isTerminated: Bool = false - ) -> ApplicationProcessSnapshot { - ApplicationProcessSnapshot( - processIdentifier: processIdentifier, - bundleIdentifier: bundleIdentifier, - displayName: "Example", - isTerminated: isTerminated - ) + assertActivationOptions(invalidProcessIdentifierApplication, []) + assertActivationOptions(terminatedApplication, []) + assertActivationOptions(mismatchedApplication, []) } + } diff --git a/Tests/AppRuntimeKitAppKitTests/Runtime/ApplicationRuntimeDependenciesTests.swift b/Tests/AppRuntimeKitAppKitTests/Runtime/ApplicationRuntimeDependenciesTests.swift index 59c40f4..1e36098 100644 --- a/Tests/AppRuntimeKitAppKitTests/Runtime/ApplicationRuntimeDependenciesTests.swift +++ b/Tests/AppRuntimeKitAppKitTests/Runtime/ApplicationRuntimeDependenciesTests.swift @@ -57,76 +57,120 @@ final class ApplicationRuntimeDependenciesTests: XCTestCase { let receivedRunningApplications = dependencies.runningApplications("com.example.app") let receivedFrontmostApplication = try XCTUnwrap(dependencies.frontmostApplication(workspace)) - Self.assertProcess(receivedCurrentApplication, matches: currentApplication) - Self.assertProcess(receivedRunningApplication, matches: runningApplication) + assertRuntimeRunningApplication(receivedCurrentApplication, matches: currentApplication) + assertRuntimeRunningApplication(receivedRunningApplication, matches: runningApplication) XCTAssertEqual(receivedRunningApplications.count, 1) - Self.assertProcess( + assertRuntimeRunningApplication( try XCTUnwrap(receivedRunningApplications.first), matches: listedApplication ) - Self.assertProcess(receivedFrontmostApplication, matches: frontmostApplication) + assertRuntimeRunningApplication(receivedFrontmostApplication, matches: frontmostApplication) XCTAssertEqual( dependencies.installedApplicationURL(workspace, "com.example.app"), installedApplicationURL ) } - func testInitializerStoresCustomOpenDependencies() async throws { + func testInitializerStoresCustomOpeningRequestDependencies() async throws { let workspace = NSWorkspace.shared - let openURL = try XCTUnwrap(URL(string: "https://example.com/open-url")) - let openApplicationURL = try XCTUnwrap(URL(string: "https://example.com/open-application")) - let configuredOpenURL = try XCTUnwrap(URL(string: "https://example.com/configured-open-url")) - let configuredOpenApplicationURL = try XCTUnwrap( - URL(string: "https://example.com/configured-open-application") + let openingURL = try XCTUnwrap(URL(string: "https://example.com/open")) + let configuredURLOpeningURL = try XCTUnwrap( + URL(string: "https://example.com/configured-url-opening") ) - let configuredOpenConfiguration = NSWorkspace.OpenConfiguration() - let configuredOpenApplicationConfiguration = NSWorkspace.OpenConfiguration() + let configuredApplicationOpeningURL = try XCTUnwrap( + URL(string: "https://example.com/configured-application-opening") + ) + let configuredURLOpeningConfiguration = NSWorkspace.OpenConfiguration() + let configuredApplicationOpeningConfiguration = NSWorkspace.OpenConfiguration() let dependencies = ApplicationRuntimeDependencies( - openURL: { receivedWorkspace, receivedURL in + requestOpening: { receivedWorkspace, receivedURL in XCTAssertTrue(receivedWorkspace === workspace) - XCTAssertEqual(receivedURL, openURL) + XCTAssertEqual(receivedURL, openingURL) return true }, - openApplicationURL: { receivedWorkspace, receivedURL in - XCTAssertTrue(receivedWorkspace === workspace) - XCTAssertEqual(receivedURL, openApplicationURL) - return false - }, - openURLWithConfiguration: { + requestConfiguredURLOpening: { receivedWorkspace, receivedURL, receivedConfiguration in XCTAssertTrue(receivedWorkspace === workspace) - XCTAssertEqual(receivedURL, configuredOpenURL) - XCTAssertTrue(receivedConfiguration === configuredOpenConfiguration) + XCTAssertEqual(receivedURL, configuredURLOpeningURL) + XCTAssertTrue(receivedConfiguration === configuredURLOpeningConfiguration) return true }, - openApplicationURLWithConfiguration: { + requestConfiguredApplicationOpening: { receivedWorkspace, receivedURL, receivedConfiguration in XCTAssertTrue(receivedWorkspace === workspace) - XCTAssertEqual(receivedURL, configuredOpenApplicationURL) - XCTAssertTrue(receivedConfiguration === configuredOpenApplicationConfiguration) + XCTAssertEqual(receivedURL, configuredApplicationOpeningURL) + XCTAssertTrue(receivedConfiguration === configuredApplicationOpeningConfiguration) return false } ) - XCTAssertTrue(dependencies.openURL(workspace, openURL)) - XCTAssertFalse(dependencies.openApplicationURL(workspace, openApplicationURL)) - let openURLResult = await dependencies.openURLWithConfiguration( - workspace, - configuredOpenURL, - configuredOpenConfiguration + XCTAssertTrue(dependencies.requestOpening(workspace, openingURL)) + let configuredURLOpeningAccepted = + await dependencies.requestConfiguredURLOpening( + workspace, + configuredURLOpeningURL, + configuredURLOpeningConfiguration + ) + let configuredApplicationOpeningAccepted = + await dependencies.requestConfiguredApplicationOpening( + workspace, + configuredApplicationOpeningURL, + configuredApplicationOpeningConfiguration + ) + + XCTAssertTrue(configuredURLOpeningAccepted) + XCTAssertFalse(configuredApplicationOpeningAccepted) + } + + func testInitializerStoresCustomOpeningValidationDependencies() throws { + let fileURL = URL(fileURLWithPath: "/tmp/example.txt") + let directoryURL = URL(fileURLWithPath: "/Applications/Example.app") + let executableURL = URL(fileURLWithPath: "/Applications/Example.app/Contents/MacOS/Example") + let bundleInfo = ApplicationBundleInfo( + packageType: "APPL", + executableURL: executableURL ) - let openApplicationResult = await dependencies.openApplicationURLWithConfiguration( - workspace, - configuredOpenApplicationURL, - configuredOpenApplicationConfiguration + let dependencies = ApplicationRuntimeDependencies( + fileExists: { url in + XCTAssertEqual(url, fileURL) + return true + }, + directoryExists: { url in + XCTAssertEqual(url, directoryURL) + return true + }, + executableFileExists: { url in + XCTAssertEqual(url, executableURL) + return true + }, + applicationBundleInfo: { url in + XCTAssertEqual(url, directoryURL) + return bundleInfo + } + ) + + XCTAssertTrue(dependencies.fileExists(fileURL)) + XCTAssertTrue(dependencies.directoryExists(directoryURL)) + XCTAssertTrue(dependencies.executableFileExists(executableURL)) + XCTAssertEqual(dependencies.applicationBundleInfo(directoryURL), bundleInfo) + } + + func testInitializerStoresCustomEventSourceDependency() { + let eventSource = TestApplicationRuntimeEventSource() + let dependencies = ApplicationRuntimeDependencies( + eventSource: { receivedWorkspace in + XCTAssertTrue(receivedWorkspace === NSWorkspace.shared) + return eventSource + } ) - XCTAssertTrue(openURLResult) - XCTAssertFalse(openApplicationResult) + XCTAssertTrue( + dependencies.eventSource(NSWorkspace.shared) as? TestApplicationRuntimeEventSource + === eventSource) } func testDefaultsAreInert() async throws { @@ -140,52 +184,39 @@ final class ApplicationRuntimeDependenciesTests: XCTestCase { XCTAssertTrue(dependencies.runningApplications("com.example.app").isEmpty) XCTAssertNil(dependencies.frontmostApplication(workspace)) XCTAssertNil(dependencies.installedApplicationURL(workspace, "com.example.app")) - XCTAssertFalse(dependencies.openURL(workspace, url)) - XCTAssertFalse(dependencies.openApplicationURL(workspace, url)) - let openURLResult = await dependencies.openURLWithConfiguration( - workspace, - url, - configuration - ) - let openApplicationResult = await dependencies.openApplicationURLWithConfiguration( - workspace, - url, - configuration + XCTAssertFalse(dependencies.requestOpening(workspace, url)) + let configuredURLOpeningAccepted = + await dependencies.requestConfiguredURLOpening( + workspace, + url, + configuration + ) + let configuredApplicationOpeningAccepted = + await dependencies.requestConfiguredApplicationOpening( + workspace, + url, + configuration + ) + + XCTAssertFalse(configuredURLOpeningAccepted) + XCTAssertFalse(configuredApplicationOpeningAccepted) + + let eventSource = dependencies.eventSource(workspace) + let eventTask = firstValueTask( + from: eventSource.eventStream(lifecycles: Set(ApplicationRuntimeEvent.Lifecycle.allCases)) ) - XCTAssertFalse(openURLResult) - XCTAssertFalse(openApplicationResult) + let event = await eventTask.value + + XCTAssertNil(event) } - private static func assertProcess( - _ process: any ApplicationRuntimeProcess, - matches expected: any ApplicationRuntimeProcess, - file: StaticString = #filePath, - line: UInt = #line - ) { - XCTAssertEqual( - process.processIdentifier, - expected.processIdentifier, - file: file, - line: line - ) - XCTAssertEqual( - process.bundleIdentifier, - expected.bundleIdentifier, - file: file, - line: line - ) - XCTAssertEqual( - process.displayName, - expected.displayName, - file: file, - line: line - ) - XCTAssertEqual( - process.isTerminated, - expected.isTerminated, - file: file, - line: line + func testLiveDependenciesUseWorkspaceEventSource() { + let dependencies = ApplicationRuntimeDependencies.live() + + XCTAssertTrue( + dependencies.eventSource(NSWorkspace.shared) is WorkspaceApplicationRuntimeEventSource ) } + } diff --git a/Tests/AppRuntimeKitAppKitTests/Runtime/ApplicationRuntimeEventWaitTests.swift b/Tests/AppRuntimeKitAppKitTests/Runtime/ApplicationRuntimeEventWaitTests.swift new file mode 100644 index 0000000..82d8c5e --- /dev/null +++ b/Tests/AppRuntimeKitAppKitTests/Runtime/ApplicationRuntimeEventWaitTests.swift @@ -0,0 +1,203 @@ +import AppKit +import XCTest + +import AppRuntimeKit +@testable import AppRuntimeKitAppKit + +@MainActor +final class ApplicationRuntimeEventWaitTests: XCTestCase { + func testApplicationRuntimeLifecycleCasesReturnNilOnTimeout() async { + let eventSource = TestApplicationRuntimeEventSource() + let runtime = applicationRuntime(eventSource: eventSource) + + for wait in ApplicationRuntimeLifecycleCase.allCases { + let result = await wait.waitForSnapshot( + from: runtime, + bundleIdentifier: Self.missingBundleIdentifier, + timeout: Self.shortTimeout + ) + + XCTAssertNil(result, wait.label) + } + } + + func testWaitForApplicationEventReturnsNilOnTimeout() async { + let eventSource = TestApplicationRuntimeEventSource() + let runtime = applicationRuntime(eventSource: eventSource) + let result = await runtime.waitForApplicationEvent( + .activated, + bundleIdentifier: Self.missingBundleIdentifier, + timeout: Self.shortTimeout + ) + + XCTAssertNil(result) + } + + func testWaitForApplicationEventIgnoresDifferentLifecycle() async { + let eventSource = TestApplicationRuntimeEventSource() + let application = applicationRuntimeEventSnapshot() + let bundleIdentifier = application.bundleIdentifier! + let runtime = applicationRuntime(eventSource: eventSource) + let task = applicationRuntimeEventWaitTask( + runtime: runtime, + lifecycle: .activated, + bundleIdentifier: bundleIdentifier, + timeout: Self.shortTimeout + ) + + eventSource.emit(.launch, snapshot: application) + + let result = await task.value + XCTAssertNil(result) + } + + func testWaitForApplicationEventContinuesAfterDifferentLifecycle() async { + let eventSource = TestApplicationRuntimeEventSource() + let application = applicationRuntimeEventSnapshot() + let bundleIdentifier = application.bundleIdentifier! + let runtime = applicationRuntime(eventSource: eventSource) + let task = applicationRuntimeEventWaitTask( + runtime: runtime, + lifecycle: .activated, + bundleIdentifier: bundleIdentifier, + timeout: Self.longTimeout + ) + + eventSource.emit(.launch, snapshot: application) + eventSource.emit(.activation, snapshot: application) + + let result = await task.value + XCTAssertEqual(result?.lifecycle, .activated) + XCTAssertEqual(result?.application.bundleIdentifier, bundleIdentifier) + } + + func testWaitForApplicationEventIgnoresDifferentBundleIdentifier() async { + let eventSource = TestApplicationRuntimeEventSource() + let application = applicationRuntimeEventSnapshot() + let runtime = applicationRuntime(eventSource: eventSource) + let task = applicationRuntimeEventWaitTask( + runtime: runtime, + lifecycle: .activated, + bundleIdentifier: Self.missingBundleIdentifier, + timeout: Self.shortTimeout + ) + + eventSource.emit(.activation, snapshot: application) + + let result = await task.value + XCTAssertNil(result) + } + + func testWaitForApplicationEventIgnoresEventsWithoutApplicationSnapshot() + async throws + { + let eventSource = TestApplicationRuntimeEventSource() + let application = applicationRuntimeEventSnapshot() + let bundleIdentifier = application.bundleIdentifier! + let runtime = applicationRuntime(eventSource: eventSource) + let task = applicationRuntimeEventWaitTask( + runtime: runtime, + lifecycle: .activated, + bundleIdentifier: bundleIdentifier, + timeout: Self.longTimeout + ) + + eventSource.emit(.activation, snapshot: nil) + eventSource.emit(.activation, snapshot: application) + + let result = await task.value + XCTAssertEqual(result?.lifecycle, .activated) + XCTAssertEqual(result?.application.bundleIdentifier, bundleIdentifier) + } + + func testApplicationRuntimeLifecycleCasesReturnNilForBlankBundleIdentifier() async { + let eventSource = TestApplicationRuntimeEventSource() + let runtime = applicationRuntime(eventSource: eventSource) + + for wait in ApplicationRuntimeLifecycleCase.allCases { + let result = await wait.waitForSnapshot( + from: runtime, + bundleIdentifier: " ", + timeout: Self.longTimeout + ) + + XCTAssertNil(result, wait.label) + } + } + + func testWaitForApplicationEventReturnsNilForBlankBundleIdentifier() async { + let eventSource = TestApplicationRuntimeEventSource() + let runtime = applicationRuntime(eventSource: eventSource) + let result = await runtime.waitForApplicationEvent( + .activated, + bundleIdentifier: " ", + timeout: Self.longTimeout + ) + + XCTAssertNil(result) + } + + func testApplicationRuntimeLifecycleCasesContinueAfterDifferentLifecycle() async { + let eventSource = TestApplicationRuntimeEventSource() + let application = applicationRuntimeEventSnapshot() + let bundleIdentifier = application.bundleIdentifier! + let runtime = applicationRuntime(eventSource: eventSource) + + for lifecycleCase in ApplicationRuntimeLifecycleCase.allCases { + let task = lifecycleCase.waitForSnapshotTask( + from: runtime, + bundleIdentifier: bundleIdentifier, + timeout: Self.longTimeout + ) + + eventSource.emit(lifecycleCase.differentLifecycleCase, snapshot: application) + eventSource.emit(lifecycleCase, snapshot: application) + + let result = await task.value + XCTAssertEqual(result?.bundleIdentifier, bundleIdentifier, lifecycleCase.label) + } + } + + func testApplicationRuntimeLifecycleCasesReturnMatchingApplication() async { + let eventSource = TestApplicationRuntimeEventSource() + let application = applicationRuntimeEventSnapshot() + let bundleIdentifier = application.bundleIdentifier! + let runtime = applicationRuntime(eventSource: eventSource) + + for lifecycleCase in ApplicationRuntimeLifecycleCase.allCases { + let task = lifecycleCase.waitForSnapshotTask( + from: runtime, + bundleIdentifier: " \(bundleIdentifier)\n", + timeout: Self.longTimeout + ) + + eventSource.emit(lifecycleCase, snapshot: application) + + let result = await task.value + XCTAssertEqual(result?.bundleIdentifier, bundleIdentifier, lifecycleCase.label) + } + } + + func testWaitForApplicationEventReturnsMatchingEvent() async { + let eventSource = TestApplicationRuntimeEventSource() + let application = applicationRuntimeEventSnapshot() + let bundleIdentifier = application.bundleIdentifier! + let runtime = applicationRuntime(eventSource: eventSource) + let task = applicationRuntimeEventWaitTask( + runtime: runtime, + lifecycle: .activated, + bundleIdentifier: " \(bundleIdentifier)\n", + timeout: Self.longTimeout + ) + + eventSource.emit(.activation, snapshot: application) + + let result = await task.value + XCTAssertEqual(result?.lifecycle, .activated) + XCTAssertEqual(result?.application.bundleIdentifier, bundleIdentifier) + } + + private static let missingBundleIdentifier = "com.example.missing.app" + private static let shortTimeout = Duration.milliseconds(50) + private static let longTimeout = Duration.seconds(1) +} diff --git a/Tests/AppRuntimeKitAppKitTests/Runtime/ApplicationRuntimeEventWaitingTests.swift b/Tests/AppRuntimeKitAppKitTests/Runtime/ApplicationRuntimeEventWaitingTests.swift deleted file mode 100644 index 6b4ca2a..0000000 --- a/Tests/AppRuntimeKitAppKitTests/Runtime/ApplicationRuntimeEventWaitingTests.swift +++ /dev/null @@ -1,244 +0,0 @@ -import AppKit -import XCTest - -import AppRuntimeKit -@testable import AppRuntimeKitAppKit - -@MainActor -final class ApplicationRuntimeEventWaitingTests: XCTestCase { - func testApplicationRuntimeLifecycleCasesReturnNilOnTimeout() async { - let runtime = ApplicationRuntime() - - for wait in ApplicationRuntimeLifecycleCase.allCases { - let result = await wait.waitForSnapshot( - from: runtime, - bundleIdentifier: Self.missingBundleIdentifier, - timeout: Self.shortTimeout - ) - - XCTAssertNil(result, wait.label) - } - } - - func testWaitForApplicationEventReturnsNilOnTimeout() async { - let runtime = ApplicationRuntime() - let result = await runtime.waitForApplicationEvent( - .activated, - bundleIdentifier: Self.missingBundleIdentifier, - timeout: Self.shortTimeout - ) - - XCTAssertNil(result) - } - - func testWaitForApplicationEventIgnoresDifferentEventKind() async throws { - let fixture = try applicationRuntimeEventNotificationFixtureOrSkip() - let application = fixture.application - let bundleIdentifier = fixture.bundleIdentifier - let runtime = ApplicationRuntime() - let task = Self.eventWaitTask( - runtime: runtime, - kind: .activated, - bundleIdentifier: bundleIdentifier, - timeout: Self.shortTimeout - ) - await Task.yield() - - postApplicationRuntimeEventNotification( - NSWorkspace.didLaunchApplicationNotification, - application: application - ) - - let result = await task.value - XCTAssertNil(result) - } - - func testWaitForApplicationEventContinuesAfterDifferentEventKind() async throws { - let fixture = try applicationRuntimeEventNotificationFixtureOrSkip() - let application = fixture.application - let bundleIdentifier = fixture.bundleIdentifier - let runtime = ApplicationRuntime() - let task = Self.eventWaitTask( - runtime: runtime, - kind: .activated, - bundleIdentifier: bundleIdentifier, - timeout: Self.longTimeout - ) - await Task.yield() - - postApplicationRuntimeEventNotification( - NSWorkspace.didLaunchApplicationNotification, - application: application - ) - postApplicationRuntimeEventNotification( - NSWorkspace.didActivateApplicationNotification, - application: application - ) - - let result = await task.value - XCTAssertEqual(result?.kind, .activated) - XCTAssertEqual(result?.application.bundleIdentifier, bundleIdentifier) - } - - func testWaitForApplicationEventIgnoresDifferentBundleIdentifier() async throws { - let application = try applicationRuntimeEventNotificationApplicationOrSkip() - let runtime = ApplicationRuntime() - let task = Self.eventWaitTask( - runtime: runtime, - kind: .activated, - bundleIdentifier: Self.missingBundleIdentifier, - timeout: Self.shortTimeout - ) - await Task.yield() - - postApplicationRuntimeEventNotification( - NSWorkspace.didActivateApplicationNotification, - application: application - ) - - let result = await task.value - XCTAssertNil(result) - } - - func testWaitForApplicationEventIgnoresNotificationsWithoutRunningApplication() - async throws - { - let fixture = try applicationRuntimeEventNotificationFixtureOrSkip() - let application = fixture.application - let bundleIdentifier = fixture.bundleIdentifier - let runtime = ApplicationRuntime() - let task = Self.eventWaitTask( - runtime: runtime, - kind: .activated, - bundleIdentifier: bundleIdentifier, - timeout: Self.longTimeout - ) - await Task.yield() - - postApplicationRuntimeEventNotification( - NSWorkspace.didActivateApplicationNotification, - application: nil - ) - postApplicationRuntimeEventNotification( - NSWorkspace.didActivateApplicationNotification, - application: application - ) - - let result = await task.value - XCTAssertEqual(result?.kind, .activated) - XCTAssertEqual(result?.application.bundleIdentifier, bundleIdentifier) - } - - func testApplicationRuntimeLifecycleCasesReturnNilForBlankBundleIdentifier() async { - let runtime = ApplicationRuntime() - - for wait in ApplicationRuntimeLifecycleCase.allCases { - let result = await wait.waitForSnapshot( - from: runtime, - bundleIdentifier: " ", - timeout: Self.longTimeout - ) - - XCTAssertNil(result, wait.label) - } - } - - func testWaitForApplicationEventReturnsNilForBlankBundleIdentifier() async { - let runtime = ApplicationRuntime() - let result = await runtime.waitForApplicationEvent( - .activated, - bundleIdentifier: " ", - timeout: Self.longTimeout - ) - - XCTAssertNil(result) - } - - func testApplicationRuntimeLifecycleCasesContinueAfterDifferentEventKind() async throws { - let fixture = try applicationRuntimeEventNotificationFixtureOrSkip() - let application = fixture.application - let bundleIdentifier = fixture.bundleIdentifier - let runtime = ApplicationRuntime() - - for wait in ApplicationRuntimeLifecycleCase.allCases { - let task = wait.waitForSnapshotTask( - from: runtime, - bundleIdentifier: bundleIdentifier, - timeout: Self.longTimeout - ) - await Task.yield() - - postApplicationRuntimeEventNotification( - wait.differentNotificationName, - application: application - ) - postApplicationRuntimeEventNotification(wait.notificationName, application: application) - - let result = await task.value - XCTAssertEqual(result?.bundleIdentifier, bundleIdentifier, wait.label) - } - } - - func testApplicationRuntimeLifecycleCasesReturnMatchingApplication() async throws { - let fixture = try applicationRuntimeEventNotificationFixtureOrSkip() - let application = fixture.application - let bundleIdentifier = fixture.bundleIdentifier - let runtime = ApplicationRuntime() - - for wait in ApplicationRuntimeLifecycleCase.allCases { - let task = wait.waitForSnapshotTask( - from: runtime, - bundleIdentifier: " \(bundleIdentifier)\n", - timeout: Self.longTimeout - ) - await Task.yield() - - postApplicationRuntimeEventNotification(wait.notificationName, application: application) - - let result = await task.value - XCTAssertEqual(result?.bundleIdentifier, bundleIdentifier, wait.label) - } - } - - func testWaitForApplicationEventReturnsMatchingEvent() async throws { - let fixture = try applicationRuntimeEventNotificationFixtureOrSkip() - let application = fixture.application - let bundleIdentifier = fixture.bundleIdentifier - let runtime = ApplicationRuntime() - let task = Self.eventWaitTask( - runtime: runtime, - kind: .activated, - bundleIdentifier: " \(bundleIdentifier)\n", - timeout: Self.longTimeout - ) - await Task.yield() - - postApplicationRuntimeEventNotification( - NSWorkspace.didActivateApplicationNotification, - application: application - ) - - let result = await task.value - XCTAssertEqual(result?.kind, .activated) - XCTAssertEqual(result?.application.bundleIdentifier, bundleIdentifier) - } - - private static let missingBundleIdentifier = "com.example.missing.app" - private static let shortTimeout = Duration.milliseconds(50) - private static let longTimeout = Duration.seconds(1) - - private static func eventWaitTask( - runtime: ApplicationRuntime, - kind: ApplicationRuntimeEvent.Kind, - bundleIdentifier: String, - timeout: Duration - ) -> Task { - Task { - await runtime.waitForApplicationEvent( - kind, - bundleIdentifier: bundleIdentifier, - timeout: timeout - ) - } - } -} diff --git a/Tests/AppRuntimeKitAppKitTests/Runtime/ApplicationRuntimeEventsTests.swift b/Tests/AppRuntimeKitAppKitTests/Runtime/ApplicationRuntimeEventsTests.swift index 0543cef..63cd5f4 100644 --- a/Tests/AppRuntimeKitAppKitTests/Runtime/ApplicationRuntimeEventsTests.swift +++ b/Tests/AppRuntimeKitAppKitTests/Runtime/ApplicationRuntimeEventsTests.swift @@ -1,4 +1,3 @@ -import AppKit import XCTest import AppRuntimeKit @@ -6,9 +5,10 @@ import AppRuntimeKit @MainActor final class ApplicationRuntimeEventsTests: XCTestCase { - func testApplicationRuntimeEventsYieldsLifecycleKindAndApplication() async throws { - let application = try applicationRuntimeEventNotificationApplicationOrSkip() - let runtime = ApplicationRuntime() + func testApplicationRuntimeEventsYieldsLifecycleAndApplication() async { + let eventSource = TestApplicationRuntimeEventSource() + let application = applicationRuntimeEventSnapshot() + let runtime = applicationRuntime(eventSource: eventSource) let stream = runtime.events() let task = Task { () -> [ApplicationRuntimeEvent] in var values: [ApplicationRuntimeEvent] = [] @@ -20,147 +20,135 @@ final class ApplicationRuntimeEventsTests: XCTestCase { } return values } - await Task.yield() - for event in ApplicationRuntimeLifecycleCase.allCases { - postApplicationRuntimeEventNotification(event.notificationName, application: application) + for lifecycleCase in ApplicationRuntimeLifecycleCase.allCases { + eventSource.emit(lifecycleCase, snapshot: application) } let results = await task.value XCTAssertEqual( - results.map(\.kind), ApplicationRuntimeLifecycleCase.allCases.map(\.kind)) + results.map(\.lifecycle), ApplicationRuntimeLifecycleCase.allCases.map(\.lifecycle)) for result in results { - assertApplicationRuntimeEventNotificationSnapshot( + assertApplicationRuntimeEventSnapshot( result.application, - application: application + matches: application ) } } - func testApplicationRuntimeEventsIgnoresNotificationsWithoutRunningApplication() async throws { - let application = try applicationRuntimeEventNotificationApplicationOrSkip() - let runtime = ApplicationRuntime() - let task = nextEventTask(from: runtime.events()) - await Task.yield() + func testApplicationRuntimeEventsIgnoreEventsWithoutApplicationSnapshot() async { + let eventSource = TestApplicationRuntimeEventSource() + let application = applicationRuntimeEventSnapshot() + let runtime = applicationRuntime(eventSource: eventSource) + let task = firstValueTask(from: runtime.events()) - postApplicationRuntimeEventNotification( - NSWorkspace.didLaunchApplicationNotification, - application: nil - ) - postApplicationRuntimeEventNotification( - NSWorkspace.didLaunchApplicationNotification, - application: application - ) + eventSource.emit(.launch, snapshot: nil) + eventSource.emit(.launch, snapshot: application) let result = await task.value - XCTAssertEqual(result?.kind, .launched) - assertApplicationRuntimeEventNotificationSnapshot( + XCTAssertEqual(result?.lifecycle, .launched) + assertApplicationRuntimeEventSnapshot( result?.application, - application: application + matches: application ) } - func testApplicationRuntimeSnapshotStreamsYieldMatchingNotifications() async throws { - let application = try applicationRuntimeEventNotificationApplicationOrSkip() - let runtime = ApplicationRuntime() + func testApplicationRuntimeSnapshotStreamsYieldMatchingEvents() async { + let eventSource = TestApplicationRuntimeEventSource() + let application = applicationRuntimeEventSnapshot() + let runtime = applicationRuntime(eventSource: eventSource) - for event in ApplicationRuntimeLifecycleCase.allCases { - let task = nextSnapshotTask(from: event.snapshotStream(from: runtime)) - await Task.yield() + for lifecycleCase in ApplicationRuntimeLifecycleCase.allCases { + let task = firstValueTask(from: lifecycleCase.applicationSnapshots(from: runtime)) - postApplicationRuntimeEventNotification(event.notificationName, application: application) + eventSource.emit(lifecycleCase, snapshot: application) let result = await task.value - assertApplicationRuntimeEventNotificationSnapshot(result, application: application) + assertApplicationRuntimeEventSnapshot(result, matches: application) } } - func testApplicationRuntimeSnapshotStreamsIgnoreOtherNotifications() async { - let runtime = ApplicationRuntime() + func testApplicationRuntimeSnapshotStreamsIgnoreOtherLifecycleEvents() async { + let application = applicationRuntimeEventSnapshot() - for event in ApplicationRuntimeLifecycleCase.allCases { - let task = nextSnapshotTask(from: event.snapshotStream(from: runtime)) - await Task.yield() + for lifecycleCase in ApplicationRuntimeLifecycleCase.allCases { + let eventSource = TestApplicationRuntimeEventSource() + let runtime = applicationRuntime(eventSource: eventSource) + let task = firstValueTask(from: lifecycleCase.applicationSnapshots(from: runtime)) - postApplicationRuntimeEventNotification(event.differentNotificationName) - await Task.yield() + eventSource.emit(lifecycleCase.differentLifecycleCase, snapshot: application) - XCTAssertFalse(task.isCancelled, event.label) + XCTAssertFalse(task.isCancelled, lifecycleCase.label) task.cancel() let result = await task.value - XCTAssertNil(result, event.label) + XCTAssertNil(result, lifecycleCase.label) } } - func testApplicationRuntimeSnapshotStreamsIgnoreNotificationsWithoutRunningApplication() + func testApplicationRuntimeSnapshotStreamsIgnoreEventsWithoutApplicationSnapshot() async { - let runtime = ApplicationRuntime() - - for event in ApplicationRuntimeLifecycleCase.allCases { - let task = nextSnapshotTask(from: event.snapshotStream(from: runtime)) - await Task.yield() + for lifecycleCase in ApplicationRuntimeLifecycleCase.allCases { + let eventSource = TestApplicationRuntimeEventSource() + let runtime = applicationRuntime(eventSource: eventSource) + let task = firstValueTask(from: lifecycleCase.applicationSnapshots(from: runtime)) - postApplicationRuntimeEventNotification(event.notificationName, application: nil) - await Task.yield() + eventSource.emit(lifecycleCase, snapshot: nil) - XCTAssertFalse(task.isCancelled, event.label) + XCTAssertFalse(task.isCancelled, lifecycleCase.label) task.cancel() let result = await task.value - XCTAssertNil(result, event.label) + XCTAssertNil(result, lifecycleCase.label) } } func testApplicationRuntimeSnapshotStreamsStopAfterCancellation() async { - let runtime = ApplicationRuntime() + let application = applicationRuntimeEventSnapshot() - for event in ApplicationRuntimeLifecycleCase.allCases { - let task = nextSnapshotTask(from: event.snapshotStream(from: runtime)) - await Task.yield() + for lifecycleCase in ApplicationRuntimeLifecycleCase.allCases { + let eventSource = TestApplicationRuntimeEventSource() + let runtime = applicationRuntime(eventSource: eventSource) + let task = firstValueTask(from: lifecycleCase.applicationSnapshots(from: runtime)) task.cancel() let result = await task.value - XCTAssertNil(result, event.label) + XCTAssertNil(result, lifecycleCase.label) - postApplicationRuntimeEventNotification(event.notificationName) - await Task.yield() + eventSource.emit(lifecycleCase, snapshot: application) } } func testApplicationRuntimeEventsStopsAfterCancellation() async { - let runtime = ApplicationRuntime() - let task = nextEventTask(from: runtime.events()) - await Task.yield() + let eventSource = TestApplicationRuntimeEventSource() + let application = applicationRuntimeEventSnapshot() + let runtime = applicationRuntime(eventSource: eventSource) + let task = firstValueTask(from: runtime.events()) task.cancel() let result = await task.value XCTAssertNil(result) - for event in ApplicationRuntimeLifecycleCase.allCases { - postApplicationRuntimeEventNotification(event.notificationName) + for lifecycleCase in ApplicationRuntimeLifecycleCase.allCases { + eventSource.emit(lifecycleCase, snapshot: application) } - await Task.yield() } - private func nextEventTask( - from stream: AsyncStream - ) -> Task { - Task { - for await value in stream { - return value - } - return nil - } + func testApplicationRuntimeEventSourceFinishesEmptyLifecycleStreams() async { + let eventSource = TestApplicationRuntimeEventSource() + let task = firstValueTask(from: eventSource.eventStream(lifecycles: [])) + + let result = await task.value + XCTAssertNil(result) } - private func nextSnapshotTask( - from stream: AsyncStream - ) -> Task { - Task { - for await value in stream { - return value - } - return nil - } + func testWorkspaceApplicationRuntimeEventSourceFinishesEmptyLifecycleStreams() + async + { + let eventSource = WorkspaceApplicationRuntimeEventSource(workspace: .shared) + let task = firstValueTask(from: eventSource.eventStream(lifecycles: [])) + + let result = await task.value + XCTAssertNil(result) } + } diff --git a/Tests/AppRuntimeKitAppKitTests/Runtime/ApplicationRuntimeOpeningTests.swift b/Tests/AppRuntimeKitAppKitTests/Runtime/ApplicationRuntimeOpeningTests.swift index 8b24acb..00b96e4 100644 --- a/Tests/AppRuntimeKitAppKitTests/Runtime/ApplicationRuntimeOpeningTests.swift +++ b/Tests/AppRuntimeKitAppKitTests/Runtime/ApplicationRuntimeOpeningTests.swift @@ -7,183 +7,190 @@ import AppRuntimeKit @MainActor final class ApplicationRuntimeOpeningTests: XCTestCase { - func testOpenURLUsesDependency() throws { - let url = try Self.webURL() - let recorder = SynchronousOpenRecorder() - let runtime = recorder.runtimeOpeningURL() + func testOpenURLRequestsURLOpening() throws { + let url = try webURL() + let recorder = SynchronousOpeningRequestRecorder() + let runtime = recorder.runtimeRequestingOpening() - XCTAssertEqual(runtime.openURL(url), .opened) - recorder.assertOpened(url) + XCTAssertEqual(runtime.openURL(url), .requestAccepted) + recorder.assertRequestedOpening(url) } - func testOpenURLReportsRequestFailure() throws { - let runtime = SynchronousOpenRecorder(result: false).runtimeOpeningURL() + func testOpenURLReportsRequestRejection() throws { + let runtime = SynchronousOpeningRequestRecorder(result: false).runtimeRequestingOpening() - XCTAssertOpenFailure( - runtime.openURL(try Self.webURL()), - .requestFailed + XCTAssertFailedOpeningOutcome( + runtime.openURL(try webURL()), + .requestRejected ) } - func testOpenFileUsesDependency() throws { + func testOpenFileRequestsURLOpening() throws { let fileURL = try temporaryFileURL() defer { removeTemporaryItem(at: fileURL) } - let recorder = SynchronousOpenRecorder() - let runtime = recorder.runtimeOpeningURL() + let recorder = SynchronousOpeningRequestRecorder() + let runtime = recorder.runtimeRequestingOpening() - XCTAssertEqual(runtime.openFile(fileURL), .opened) - recorder.assertOpened(fileURL) + XCTAssertEqual(runtime.openFile(fileURL), .requestAccepted) + recorder.assertRequestedOpening(fileURL) } - func testOpenFileReportsRequestFailure() throws { + func testOpenFileReportsRequestRejection() throws { let fileURL = try temporaryFileURL() defer { removeTemporaryItem(at: fileURL) } - let runtime = SynchronousOpenRecorder(result: false).runtimeOpeningURL() + let runtime = SynchronousOpeningRequestRecorder(result: false).runtimeRequestingOpening() - XCTAssertOpenFailure(runtime.openFile(fileURL), .requestFailed) + XCTAssertFailedOpeningOutcome(runtime.openFile(fileURL), .requestRejected) } - func testOpenApplicationByBundleIdentifierUsesTrimmedLookup() throws { + func testOpenApplicationByBundleIdentifierRequestsTrimmedResolvedApplicationOpening() throws { let applicationURL = try temporaryApplicationBundleURL() defer { removeTemporaryItem(at: applicationURL) } - let recorder = InstalledApplicationOpenRecorder(applicationURL: applicationURL) - let runtime = recorder.runtimeOpeningApplicationURL() + let recorder = InstalledApplicationOpeningRequestRecorder(applicationURL: applicationURL) + let runtime = recorder.runtimeRequestingResolvedApplicationOpening() XCTAssertEqual( runtime.openApplication(bundleIdentifier: Self.paddedBundleIdentifier), - .opened + .requestAccepted ) recorder.assertLookedUp(Self.bundleIdentifier) - recorder.assertOpened(applicationURL) + recorder.assertRequestedOpening(applicationURL) } func testOpenApplicationByBundleIdentifierValidatesResolvedURL() throws { let fileURL = try temporaryFileURL() defer { removeTemporaryItem(at: fileURL) } - let runtime = InstalledApplicationOpenRecorder( + let runtime = InstalledApplicationOpeningRequestRecorder( applicationURL: fileURL, - unexpectedOpenMessage: "Invalid resolved application URL should not be opened." - ).runtimeOpeningApplicationURL() + unexpectedOpenMessage: "Invalid resolved application URL should not request opening." + ).runtimeRequestingResolvedApplicationOpening() - XCTAssertOpenFailure( + XCTAssertFailedOpeningOutcome( runtime.openApplication(bundleIdentifier: Self.invalidBundleIdentifier), - .invalidApplicationURL + .invalidApplicationBundleURL ) } func testOpenApplicationByBundleIdentifierReportsMissingApplication() { - let runtime = InstalledApplicationOpenRecorder( + let runtime = InstalledApplicationOpeningRequestRecorder( applicationURL: nil, - unexpectedOpenMessage: "Missing bundle identifier lookup should not open a URL." - ).runtimeOpeningApplicationURL() + unexpectedOpenMessage: "Missing bundle identifier lookup should not request URL opening." + ).runtimeRequestingResolvedApplicationOpening() - XCTAssertOpenFailure( + XCTAssertFailedOpeningOutcome( runtime.openApplication(bundleIdentifier: Self.missingBundleIdentifier), - .applicationNotFound + .installedApplicationNotFound ) } - func testOpenApplicationByBundleIdentifierReportsRequestFailure() throws { + func testOpenApplicationByBundleIdentifierReportsRequestRejection() throws { let applicationURL = try temporaryApplicationBundleURL() defer { removeTemporaryItem(at: applicationURL) } - let runtime = InstalledApplicationOpenRecorder( + let runtime = InstalledApplicationOpeningRequestRecorder( applicationURL: applicationURL, result: false - ).runtimeOpeningApplicationURL() + ).runtimeRequestingResolvedApplicationOpening() - XCTAssertOpenFailure( + XCTAssertFailedOpeningOutcome( runtime.openApplication(bundleIdentifier: Self.bundleIdentifier), - .requestFailed + .requestRejected ) } - func testOpenApplicationAtURLUsesApplicationOpenDependency() throws { + func testOpenApplicationAtURLRequestsApplicationOpening() throws { let applicationDirectory = try temporaryApplicationBundleURL() defer { removeTemporaryItem(at: applicationDirectory) } - let recorder = SynchronousOpenRecorder() - let runtime = recorder.runtimeOpeningApplicationURL() + let recorder = SynchronousOpeningRequestRecorder() + let runtime = recorder.runtimeRequestingOpening() - XCTAssertEqual(runtime.openApplication(at: applicationDirectory), .opened) - recorder.assertOpened(applicationDirectory) + XCTAssertEqual(runtime.openApplication(at: applicationDirectory), .requestAccepted) + recorder.assertRequestedOpening(applicationDirectory) } - func testOpenApplicationAtURLReportsRequestFailure() throws { + func testOpenApplicationAtURLReportsRequestRejection() throws { let applicationDirectory = try temporaryApplicationBundleURL() defer { removeTemporaryItem(at: applicationDirectory) } - let runtime = SynchronousOpenRecorder(result: false) - .runtimeOpeningApplicationURL() + let runtime = SynchronousOpeningRequestRecorder(result: false) + .runtimeRequestingOpening() - XCTAssertOpenFailure(runtime.openApplication(at: applicationDirectory), .requestFailed) + XCTAssertFailedOpeningOutcome( + runtime.openApplication(at: applicationDirectory), + .requestRejected + ) } - func testAsyncOpenURLUsesDependency() async throws { - let url = try Self.webURL() + func testAsyncOpenURLRequestsConfiguredURLOpening() async throws { + let url = try webURL() let configuration = NSWorkspace.OpenConfiguration() - let recorder = AsynchronousOpenRecorder() - let runtime = recorder.runtimeOpeningURL() + let recorder = ConfiguredOpeningRequestRecorder() + let runtime = recorder.runtimeRequestingConfiguredURLOpening() let result = await runtime.openURL(url, configuration: configuration) - XCTAssertEqual(result, .opened) - recorder.assertOpened(url, configuration: configuration) + XCTAssertEqual(result, .requestAccepted) + recorder.assertRequestedOpening(url, configuration: configuration) } - func testAsyncOpenURLReportsRequestFailure() async throws { - let runtime = AsynchronousOpenRecorder(result: false).runtimeOpeningURL() + func testAsyncOpenURLReportsRequestRejection() async throws { + let runtime = + ConfiguredOpeningRequestRecorder(result: false).runtimeRequestingConfiguredURLOpening() let result = await runtime.openURL( - try Self.webURL(), + try webURL(), configuration: NSWorkspace.OpenConfiguration() ) - XCTAssertOpenFailure(result, .requestFailed) + XCTAssertFailedOpeningOutcome(result, .requestRejected) } - func testAsyncOpenFileUsesDependency() async throws { + func testAsyncOpenFileRequestsConfiguredURLOpening() async throws { let fileURL = try temporaryFileURL() defer { removeTemporaryItem(at: fileURL) } let configuration = NSWorkspace.OpenConfiguration() - let recorder = AsynchronousOpenRecorder() - let runtime = recorder.runtimeOpeningURL() + let recorder = ConfiguredOpeningRequestRecorder() + let runtime = recorder.runtimeRequestingConfiguredURLOpening() let result = await runtime.openFile(fileURL, configuration: configuration) - XCTAssertEqual(result, .opened) - recorder.assertOpened(fileURL, configuration: configuration) + XCTAssertEqual(result, .requestAccepted) + recorder.assertRequestedOpening(fileURL, configuration: configuration) } - func testAsyncOpenFileReportsRequestFailure() async throws { + func testAsyncOpenFileReportsRequestRejection() async throws { let fileURL = try temporaryFileURL() defer { removeTemporaryItem(at: fileURL) } - let runtime = AsynchronousOpenRecorder(result: false).runtimeOpeningURL() + let runtime = + ConfiguredOpeningRequestRecorder(result: false).runtimeRequestingConfiguredURLOpening() let result = await runtime.openFile( fileURL, configuration: NSWorkspace.OpenConfiguration() ) - XCTAssertOpenFailure(result, .requestFailed) + XCTAssertFailedOpeningOutcome(result, .requestRejected) } - func testAsyncOpenApplicationByBundleIdentifierUsesTrimmedLookup() async throws { + func testAsyncOpenApplicationByBundleIdentifierRequestsTrimmedResolvedApplicationOpening() + async throws + { let applicationURL = try temporaryApplicationBundleURL() defer { removeTemporaryItem(at: applicationURL) } let configuration = NSWorkspace.OpenConfiguration() - let recorder = InstalledApplicationOpenRecorder(applicationURL: applicationURL) - let runtime = recorder.runtimeOpeningApplicationURLWithConfiguration() + let recorder = InstalledApplicationOpeningRequestRecorder(applicationURL: applicationURL) + let runtime = recorder.runtimeRequestingConfiguredResolvedApplicationOpening() let result = await runtime.openApplication( bundleIdentifier: Self.paddedBundleIdentifier, configuration: configuration ) - XCTAssertEqual(result, .opened) + XCTAssertEqual(result, .requestAccepted) recorder.assertLookedUp(Self.bundleIdentifier) - recorder.assertOpened(applicationURL, configuration: configuration) + recorder.assertRequestedOpening(applicationURL, configuration: configuration) } func testAsyncOpenApplicationByBundleIdentifierValidatesResolvedURL() @@ -191,82 +198,82 @@ final class ApplicationRuntimeOpeningTests: XCTestCase { { let fileURL = try temporaryFileURL() defer { removeTemporaryItem(at: fileURL) } - let runtime = InstalledApplicationOpenRecorder( + let runtime = InstalledApplicationOpeningRequestRecorder( applicationURL: fileURL, - unexpectedOpenMessage: "Invalid resolved application URL should not be opened." - ).runtimeOpeningApplicationURLWithConfiguration() + unexpectedOpenMessage: "Invalid resolved application URL should not request opening." + ).runtimeRequestingConfiguredResolvedApplicationOpening() let result = await runtime.openApplication( bundleIdentifier: Self.invalidBundleIdentifier, configuration: NSWorkspace.OpenConfiguration() ) - XCTAssertOpenFailure(result, .invalidApplicationURL) + XCTAssertFailedOpeningOutcome(result, .invalidApplicationBundleURL) } func testAsyncOpenApplicationByBundleIdentifierReportsMissingApplication() async { - let runtime = InstalledApplicationOpenRecorder( + let runtime = InstalledApplicationOpeningRequestRecorder( applicationURL: nil, - unexpectedOpenMessage: "Missing bundle identifier lookup should not open a URL." - ).runtimeOpeningApplicationURLWithConfiguration() + unexpectedOpenMessage: "Missing bundle identifier lookup should not request URL opening." + ).runtimeRequestingConfiguredResolvedApplicationOpening() let result = await runtime.openApplication( bundleIdentifier: Self.missingBundleIdentifier, configuration: NSWorkspace.OpenConfiguration() ) - XCTAssertOpenFailure(result, .applicationNotFound) + XCTAssertFailedOpeningOutcome(result, .installedApplicationNotFound) } - func testAsyncOpenApplicationByBundleIdentifierReportsRequestFailure() + func testAsyncOpenApplicationByBundleIdentifierReportsRequestRejection() async throws { let applicationURL = try temporaryApplicationBundleURL() defer { removeTemporaryItem(at: applicationURL) } - let runtime = InstalledApplicationOpenRecorder( + let runtime = InstalledApplicationOpeningRequestRecorder( applicationURL: applicationURL, result: false - ).runtimeOpeningApplicationURLWithConfiguration() + ).runtimeRequestingConfiguredResolvedApplicationOpening() let result = await runtime.openApplication( bundleIdentifier: Self.bundleIdentifier, configuration: NSWorkspace.OpenConfiguration() ) - XCTAssertOpenFailure(result, .requestFailed) + XCTAssertFailedOpeningOutcome(result, .requestRejected) } - func testAsyncOpenApplicationAtURLUsesApplicationOpenDependency() async throws { + func testAsyncOpenApplicationAtURLRequestsConfiguredApplicationOpening() async throws { let applicationDirectory = try temporaryApplicationBundleURL() defer { removeTemporaryItem(at: applicationDirectory) } let configuration = NSWorkspace.OpenConfiguration() - let recorder = AsynchronousOpenRecorder() - let runtime = recorder.runtimeOpeningApplicationURL() + let recorder = ConfiguredOpeningRequestRecorder() + let runtime = recorder.runtimeRequestingConfiguredApplicationOpening() let result = await runtime.openApplication( at: applicationDirectory, configuration: configuration ) - XCTAssertEqual(result, .opened) - recorder.assertOpened(applicationDirectory, configuration: configuration) + XCTAssertEqual(result, .requestAccepted) + recorder.assertRequestedOpening(applicationDirectory, configuration: configuration) } - func testAsyncOpenApplicationAtURLReportsRequestFailure() async throws { + func testAsyncOpenApplicationAtURLReportsRequestRejection() async throws { let applicationDirectory = try temporaryApplicationBundleURL() defer { removeTemporaryItem(at: applicationDirectory) } - let runtime = AsynchronousOpenRecorder(result: false) - .runtimeOpeningApplicationURL() + let runtime = ConfiguredOpeningRequestRecorder(result: false) + .runtimeRequestingConfiguredApplicationOpening() let result = await runtime.openApplication( at: applicationDirectory, configuration: NSWorkspace.OpenConfiguration() ) - XCTAssertOpenFailure(result, .requestFailed) + XCTAssertFailedOpeningOutcome(result, .requestRejected) } private static let bundleIdentifier = "com.example.app" @@ -274,220 +281,4 @@ final class ApplicationRuntimeOpeningTests: XCTestCase { private static let missingBundleIdentifier = "com.example.missing" private static let paddedBundleIdentifier = " \(bundleIdentifier)\n" - private static func webURL( - file: StaticString = #filePath, - line: UInt = #line - ) throws -> URL { - try XCTUnwrap(URL(string: "https://example.com"), file: file, line: line) - } -} - -@MainActor -private final class SynchronousOpenRecorder { - private let workspace: NSWorkspace - private let result: Bool - private var openedWorkspace: NSWorkspace? - private var openedURL: URL? - - init( - workspace: NSWorkspace = NSWorkspace(), - result: Bool = true - ) { - self.workspace = workspace - self.result = result - } - - func runtimeOpeningURL() -> ApplicationRuntime { - ApplicationRuntime( - workspace: workspace, - dependencies: ApplicationRuntimeDependencies( - openURL: { workspace, url in - self.openedWorkspace = workspace - self.openedURL = url - return self.result - } - ) - ) - } - - func runtimeOpeningApplicationURL() -> ApplicationRuntime { - ApplicationRuntime( - workspace: workspace, - dependencies: ApplicationRuntimeDependencies( - openApplicationURL: { workspace, url in - self.openedWorkspace = workspace - self.openedURL = url - return self.result - } - ) - ) - } - - func assertOpened( - _ expectedURL: URL, - file: StaticString = #filePath, - line: UInt = #line - ) { - XCTAssertIdentical(openedWorkspace, workspace, file: file, line: line) - XCTAssertEqual(openedURL, expectedURL, file: file, line: line) - } -} - -@MainActor -private final class AsynchronousOpenRecorder { - private let workspace: NSWorkspace - private let result: Bool - private var openedWorkspace: NSWorkspace? - private var openedURL: URL? - private var openedConfiguration: NSWorkspace.OpenConfiguration? - - init( - workspace: NSWorkspace = NSWorkspace(), - result: Bool = true - ) { - self.workspace = workspace - self.result = result - } - - func runtimeOpeningURL() -> ApplicationRuntime { - ApplicationRuntime( - workspace: workspace, - dependencies: ApplicationRuntimeDependencies( - openURLWithConfiguration: { workspace, url, configuration in - self.openedWorkspace = workspace - self.openedURL = url - self.openedConfiguration = configuration - return self.result - } - ) - ) - } - - func runtimeOpeningApplicationURL() -> ApplicationRuntime { - ApplicationRuntime( - workspace: workspace, - dependencies: ApplicationRuntimeDependencies( - openApplicationURLWithConfiguration: { workspace, url, configuration in - self.openedWorkspace = workspace - self.openedURL = url - self.openedConfiguration = configuration - return self.result - } - ) - ) - } - - func assertOpened( - _ expectedURL: URL, - configuration expectedConfiguration: NSWorkspace.OpenConfiguration, - file: StaticString = #filePath, - line: UInt = #line - ) { - XCTAssertIdentical(openedWorkspace, workspace, file: file, line: line) - XCTAssertEqual(openedURL, expectedURL, file: file, line: line) - XCTAssertIdentical(openedConfiguration, expectedConfiguration, file: file, line: line) - } -} - -@MainActor -private final class InstalledApplicationOpenRecorder { - private let workspace: NSWorkspace - private let applicationURL: URL? - private let result: Bool - private let unexpectedOpenMessage: String? - private var lookedUpWorkspace: NSWorkspace? - private var lookedUpBundleIdentifier: String? - private var openedWorkspace: NSWorkspace? - private var openedURL: URL? - private var openedConfiguration: NSWorkspace.OpenConfiguration? - - init( - workspace: NSWorkspace = NSWorkspace(), - applicationURL: URL?, - result: Bool = true, - unexpectedOpenMessage: String? = nil - ) { - self.workspace = workspace - self.applicationURL = applicationURL - self.result = result - self.unexpectedOpenMessage = unexpectedOpenMessage - } - - func runtimeOpeningApplicationURL() -> ApplicationRuntime { - ApplicationRuntime( - workspace: workspace, - dependencies: ApplicationRuntimeDependencies( - installedApplicationURL: lookupInstalledApplicationURL, - openApplicationURL: { workspace, url in - self.recordOpen(workspace: workspace, url: url) - return self.result - } - ) - ) - } - - func runtimeOpeningApplicationURLWithConfiguration() -> ApplicationRuntime { - ApplicationRuntime( - workspace: workspace, - dependencies: ApplicationRuntimeDependencies( - installedApplicationURL: lookupInstalledApplicationURL, - openApplicationURLWithConfiguration: { workspace, url, configuration in - self.recordOpen(workspace: workspace, url: url, configuration: configuration) - return self.result - } - ) - ) - } - - func assertLookedUp( - _ expectedBundleIdentifier: String, - file: StaticString = #filePath, - line: UInt = #line - ) { - XCTAssertIdentical(lookedUpWorkspace, workspace, file: file, line: line) - XCTAssertEqual(lookedUpBundleIdentifier, expectedBundleIdentifier, file: file, line: line) - } - - func assertOpened( - _ expectedURL: URL, - file: StaticString = #filePath, - line: UInt = #line - ) { - XCTAssertIdentical(openedWorkspace, workspace, file: file, line: line) - XCTAssertEqual(openedURL, expectedURL, file: file, line: line) - XCTAssertNil(openedConfiguration, file: file, line: line) - } - - func assertOpened( - _ expectedURL: URL, - configuration expectedConfiguration: NSWorkspace.OpenConfiguration, - file: StaticString = #filePath, - line: UInt = #line - ) { - XCTAssertIdentical(openedWorkspace, workspace, file: file, line: line) - XCTAssertEqual(openedURL, expectedURL, file: file, line: line) - XCTAssertIdentical(openedConfiguration, expectedConfiguration, file: file, line: line) - } - - private func lookupInstalledApplicationURL( - workspace: NSWorkspace, - bundleIdentifier: String - ) -> URL? { - lookedUpWorkspace = workspace - lookedUpBundleIdentifier = bundleIdentifier - return applicationURL - } - - private func recordOpen( - workspace: NSWorkspace, - url: URL, - configuration: NSWorkspace.OpenConfiguration? = nil - ) { - if let unexpectedOpenMessage { - XCTFail(unexpectedOpenMessage) - } - openedWorkspace = workspace - openedURL = url - openedConfiguration = configuration - } } diff --git a/Tests/AppRuntimeKitAppKitTests/Runtime/ApplicationRuntimeOpeningValidationTests.swift b/Tests/AppRuntimeKitAppKitTests/Runtime/ApplicationRuntimeOpeningValidationTests.swift index 5303216..09bd283 100644 --- a/Tests/AppRuntimeKitAppKitTests/Runtime/ApplicationRuntimeOpeningValidationTests.swift +++ b/Tests/AppRuntimeKitAppKitTests/Runtime/ApplicationRuntimeOpeningValidationTests.swift @@ -8,30 +8,30 @@ import AppRuntimeKit @MainActor final class ApplicationRuntimeOpeningValidationTests: XCTestCase { func testValidatesURLInputs() async throws { - await Self.assertOpenURLFailure(try Self.invalidURL(), .invalidURL) + await assertOpeningURLValidationError(try missingURLScheme(), .missingURLScheme) } func testValidatesFileInputs() async throws { - await Self.assertOpenFileFailure(try Self.webURL(), .invalidFileURL) - await Self.assertOpenFileFailure( - Self.missingFileURL(pathExtension: "txt"), - .fileNotFound + await assertOpeningFileValidationError(try webURL(), .nonFileURL) + await assertOpeningFileValidationError( + missingFileURL(pathExtension: "txt"), + .fileSystemItemNotFound ) } func testValidatesBundleIdentifierInputs() async { - await Self.assertOpenApplicationFailure( + await assertOpeningApplicationValidationError( bundleIdentifier: Self.blankBundleIdentifier, .invalidBundleIdentifier ) } - func testValidatesApplicationURLInputs() async throws { - for scenario in InvalidApplicationURLScenario.allCases { + func testValidatesApplicationBundleURLInputs() async throws { + for scenario in InvalidApplicationBundleURLScenario.allCases { let url = try scenario.makeURL() defer { scenario.removeTemporaryItemIfNeeded(at: url) } - await Self.assertOpenApplicationFailure( + await assertOpeningApplicationValidationError( at: url, scenario.expectedError, message: scenario.name @@ -39,14 +39,15 @@ final class ApplicationRuntimeOpeningValidationTests: XCTestCase { } } - func testValidatesResolvedApplicationURLInputs() async throws { - for scenario in InvalidApplicationURLScenario.allCases { + func testValidatesResolvedApplicationBundleURLInputs() async throws { + for scenario in InvalidApplicationBundleURLScenario.allCases { let url = try scenario.makeURL() defer { scenario.removeTemporaryItemIfNeeded(at: url) } - await Self.assertOpenApplicationFailure( - resolvedApplicationURL: url, + await assertOpeningResolvedApplicationValidationError( + url, scenario.expectedError, + bundleIdentifier: Self.bundleIdentifier, message: scenario.name ) } @@ -55,185 +56,9 @@ final class ApplicationRuntimeOpeningValidationTests: XCTestCase { private static let blankBundleIdentifier = " " private static let bundleIdentifier = "com.example.app" - private static func assertOpenURLFailure( - _ url: URL, - _ expectedError: ApplicationOpenError, - file: StaticString = #filePath, - line: UInt = #line - ) async { - let runtime = Self.runtimeRejectingDependencyCalls(file: file, line: line) - XCTAssertOpenFailure(runtime.openURL(url), expectedError, file: file, line: line) - let result = await runtime.openURL( - url, - configuration: NSWorkspace.OpenConfiguration() - ) - XCTAssertOpenFailure(result, expectedError, file: file, line: line) - } - - private static func assertOpenFileFailure( - _ url: URL, - _ expectedError: ApplicationOpenError, - file: StaticString = #filePath, - line: UInt = #line - ) async { - let runtime = Self.runtimeRejectingDependencyCalls(file: file, line: line) - XCTAssertOpenFailure(runtime.openFile(url), expectedError, file: file, line: line) - let result = await runtime.openFile( - url, - configuration: NSWorkspace.OpenConfiguration() - ) - XCTAssertOpenFailure(result, expectedError, file: file, line: line) - } - - private static func assertOpenApplicationFailure( - bundleIdentifier: String, - _ expectedError: ApplicationOpenError, - file: StaticString = #filePath, - line: UInt = #line - ) async { - let runtime = Self.runtimeRejectingDependencyCalls(file: file, line: line) - XCTAssertOpenFailure( - runtime.openApplication(bundleIdentifier: bundleIdentifier), - expectedError, - file: file, - line: line - ) - let result = await runtime.openApplication( - bundleIdentifier: bundleIdentifier, - configuration: NSWorkspace.OpenConfiguration() - ) - XCTAssertOpenFailure(result, expectedError, file: file, line: line) - } - - private static func assertOpenApplicationFailure( - at url: URL, - _ expectedError: ApplicationOpenError, - message: String = "", - file: StaticString = #filePath, - line: UInt = #line - ) async { - let runtime = Self.runtimeRejectingDependencyCalls(file: file, line: line) - XCTAssertOpenFailure( - runtime.openApplication(at: url), - expectedError, - message, - file: file, - line: line - ) - let result = await runtime.openApplication( - at: url, - configuration: NSWorkspace.OpenConfiguration() - ) - XCTAssertOpenFailure(result, expectedError, message, file: file, line: line) - } - - private static func assertOpenApplicationFailure( - resolvedApplicationURL url: URL, - _ expectedError: ApplicationOpenError, - message: String = "", - file: StaticString = #filePath, - line: UInt = #line - ) async { - let runtime = Self.runtimeResolvingApplicationURL( - url, - unexpectedOpenMessage: "Invalid resolved application URL should not be opened.", - file: file, - line: line - ) - XCTAssertOpenFailure( - runtime.openApplication(bundleIdentifier: Self.bundleIdentifier), - expectedError, - message, - file: file, - line: line - ) - let result = await runtime.openApplication( - bundleIdentifier: Self.bundleIdentifier, - configuration: NSWorkspace.OpenConfiguration() - ) - XCTAssertOpenFailure(result, expectedError, message, file: file, line: line) - } - - private static func invalidURL( - file: StaticString = #filePath, - line: UInt = #line - ) throws -> URL { - try XCTUnwrap(URL(string: "invalid"), file: file, line: line) - } - - private static func webURL( - file: StaticString = #filePath, - line: UInt = #line - ) throws -> URL { - try XCTUnwrap(URL(string: "https://example.com"), file: file, line: line) - } - - private static func missingFileURL(pathExtension: String) -> URL { - FileManager.default.temporaryDirectory - .appendingPathComponent("missing-\(UUID().uuidString)") - .appendingPathExtension(pathExtension) - } - - private static func runtimeRejectingDependencyCalls( - file: StaticString = #filePath, - line: UInt = #line - ) -> ApplicationRuntime { - ApplicationRuntime( - dependencies: ApplicationRuntimeDependencies( - installedApplicationURL: { _, _ in - XCTFail("Invalid input should not perform an application lookup.", file: file, line: line) - return nil - }, - openURL: { _, _ in - XCTFail("Invalid input should not open a URL.", file: file, line: line) - return true - }, - openApplicationURL: { _, _ in - XCTFail("Invalid input should not open an application URL.", file: file, line: line) - return true - }, - openURLWithConfiguration: { _, _, _ in - XCTFail("Invalid input should not open a configured URL.", file: file, line: line) - return true - }, - openApplicationURLWithConfiguration: { _, _, _ in - XCTFail( - "Invalid input should not open a configured application URL.", - file: file, - line: line - ) - return true - } - ) - ) - } - - private static func runtimeResolvingApplicationURL( - _ url: URL, - unexpectedOpenMessage: String, - file: StaticString = #filePath, - line: UInt = #line - ) -> ApplicationRuntime { - ApplicationRuntime( - dependencies: ApplicationRuntimeDependencies( - installedApplicationURL: { _, bundleIdentifier in - XCTAssertEqual(bundleIdentifier, Self.bundleIdentifier, file: file, line: line) - return url - }, - openApplicationURL: { _, _ in - XCTFail(unexpectedOpenMessage, file: file, line: line) - return true - }, - openApplicationURLWithConfiguration: { _, _, _ in - XCTFail(unexpectedOpenMessage, file: file, line: line) - return true - } - ) - ) - } } -private enum InvalidApplicationURLScenario: CaseIterable { +private enum InvalidApplicationBundleURLScenario: CaseIterable { case remoteURL case missingLowercaseApp case missingUppercaseApp @@ -270,10 +95,10 @@ private enum InvalidApplicationURLScenario: CaseIterable { } } - var expectedError: ApplicationOpenError { + var expectedError: ApplicationOpeningError { switch self { case .missingLowercaseApp, .missingUppercaseApp: - .applicationNotFound + .applicationBundleNotFound case .remoteURL, .applicationFile, .nonApplicationDirectory, @@ -282,7 +107,7 @@ private enum InvalidApplicationURLScenario: CaseIterable { .missingExecutableBundle, .directoryExecutableBundle, .nonExecutableBundle: - .invalidApplicationURL + .invalidApplicationBundleURL } } @@ -335,19 +160,4 @@ private enum InvalidApplicationURLScenario: CaseIterable { } } - private func missingFileURL(pathExtension: String) -> URL { - FileManager.default.temporaryDirectory - .appendingPathComponent("missing-\(UUID().uuidString)") - .appendingPathExtension(pathExtension) - } - - private func temporaryDirectoryURL( - prefix: String, - pathExtension: String? = nil - ) -> URL { - let url = FileManager.default.temporaryDirectory - .appendingPathComponent("\(prefix)-\(UUID().uuidString)") - guard let pathExtension else { return url } - return url.appendingPathExtension(pathExtension) - } } diff --git a/Tests/AppRuntimeKitAppKitTests/Runtime/ApplicationRuntimeTests.swift b/Tests/AppRuntimeKitAppKitTests/Runtime/ApplicationRuntimeQueryTests.swift similarity index 51% rename from Tests/AppRuntimeKitAppKitTests/Runtime/ApplicationRuntimeTests.swift rename to Tests/AppRuntimeKitAppKitTests/Runtime/ApplicationRuntimeQueryTests.swift index 1a712df..f548d8f 100644 --- a/Tests/AppRuntimeKitAppKitTests/Runtime/ApplicationRuntimeTests.swift +++ b/Tests/AppRuntimeKitAppKitTests/Runtime/ApplicationRuntimeQueryTests.swift @@ -5,9 +5,9 @@ import AppRuntimeKit @testable import AppRuntimeKitAppKit @MainActor -final class ApplicationRuntimeTests: XCTestCase { +final class ApplicationRuntimeQueryTests: XCTestCase { func testRuntimeReturnsCurrentApplication() throws { - let runtime = Self.runtimeReturningCurrentApplication( + let runtime = applicationRuntimeReturningCurrentApplication( FakeRuntimeRunningApplication( processIdentifier: 456, bundleIdentifier: "com.example.current", @@ -17,7 +17,7 @@ final class ApplicationRuntimeTests: XCTestCase { let application = try XCTUnwrap(runtime.currentApplication()) - Self.assertApplication( + assertApplicationProcessSnapshot( application, processIdentifier: 456, bundleIdentifier: "com.example.current", @@ -26,13 +26,13 @@ final class ApplicationRuntimeTests: XCTestCase { } func testRuntimeReturnsNilForMissingCurrentApplication() { - let runtime = Self.runtimeReturningCurrentApplication(nil) + let runtime = applicationRuntimeReturningCurrentApplication(nil) XCTAssertNil(runtime.currentApplication()) } func testRuntimeReturnsNilForTerminatedCurrentApplication() { - let runtime = Self.runtimeReturningCurrentApplication( + let runtime = applicationRuntimeReturningCurrentApplication( FakeRuntimeRunningApplication(isTerminated: true) ) @@ -40,7 +40,7 @@ final class ApplicationRuntimeTests: XCTestCase { } func testRuntimeReturnsNilForInvalidCurrentApplicationProcessIdentifier() { - let runtime = Self.runtimeReturningCurrentApplication( + let runtime = applicationRuntimeReturningCurrentApplication( FakeRuntimeRunningApplication(processIdentifier: 0) ) @@ -48,7 +48,7 @@ final class ApplicationRuntimeTests: XCTestCase { } func testRuntimeReturnsNilForNegativeCurrentApplicationProcessIdentifier() { - let runtime = Self.runtimeReturningCurrentApplication( + let runtime = applicationRuntimeReturningCurrentApplication( FakeRuntimeRunningApplication(processIdentifier: -1) ) @@ -56,7 +56,7 @@ final class ApplicationRuntimeTests: XCTestCase { } func testRuntimeReturnsRunningApplicationSnapshot() throws { - let runtime = Self.runtimeReturningRunningApplication( + let runtime = applicationRuntimeReturningRunningApplication( { processIdentifier in FakeRuntimeRunningApplication( processIdentifier: processIdentifier, @@ -69,7 +69,7 @@ final class ApplicationRuntimeTests: XCTestCase { let application = try XCTUnwrap(runtime.runningApplication(processIdentifier: 789)) - Self.assertApplication( + assertApplicationProcessSnapshot( application, processIdentifier: 789, bundleIdentifier: "com.example.running", @@ -78,13 +78,13 @@ final class ApplicationRuntimeTests: XCTestCase { } func testRuntimeReturnsNilForMissingRunningApplication() { - let runtime = Self.runtimeReturningRunningApplication(nil, for: 789) + let runtime = applicationRuntimeReturningRunningApplication(nil, for: 789) XCTAssertNil(runtime.runningApplication(processIdentifier: 789)) } func testRuntimeReturnsNilForInvalidRunningApplicationProcessIdentifier() { - let runtime = Self.runtimeRejectingRunningApplicationLookup( + let runtime = applicationRuntimeRejectingRunningApplicationLookup( "Invalid process identifier should not perform a live lookup." ) @@ -92,7 +92,7 @@ final class ApplicationRuntimeTests: XCTestCase { } func testRuntimeReturnsNilForNegativeRunningApplicationProcessIdentifier() { - let runtime = Self.runtimeRejectingRunningApplicationLookup( + let runtime = applicationRuntimeRejectingRunningApplicationLookup( "Negative process identifier should not perform a live lookup." ) @@ -100,7 +100,7 @@ final class ApplicationRuntimeTests: XCTestCase { } func testRuntimeReturnsNilWhenRunningApplicationProcessIdentifierDoesNotMatchRequest() { - let runtime = Self.runtimeReturningRunningApplication( + let runtime = applicationRuntimeReturningRunningApplication( FakeRuntimeRunningApplication(processIdentifier: 987), for: 789 ) @@ -109,7 +109,7 @@ final class ApplicationRuntimeTests: XCTestCase { } func testRuntimeReturnsNilForTerminatedRunningApplication() { - let runtime = Self.runtimeReturningRunningApplication( + let runtime = applicationRuntimeReturningRunningApplication( { processIdentifier in FakeRuntimeRunningApplication(processIdentifier: processIdentifier, isTerminated: true) }, @@ -120,7 +120,7 @@ final class ApplicationRuntimeTests: XCTestCase { } func testRuntimeReturnsRunningApplicationsMatchingBundleIdentifier() { - let runtime = Self.runtimeReturningRunningApplications([ + let runtime = applicationRuntimeReturningRunningApplications([ FakeRuntimeRunningApplication(processIdentifier: 0), FakeRuntimeRunningApplication(processIdentifier: -1), FakeRuntimeRunningApplication(processIdentifier: 123, displayName: "First"), @@ -156,7 +156,7 @@ final class ApplicationRuntimeTests: XCTestCase { } func testRuntimeReturnsEmptyForMissingRunningApplications() { - let runtime = Self.runtimeReturningRunningApplications( + let runtime = applicationRuntimeReturningRunningApplications( [], for: "com.example.missing.app" ) @@ -165,7 +165,7 @@ final class ApplicationRuntimeTests: XCTestCase { } func testRuntimeReturnsEmptyForBlankBundleIdentifier() { - let runtime = Self.runtimeRejectingRunningApplicationsLookup( + let runtime = applicationRuntimeRejectingRunningApplicationsLookup( "Blank bundle identifier should not perform a live lookup." ) @@ -173,7 +173,7 @@ final class ApplicationRuntimeTests: XCTestCase { } func testRuntimeReturnsFrontmostApplicationSnapshot() throws { - let runtime = Self.runtimeReturningFrontmostApplication( + let runtime = applicationRuntimeReturningFrontmostApplication( FakeRuntimeRunningApplication( processIdentifier: 321, bundleIdentifier: "com.example.frontmost", @@ -183,7 +183,7 @@ final class ApplicationRuntimeTests: XCTestCase { let frontmost = try XCTUnwrap(runtime.frontmostApplication()) - Self.assertApplication( + assertApplicationProcessSnapshot( frontmost, processIdentifier: 321, bundleIdentifier: "com.example.frontmost", @@ -192,13 +192,13 @@ final class ApplicationRuntimeTests: XCTestCase { } func testRuntimeReturnsNilForMissingFrontmostApplication() { - let runtime = Self.runtimeReturningFrontmostApplication(nil) + let runtime = applicationRuntimeReturningFrontmostApplication(nil) XCTAssertNil(runtime.frontmostApplication()) } func testRuntimeReturnsNilForTerminatedFrontmostApplication() { - let runtime = Self.runtimeReturningFrontmostApplication( + let runtime = applicationRuntimeReturningFrontmostApplication( FakeRuntimeRunningApplication(isTerminated: true) ) @@ -206,7 +206,7 @@ final class ApplicationRuntimeTests: XCTestCase { } func testRuntimeReturnsNilForInvalidFrontmostApplicationProcessIdentifier() { - let runtime = Self.runtimeReturningFrontmostApplication( + let runtime = applicationRuntimeReturningFrontmostApplication( FakeRuntimeRunningApplication(processIdentifier: 0) ) @@ -214,7 +214,7 @@ final class ApplicationRuntimeTests: XCTestCase { } func testRuntimeReturnsNilForNegativeFrontmostApplicationProcessIdentifier() { - let runtime = Self.runtimeReturningFrontmostApplication( + let runtime = applicationRuntimeReturningFrontmostApplication( FakeRuntimeRunningApplication(processIdentifier: -1) ) @@ -223,7 +223,7 @@ final class ApplicationRuntimeTests: XCTestCase { func testRuntimeReturnsInstalledApplicationURL() { let expectedURL = URL(fileURLWithPath: "/Applications/Example.app") - let runtime = Self.runtimeReturningInstalledApplicationURL(expectedURL) + let runtime = applicationRuntimeReturningInstalledApplicationURL(expectedURL) XCTAssertEqual( runtime.installedApplicationURL(bundleIdentifier: " \(Self.bundleIdentifier)\n"), @@ -232,7 +232,7 @@ final class ApplicationRuntimeTests: XCTestCase { } func testRuntimeReturnsNilForMissingInstalledApplication() { - let runtime = Self.runtimeReturningInstalledApplicationURL( + let runtime = applicationRuntimeReturningInstalledApplicationURL( nil, for: "com.example.missing.app" ) @@ -241,7 +241,7 @@ final class ApplicationRuntimeTests: XCTestCase { } func testRuntimeReturnsNilForBlankInstalledApplicationBundleIdentifier() { - let runtime = Self.runtimeRejectingInstalledApplicationLookup( + let runtime = applicationRuntimeRejectingInstalledApplicationLookup( "Blank bundle identifier should not perform a live lookup." ) @@ -250,155 +250,4 @@ final class ApplicationRuntimeTests: XCTestCase { private static let bundleIdentifier = "com.example.app" - private static func assertApplication( - _ application: ApplicationProcessSnapshot, - processIdentifier: pid_t, - bundleIdentifier: String?, - displayName: String?, - isTerminated: Bool = false, - file: StaticString = #filePath, - line: UInt = #line - ) { - XCTAssertEqual(application.processIdentifier, processIdentifier, file: file, line: line) - XCTAssertEqual(application.bundleIdentifier, bundleIdentifier, file: file, line: line) - XCTAssertEqual(application.displayName, displayName, file: file, line: line) - XCTAssertEqual(application.isTerminated, isTerminated, file: file, line: line) - } - - private static func runtimeReturningCurrentApplication( - _ application: FakeRuntimeRunningApplication? - ) -> ApplicationRuntime { - ApplicationRuntime( - dependencies: ApplicationRuntimeDependencies( - currentApplication: { application } - ) - ) - } - - private static func runtimeReturningRunningApplication( - _ application: FakeRuntimeRunningApplication?, - for expectedProcessIdentifier: pid_t, - file: StaticString = #filePath, - line: UInt = #line - ) -> ApplicationRuntime { - runtimeReturningRunningApplication( - { _ in application }, - for: expectedProcessIdentifier, - file: file, - line: line - ) - } - - private static func runtimeReturningRunningApplication( - _ applicationProvider: - @escaping @MainActor @Sendable (pid_t) - -> FakeRuntimeRunningApplication?, - for expectedProcessIdentifier: pid_t, - file: StaticString = #filePath, - line: UInt = #line - ) -> ApplicationRuntime { - ApplicationRuntime( - dependencies: ApplicationRuntimeDependencies( - runningApplication: { processIdentifier in - XCTAssertEqual( - processIdentifier, - expectedProcessIdentifier, - file: file, - line: line - ) - return applicationProvider(processIdentifier) - } - ) - ) - } - - private static func runtimeRejectingRunningApplicationLookup( - _ message: String - ) -> ApplicationRuntime { - ApplicationRuntime( - dependencies: ApplicationRuntimeDependencies( - runningApplication: { _ in - XCTFail(message) - return FakeRuntimeRunningApplication() - } - ) - ) - } - - private static func runtimeReturningRunningApplications( - _ applications: [FakeRuntimeRunningApplication], - for expectedBundleIdentifier: String = bundleIdentifier, - file: StaticString = #filePath, - line: UInt = #line - ) -> ApplicationRuntime { - ApplicationRuntime( - dependencies: ApplicationRuntimeDependencies( - runningApplications: { bundleIdentifier in - XCTAssertEqual(bundleIdentifier, expectedBundleIdentifier, file: file, line: line) - return applications - } - ) - ) - } - - private static func runtimeRejectingRunningApplicationsLookup( - _ message: String - ) -> ApplicationRuntime { - ApplicationRuntime( - dependencies: ApplicationRuntimeDependencies( - runningApplications: { _ in - XCTFail(message) - return [FakeRuntimeRunningApplication()] - } - ) - ) - } - - private static func runtimeReturningFrontmostApplication( - _ application: FakeRuntimeRunningApplication?, - file: StaticString = #filePath, - line: UInt = #line - ) -> ApplicationRuntime { - ApplicationRuntime( - workspace: .shared, - dependencies: ApplicationRuntimeDependencies( - frontmostApplication: { workspace in - XCTAssertTrue(workspace === NSWorkspace.shared, file: file, line: line) - return application - } - ) - ) - } - - private static func runtimeReturningInstalledApplicationURL( - _ url: URL?, - for expectedBundleIdentifier: String = bundleIdentifier, - file: StaticString = #filePath, - line: UInt = #line - ) -> ApplicationRuntime { - ApplicationRuntime( - workspace: .shared, - dependencies: ApplicationRuntimeDependencies( - installedApplicationURL: { workspace, bundleIdentifier in - XCTAssertTrue(workspace === NSWorkspace.shared, file: file, line: line) - XCTAssertEqual(bundleIdentifier, expectedBundleIdentifier, file: file, line: line) - return url - } - ) - ) - } - - private static func runtimeRejectingInstalledApplicationLookup( - _ message: String - ) -> ApplicationRuntime { - ApplicationRuntime( - workspace: .shared, - dependencies: ApplicationRuntimeDependencies( - installedApplicationURL: { _, _ in - XCTFail(message) - return URL(fileURLWithPath: "/Applications/Example.app") - } - ) - ) - } } diff --git a/Tests/AppRuntimeKitAppKitTests/Runtime/ApplicationRuntimeTerminationTests.swift b/Tests/AppRuntimeKitAppKitTests/Runtime/ApplicationRuntimeTerminationTests.swift index a6cd87b..fa1eca3 100644 --- a/Tests/AppRuntimeKitAppKitTests/Runtime/ApplicationRuntimeTerminationTests.swift +++ b/Tests/AppRuntimeKitAppKitTests/Runtime/ApplicationRuntimeTerminationTests.swift @@ -7,93 +7,93 @@ import AppRuntimeKit final class ApplicationRuntimeTerminationTests: XCTestCase { func testTerminateApplicationByProcessIdentifierTerminatesMatchingApplication() { let application = FakeRuntimeRunningApplication() - let runtime = Self.runtimeReturningRunningApplication(application, for: 123) + let runtime = applicationRuntimeReturningRunningApplication(application, for: 123) let result = runtime.terminateApplication(processIdentifier: 123) - XCTAssertEqual(result, .requested) - Self.assertTerminationRequests(application, graceful: 1, forced: 0) + XCTAssertEqual(result, .requestAccepted) + assertTerminationRequests(application, graceful: 1, forced: 0) } func testForceTerminateApplicationByProcessIdentifierForceTerminatesMatchingApplication() { let application = FakeRuntimeRunningApplication() - let runtime = Self.runtimeReturningRunningApplication(application, for: 123) + let runtime = applicationRuntimeReturningRunningApplication(application, for: 123) let result = runtime.terminateApplication( processIdentifier: 123, mode: .forced ) - XCTAssertEqual(result, .requested) - Self.assertTerminationRequests(application, graceful: 0, forced: 1) + XCTAssertEqual(result, .requestAccepted) + assertTerminationRequests(application, graceful: 0, forced: 1) } func testTerminateApplicationBySnapshotUsesProcessIdentifier() { let application = FakeRuntimeRunningApplication(processIdentifier: 456) - let runtime = Self.runtimeReturningRunningApplication(application, for: 456) - let snapshot = Self.snapshot() + let runtime = applicationRuntimeReturningRunningApplication(application, for: 456) + let snapshot = applicationRuntimeSnapshot() let result = runtime.terminateApplication(snapshot) - XCTAssertEqual(result, .requested) - Self.assertTerminationRequests(application, graceful: 1, forced: 0) + XCTAssertEqual(result, .requestAccepted) + assertTerminationRequests(application, graceful: 1, forced: 0) } func testForceTerminateApplicationBySnapshotForceTerminatesMatchingApplication() { let application = FakeRuntimeRunningApplication(processIdentifier: 456) - let runtime = Self.runtimeReturningRunningApplication(application, for: 456) - let snapshot = Self.snapshot() + let runtime = applicationRuntimeReturningRunningApplication(application, for: 456) + let snapshot = applicationRuntimeSnapshot() let result = runtime.terminateApplication(snapshot, mode: .forced) - XCTAssertEqual(result, .requested) - Self.assertTerminationRequests(application, graceful: 0, forced: 1) + XCTAssertEqual(result, .requestAccepted) + assertTerminationRequests(application, graceful: 0, forced: 1) } - func testTerminateApplicationBySnapshotReportsTerminationFailure() { + func testTerminateApplicationBySnapshotReportsTerminationRequestRejection() { let application = FakeRuntimeRunningApplication( processIdentifier: 456, - gracefulTerminationResult: false + gracefulTerminationRequestAccepted: false ) - let runtime = Self.runtimeReturningRunningApplication(application, for: 456) - let snapshot = Self.snapshot() + let runtime = applicationRuntimeReturningRunningApplication(application, for: 456) + let snapshot = applicationRuntimeSnapshot() XCTAssertEqual( runtime.terminateApplication(snapshot), - .failed(.requestFailed) + .failed(.requestRejected) ) - Self.assertTerminationRequests(application, graceful: 1, forced: 0) + assertTerminationRequests(application, graceful: 1, forced: 0) } - func testForceTerminateApplicationBySnapshotReportsTerminationFailure() { + func testForceTerminateApplicationBySnapshotReportsTerminationRequestRejection() { let application = FakeRuntimeRunningApplication( processIdentifier: 456, - forcedTerminationResult: false + forcedTerminationRequestAccepted: false ) - let runtime = Self.runtimeReturningRunningApplication(application, for: 456) - let snapshot = Self.snapshot() + let runtime = applicationRuntimeReturningRunningApplication(application, for: 456) + let snapshot = applicationRuntimeSnapshot() XCTAssertEqual( runtime.terminateApplication(snapshot, mode: .forced), - .failed(.requestFailed) + .failed(.requestRejected) ) - Self.assertTerminationRequests(application, graceful: 0, forced: 1) + assertTerminationRequests(application, graceful: 0, forced: 1) } func testTerminateApplicationBySnapshotTrimsBundleIdentifier() { let application = FakeRuntimeRunningApplication(processIdentifier: 456) - let runtime = Self.runtimeReturningRunningApplication(application, for: 456) - let snapshot = Self.snapshot(bundleIdentifier: " com.example.app\n") + let runtime = applicationRuntimeReturningRunningApplication(application, for: 456) + let snapshot = applicationRuntimeSnapshot(bundleIdentifier: " com.example.app\n") - XCTAssertEqual(runtime.terminateApplication(snapshot), .requested) - Self.assertTerminationRequests(application, graceful: 1, forced: 0) + XCTAssertEqual(runtime.terminateApplication(snapshot), .requestAccepted) + assertTerminationRequests(application, graceful: 1, forced: 0) } func testTerminateApplicationBySnapshotRejectsBlankBundleIdentifier() { - let runtime = Self.runtimeRejectingRunningApplicationLookup( + let runtime = applicationRuntimeRejectingRunningApplicationLookup( "Blank bundle identifier should not perform a live lookup." ) - let snapshot = Self.snapshot(bundleIdentifier: " ") + let snapshot = applicationRuntimeSnapshot(bundleIdentifier: " ") XCTAssertEqual( runtime.terminateApplication(snapshot), @@ -102,63 +102,69 @@ final class ApplicationRuntimeTerminationTests: XCTestCase { } func testTerminateApplicationBySnapshotRejectsInvalidProcessIdentifier() { - let runtime = Self.runtimeRejectingRunningApplicationLookup( + let runtime = applicationRuntimeRejectingRunningApplicationLookup( "Invalid process identifier should not perform a live lookup." ) - let snapshot = Self.snapshot(processIdentifier: 0) + let snapshot = applicationRuntimeSnapshot(processIdentifier: 0) XCTAssertEqual( runtime.terminateApplication(snapshot), - .failed(.applicationNotFound) + .failed(.invalidProcessIdentifier) ) } func testTerminateApplicationBySnapshotRejectsNegativeProcessIdentifier() { - let runtime = Self.runtimeRejectingRunningApplicationLookup( + let runtime = applicationRuntimeRejectingRunningApplicationLookup( "Negative process identifier should not perform a live lookup." ) - let snapshot = Self.snapshot(processIdentifier: -1) + let snapshot = applicationRuntimeSnapshot(processIdentifier: -1) XCTAssertEqual( runtime.terminateApplication(snapshot), - .failed(.applicationNotFound) + .failed(.invalidProcessIdentifier) ) } func testTerminateApplicationBySnapshotRejectsMismatchedRunningApplicationBundleIdentifier() { - let application = FakeRuntimeRunningApplication(bundleIdentifier: "com.example.other") - let runtime = Self.runtimeReturningRunningApplication(application, for: 456) - let snapshot = Self.snapshot() + let application = FakeRuntimeRunningApplication( + processIdentifier: 456, + bundleIdentifier: "com.example.other" + ) + let runtime = applicationRuntimeReturningRunningApplication(application, for: 456) + let snapshot = applicationRuntimeSnapshot() XCTAssertEqual( runtime.terminateApplication(snapshot), - .failed(.applicationNotFound) + .failed(.applicationBundleIdentifierMismatch) ) - Self.assertTerminationRequests(application, graceful: 0, forced: 0) + assertTerminationRequests(application, graceful: 0, forced: 0) } func testTerminateApplicationBySnapshotRejectsMissingRunningApplicationBundleIdentifier() { - let application = FakeRuntimeRunningApplication(bundleIdentifier: nil) - let runtime = Self.runtimeReturningRunningApplication(application, for: 456) - let snapshot = Self.snapshot() + let application = FakeRuntimeRunningApplication( + processIdentifier: 456, + bundleIdentifier: nil + ) + let runtime = applicationRuntimeReturningRunningApplication(application, for: 456) + let snapshot = applicationRuntimeSnapshot() XCTAssertEqual( runtime.terminateApplication(snapshot), - .failed(.applicationNotFound) + .failed(.applicationBundleIdentifierUnavailable) ) - Self.assertTerminationRequests(application, graceful: 0, forced: 0) + assertTerminationRequests(application, graceful: 0, forced: 0) } func testTerminateApplicationBySnapshotRejectsMismatchedRunningApplicationProcessIdentifier() { let application = FakeRuntimeRunningApplication(processIdentifier: 789) - let runtime = Self.runtimeReturningRunningApplication(application, for: 456) - let snapshot = Self.snapshot() + let runtime = applicationRuntimeReturningRunningApplication(application, for: 456) + let snapshot = applicationRuntimeSnapshot() XCTAssertEqual( runtime.terminateApplication(snapshot), .failed(.applicationNotFound) ) - Self.assertTerminationRequests(application, graceful: 0, forced: 0) + assertTerminationRequests(application, graceful: 0, forced: 0) } func testTerminateApplicationBySnapshotAllowsMissingSnapshotBundleIdentifier() { @@ -166,27 +172,27 @@ final class ApplicationRuntimeTerminationTests: XCTestCase { processIdentifier: 456, bundleIdentifier: "com.example.app" ) - let runtime = Self.runtimeReturningRunningApplication(application, for: 456) - let snapshot = Self.snapshot(bundleIdentifier: nil) + let runtime = applicationRuntimeReturningRunningApplication(application, for: 456) + let snapshot = applicationRuntimeSnapshot(bundleIdentifier: nil) - XCTAssertEqual(runtime.terminateApplication(snapshot), .requested) - Self.assertTerminationRequests(application, graceful: 1, forced: 0) + XCTAssertEqual(runtime.terminateApplication(snapshot), .requestAccepted) + assertTerminationRequests(application, graceful: 1, forced: 0) } func testTerminateApplicationBySnapshotRejectsTerminatedSnapshot() { - let runtime = Self.runtimeRejectingRunningApplicationLookup( + let runtime = applicationRuntimeRejectingRunningApplicationLookup( "Terminated snapshot should not perform a live lookup." ) - let snapshot = Self.snapshot(isTerminated: true) + let snapshot = applicationRuntimeSnapshot(isTerminated: true) XCTAssertEqual( runtime.terminateApplication(snapshot), - .failed(.applicationNotFound) + .failed(.applicationTerminated) ) } func testTerminateApplicationByProcessIdentifierRejectsMissingApplication() { - let runtime = Self.runtimeReturningRunningApplication(nil, for: 123) + let runtime = applicationRuntimeReturningRunningApplication(nil, for: 123) XCTAssertEqual( runtime.terminateApplication(processIdentifier: 123), @@ -195,116 +201,202 @@ final class ApplicationRuntimeTerminationTests: XCTestCase { } func testTerminateApplicationByProcessIdentifierRejectsInvalidProcessIdentifier() { - let runtime = Self.runtimeRejectingRunningApplicationLookup( + let runtime = applicationRuntimeRejectingRunningApplicationLookup( "Invalid process identifier should not perform a live lookup." ) XCTAssertEqual( runtime.terminateApplication(processIdentifier: 0), - .failed(.applicationNotFound) + .failed(.invalidProcessIdentifier) ) } func testTerminateApplicationByProcessIdentifierRejectsNegativeProcessIdentifier() { - let runtime = Self.runtimeRejectingRunningApplicationLookup( + let runtime = applicationRuntimeRejectingRunningApplicationLookup( "Negative process identifier should not perform a live lookup." ) XCTAssertEqual( runtime.terminateApplication(processIdentifier: -1), - .failed(.applicationNotFound) + .failed(.invalidProcessIdentifier) ) } func testTerminateApplicationByProcessIdentifierRejectsTerminatedApplication() { let application = FakeRuntimeRunningApplication(isTerminated: true) - let runtime = Self.runtimeReturningRunningApplication(application, for: 123) + let runtime = applicationRuntimeReturningRunningApplication(application, for: 123) XCTAssertEqual( runtime.terminateApplication(processIdentifier: 123), - .failed(.applicationNotFound) + .failed(.applicationTerminated) ) - Self.assertTerminationRequests(application, graceful: 0, forced: 0) + assertTerminationRequests(application, graceful: 0, forced: 0) } func testTerminateApplicationByProcessIdentifierRejectsMismatchedRunningProcessIdentifier() { let application = FakeRuntimeRunningApplication(processIdentifier: 456) - let runtime = Self.runtimeReturningRunningApplication(application, for: 123) + let runtime = applicationRuntimeReturningRunningApplication(application, for: 123) XCTAssertEqual( runtime.terminateApplication(processIdentifier: 123), .failed(.applicationNotFound) ) - Self.assertTerminationRequests(application, graceful: 0, forced: 0) + assertTerminationRequests(application, graceful: 0, forced: 0) } - func testTerminateApplicationByProcessIdentifierReportsTerminationFailure() { - let application = FakeRuntimeRunningApplication(gracefulTerminationResult: false) - let runtime = Self.runtimeReturningRunningApplication(application, for: 123) + func testTerminateApplicationByProcessIdentifierReportsTerminationRequestRejection() { + let application = FakeRuntimeRunningApplication(gracefulTerminationRequestAccepted: false) + let runtime = applicationRuntimeReturningRunningApplication(application, for: 123) XCTAssertEqual( runtime.terminateApplication(processIdentifier: 123), - .failed(.requestFailed) + .failed(.requestRejected) ) - Self.assertTerminationRequests(application, graceful: 1, forced: 0) + assertTerminationRequests(application, graceful: 1, forced: 0) } - func testForceTerminateApplicationByProcessIdentifierReportsTerminationFailure() { - let application = FakeRuntimeRunningApplication(forcedTerminationResult: false) - let runtime = Self.runtimeReturningRunningApplication(application, for: 123) + func testForceTerminateApplicationByProcessIdentifierReportsTerminationRequestRejection() { + let application = FakeRuntimeRunningApplication(forcedTerminationRequestAccepted: false) + let runtime = applicationRuntimeReturningRunningApplication(application, for: 123) XCTAssertEqual( runtime.terminateApplication(processIdentifier: 123, mode: .forced), - .failed(.requestFailed) + .failed(.requestRejected) + ) + assertTerminationRequests(application, graceful: 0, forced: 1) + } + + func testTerminateApplicationByProcessIdentifierAndExpectedBundleIdentifierTerminatesMatch() { + let application = FakeRuntimeRunningApplication() + let runtime = applicationRuntimeReturningRunningApplication(application, for: 123) + + let result = runtime.terminateApplication( + processIdentifier: 123, + expectedBundleIdentifier: " com.example.app\n" + ) + + XCTAssertEqual(result, .requestAccepted) + assertTerminationRequests(application, graceful: 1, forced: 0) + } + + func testTerminateApplicationByProcessIdentifierAndExpectedBundleIdentifierForwardsMode() { + let application = FakeRuntimeRunningApplication() + let runtime = applicationRuntimeReturningRunningApplication(application, for: 123) + + let result = runtime.terminateApplication( + processIdentifier: 123, + expectedBundleIdentifier: "com.example.app", + mode: .forced + ) + + XCTAssertEqual(result, .requestAccepted) + assertTerminationRequests(application, graceful: 0, forced: 1) + } + + func testTerminateApplicationByProcessIdentifierAndExpectedBundleIdentifierRejectsBlankBundle() { + let runtime = applicationRuntimeRejectingRunningApplicationLookup( + "Blank bundle identifier should not perform a live lookup." + ) + + XCTAssertEqual( + runtime.terminateApplication( + processIdentifier: 123, + expectedBundleIdentifier: " " + ), + .failed(.invalidBundleIdentifier) + ) + } + + func testTerminateApplicationByProcessIdentifierAndExpectedBundleIdentifierRejectsMismatch() { + let application = FakeRuntimeRunningApplication(bundleIdentifier: "com.example.other") + let runtime = applicationRuntimeReturningRunningApplication(application, for: 123) + + XCTAssertEqual( + runtime.terminateApplication( + processIdentifier: 123, + expectedBundleIdentifier: "com.example.app" + ), + .failed(.applicationBundleIdentifierMismatch) ) - Self.assertTerminationRequests(application, graceful: 0, forced: 1) + assertTerminationRequests(application, graceful: 0, forced: 0) + } + + func testTerminateApplicationByProcessIdentifierAndExpectedBundleIdentifierRejectsMissingBundle() + { + let application = FakeRuntimeRunningApplication(bundleIdentifier: nil) + let runtime = applicationRuntimeReturningRunningApplication(application, for: 123) + + XCTAssertEqual( + runtime.terminateApplication( + processIdentifier: 123, + expectedBundleIdentifier: "com.example.app" + ), + .failed(.applicationBundleIdentifierUnavailable) + ) + assertTerminationRequests(application, graceful: 0, forced: 0) } func testTerminateApplicationByBundleIdentifierTrimsInput() { let terminatedApplication = FakeRuntimeRunningApplication(isTerminated: true) let application = FakeRuntimeRunningApplication() - let runtime = Self.runtimeReturningRunningApplications([terminatedApplication, application]) + let runtime = applicationRuntimeReturningRunningApplications( + [terminatedApplication, application], + unexpectedProcessLookupMessage: + "Bundle identifier termination should not perform a process lookup." + ) let result = runtime.terminateApplication(bundleIdentifier: " com.example.app\n") - XCTAssertEqual(result, .requested) - Self.assertTerminationRequests(terminatedApplication, graceful: 0, forced: 0) - Self.assertTerminationRequests(application, graceful: 1, forced: 0) + XCTAssertEqual(result, .requestAccepted) + assertTerminationRequests(terminatedApplication, graceful: 0, forced: 0) + assertTerminationRequests(application, graceful: 1, forced: 0) } func testForceTerminateApplicationByBundleIdentifierForceTerminatesMatchingApplication() { let application = FakeRuntimeRunningApplication() - let runtime = Self.runtimeReturningRunningApplications([application]) + let runtime = applicationRuntimeReturningRunningApplications( + [application], + unexpectedProcessLookupMessage: + "Bundle identifier termination should not perform a process lookup." + ) let result = runtime.terminateApplication( bundleIdentifier: "com.example.app", mode: .forced ) - XCTAssertEqual(result, .requested) - Self.assertTerminationRequests(application, graceful: 0, forced: 1) + XCTAssertEqual(result, .requestAccepted) + assertTerminationRequests(application, graceful: 0, forced: 1) } - func testTerminateApplicationByBundleIdentifierReportsTerminationFailure() { - let application = FakeRuntimeRunningApplication(gracefulTerminationResult: false) - let runtime = Self.runtimeReturningRunningApplications([application]) + func testTerminateApplicationByBundleIdentifierReportsTerminationRequestRejection() { + let application = FakeRuntimeRunningApplication(gracefulTerminationRequestAccepted: false) + let runtime = applicationRuntimeReturningRunningApplications( + [application], + unexpectedProcessLookupMessage: + "Bundle identifier termination should not perform a process lookup." + ) XCTAssertEqual( runtime.terminateApplication(bundleIdentifier: "com.example.app"), - .failed(.requestFailed) + .failed(.requestRejected) ) - Self.assertTerminationRequests(application, graceful: 1, forced: 0) + assertTerminationRequests(application, graceful: 1, forced: 0) } - func testForceTerminateApplicationByBundleIdentifierReportsTerminationFailure() { - let application = FakeRuntimeRunningApplication(forcedTerminationResult: false) - let runtime = Self.runtimeReturningRunningApplications([application]) + func testForceTerminateApplicationByBundleIdentifierReportsTerminationRequestRejection() { + let application = FakeRuntimeRunningApplication(forcedTerminationRequestAccepted: false) + let runtime = applicationRuntimeReturningRunningApplications( + [application], + unexpectedProcessLookupMessage: + "Bundle identifier termination should not perform a process lookup." + ) XCTAssertEqual( runtime.terminateApplication(bundleIdentifier: "com.example.app", mode: .forced), - .failed(.requestFailed) + .failed(.requestRejected) ) - Self.assertTerminationRequests(application, graceful: 0, forced: 1) + assertTerminationRequests(application, graceful: 0, forced: 1) } func testTerminateApplicationByBundleIdentifierSkipsInvalidCandidates() { @@ -313,26 +405,29 @@ final class ApplicationRuntimeTerminationTests: XCTestCase { let missingBundleIdentifierApplication = FakeRuntimeRunningApplication(bundleIdentifier: nil) let mismatchedApplication = FakeRuntimeRunningApplication(bundleIdentifier: "com.example.other") let application = FakeRuntimeRunningApplication() - let runtime = Self.runtimeReturningRunningApplications([ - invalidProcessIdentifierApplication, - terminatedApplication, - missingBundleIdentifierApplication, - mismatchedApplication, - application, - ]) + let runtime = applicationRuntimeReturningRunningApplications( + [ + invalidProcessIdentifierApplication, + terminatedApplication, + missingBundleIdentifierApplication, + mismatchedApplication, + application, + ], + unexpectedProcessLookupMessage: + "Bundle identifier termination should not perform a process lookup.") let result = runtime.terminateApplication(bundleIdentifier: "com.example.app") - XCTAssertEqual(result, .requested) - Self.assertTerminationRequests(invalidProcessIdentifierApplication, graceful: 0, forced: 0) - Self.assertTerminationRequests(terminatedApplication, graceful: 0, forced: 0) - Self.assertTerminationRequests(missingBundleIdentifierApplication, graceful: 0, forced: 0) - Self.assertTerminationRequests(mismatchedApplication, graceful: 0, forced: 0) - Self.assertTerminationRequests(application, graceful: 1, forced: 0) + XCTAssertEqual(result, .requestAccepted) + assertTerminationRequests(invalidProcessIdentifierApplication, graceful: 0, forced: 0) + assertTerminationRequests(terminatedApplication, graceful: 0, forced: 0) + assertTerminationRequests(missingBundleIdentifierApplication, graceful: 0, forced: 0) + assertTerminationRequests(mismatchedApplication, graceful: 0, forced: 0) + assertTerminationRequests(application, graceful: 1, forced: 0) } func testTerminateApplicationByBundleIdentifierRejectsBlankInput() { - let runtime = Self.runtimeRejectingBundleIdentifierLookups() + let runtime = applicationRuntimeRejectingBundleIdentifierLookups() XCTAssertEqual( runtime.terminateApplication(bundleIdentifier: " "), @@ -345,145 +440,24 @@ final class ApplicationRuntimeTerminationTests: XCTestCase { let terminatedApplication = FakeRuntimeRunningApplication(isTerminated: true) let missingBundleIdentifierApplication = FakeRuntimeRunningApplication(bundleIdentifier: nil) let mismatchedApplication = FakeRuntimeRunningApplication(bundleIdentifier: "com.example.other") - let runtime = Self.runtimeReturningRunningApplications([ - invalidProcessIdentifierApplication, - terminatedApplication, - missingBundleIdentifierApplication, - mismatchedApplication, - ]) + let runtime = applicationRuntimeReturningRunningApplications( + [ + invalidProcessIdentifierApplication, + terminatedApplication, + missingBundleIdentifierApplication, + mismatchedApplication, + ], + unexpectedProcessLookupMessage: + "Bundle identifier termination should not perform a process lookup.") XCTAssertEqual( runtime.terminateApplication(bundleIdentifier: "com.example.app"), .failed(.applicationNotFound) ) - Self.assertTerminationRequests(invalidProcessIdentifierApplication, graceful: 0, forced: 0) - Self.assertTerminationRequests(terminatedApplication, graceful: 0, forced: 0) - Self.assertTerminationRequests(missingBundleIdentifierApplication, graceful: 0, forced: 0) - Self.assertTerminationRequests(mismatchedApplication, graceful: 0, forced: 0) + assertTerminationRequests(invalidProcessIdentifierApplication, graceful: 0, forced: 0) + assertTerminationRequests(terminatedApplication, graceful: 0, forced: 0) + assertTerminationRequests(missingBundleIdentifierApplication, graceful: 0, forced: 0) + assertTerminationRequests(mismatchedApplication, graceful: 0, forced: 0) } - private static func assertTerminationRequests( - _ application: FakeRuntimeRunningApplication, - graceful expectedGracefulRequestCount: Int, - forced expectedForcedRequestCount: Int, - file: StaticString = #filePath, - line: UInt = #line - ) { - XCTAssertEqual( - application.gracefulTerminationRequestCount, - expectedGracefulRequestCount, - file: file, - line: line - ) - XCTAssertEqual( - application.forcedTerminationRequestCount, - expectedForcedRequestCount, - file: file, - line: line - ) - } - - private static func runtimeReturningRunningApplication( - _ application: FakeRuntimeRunningApplication?, - for expectedProcessIdentifier: pid_t, - file: StaticString = #filePath, - line: UInt = #line - ) -> ApplicationRuntime { - ApplicationRuntime( - dependencies: ApplicationRuntimeDependencies( - runningApplication: { processIdentifier in - XCTAssertEqual( - processIdentifier, - expectedProcessIdentifier, - file: file, - line: line - ) - return application - } - ) - ) - } - - private static func runtimeRejectingRunningApplicationLookup( - _ message: String, - file: StaticString = #filePath, - line: UInt = #line - ) -> ApplicationRuntime { - ApplicationRuntime( - dependencies: ApplicationRuntimeDependencies( - runningApplication: { _ in - XCTFail(message, file: file, line: line) - return FakeRuntimeRunningApplication() - } - ) - ) - } - - private static func runtimeReturningRunningApplications( - _ applications: [FakeRuntimeRunningApplication], - for expectedBundleIdentifier: String = "com.example.app", - file: StaticString = #filePath, - line: UInt = #line - ) -> ApplicationRuntime { - ApplicationRuntime( - dependencies: ApplicationRuntimeDependencies( - runningApplication: { _ in - XCTFail( - "Bundle identifier termination should not perform a process lookup.", - file: file, - line: line - ) - return nil - }, - runningApplications: { bundleIdentifier in - XCTAssertEqual( - bundleIdentifier, - expectedBundleIdentifier, - file: file, - line: line - ) - return applications - } - ) - ) - } - - private static func runtimeRejectingBundleIdentifierLookups( - file: StaticString = #filePath, - line: UInt = #line - ) -> ApplicationRuntime { - ApplicationRuntime( - dependencies: ApplicationRuntimeDependencies( - runningApplication: { _ in - XCTFail( - "Blank bundle identifier should not perform a process lookup.", - file: file, - line: line - ) - return nil - }, - runningApplications: { _ in - XCTFail( - "Blank bundle identifier should not perform a running application lookup.", - file: file, - line: line - ) - return [FakeRuntimeRunningApplication()] - } - ) - ) - } - - private static func snapshot( - processIdentifier: pid_t = 456, - bundleIdentifier: String? = "com.example.app", - isTerminated: Bool = false - ) -> ApplicationProcessSnapshot { - ApplicationProcessSnapshot( - processIdentifier: processIdentifier, - bundleIdentifier: bundleIdentifier, - displayName: "Example", - isTerminated: isTerminated - ) - } } diff --git a/Tests/AppRuntimeKitAppKitTests/SingleInstance/ApplicationSingleInstanceCoordinatorTests.swift b/Tests/AppRuntimeKitAppKitTests/SingleInstance/ApplicationSingleInstanceCoordinatorTests.swift deleted file mode 100644 index 8bacb2d..0000000 --- a/Tests/AppRuntimeKitAppKitTests/SingleInstance/ApplicationSingleInstanceCoordinatorTests.swift +++ /dev/null @@ -1,503 +0,0 @@ -import AppKit -import Foundation -import XCTest - -import AppRuntimeKit -@testable import AppRuntimeKitAppKit - -@MainActor -final class ApplicationSingleInstanceCoordinatorTests: XCTestCase { - private final class FakeSingleInstanceActivationRequestTransport: - ApplicationSingleInstanceActivationRequestTransport - { - struct Post: Equatable { - let name: Notification.Name - let bundleIdentifier: String - } - - private(set) var posts: [Post] = [] - private(set) var removedObservers: [AnyHashable] = [] - - private var observerTokenCounter = 0 - private struct ObserverEntry { - let name: Notification.Name - let bundleIdentifier: String? - let handler: @MainActor @Sendable () -> Void - } - - private var observers: [AnyHashable: ObserverEntry] = [:] - - func postActivationRequest(name: Notification.Name, bundleIdentifier: String) { - posts.append(Post(name: name, bundleIdentifier: bundleIdentifier)) - } - - func observeActivationRequests( - name: Notification.Name, - bundleIdentifier: String, - handler: @escaping @MainActor @Sendable () -> Void - ) -> Any { - observerTokenCounter += 1 - let token = AnyHashable(observerTokenCounter) - observers[token] = ObserverEntry( - name: name, - bundleIdentifier: bundleIdentifier, - handler: handler - ) - return token - } - - func removeObservation(_ observer: Any) { - guard let token = observer as? AnyHashable else { return } - removedObservers.append(token) - observers[token] = nil - } - - @MainActor - func trigger(name: Notification.Name, bundleIdentifier: String) { - for (_, entry) in observers { - guard entry.name == name else { continue } - if let expectedBundleIdentifier = entry.bundleIdentifier, - expectedBundleIdentifier != bundleIdentifier - { - continue - } - entry.handler() - } - } - } - - private final class FakeRunningProcessIdentifiers: @unchecked Sendable { - var identifiers: [pid_t] - - init(_ identifiers: [pid_t]) { - self.identifiers = identifiers - } - } - - func testConvenienceInitializerUsesDefaultNotificationName() { - XCTAssertEqual( - ApplicationSingleInstanceCoordinator.defaultNotificationName( - trimmedBundleIdentifier: Self.bundleIdentifier), - Notification.Name("com.example.app.single-instance.activate") - ) - } - - func testConfigurationErrorAndCoordinationResultAreHashable() { - let errors: Set = [ - .invalidBundleIdentifier, - .invalidNotificationName, - ] - let results: Set = [ - .unique, - .duplicate, - ] - - XCTAssertTrue(errors.contains(.invalidNotificationName)) - XCTAssertTrue(results.contains(.duplicate)) - } - - func testConfigurationErrorAllCasesListsFailureReasons() { - XCTAssertEqual( - ApplicationSingleInstanceCoordinator.ConfigurationError.allCases, - [.invalidBundleIdentifier, .invalidNotificationName] - ) - } - - func testCoordinationResultAllCasesListsOutcomes() { - XCTAssertEqual( - ApplicationSingleInstanceCoordinator.CoordinationResult.allCases, - [.unique, .duplicate] - ) - } - - func testInitializerRejectsBlankBundleIdentifier() { - XCTAssertThrowsError(try ApplicationSingleInstanceCoordinator(bundleIdentifier: " ")) { - error in - XCTAssertEqual( - error as? ApplicationSingleInstanceCoordinator.ConfigurationError, - .invalidBundleIdentifier - ) - } - } - - func testInitializerRejectsBlankNotificationName() { - XCTAssertThrowsError( - try ApplicationSingleInstanceCoordinator( - bundleIdentifier: Self.bundleIdentifier, - notificationName: Notification.Name(" ") - ) - ) { error in - XCTAssertEqual( - error as? ApplicationSingleInstanceCoordinator.ConfigurationError, - .invalidNotificationName - ) - } - } - - func testCoordinateTrimsNotificationName() throws { - let transport = FakeSingleInstanceActivationRequestTransport() - let coordinator = try Self.makeCoordinator( - notificationName: Notification.Name(" test.singleinstance\n"), - runningProcessIdentifiers: [Self.currentProcessIdentifier, Self.duplicateProcessIdentifier], - transport: transport - ) - - let result = coordinator.coordinate(onDuplicateActivationRequest: {}) - - XCTAssertEqual(result, .duplicate) - XCTAssertEqual( - transport.posts, - [ - .init( - name: Self.notificationName, - bundleIdentifier: Self.bundleIdentifier - ) - ] - ) - } - - func testCoordinateTrimsBundleIdentifier() throws { - let transport = FakeSingleInstanceActivationRequestTransport() - let coordinator = try Self.makeCoordinator( - bundleIdentifier: " \(Self.bundleIdentifier)\n", - runningProcessIdentifiers: [Self.currentProcessIdentifier, Self.duplicateProcessIdentifier], - transport: transport - ) - - let result = coordinator.coordinate(onDuplicateActivationRequest: {}) - - XCTAssertEqual(result, .duplicate) - XCTAssertEqual(transport.posts, [Self.activationPost()]) - } - - func testCoordinateWhenDuplicatePostsAndReturnsDuplicate() throws { - let transport = FakeSingleInstanceActivationRequestTransport() - let coordinator = try Self.makeCoordinator( - runningProcessIdentifiers: [Self.currentProcessIdentifier, Self.duplicateProcessIdentifier], - transport: transport - ) - - let result = coordinator.coordinate(onDuplicateActivationRequest: {}) - - XCTAssertEqual(result, .duplicate) - XCTAssertEqual(transport.posts, [Self.activationPost()]) - } - - func testCoordinateIgnoresInvalidProcessIdentifiers() throws { - let transport = FakeSingleInstanceActivationRequestTransport() - let coordinator = try Self.makeCoordinator( - runningProcessIdentifiers: [0, -1, Self.currentProcessIdentifier], - transport: transport - ) - - let result = coordinator.coordinate(onDuplicateActivationRequest: {}) - - XCTAssertEqual(result, .unique) - XCTAssertTrue(transport.posts.isEmpty) - } - - func testCoordinateWithInvalidCurrentProcessIdentifierDetectsDuplicate() - throws - { - let transport = FakeSingleInstanceActivationRequestTransport() - let coordinator = try Self.makeCoordinator( - currentProcessIdentifier: 0, - runningProcessIdentifiers: [Self.duplicateProcessIdentifier], - transport: transport - ) - - let result = coordinator.coordinate(onDuplicateActivationRequest: {}) - - XCTAssertEqual(result, .duplicate) - XCTAssertEqual(transport.posts, [Self.activationPost()]) - } - - func testCoordinateWithInvalidCurrentProcessIdentifierIgnoresInvalidCandidates() - throws - { - let transport = FakeSingleInstanceActivationRequestTransport() - let coordinator = try Self.makeCoordinator( - currentProcessIdentifier: 0, - runningProcessIdentifiers: [0, -1], - transport: transport - ) - - let result = coordinator.coordinate(onDuplicateActivationRequest: {}) - - XCTAssertEqual(result, .unique) - XCTAssertTrue(transport.posts.isEmpty) - } - - func testCoordinateWhenUniqueRegistersObserverAndCallbackRuns() async throws { - let transport = FakeSingleInstanceActivationRequestTransport() - let expectation = expectation(description: "activate existing instance callback runs") - let coordinator = try Self.makeCoordinator( - runningProcessIdentifiers: [Self.currentProcessIdentifier], - transport: transport - ) - - let result = coordinator.coordinate { - expectation.fulfill() - } - XCTAssertEqual(result, .unique) - - transport.trigger(name: Self.notificationName, bundleIdentifier: Self.bundleIdentifier) - await fulfillment(of: [expectation], timeout: 1.0) - } - - func testCoordinateActivatingCurrentApplicationRegistersActivationCallback() async throws { - let transport = FakeSingleInstanceActivationRequestTransport() - let expectation = expectation(description: "current application is activated") - var activationOptions: [NSApplication.ActivationOptions] = [] - let coordinator = try Self.makeCoordinator( - runningProcessIdentifiers: [Self.currentProcessIdentifier], - transport: transport, - activateCurrentApplication: { options in - activationOptions.append(options) - expectation.fulfill() - return true - } - ) - - let result = coordinator.coordinateActivatingCurrentApplication(options: [.activateAllWindows]) - XCTAssertEqual(result, .unique) - - transport.trigger(name: Self.notificationName, bundleIdentifier: Self.bundleIdentifier) - await fulfillment(of: [expectation], timeout: 1.0) - - XCTAssertEqual(activationOptions, [.activateAllWindows]) - } - - func testCoordinateActivatingCurrentApplicationDoesNotActivateDuplicateProcess() throws { - let transport = FakeSingleInstanceActivationRequestTransport() - var activationRequestCount = 0 - let coordinator = try Self.makeCoordinator( - runningProcessIdentifiers: [Self.currentProcessIdentifier, Self.duplicateProcessIdentifier], - transport: transport, - activateCurrentApplication: { _ in - activationRequestCount += 1 - return true - } - ) - - let result = coordinator.coordinateActivatingCurrentApplication() - - XCTAssertEqual(result, .duplicate) - XCTAssertEqual(activationRequestCount, 0) - XCTAssertEqual(transport.posts, [Self.activationPost()]) - } - - func testCoordinateActivatingCurrentApplicationDoesNotRetainCoordinator() throws { - let transport = FakeSingleInstanceActivationRequestTransport() - weak var weakCoordinator: ApplicationSingleInstanceCoordinator? - - do { - let coordinator = try Self.makeCoordinator( - runningProcessIdentifiers: [Self.currentProcessIdentifier], - transport: transport - ) - weakCoordinator = coordinator - - XCTAssertEqual(coordinator.coordinateActivatingCurrentApplication(), .unique) - } - - XCTAssertNil(weakCoordinator) - XCTAssertEqual(transport.removedObservers.count, 1) - } - - func testStopRemovesObserver() throws { - let transport = FakeSingleInstanceActivationRequestTransport() - let coordinator = try Self.makeCoordinator( - runningProcessIdentifiers: [], - transport: transport - ) - - _ = coordinator.coordinate(onDuplicateActivationRequest: {}) - coordinator.stop() - - XCTAssertEqual(transport.removedObservers.count, 1) - } - - func testStopPreventsFutureActivationRequests() async throws { - let transport = FakeSingleInstanceActivationRequestTransport() - let coordinator = try Self.makeCoordinator( - runningProcessIdentifiers: [], - transport: transport - ) - - var activationCount = 0 - _ = coordinator.coordinate { - activationCount += 1 - } - coordinator.stop() - - transport.trigger(name: Self.notificationName, bundleIdentifier: Self.bundleIdentifier) - await Task.yield() - - XCTAssertEqual(activationCount, 0) - XCTAssertEqual(transport.removedObservers.count, 1) - } - - func testRepeatedCoordinateReplacesObserver() throws { - let transport = FakeSingleInstanceActivationRequestTransport() - let coordinator = try Self.makeCoordinator( - runningProcessIdentifiers: [], - transport: transport - ) - - XCTAssertEqual(coordinator.coordinate(onDuplicateActivationRequest: {}), .unique) - XCTAssertEqual(coordinator.coordinate(onDuplicateActivationRequest: {}), .unique) - - XCTAssertEqual(transport.removedObservers.count, 1) - } - - func testRepeatedCoordinateDoesNotRunReplacedObserver() async throws { - let transport = FakeSingleInstanceActivationRequestTransport() - let coordinator = try Self.makeCoordinator( - runningProcessIdentifiers: [], - transport: transport - ) - - var firstActivationCount = 0 - var secondActivationCount = 0 - - XCTAssertEqual( - coordinator.coordinate { - firstActivationCount += 1 - }, - .unique - ) - XCTAssertEqual( - coordinator.coordinate { - secondActivationCount += 1 - }, - .unique - ) - - transport.trigger(name: Self.notificationName, bundleIdentifier: Self.bundleIdentifier) - await Task.yield() - - XCTAssertEqual(firstActivationCount, 0) - XCTAssertEqual(secondActivationCount, 1) - XCTAssertEqual(transport.removedObservers.count, 1) - } - - func testDuplicateCoordinateRemovesExistingObserver() throws { - let transport = FakeSingleInstanceActivationRequestTransport() - let runningProcessIdentifiers = FakeRunningProcessIdentifiers([ - Self.currentProcessIdentifier - ]) - let coordinator = try Self.makeCoordinatorWithProvider( - runningProcessIdentifiers: { _ in runningProcessIdentifiers.identifiers }, - transport: transport - ) - - XCTAssertEqual(coordinator.coordinate(onDuplicateActivationRequest: {}), .unique) - - runningProcessIdentifiers.identifiers = [ - Self.currentProcessIdentifier, - Self.duplicateProcessIdentifier, - ] - XCTAssertEqual(coordinator.coordinate(onDuplicateActivationRequest: {}), .duplicate) - - XCTAssertEqual(transport.removedObservers.count, 1) - XCTAssertEqual(transport.posts, [Self.activationPost()]) - } - - func testStopIsIdempotentWithoutObserver() throws { - let transport = FakeSingleInstanceActivationRequestTransport() - let coordinator = try Self.makeCoordinator( - runningProcessIdentifiers: [], - transport: transport - ) - - coordinator.stop() - coordinator.stop() - - XCTAssertTrue(transport.removedObservers.isEmpty) - } - - func testObserverIgnoresDifferentBundleIdentifier() async throws { - let transport = FakeSingleInstanceActivationRequestTransport() - let coordinator = try Self.makeCoordinator( - runningProcessIdentifiers: [Self.currentProcessIdentifier], - transport: transport - ) - - var activationCount = 0 - let result = coordinator.coordinate { - activationCount += 1 - } - XCTAssertEqual(result, .unique) - - transport.trigger(name: Self.notificationName, bundleIdentifier: "com.example.other") - await Task.yield() - - XCTAssertEqual(activationCount, 0) - } - - private static let bundleIdentifier = "com.example.app" - private static let currentProcessIdentifier: pid_t = 100 - private static let duplicateProcessIdentifier: pid_t = 200 - private static let notificationName = Notification.Name("test.singleinstance") - - private static func activationPost( - name: Notification.Name = notificationName, - bundleIdentifier: String = bundleIdentifier - ) -> FakeSingleInstanceActivationRequestTransport.Post { - .init(name: name, bundleIdentifier: bundleIdentifier) - } - - private static func makeCoordinator( - bundleIdentifier: String = bundleIdentifier, - notificationName: Notification.Name = notificationName, - currentProcessIdentifier: pid_t = currentProcessIdentifier, - runningProcessIdentifiers: [pid_t], - transport: FakeSingleInstanceActivationRequestTransport, - activateCurrentApplication: - @escaping ApplicationSingleInstanceCoordinatorDependencies - .CurrentApplicationActivator = { _ in true }, - file: StaticString = #filePath, - line: UInt = #line - ) throws -> ApplicationSingleInstanceCoordinator { - try makeCoordinatorWithProvider( - bundleIdentifier: bundleIdentifier, - notificationName: notificationName, - currentProcessIdentifier: { currentProcessIdentifier }, - runningProcessIdentifiers: { lookedUpBundleIdentifier in - XCTAssertEqual(lookedUpBundleIdentifier, Self.bundleIdentifier, file: file, line: line) - return runningProcessIdentifiers - }, - transport: transport, - activateCurrentApplication: activateCurrentApplication - ) - } - - private static func makeCoordinatorWithProvider( - bundleIdentifier: String = bundleIdentifier, - notificationName: Notification.Name = notificationName, - currentProcessIdentifier: - @escaping ApplicationSingleInstanceCoordinatorDependencies.CurrentProcessIdentifierProvider = - { - 100 - }, - runningProcessIdentifiers: - @escaping ApplicationSingleInstanceCoordinatorDependencies - .RunningProcessIdentifiersProvider, - transport: FakeSingleInstanceActivationRequestTransport, - activateCurrentApplication: - @escaping ApplicationSingleInstanceCoordinatorDependencies - .CurrentApplicationActivator = { _ in true } - ) throws -> ApplicationSingleInstanceCoordinator { - try ApplicationSingleInstanceCoordinator( - bundleIdentifier: bundleIdentifier, - notificationName: notificationName, - dependencies: ApplicationSingleInstanceCoordinatorDependencies( - activateCurrentApplication: activateCurrentApplication, - currentProcessIdentifier: currentProcessIdentifier, - runningProcessIdentifiers: runningProcessIdentifiers, - activationRequestTransport: transport - ) - ) - } -} diff --git a/Tests/AppRuntimeKitAppKitTests/Support/ApplicationDuplicateActivationTestSupport.swift b/Tests/AppRuntimeKitAppKitTests/Support/ApplicationDuplicateActivationTestSupport.swift new file mode 100644 index 0000000..983a277 --- /dev/null +++ b/Tests/AppRuntimeKitAppKitTests/Support/ApplicationDuplicateActivationTestSupport.swift @@ -0,0 +1,194 @@ +import AppKit +import Foundation +import XCTest + +@testable import AppRuntimeKitAppKit + +let duplicateActivationBundleIdentifier = "com.example.app" +let duplicateActivationCurrentProcessIdentifier: pid_t = 100 +let duplicateActivationProcessIdentifier: pid_t = 200 +let duplicateActivationNotificationName = Notification.Name( + "test.duplicate-process-activation-request" +) + +final class FakeDuplicateActivationRequestTransport: + ApplicationDuplicateActivationRequestTransport +{ + private final class ObserverToken: NSObject {} + + struct Post: Equatable { + let name: Notification.Name + let bundleIdentifier: String + } + + private(set) var posts: [Post] = [] + private(set) var removedObservers: [ObjectIdentifier] = [] + + private var observerTokenCounter = 0 + private struct ObserverEntry { + let name: Notification.Name + let bundleIdentifier: String? + let handler: @MainActor @Sendable () -> Void + } + + private var observers: [ObjectIdentifier: ObserverEntry] = [:] + + func postActivationRequest(name: Notification.Name, bundleIdentifier: String) { + posts.append(Post(name: name, bundleIdentifier: bundleIdentifier)) + } + + func observeActivationRequests( + name: Notification.Name, + bundleIdentifier: String, + handler: @escaping @MainActor @Sendable () -> Void + ) -> NSObjectProtocol { + observerTokenCounter += 1 + let token = ObserverToken() + observers[ObjectIdentifier(token)] = ObserverEntry( + name: name, + bundleIdentifier: bundleIdentifier, + handler: handler + ) + return token + } + + func removeActivationRequestObserver(_ observer: NSObjectProtocol) { + let token = ObjectIdentifier(observer as AnyObject) + removedObservers.append(token) + observers[token] = nil + } + + @MainActor + func trigger(name: Notification.Name, bundleIdentifier: String) { + for (_, entry) in observers { + guard entry.name == name else { continue } + if let expectedBundleIdentifier = entry.bundleIdentifier, + expectedBundleIdentifier != bundleIdentifier + { + continue + } + entry.handler() + } + } +} + +final class FakeDuplicateActivationNotificationCenter: + ApplicationDuplicateActivationNotificationCenter +{ + private final class ObserverToken: NSObject {} + + struct Post: Equatable { + let name: Notification.Name + let object: String? + } + + struct ObserverRegistration { + let name: Notification.Name? + let object: String? + let queue: OperationQueue? + let block: @Sendable (Notification) -> Void + } + + private(set) var posts: [Post] = [] + private(set) var observerRegistrations: [ObserverRegistration] = [] + private(set) var removedObservers: [ObjectIdentifier] = [] + private var nextObserver = 0 + + func post(name: Notification.Name, object: String?) { + posts.append(Post(name: name, object: object)) + } + + func addObserver( + forName name: Notification.Name?, + object: String?, + queue: OperationQueue?, + using block: @escaping @Sendable (Notification) -> Void + ) -> NSObjectProtocol { + observerRegistrations.append( + ObserverRegistration(name: name, object: object, queue: queue, block: block) + ) + nextObserver += 1 + return ObserverToken() + } + + func removeObserver(_ observer: NSObjectProtocol) { + removedObservers.append(ObjectIdentifier(observer as AnyObject)) + } +} + +@MainActor +final class FakeRunningProcessIdentifiers { + var identifiers: [pid_t] + + init(_ identifiers: [pid_t]) { + self.identifiers = identifiers + } +} + +func duplicateActivationPost( + name: Notification.Name = duplicateActivationNotificationName, + bundleIdentifier: String = duplicateActivationBundleIdentifier +) -> FakeDuplicateActivationRequestTransport.Post { + .init(name: name, bundleIdentifier: bundleIdentifier) +} + +@MainActor +func makeDuplicateActivationCoordinator( + bundleIdentifier: String = duplicateActivationBundleIdentifier, + notificationName: Notification.Name = duplicateActivationNotificationName, + currentProcessIdentifier: pid_t = duplicateActivationCurrentProcessIdentifier, + runningProcessIdentifiers: [pid_t], + transport: FakeDuplicateActivationRequestTransport, + activateCurrentApplication: + @escaping @MainActor @Sendable (_ options: NSApplication.ActivationOptions) -> Bool = { + _ in true + }, + file: StaticString = #filePath, + line: UInt = #line +) throws -> ApplicationDuplicateActivationCoordinator { + try makeDuplicateActivationCoordinator( + bundleIdentifier: bundleIdentifier, + notificationName: notificationName, + currentProcessIdentifier: { currentProcessIdentifier }, + runningProcessIdentifiers: { lookedUpBundleIdentifier in + XCTAssertEqual( + lookedUpBundleIdentifier, + duplicateActivationBundleIdentifier, + file: file, + line: line + ) + return runningProcessIdentifiers + }, + transport: transport, + activateCurrentApplication: activateCurrentApplication + ) +} + +@MainActor +func makeDuplicateActivationCoordinator( + bundleIdentifier: String = duplicateActivationBundleIdentifier, + notificationName: Notification.Name = duplicateActivationNotificationName, + currentProcessIdentifier: + @escaping @MainActor @Sendable () -> pid_t = + { + duplicateActivationCurrentProcessIdentifier + }, + runningProcessIdentifiers: + @escaping @MainActor @Sendable (_ bundleIdentifier: String) -> [pid_t], + transport: FakeDuplicateActivationRequestTransport, + activateCurrentApplication: + @escaping @MainActor @Sendable (_ options: NSApplication.ActivationOptions) -> Bool = { + _ in true + } +) throws -> ApplicationDuplicateActivationCoordinator { + try ApplicationDuplicateActivationCoordinator( + bundleIdentifier: bundleIdentifier, + notificationName: notificationName, + dependencies: ApplicationDuplicateActivationCoordinatorDependencies( + activateCurrentApplication: activateCurrentApplication, + currentProcessIdentifier: currentProcessIdentifier, + runningProcessIdentifiers: runningProcessIdentifiers, + activationRequestTransport: transport + ) + ) +} diff --git a/Tests/AppRuntimeKitAppKitTests/Support/ApplicationRuntimeEventNotificationTestSupport.swift b/Tests/AppRuntimeKitAppKitTests/Support/ApplicationRuntimeEventNotificationTestSupport.swift deleted file mode 100644 index 23a30ac..0000000 --- a/Tests/AppRuntimeKitAppKitTests/Support/ApplicationRuntimeEventNotificationTestSupport.swift +++ /dev/null @@ -1,230 +0,0 @@ -import AppKit -import XCTest - -import AppRuntimeKit -@testable import AppRuntimeKitAppKit - -struct ApplicationRuntimeEventNotificationFixture { - let application: NSRunningApplication - let bundleIdentifier: String -} - -@MainActor -func postApplicationRuntimeEventNotification( - _ name: Notification.Name, - application: NSRunningApplication? = applicationRuntimeEventNotificationApplication() -) { - let userInfo = application.map { [NSWorkspace.applicationUserInfoKey: $0] } - NSWorkspace.shared.notificationCenter.post( - name: name, - object: nil, - userInfo: userInfo - ) -} - -@MainActor -func applicationRuntimeEventNotificationApplication() -> NSRunningApplication? { - if let bundleIdentifier = NSRunningApplication.current.bundleIdentifier, - !bundleIdentifier.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, - NSRunningApplication.current.processIdentifier > 0, - !NSRunningApplication.current.isTerminated - { - return .current - } - return NSWorkspace.shared.runningApplications.first { application in - guard - let bundleIdentifier = application.bundleIdentifier, - !bundleIdentifier.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - else { - return false - } - return application.processIdentifier > 0 && !application.isTerminated - } -} - -@MainActor -func applicationRuntimeEventNotificationApplicationOrSkip( - file: StaticString = #filePath, - line: UInt = #line -) throws -> NSRunningApplication { - guard let application = applicationRuntimeEventNotificationApplication() else { - throw XCTSkip("No running application with a bundle identifier", file: file, line: line) - } - return application -} - -@MainActor -func applicationRuntimeEventNotificationFixtureOrSkip( - file: StaticString = #filePath, - line: UInt = #line -) throws -> ApplicationRuntimeEventNotificationFixture { - let application = try applicationRuntimeEventNotificationApplicationOrSkip(file: file, line: line) - guard let bundleIdentifier = application.bundleIdentifier else { - throw XCTSkip( - "Bundle identifier unavailable for notification application", - file: file, - line: line - ) - } - return ApplicationRuntimeEventNotificationFixture( - application: application, - bundleIdentifier: bundleIdentifier - ) -} - -@MainActor -func assertApplicationRuntimeEventNotificationSnapshot( - _ snapshot: ApplicationProcessSnapshot?, - application: NSRunningApplication = applicationRuntimeEventNotificationApplication() ?? .current, - file: StaticString = #filePath, - line: UInt = #line -) { - guard let snapshot else { - XCTFail("Expected notification application snapshot.", file: file, line: line) - return - } - XCTAssertEqual( - snapshot.processIdentifier, - application.processIdentifier, - file: file, - line: line - ) - XCTAssertEqual( - snapshot.bundleIdentifier, - application.bundleIdentifier, - file: file, - line: line - ) - XCTAssertEqual( - snapshot.displayName, - application.localizedName, - file: file, - line: line - ) - XCTAssertEqual( - snapshot.isTerminated, - application.isTerminated, - file: file, - line: line - ) -} - -enum ApplicationRuntimeLifecycleCase: CaseIterable { - case launch - case termination - case activation - case deactivation - - var label: String { - switch self { - case .launch: - "launch" - case .termination: - "termination" - case .activation: - "activation" - case .deactivation: - "deactivation" - } - } - - var kind: ApplicationRuntimeEvent.Kind { - switch self { - case .launch: - .launched - case .termination: - .terminated - case .activation: - .activated - case .deactivation: - .deactivated - } - } - - var notificationName: Notification.Name { - switch self { - case .launch: - NSWorkspace.didLaunchApplicationNotification - case .termination: - NSWorkspace.didTerminateApplicationNotification - case .activation: - NSWorkspace.didActivateApplicationNotification - case .deactivation: - NSWorkspace.didDeactivateApplicationNotification - } - } - - var differentNotificationName: Notification.Name { - switch self { - case .launch: - NSWorkspace.didActivateApplicationNotification - case .termination: - NSWorkspace.didLaunchApplicationNotification - case .activation: - NSWorkspace.didLaunchApplicationNotification - case .deactivation: - NSWorkspace.didLaunchApplicationNotification - } - } - - @MainActor - func snapshotStream(from runtime: ApplicationRuntime) -> AsyncStream< - ApplicationProcessSnapshot - > { - switch self { - case .launch: - runtime.launchEvents() - case .termination: - runtime.terminationEvents() - case .activation: - runtime.activationEvents() - case .deactivation: - runtime.deactivationEvents() - } - } - - @MainActor - func waitForSnapshot( - from runtime: ApplicationRuntime, - bundleIdentifier: String, - timeout: Duration - ) async -> ApplicationProcessSnapshot? { - await waitForSnapshotTask( - from: runtime, - bundleIdentifier: bundleIdentifier, - timeout: timeout - ).value - } - - @MainActor - func waitForSnapshotTask( - from runtime: ApplicationRuntime, - bundleIdentifier: String, - timeout: Duration - ) -> Task { - Task { - switch self { - case .launch: - await runtime.waitForApplicationLaunch( - bundleIdentifier: bundleIdentifier, - timeout: timeout - ) - case .termination: - await runtime.waitForApplicationTermination( - bundleIdentifier: bundleIdentifier, - timeout: timeout - ) - case .activation: - await runtime.waitForApplicationActivation( - bundleIdentifier: bundleIdentifier, - timeout: timeout - ) - case .deactivation: - await runtime.waitForApplicationDeactivation( - bundleIdentifier: bundleIdentifier, - timeout: timeout - ) - } - } - } -} diff --git a/Tests/AppRuntimeKitAppKitTests/Support/ApplicationRuntimeEventSourceTestSupport.swift b/Tests/AppRuntimeKitAppKitTests/Support/ApplicationRuntimeEventSourceTestSupport.swift new file mode 100644 index 0000000..791ce54 --- /dev/null +++ b/Tests/AppRuntimeKitAppKitTests/Support/ApplicationRuntimeEventSourceTestSupport.swift @@ -0,0 +1,223 @@ +import XCTest + +import AppRuntimeKit +@testable import AppRuntimeKitAppKit + +@MainActor +final class TestApplicationRuntimeEventSource: ApplicationRuntimeEventSource { + private struct EventSubscription { + let id: Int + let lifecycles: Set + let continuation: AsyncStream.Continuation + } + + private var eventSubscriptions: [EventSubscription] = [] + private var nextEventSubscriptionID = 0 + private var replayedEvents: [ApplicationRuntimeEvent] = [] + + func eventStream(lifecycles: Set) -> AsyncStream< + ApplicationRuntimeEvent + > { + return AsyncStream { continuation in + guard !lifecycles.isEmpty else { + continuation.finish() + return + } + for event in replayedEvents where lifecycles.contains(event.lifecycle) { + continuation.yield(event) + } + nextEventSubscriptionID += 1 + let id = nextEventSubscriptionID + eventSubscriptions.append( + EventSubscription(id: id, lifecycles: lifecycles, continuation: continuation) + ) + continuation.onTermination = { [weak self] _ in + Task { @MainActor in + self?.removeEventSubscription(id: id) + } + } + } + } + + func emit(_ lifecycleCase: ApplicationRuntimeLifecycleCase, snapshot: ApplicationProcessSnapshot?) + { + guard let snapshot else { return } + let event = ApplicationRuntimeEvent(lifecycle: lifecycleCase.lifecycle, application: snapshot) + replayedEvents.append(event) + for subscription in eventSubscriptions where subscription.lifecycles.contains(event.lifecycle) { + subscription.continuation.yield(event) + } + } + + private func removeEventSubscription(id: Int) { + eventSubscriptions.removeAll { $0.id == id } + } +} + +@MainActor +func applicationRuntime(eventSource: TestApplicationRuntimeEventSource) -> ApplicationRuntime { + ApplicationRuntime( + dependencies: ApplicationRuntimeDependencies( + eventSource: { _ in eventSource } + ) + ) +} + +func applicationRuntimeEventSnapshot( + processIdentifier: pid_t = 123, + bundleIdentifier: String = "com.example.event", + displayName: String = "Example Event App", + isTerminated: Bool = false +) -> ApplicationProcessSnapshot { + ApplicationProcessSnapshot( + processIdentifier: processIdentifier, + bundleIdentifier: bundleIdentifier, + displayName: displayName, + isTerminated: isTerminated + ) +} + +func assertApplicationRuntimeEventSnapshot( + _ snapshot: ApplicationProcessSnapshot?, + matches expected: ApplicationProcessSnapshot, + file: StaticString = #filePath, + line: UInt = #line +) { + XCTAssertEqual(snapshot, expected, file: file, line: line) +} + +@MainActor +func applicationRuntimeEventWaitTask( + runtime: ApplicationRuntime, + lifecycle: ApplicationRuntimeEvent.Lifecycle, + bundleIdentifier: String, + timeout: Duration +) -> Task { + Task { + await runtime.waitForApplicationEvent( + lifecycle, + bundleIdentifier: bundleIdentifier, + timeout: timeout + ) + } +} + +enum ApplicationRuntimeLifecycleCase: CaseIterable { + case launch + case termination + case activation + case deactivation + + var label: String { + switch self { + case .launch: + "launch" + case .termination: + "termination" + case .activation: + "activation" + case .deactivation: + "deactivation" + } + } + + var lifecycle: ApplicationRuntimeEvent.Lifecycle { + switch self { + case .launch: + .launched + case .termination: + .terminated + case .activation: + .activated + case .deactivation: + .deactivated + } + } + + var differentLifecycleCase: ApplicationRuntimeLifecycleCase { + switch self { + case .launch: + .activation + case .termination: + .launch + case .activation: + .launch + case .deactivation: + .launch + } + } + + @MainActor + func applicationSnapshots(from runtime: ApplicationRuntime) -> AsyncStream< + ApplicationProcessSnapshot + > { + switch self { + case .launch: + runtime.launchEvents() + case .termination: + runtime.terminationEvents() + case .activation: + runtime.activationEvents() + case .deactivation: + runtime.deactivationEvents() + } + } + + @MainActor + func waitForSnapshot( + from runtime: ApplicationRuntime, + bundleIdentifier: String, + timeout: Duration + ) async -> ApplicationProcessSnapshot? { + await waitForSnapshotTask( + from: runtime, + bundleIdentifier: bundleIdentifier, + timeout: timeout + ).value + } + + @MainActor + func waitForSnapshotTask( + from runtime: ApplicationRuntime, + bundleIdentifier: String, + timeout: Duration + ) -> Task { + Task { + await self.waitForSnapshotValue( + from: runtime, + bundleIdentifier: bundleIdentifier, + timeout: timeout + ) + } + } + + @MainActor + private func waitForSnapshotValue( + from runtime: ApplicationRuntime, + bundleIdentifier: String, + timeout: Duration + ) async -> ApplicationProcessSnapshot? { + switch self { + case .launch: + await runtime.waitForApplicationLaunch( + bundleIdentifier: bundleIdentifier, + timeout: timeout + ) + case .termination: + await runtime.waitForApplicationTermination( + bundleIdentifier: bundleIdentifier, + timeout: timeout + ) + case .activation: + await runtime.waitForApplicationActivation( + bundleIdentifier: bundleIdentifier, + timeout: timeout + ) + case .deactivation: + await runtime.waitForApplicationDeactivation( + bundleIdentifier: bundleIdentifier, + timeout: timeout + ) + } + } +} diff --git a/Tests/AppRuntimeKitAppKitTests/Support/ApplicationRuntimeFactoryTestSupport.swift b/Tests/AppRuntimeKitAppKitTests/Support/ApplicationRuntimeFactoryTestSupport.swift new file mode 100644 index 0000000..f4e634a --- /dev/null +++ b/Tests/AppRuntimeKitAppKitTests/Support/ApplicationRuntimeFactoryTestSupport.swift @@ -0,0 +1,205 @@ +import AppKit +import Darwin +import XCTest + +@testable import AppRuntimeKitAppKit + +@MainActor +func applicationRuntimeReturningCurrentApplication( + _ application: FakeRuntimeRunningApplication?, + unexpectedProcessLookupMessage: String? = nil, + file: StaticString = #filePath, + line: UInt = #line +) -> ApplicationRuntime { + ApplicationRuntime( + dependencies: ApplicationRuntimeDependencies( + currentApplication: { application }, + runningApplication: { _ in + if let unexpectedProcessLookupMessage { + XCTFail(unexpectedProcessLookupMessage, file: file, line: line) + } + return nil + } + ) + ) +} + +@MainActor +func applicationRuntimeReturningRunningApplication( + _ application: FakeRuntimeRunningApplication?, + for expectedProcessIdentifier: pid_t, + file: StaticString = #filePath, + line: UInt = #line +) -> ApplicationRuntime { + applicationRuntimeReturningRunningApplication( + { _ in application }, + for: expectedProcessIdentifier, + file: file, + line: line + ) +} + +@MainActor +func applicationRuntimeReturningRunningApplication( + _ applicationProvider: + @escaping @MainActor @Sendable (pid_t) + -> FakeRuntimeRunningApplication?, + for expectedProcessIdentifier: pid_t, + file: StaticString = #filePath, + line: UInt = #line +) -> ApplicationRuntime { + ApplicationRuntime( + dependencies: ApplicationRuntimeDependencies( + runningApplication: { processIdentifier in + XCTAssertEqual( + processIdentifier, + expectedProcessIdentifier, + file: file, + line: line + ) + return applicationProvider(processIdentifier) + } + ) + ) +} + +@MainActor +func applicationRuntimeRejectingRunningApplicationLookup( + _ message: String, + file: StaticString = #filePath, + line: UInt = #line +) -> ApplicationRuntime { + ApplicationRuntime( + dependencies: ApplicationRuntimeDependencies( + runningApplication: { _ in + XCTFail(message, file: file, line: line) + return FakeRuntimeRunningApplication() + } + ) + ) +} + +@MainActor +func applicationRuntimeReturningRunningApplications( + _ applications: [FakeRuntimeRunningApplication], + for expectedBundleIdentifier: String = "com.example.app", + unexpectedProcessLookupMessage: String? = nil, + file: StaticString = #filePath, + line: UInt = #line +) -> ApplicationRuntime { + ApplicationRuntime( + dependencies: ApplicationRuntimeDependencies( + runningApplication: { _ in + if let unexpectedProcessLookupMessage { + XCTFail(unexpectedProcessLookupMessage, file: file, line: line) + } + return nil + }, + runningApplications: { bundleIdentifier in + XCTAssertEqual( + bundleIdentifier, + expectedBundleIdentifier, + file: file, + line: line + ) + return applications + } + ) + ) +} + +@MainActor +func applicationRuntimeRejectingRunningApplicationsLookup( + _ message: String, + file: StaticString = #filePath, + line: UInt = #line +) -> ApplicationRuntime { + ApplicationRuntime( + dependencies: ApplicationRuntimeDependencies( + runningApplications: { _ in + XCTFail(message, file: file, line: line) + return [FakeRuntimeRunningApplication()] + } + ) + ) +} + +@MainActor +func applicationRuntimeRejectingBundleIdentifierLookups( + file: StaticString = #filePath, + line: UInt = #line +) -> ApplicationRuntime { + ApplicationRuntime( + dependencies: ApplicationRuntimeDependencies( + runningApplication: { _ in + XCTFail( + "Blank bundle identifier should not perform a process lookup.", + file: file, + line: line + ) + return nil + }, + runningApplications: { _ in + XCTFail( + "Blank bundle identifier should not perform a running application lookup.", + file: file, + line: line + ) + return [FakeRuntimeRunningApplication()] + } + ) + ) +} + +@MainActor +func applicationRuntimeReturningFrontmostApplication( + _ application: FakeRuntimeRunningApplication?, + file: StaticString = #filePath, + line: UInt = #line +) -> ApplicationRuntime { + ApplicationRuntime( + workspace: .shared, + dependencies: ApplicationRuntimeDependencies( + frontmostApplication: { workspace in + XCTAssertTrue(workspace === NSWorkspace.shared, file: file, line: line) + return application + } + ) + ) +} + +@MainActor +func applicationRuntimeReturningInstalledApplicationURL( + _ url: URL?, + for expectedBundleIdentifier: String = "com.example.app", + file: StaticString = #filePath, + line: UInt = #line +) -> ApplicationRuntime { + ApplicationRuntime( + workspace: .shared, + dependencies: ApplicationRuntimeDependencies( + installedApplicationURL: { workspace, bundleIdentifier in + XCTAssertTrue(workspace === NSWorkspace.shared, file: file, line: line) + XCTAssertEqual(bundleIdentifier, expectedBundleIdentifier, file: file, line: line) + return url + } + ) + ) +} + +@MainActor +func applicationRuntimeRejectingInstalledApplicationLookup( + _ message: String, + file: StaticString = #filePath, + line: UInt = #line +) -> ApplicationRuntime { + ApplicationRuntime( + workspace: .shared, + dependencies: ApplicationRuntimeDependencies( + installedApplicationURL: { _, _ in + XCTFail(message, file: file, line: line) + return URL(fileURLWithPath: "/Applications/Example.app") + } + ) + ) +} diff --git a/Tests/AppRuntimeKitAppKitTests/Support/ApplicationRuntimeOpeningAssertions.swift b/Tests/AppRuntimeKitAppKitTests/Support/ApplicationRuntimeOpeningAssertions.swift new file mode 100644 index 0000000..da18119 --- /dev/null +++ b/Tests/AppRuntimeKitAppKitTests/Support/ApplicationRuntimeOpeningAssertions.swift @@ -0,0 +1,128 @@ +import AppKit +import Foundation +import XCTest + +import AppRuntimeKit +@testable import AppRuntimeKitAppKit + +func XCTAssertFailedOpeningOutcome( + _ result: ApplicationRequestResult, + _ expectedError: ApplicationOpeningError, + _ message: @autoclosure () -> String = "", + file: StaticString = #filePath, + line: UInt = #line +) { + switch result { + case .requestAccepted: + XCTFail("Expected failed opening outcome. \(message())", file: file, line: line) + case let .failed(error): + XCTAssertEqual(error, expectedError, message(), file: file, line: line) + } +} + +@MainActor +func assertOpeningURLValidationError( + _ url: URL, + _ expectedError: ApplicationOpeningError, + file: StaticString = #filePath, + line: UInt = #line +) async { + let runtime = applicationRuntimeRejectingOpeningRequestDependencies(file: file, line: line) + XCTAssertFailedOpeningOutcome(runtime.openURL(url), expectedError, file: file, line: line) + let result = await runtime.openURL( + url, + configuration: NSWorkspace.OpenConfiguration() + ) + XCTAssertFailedOpeningOutcome(result, expectedError, file: file, line: line) +} + +@MainActor +func assertOpeningFileValidationError( + _ url: URL, + _ expectedError: ApplicationOpeningError, + file: StaticString = #filePath, + line: UInt = #line +) async { + let runtime = applicationRuntimeRejectingOpeningRequestDependencies(file: file, line: line) + XCTAssertFailedOpeningOutcome(runtime.openFile(url), expectedError, file: file, line: line) + let result = await runtime.openFile( + url, + configuration: NSWorkspace.OpenConfiguration() + ) + XCTAssertFailedOpeningOutcome(result, expectedError, file: file, line: line) +} + +@MainActor +func assertOpeningApplicationValidationError( + bundleIdentifier: String, + _ expectedError: ApplicationOpeningError, + file: StaticString = #filePath, + line: UInt = #line +) async { + let runtime = applicationRuntimeRejectingOpeningRequestDependencies(file: file, line: line) + XCTAssertFailedOpeningOutcome( + runtime.openApplication(bundleIdentifier: bundleIdentifier), + expectedError, + file: file, + line: line + ) + let result = await runtime.openApplication( + bundleIdentifier: bundleIdentifier, + configuration: NSWorkspace.OpenConfiguration() + ) + XCTAssertFailedOpeningOutcome(result, expectedError, file: file, line: line) +} + +@MainActor +func assertOpeningApplicationValidationError( + at url: URL, + _ expectedError: ApplicationOpeningError, + message: String = "", + file: StaticString = #filePath, + line: UInt = #line +) async { + let runtime = applicationRuntimeRejectingOpeningRequestDependencies(file: file, line: line) + XCTAssertFailedOpeningOutcome( + runtime.openApplication(at: url), + expectedError, + message, + file: file, + line: line + ) + let result = await runtime.openApplication( + at: url, + configuration: NSWorkspace.OpenConfiguration() + ) + XCTAssertFailedOpeningOutcome(result, expectedError, message, file: file, line: line) +} + +@MainActor +func assertOpeningResolvedApplicationValidationError( + _ url: URL, + _ expectedError: ApplicationOpeningError, + bundleIdentifier: String = "com.example.app", + message: String = "", + file: StaticString = #filePath, + line: UInt = #line +) async { + let runtime = applicationRuntimeResolvingApplicationBundleURL( + url, + for: bundleIdentifier, + unexpectedOpenMessage: + "Invalid resolved application bundle URL should not request opening.", + file: file, + line: line + ) + XCTAssertFailedOpeningOutcome( + runtime.openApplication(bundleIdentifier: bundleIdentifier), + expectedError, + message, + file: file, + line: line + ) + let result = await runtime.openApplication( + bundleIdentifier: bundleIdentifier, + configuration: NSWorkspace.OpenConfiguration() + ) + XCTAssertFailedOpeningOutcome(result, expectedError, message, file: file, line: line) +} diff --git a/Tests/AppRuntimeKitAppKitTests/Support/ApplicationRuntimeOpeningFactories.swift b/Tests/AppRuntimeKitAppKitTests/Support/ApplicationRuntimeOpeningFactories.swift new file mode 100644 index 0000000..9215303 --- /dev/null +++ b/Tests/AppRuntimeKitAppKitTests/Support/ApplicationRuntimeOpeningFactories.swift @@ -0,0 +1,66 @@ +import AppKit +import Foundation +import XCTest + +@testable import AppRuntimeKitAppKit + +@MainActor +func applicationRuntimeRejectingOpeningRequestDependencies( + file: StaticString = #filePath, + line: UInt = #line +) -> ApplicationRuntime { + ApplicationRuntime( + dependencies: ApplicationRuntimeDependencies( + installedApplicationURL: { _, _ in + XCTFail("Invalid input should not perform an application lookup.", file: file, line: line) + return nil + }, + requestOpening: { _, _ in + XCTFail("Invalid input should not request opening.", file: file, line: line) + return true + }, + requestConfiguredURLOpening: { _, _, _ in + XCTFail( + "Invalid input should not request configured URL opening.", + file: file, + line: line + ) + return true + }, + requestConfiguredApplicationOpening: { _, _, _ in + XCTFail( + "Invalid input should not request configured application bundle opening.", + file: file, + line: line + ) + return true + } + ) + ) +} + +@MainActor +func applicationRuntimeResolvingApplicationBundleURL( + _ url: URL, + for expectedBundleIdentifier: String = "com.example.app", + unexpectedOpenMessage: String, + file: StaticString = #filePath, + line: UInt = #line +) -> ApplicationRuntime { + ApplicationRuntime( + dependencies: ApplicationRuntimeDependencies( + installedApplicationURL: { _, bundleIdentifier in + XCTAssertEqual(bundleIdentifier, expectedBundleIdentifier, file: file, line: line) + return url + }, + requestOpening: { _, _ in + XCTFail(unexpectedOpenMessage, file: file, line: line) + return true + }, + requestConfiguredApplicationOpening: { _, _, _ in + XCTFail(unexpectedOpenMessage, file: file, line: line) + return true + } + ) + ) +} diff --git a/Tests/AppRuntimeKitAppKitTests/Support/ApplicationRuntimeOpeningRequestRecorders.swift b/Tests/AppRuntimeKitAppKitTests/Support/ApplicationRuntimeOpeningRequestRecorders.swift new file mode 100644 index 0000000..7d7736e --- /dev/null +++ b/Tests/AppRuntimeKitAppKitTests/Support/ApplicationRuntimeOpeningRequestRecorders.swift @@ -0,0 +1,202 @@ +import AppKit +import Foundation +import XCTest + +@testable import AppRuntimeKitAppKit + +@MainActor +final class SynchronousOpeningRequestRecorder { + private let workspace: NSWorkspace + private let result: Bool + private var requestedWorkspace: NSWorkspace? + private var requestedURL: URL? + + init( + workspace: NSWorkspace = NSWorkspace(), + result: Bool = true + ) { + self.workspace = workspace + self.result = result + } + + func runtimeRequestingOpening() -> ApplicationRuntime { + ApplicationRuntime( + workspace: workspace, + dependencies: ApplicationRuntimeDependencies( + requestOpening: { workspace, url in + self.requestedWorkspace = workspace + self.requestedURL = url + return self.result + } + ) + ) + } + + func assertRequestedOpening( + _ expectedURL: URL, + file: StaticString = #filePath, + line: UInt = #line + ) { + XCTAssertIdentical(requestedWorkspace, workspace, file: file, line: line) + XCTAssertEqual(requestedURL, expectedURL, file: file, line: line) + } +} + +@MainActor +final class ConfiguredOpeningRequestRecorder { + private let workspace: NSWorkspace + private let result: Bool + private var requestedWorkspace: NSWorkspace? + private var requestedURL: URL? + private var requestedConfiguration: NSWorkspace.OpenConfiguration? + + init( + workspace: NSWorkspace = NSWorkspace(), + result: Bool = true + ) { + self.workspace = workspace + self.result = result + } + + func runtimeRequestingConfiguredURLOpening() -> ApplicationRuntime { + ApplicationRuntime( + workspace: workspace, + dependencies: ApplicationRuntimeDependencies( + requestConfiguredURLOpening: { workspace, url, configuration in + self.requestedWorkspace = workspace + self.requestedURL = url + self.requestedConfiguration = configuration + return self.result + } + ) + ) + } + + func runtimeRequestingConfiguredApplicationOpening() -> ApplicationRuntime { + ApplicationRuntime( + workspace: workspace, + dependencies: ApplicationRuntimeDependencies( + requestConfiguredApplicationOpening: { workspace, url, configuration in + self.requestedWorkspace = workspace + self.requestedURL = url + self.requestedConfiguration = configuration + return self.result + } + ) + ) + } + + func assertRequestedOpening( + _ expectedURL: URL, + configuration expectedConfiguration: NSWorkspace.OpenConfiguration, + file: StaticString = #filePath, + line: UInt = #line + ) { + XCTAssertIdentical(requestedWorkspace, workspace, file: file, line: line) + XCTAssertEqual(requestedURL, expectedURL, file: file, line: line) + XCTAssertIdentical(requestedConfiguration, expectedConfiguration, file: file, line: line) + } +} + +@MainActor +final class InstalledApplicationOpeningRequestRecorder { + private let workspace: NSWorkspace + private let applicationURL: URL? + private let result: Bool + private let unexpectedOpenMessage: String? + private var lookedUpWorkspace: NSWorkspace? + private var lookedUpBundleIdentifier: String? + private var requestedWorkspace: NSWorkspace? + private var requestedURL: URL? + private var requestedConfiguration: NSWorkspace.OpenConfiguration? + + init( + workspace: NSWorkspace = NSWorkspace(), + applicationURL: URL?, + result: Bool = true, + unexpectedOpenMessage: String? = nil + ) { + self.workspace = workspace + self.applicationURL = applicationURL + self.result = result + self.unexpectedOpenMessage = unexpectedOpenMessage + } + + func runtimeRequestingResolvedApplicationOpening() -> ApplicationRuntime { + ApplicationRuntime( + workspace: workspace, + dependencies: ApplicationRuntimeDependencies( + installedApplicationURL: lookupInstalledApplicationURL, + requestOpening: { workspace, url in + self.recordOpeningRequest(workspace: workspace, url: url) + return self.result + } + ) + ) + } + + func runtimeRequestingConfiguredResolvedApplicationOpening() -> ApplicationRuntime { + ApplicationRuntime( + workspace: workspace, + dependencies: ApplicationRuntimeDependencies( + installedApplicationURL: lookupInstalledApplicationURL, + requestConfiguredApplicationOpening: { workspace, url, configuration in + self.recordOpeningRequest(workspace: workspace, url: url, configuration: configuration) + return self.result + } + ) + ) + } + + func assertLookedUp( + _ expectedBundleIdentifier: String, + file: StaticString = #filePath, + line: UInt = #line + ) { + XCTAssertIdentical(lookedUpWorkspace, workspace, file: file, line: line) + XCTAssertEqual(lookedUpBundleIdentifier, expectedBundleIdentifier, file: file, line: line) + } + + func assertRequestedOpening( + _ expectedURL: URL, + file: StaticString = #filePath, + line: UInt = #line + ) { + XCTAssertIdentical(requestedWorkspace, workspace, file: file, line: line) + XCTAssertEqual(requestedURL, expectedURL, file: file, line: line) + XCTAssertNil(requestedConfiguration, file: file, line: line) + } + + func assertRequestedOpening( + _ expectedURL: URL, + configuration expectedConfiguration: NSWorkspace.OpenConfiguration, + file: StaticString = #filePath, + line: UInt = #line + ) { + XCTAssertIdentical(requestedWorkspace, workspace, file: file, line: line) + XCTAssertEqual(requestedURL, expectedURL, file: file, line: line) + XCTAssertIdentical(requestedConfiguration, expectedConfiguration, file: file, line: line) + } + + private func lookupInstalledApplicationURL( + workspace: NSWorkspace, + bundleIdentifier: String + ) -> URL? { + lookedUpWorkspace = workspace + lookedUpBundleIdentifier = bundleIdentifier + return applicationURL + } + + private func recordOpeningRequest( + workspace: NSWorkspace, + url: URL, + configuration: NSWorkspace.OpenConfiguration? = nil + ) { + if let unexpectedOpenMessage { + XCTFail(unexpectedOpenMessage) + } + requestedWorkspace = workspace + requestedURL = url + requestedConfiguration = configuration + } +} diff --git a/Tests/AppRuntimeKitAppKitTests/Support/ApplicationRuntimeOpeningTestSupport.swift b/Tests/AppRuntimeKitAppKitTests/Support/ApplicationRuntimeOpeningTemporaryFixtures.swift similarity index 73% rename from Tests/AppRuntimeKitAppKitTests/Support/ApplicationRuntimeOpeningTestSupport.swift rename to Tests/AppRuntimeKitAppKitTests/Support/ApplicationRuntimeOpeningTemporaryFixtures.swift index 4068875..6e346fd 100644 --- a/Tests/AppRuntimeKitAppKitTests/Support/ApplicationRuntimeOpeningTestSupport.swift +++ b/Tests/AppRuntimeKitAppKitTests/Support/ApplicationRuntimeOpeningTemporaryFixtures.swift @@ -1,22 +1,18 @@ import Foundation import XCTest -import AppRuntimeKit -@testable import AppRuntimeKitAppKit +func webURL( + file: StaticString = #filePath, + line: UInt = #line +) throws -> URL { + try XCTUnwrap(URL(string: "https://example.com"), file: file, line: line) +} -func XCTAssertOpenFailure( - _ result: ApplicationOpenResult, - _ expectedError: ApplicationOpenError, - _ message: @autoclosure () -> String = "", +func missingURLScheme( file: StaticString = #filePath, line: UInt = #line -) { - switch result { - case .opened: - XCTFail("Expected open failure. \(message())", file: file, line: line) - case let .failed(error): - XCTAssertEqual(error, expectedError, message(), file: file, line: line) - } +) throws -> URL { + try XCTUnwrap(URL(string: "invalid"), file: file, line: line) } func temporaryFileURL(pathExtension: String = "txt") throws -> URL { @@ -26,6 +22,22 @@ func temporaryFileURL(pathExtension: String = "txt") throws -> URL { return fileURL } +func missingFileURL(pathExtension: String) -> URL { + FileManager.default.temporaryDirectory + .appendingPathComponent("missing-\(UUID().uuidString)") + .appendingPathExtension(pathExtension) +} + +func temporaryDirectoryURL( + prefix: String, + pathExtension: String? = nil +) -> URL { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("\(prefix)-\(UUID().uuidString)") + guard let pathExtension else { return url } + return url.appendingPathExtension(pathExtension) +} + enum TemporaryExecutableKind { case executableFile case nonExecutableFile diff --git a/Tests/AppRuntimeKitAppKitTests/Support/ApplicationRuntimeRunningApplicationAssertions.swift b/Tests/AppRuntimeKitAppKitTests/Support/ApplicationRuntimeRunningApplicationAssertions.swift new file mode 100644 index 0000000..7a977da --- /dev/null +++ b/Tests/AppRuntimeKitAppKitTests/Support/ApplicationRuntimeRunningApplicationAssertions.swift @@ -0,0 +1,67 @@ +import Darwin +import XCTest + +import AppRuntimeKit +@testable import AppRuntimeKitAppKit + +@MainActor +func assertRuntimeRunningApplication( + _ application: any ApplicationRuntimeRunningApplication, + matches expected: any ApplicationRuntimeRunningApplication, + file: StaticString = #filePath, + line: UInt = #line +) { + assertRuntimeApplicationFields( + processIdentifier: application.processIdentifier, + bundleIdentifier: application.bundleIdentifier, + displayName: application.displayName, + isTerminated: application.isTerminated, + expectedProcessIdentifier: expected.processIdentifier, + expectedBundleIdentifier: expected.bundleIdentifier, + expectedDisplayName: expected.displayName, + expectedIsTerminated: expected.isTerminated, + file: file, + line: line + ) +} + +func assertApplicationProcessSnapshot( + _ snapshot: ApplicationProcessSnapshot, + processIdentifier: pid_t, + bundleIdentifier: String?, + displayName: String?, + isTerminated: Bool = false, + file: StaticString = #filePath, + line: UInt = #line +) { + assertRuntimeApplicationFields( + processIdentifier: snapshot.processIdentifier, + bundleIdentifier: snapshot.bundleIdentifier, + displayName: snapshot.displayName, + isTerminated: snapshot.isTerminated, + expectedProcessIdentifier: processIdentifier, + expectedBundleIdentifier: bundleIdentifier, + expectedDisplayName: displayName, + expectedIsTerminated: isTerminated, + file: file, + line: line + ) +} + +private func assertRuntimeApplicationFields( + processIdentifier: pid_t, + bundleIdentifier: String?, + displayName: String?, + isTerminated: Bool, + expectedProcessIdentifier: pid_t, + expectedBundleIdentifier: String?, + expectedDisplayName: String?, + expectedIsTerminated: Bool, + file: StaticString, + line: UInt +) { + XCTAssertEqual(processIdentifier, expectedProcessIdentifier, file: file, line: line) + XCTAssertEqual(bundleIdentifier, expectedBundleIdentifier, file: file, line: line) + XCTAssertEqual(displayName, expectedDisplayName, file: file, line: line) + XCTAssertEqual(isTerminated, expectedIsTerminated, file: file, line: line) +} diff --git a/Tests/AppRuntimeKitAppKitTests/Support/ApplicationRuntimeSnapshotTestSupport.swift b/Tests/AppRuntimeKitAppKitTests/Support/ApplicationRuntimeSnapshotTestSupport.swift new file mode 100644 index 0000000..e0e4d9a --- /dev/null +++ b/Tests/AppRuntimeKitAppKitTests/Support/ApplicationRuntimeSnapshotTestSupport.swift @@ -0,0 +1,17 @@ +import Darwin + +import AppRuntimeKit + +func applicationRuntimeSnapshot( + processIdentifier: pid_t = 456, + bundleIdentifier: String? = "com.example.app", + displayName: String? = "Example", + isTerminated: Bool = false +) -> ApplicationProcessSnapshot { + ApplicationProcessSnapshot( + processIdentifier: processIdentifier, + bundleIdentifier: bundleIdentifier, + displayName: displayName, + isTerminated: isTerminated + ) +} diff --git a/Tests/AppRuntimeKitAppKitTests/Support/AsyncStreamTestSupport.swift b/Tests/AppRuntimeKitAppKitTests/Support/AsyncStreamTestSupport.swift new file mode 100644 index 0000000..3587c58 --- /dev/null +++ b/Tests/AppRuntimeKitAppKitTests/Support/AsyncStreamTestSupport.swift @@ -0,0 +1,10 @@ +func firstValueTask( + from stream: AsyncStream +) -> Task { + Task { + for await value in stream { + return value + } + return nil + } +} diff --git a/Tests/AppRuntimeKitAppKitTests/Support/ConformanceAssertions.swift b/Tests/AppRuntimeKitAppKitTests/Support/ConformanceAssertions.swift new file mode 100644 index 0000000..6ac12a4 --- /dev/null +++ b/Tests/AppRuntimeKitAppKitTests/Support/ConformanceAssertions.swift @@ -0,0 +1,37 @@ +import XCTest + +func assertError( + _ value: T, + file: StaticString = #filePath, + line: UInt = #line +) { + XCTAssertNotNil(value, file: file, line: line) +} + +func assertSendable( + _ value: T, + file: StaticString = #filePath, + line: UInt = #line +) { + XCTAssertNotNil(value, file: file, line: line) +} + +func assertHashableSendable( + _: T.Type, + _ value: T, + file: StaticString = #filePath, + line: UInt = #line +) where T: Hashable & Sendable { + XCTAssertEqual(value, value, file: file, line: line) + XCTAssertEqual(value.hashValue, value.hashValue, file: file, line: line) +} + +func assertErrorHashableSendable( + _: T.Type, + _ value: T, + file: StaticString = #filePath, + line: UInt = #line +) where T: Error & Hashable & Sendable { + assertError(value, file: file, line: line) + assertHashableSendable(T.self, value, file: file, line: line) +} diff --git a/Tests/AppRuntimeKitAppKitTests/Support/FakeRuntimeRunningApplication.swift b/Tests/AppRuntimeKitAppKitTests/Support/FakeRuntimeRunningApplication.swift index 51ae12b..37c61b5 100644 --- a/Tests/AppRuntimeKitAppKitTests/Support/FakeRuntimeRunningApplication.swift +++ b/Tests/AppRuntimeKitAppKitTests/Support/FakeRuntimeRunningApplication.swift @@ -9,9 +9,9 @@ final class FakeRuntimeRunningApplication: ApplicationRuntimeRunningApplication var bundleIdentifier: String? var displayName: String? var isTerminated: Bool - var activationResult: Bool - var gracefulTerminationResult: Bool - var forcedTerminationResult: Bool + var activationRequestAccepted: Bool + var gracefulTerminationRequestAccepted: Bool + var forcedTerminationRequestAccepted: Bool private(set) var activationOptions: [NSApplication.ActivationOptions] = [] private(set) var gracefulTerminationRequestCount = 0 private(set) var forcedTerminationRequestCount = 0 @@ -21,31 +21,31 @@ final class FakeRuntimeRunningApplication: ApplicationRuntimeRunningApplication bundleIdentifier: String? = "com.example.app", displayName: String? = "Example", isTerminated: Bool = false, - activationResult: Bool = true, - gracefulTerminationResult: Bool = true, - forcedTerminationResult: Bool = true + activationRequestAccepted: Bool = true, + gracefulTerminationRequestAccepted: Bool = true, + forcedTerminationRequestAccepted: Bool = true ) { self.processIdentifier = processIdentifier self.bundleIdentifier = bundleIdentifier self.displayName = displayName self.isTerminated = isTerminated - self.activationResult = activationResult - self.gracefulTerminationResult = gracefulTerminationResult - self.forcedTerminationResult = forcedTerminationResult + self.activationRequestAccepted = activationRequestAccepted + self.gracefulTerminationRequestAccepted = gracefulTerminationRequestAccepted + self.forcedTerminationRequestAccepted = forcedTerminationRequestAccepted } func activate(options: NSApplication.ActivationOptions) -> Bool { activationOptions.append(options) - return activationResult + return activationRequestAccepted } func requestGracefulTermination() -> Bool { gracefulTerminationRequestCount += 1 - return gracefulTerminationResult + return gracefulTerminationRequestAccepted } func requestForcedTermination() -> Bool { forcedTerminationRequestCount += 1 - return forcedTerminationResult + return forcedTerminationRequestAccepted } } diff --git a/Tests/AppRuntimeKitAppKitTests/Support/FakeRuntimeRunningApplicationAssertions.swift b/Tests/AppRuntimeKitAppKitTests/Support/FakeRuntimeRunningApplicationAssertions.swift new file mode 100644 index 0000000..084033a --- /dev/null +++ b/Tests/AppRuntimeKitAppKitTests/Support/FakeRuntimeRunningApplicationAssertions.swift @@ -0,0 +1,34 @@ +import AppKit +import XCTest + +@MainActor +func assertActivationOptions( + _ application: FakeRuntimeRunningApplication, + _ expectedOptions: [NSApplication.ActivationOptions], + file: StaticString = #filePath, + line: UInt = #line +) { + XCTAssertEqual(application.activationOptions, expectedOptions, file: file, line: line) +} + +@MainActor +func assertTerminationRequests( + _ application: FakeRuntimeRunningApplication, + graceful expectedGracefulRequestCount: Int, + forced expectedForcedRequestCount: Int, + file: StaticString = #filePath, + line: UInt = #line +) { + XCTAssertEqual( + application.gracefulTerminationRequestCount, + expectedGracefulRequestCount, + file: file, + line: line + ) + XCTAssertEqual( + application.forcedTerminationRequestCount, + expectedForcedRequestCount, + file: file, + line: line + ) +} diff --git a/Tests/AppRuntimeKitAppKitTests/Support/PackageFileTestSupport.swift b/Tests/AppRuntimeKitAppKitTests/Support/PackageFileTestSupport.swift new file mode 100644 index 0000000..b3d76dc --- /dev/null +++ b/Tests/AppRuntimeKitAppKitTests/Support/PackageFileTestSupport.swift @@ -0,0 +1,29 @@ +import Foundation +import XCTest + +func packageFileText(relativePath: String) throws -> String { + try String( + contentsOf: packageRepositoryRoot().appendingPathComponent(relativePath), + encoding: .utf8 + ) +} + +func packageSourceFilesText(relativeRoot: String) throws -> String { + let sourcesURL = packageRepositoryRoot().appendingPathComponent(relativeRoot) + 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") +} + +func packageRepositoryRoot() -> URL { + URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() +} diff --git a/Tests/AppRuntimeKitTests/AppRuntimeKitPublicAPITests.swift b/Tests/AppRuntimeKitTests/AppRuntimeKitPublicAPITests.swift index ac7fbce..59225b5 100644 --- a/Tests/AppRuntimeKitTests/AppRuntimeKitPublicAPITests.swift +++ b/Tests/AppRuntimeKitTests/AppRuntimeKitPublicAPITests.swift @@ -3,74 +3,65 @@ import XCTest import AppRuntimeKit final class AppRuntimeKitPublicAPITests: XCTestCase { - func testPublicValueTypesAreUsableWithoutTestableImport() { + func testDocumentationExamplesAreUsableWithoutTestableImport() { + let snapshot = documentationProcessSnapshot + let event = documentationRuntimeEvent + + XCTAssertEqual(snapshot.processIdentifier, 123) + XCTAssertEqual(snapshot.bundleIdentifier, "com.example.app") + XCTAssertEqual(snapshot.displayName, "Example") + XCTAssertFalse(snapshot.isTerminated) + XCTAssertEqual(event.lifecycle, .activated) + XCTAssertEqual(event.application, snapshot) + } + + func testPublicConformancesAreUsableWithoutTestableImport() { let snapshot = ApplicationProcessSnapshot( processIdentifier: 123, bundleIdentifier: "com.example.app", displayName: "Example" ) - let terminatedSnapshot = ApplicationProcessSnapshot( - processIdentifier: 456, - isTerminated: true - ) - let event = ApplicationRuntimeEvent(kind: .activated, application: snapshot) + let event = ApplicationRuntimeEvent(lifecycle: .activated, application: snapshot) - let activationResult: ApplicationActivationResult = .failed(.invalidBundleIdentifier) - let openResult: ApplicationOpenResult = .failed(.invalidURL) - let terminationMode: ApplicationTerminationMode = .forced - let terminationResult: ApplicationTerminationResult = .failed(.applicationNotFound) + assertHashableSendable(ApplicationProcessSnapshot.self, snapshot) + assertHashableSendable(ApplicationRuntimeEvent.self, event) + assertHashableSendable(ApplicationRuntimeEvent.Lifecycle.self, .activated) + assertHashableSendable( + ApplicationRequestResult.self, .failed(.requestRejected)) + assertHashableSendable( + ApplicationRequestResult.self, .failed(.missingURLScheme)) + assertHashableSendable(ApplicationTerminationMode.self, .forced) + assertErrorHashableSendable(ApplicationRuntimeRequestError.self, .requestRejected) + assertErrorHashableSendable(ApplicationOpeningError.self, .requestRejected) + } - assertError(ApplicationActivationError.requestFailed) - assertError(ApplicationOpenError.requestFailed) - assertError(ApplicationTerminationError.requestFailed) - assertSendable(ApplicationRuntimeEvent.Kind.activated) - assertSendable(activationResult) - assertSendable(openResult) - assertSendable(terminationMode) - assertSendable(terminationResult) - XCTAssertEqual(event.kind, .activated) - XCTAssertEqual(event.application, snapshot) - XCTAssertTrue(terminatedSnapshot.isTerminated) - XCTAssertEqual( - ApplicationRuntimeEvent.Kind.allCases, - [.launched, .terminated, .activated, .deactivated] - ) - XCTAssertEqual( - ApplicationActivationError.allCases, - [.invalidBundleIdentifier, .applicationNotFound, .requestFailed] - ) - XCTAssertEqual( - ApplicationOpenError.allCases, - [ - .invalidURL, - .invalidFileURL, - .fileNotFound, - .invalidApplicationURL, - .invalidBundleIdentifier, - .applicationNotFound, - .requestFailed, - ] - ) - XCTAssertEqual(ApplicationTerminationMode.allCases, [.graceful, .forced]) - XCTAssertEqual( - ApplicationTerminationError.allCases, - [.invalidBundleIdentifier, .applicationNotFound, .requestFailed] - ) - XCTAssertEqual(activationResult, .failed(.invalidBundleIdentifier)) - XCTAssertEqual(openResult, .failed(.invalidURL)) - XCTAssertEqual(terminationMode, .forced) - XCTAssertEqual(terminationResult, .failed(.applicationNotFound)) + func testRequestResultDoesNotRequireHashableFailureForPlainUse() { + let result = nonHashableRequestResult() + + switch result { + case .requestAccepted: + XCTFail("The result should preserve the non-hashable failure payload.") + case let .failed(error): + XCTAssertEqual(error, NonHashableRequestError(code: 1)) + } } } -private func assertError(_ error: E, file: StaticString = #filePath, line: UInt = #line) { - XCTAssertNotNil(error, file: file, line: line) +private func nonHashableRequestResult() -> ApplicationRequestResult { + .failed(NonHashableRequestError(code: 1)) } -private func assertSendable( - _ value: T, - file: StaticString = #filePath, - line: UInt = #line -) { - XCTAssertNotNil(value, file: file, line: line) +private struct NonHashableRequestError: Error, Equatable { + let code: Int } + +private let documentationProcessSnapshot = ApplicationProcessSnapshot( + processIdentifier: 123, + bundleIdentifier: "com.example.app", + displayName: "Example" +) + +private let documentationRuntimeEvent = ApplicationRuntimeEvent( + lifecycle: .activated, + application: documentationProcessSnapshot +) diff --git a/Tests/AppRuntimeKitTests/Application/ApplicationProcessSnapshotTests.swift b/Tests/AppRuntimeKitTests/Application/ApplicationProcessSnapshotTests.swift index cf55190..508ea4f 100644 --- a/Tests/AppRuntimeKitTests/Application/ApplicationProcessSnapshotTests.swift +++ b/Tests/AppRuntimeKitTests/Application/ApplicationProcessSnapshotTests.swift @@ -14,7 +14,7 @@ final class ApplicationProcessSnapshotTests: XCTestCase { XCTAssertEqual(snapshot.processIdentifier, 123) XCTAssertEqual(snapshot.bundleIdentifier, "com.example.app") XCTAssertEqual(snapshot.displayName, "Example") - XCTAssertEqual(snapshot.isTerminated, false) + XCTAssertFalse(snapshot.isTerminated) } func testInitializerDoesNotValidateExplicitValues() { @@ -61,61 +61,48 @@ final class ApplicationProcessSnapshotTests: XCTestCase { func testEquatableDistinguishesProcessIdentifier() { XCTAssertNotEqual( - Self.snapshot(processIdentifier: 1), - Self.snapshot(processIdentifier: 2) + applicationProcessSnapshot(processIdentifier: 1), + applicationProcessSnapshot(processIdentifier: 2) ) } func testEquatableDistinguishesBundleIdentifier() { XCTAssertNotEqual( - Self.snapshot(bundleIdentifier: "com.example.one"), - Self.snapshot(bundleIdentifier: "com.example.two") + applicationProcessSnapshot(bundleIdentifier: "com.example.one"), + applicationProcessSnapshot(bundleIdentifier: "com.example.two") ) XCTAssertNotEqual( - Self.snapshot(bundleIdentifier: "com.example.one"), - Self.snapshot(bundleIdentifier: nil) + applicationProcessSnapshot(bundleIdentifier: "com.example.one"), + applicationProcessSnapshot(bundleIdentifier: nil) ) } func testEquatableDistinguishesDisplayName() { XCTAssertNotEqual( - Self.snapshot(displayName: "One"), - Self.snapshot(displayName: "Two") + applicationProcessSnapshot(displayName: "One"), + applicationProcessSnapshot(displayName: "Two") ) XCTAssertNotEqual( - Self.snapshot(displayName: "One"), - Self.snapshot(displayName: nil) + applicationProcessSnapshot(displayName: "One"), + applicationProcessSnapshot(displayName: nil) ) } func testEquatableDistinguishesTerminationState() { XCTAssertNotEqual( - Self.snapshot(isTerminated: false), - Self.snapshot(isTerminated: true) + applicationProcessSnapshot(isTerminated: false), + applicationProcessSnapshot(isTerminated: true) ) } func testHashableUsesValueEquality() { let values: Set = [ - Self.snapshot(processIdentifier: 1), - Self.snapshot(processIdentifier: 1), - Self.snapshot(processIdentifier: 2), + applicationProcessSnapshot(processIdentifier: 1), + applicationProcessSnapshot(processIdentifier: 1), + applicationProcessSnapshot(processIdentifier: 2), ] XCTAssertEqual(values.count, 2) } - private static func snapshot( - processIdentifier: pid_t = 1, - bundleIdentifier: String? = "com.example.app", - displayName: String? = "Example", - isTerminated: Bool = false - ) -> ApplicationProcessSnapshot { - ApplicationProcessSnapshot( - processIdentifier: processIdentifier, - bundleIdentifier: bundleIdentifier, - displayName: displayName, - isTerminated: isTerminated - ) - } } diff --git a/Tests/AppRuntimeKitTests/Runtime/ApplicationActivationResultTests.swift b/Tests/AppRuntimeKitTests/Runtime/ApplicationActivationResultTests.swift deleted file mode 100644 index d83cf7a..0000000 --- a/Tests/AppRuntimeKitTests/Runtime/ApplicationActivationResultTests.swift +++ /dev/null @@ -1,57 +0,0 @@ -import XCTest - -import AppRuntimeKit - -final class ApplicationActivationResultTests: XCTestCase { - func testErrorAllCasesListsFailureReasons() { - XCTAssertEqual( - ApplicationActivationError.allCases, - [.invalidBundleIdentifier, .applicationNotFound, .requestFailed] - ) - } - - func testRepresentsSuccessAndFailure() { - let success: ApplicationActivationResult = .activated - let invalidBundleIdentifier: ApplicationActivationResult = .failed(.invalidBundleIdentifier) - let applicationNotFound: ApplicationActivationResult = .failed(.applicationNotFound) - let requestFailed: ApplicationActivationResult = .failed(.requestFailed) - - XCTAssertEqual(success, .activated) - XCTAssertEqual(invalidBundleIdentifier, .failed(.invalidBundleIdentifier)) - XCTAssertEqual(applicationNotFound, .failed(.applicationNotFound)) - XCTAssertEqual(requestFailed, .failed(.requestFailed)) - } - - func testErrorAndResultAreHashable() { - let errors: Set = [ - .invalidBundleIdentifier, - .applicationNotFound, - .requestFailed, - ] - let results: Set = [ - .activated, - .failed(.invalidBundleIdentifier), - .failed(.applicationNotFound), - .failed(.requestFailed), - ] - - XCTAssertTrue(errors.contains(.requestFailed)) - XCTAssertTrue(results.contains(.activated)) - XCTAssertTrue(results.contains(.failed(.requestFailed))) - } - - func testErrorAndSendableConformancesAreUsable() { - assertError(ApplicationActivationError.requestFailed) - assertSendable(ApplicationActivationError.requestFailed) - assertSendable(ApplicationActivationResult.activated) - assertSendable(ApplicationActivationResult.failed(.requestFailed)) - } -} - -private func assertError(_ value: T) { - _ = value -} - -private func assertSendable(_ value: T) { - _ = value -} diff --git a/Tests/AppRuntimeKitTests/Runtime/ApplicationOpenResultTests.swift b/Tests/AppRuntimeKitTests/Runtime/ApplicationOpenResultTests.swift deleted file mode 100644 index 848bda1..0000000 --- a/Tests/AppRuntimeKitTests/Runtime/ApplicationOpenResultTests.swift +++ /dev/null @@ -1,81 +0,0 @@ -import XCTest - -import AppRuntimeKit - -final class ApplicationOpenResultTests: XCTestCase { - func testErrorAllCasesListsFailureReasons() { - XCTAssertEqual( - ApplicationOpenError.allCases, - [ - .invalidURL, - .invalidFileURL, - .fileNotFound, - .invalidApplicationURL, - .invalidBundleIdentifier, - .applicationNotFound, - .requestFailed, - ] - ) - } - - func testRepresentsSuccessAndFailure() { - let success: ApplicationOpenResult = .opened - let invalidURL: ApplicationOpenResult = .failed(.invalidURL) - let invalidFileURL: ApplicationOpenResult = .failed(.invalidFileURL) - let fileNotFound: ApplicationOpenResult = .failed(.fileNotFound) - let invalidApplicationURL: ApplicationOpenResult = .failed(.invalidApplicationURL) - let invalidBundleIdentifier: ApplicationOpenResult = .failed(.invalidBundleIdentifier) - let applicationNotFound: ApplicationOpenResult = .failed(.applicationNotFound) - let requestFailed: ApplicationOpenResult = .failed(.requestFailed) - - XCTAssertEqual(success, .opened) - XCTAssertEqual(invalidURL, .failed(.invalidURL)) - XCTAssertEqual(invalidFileURL, .failed(.invalidFileURL)) - XCTAssertEqual(fileNotFound, .failed(.fileNotFound)) - XCTAssertEqual(invalidApplicationURL, .failed(.invalidApplicationURL)) - XCTAssertEqual(invalidBundleIdentifier, .failed(.invalidBundleIdentifier)) - XCTAssertEqual(applicationNotFound, .failed(.applicationNotFound)) - XCTAssertEqual(requestFailed, .failed(.requestFailed)) - } - - func testErrorAndResultAreHashable() { - let errors: Set = [ - .invalidURL, - .invalidFileURL, - .fileNotFound, - .invalidApplicationURL, - .invalidBundleIdentifier, - .applicationNotFound, - .requestFailed, - ] - let results: Set = [ - .opened, - .failed(.invalidURL), - .failed(.invalidFileURL), - .failed(.fileNotFound), - .failed(.invalidApplicationURL), - .failed(.invalidBundleIdentifier), - .failed(.applicationNotFound), - .failed(.requestFailed), - ] - - XCTAssertTrue(errors.contains(.requestFailed)) - XCTAssertTrue(results.contains(.opened)) - XCTAssertTrue(results.contains(.failed(.requestFailed))) - } - - func testErrorAndSendableConformancesAreUsable() { - assertError(ApplicationOpenError.requestFailed) - assertSendable(ApplicationOpenError.requestFailed) - assertSendable(ApplicationOpenResult.opened) - assertSendable(ApplicationOpenResult.failed(.requestFailed)) - } -} - -private func assertError(_ value: T) { - _ = value -} - -private func assertSendable(_ value: T) { - _ = value -} diff --git a/Tests/AppRuntimeKitTests/Runtime/ApplicationRequestResultTests.swift b/Tests/AppRuntimeKitTests/Runtime/ApplicationRequestResultTests.swift new file mode 100644 index 0000000..6d294e2 --- /dev/null +++ b/Tests/AppRuntimeKitTests/Runtime/ApplicationRequestResultTests.swift @@ -0,0 +1,116 @@ +import XCTest + +import AppRuntimeKit + +final class ApplicationRequestResultTests: XCTestCase { + func testRepresentsOpeningRequestResults() { + let requestAccepted: ApplicationRequestResult = .requestAccepted + let missingURLScheme: ApplicationRequestResult = + .failed(.missingURLScheme) + let nonFileURL: ApplicationRequestResult = .failed(.nonFileURL) + let fileSystemItemNotFound: ApplicationRequestResult = + .failed(.fileSystemItemNotFound) + let invalidApplicationBundleURL: ApplicationRequestResult = + .failed(.invalidApplicationBundleURL) + let invalidBundleIdentifier: ApplicationRequestResult = + .failed(.invalidBundleIdentifier) + let installedApplicationNotFound: ApplicationRequestResult = + .failed(.installedApplicationNotFound) + let applicationBundleNotFound: ApplicationRequestResult = + .failed(.applicationBundleNotFound) + let requestRejected: ApplicationRequestResult = + .failed(.requestRejected) + + XCTAssertEqual(requestAccepted, .requestAccepted) + XCTAssertEqual(missingURLScheme, .failed(.missingURLScheme)) + XCTAssertEqual(nonFileURL, .failed(.nonFileURL)) + XCTAssertEqual(fileSystemItemNotFound, .failed(.fileSystemItemNotFound)) + XCTAssertEqual(invalidApplicationBundleURL, .failed(.invalidApplicationBundleURL)) + XCTAssertEqual(invalidBundleIdentifier, .failed(.invalidBundleIdentifier)) + XCTAssertEqual(installedApplicationNotFound, .failed(.installedApplicationNotFound)) + XCTAssertEqual(applicationBundleNotFound, .failed(.applicationBundleNotFound)) + XCTAssertEqual(requestRejected, .failed(.requestRejected)) + } + + func testRepresentsRuntimeRequestResults() { + let requestAccepted: ApplicationRequestResult = .requestAccepted + let invalidProcessIdentifier: ApplicationRequestResult = + .failed(.invalidProcessIdentifier) + let invalidBundleIdentifier: ApplicationRequestResult = + .failed(.invalidBundleIdentifier) + let applicationNotFound: ApplicationRequestResult = + .failed(.applicationNotFound) + let applicationTerminated: ApplicationRequestResult = + .failed(.applicationTerminated) + let applicationBundleIdentifierUnavailable: + ApplicationRequestResult = + .failed(.applicationBundleIdentifierUnavailable) + let applicationBundleIdentifierMismatch: + ApplicationRequestResult = + .failed(.applicationBundleIdentifierMismatch) + let requestRejected: ApplicationRequestResult = + .failed(.requestRejected) + + XCTAssertEqual(requestAccepted, .requestAccepted) + XCTAssertEqual(invalidProcessIdentifier, .failed(.invalidProcessIdentifier)) + XCTAssertEqual(invalidBundleIdentifier, .failed(.invalidBundleIdentifier)) + XCTAssertEqual(applicationNotFound, .failed(.applicationNotFound)) + XCTAssertEqual(applicationTerminated, .failed(.applicationTerminated)) + XCTAssertEqual( + applicationBundleIdentifierUnavailable, .failed(.applicationBundleIdentifierUnavailable)) + XCTAssertEqual( + applicationBundleIdentifierMismatch, .failed(.applicationBundleIdentifierMismatch)) + XCTAssertEqual(requestRejected, .failed(.requestRejected)) + } + + func testErrorAndResultAreHashable() { + let openingErrors: Set = [ + .missingURLScheme, + .nonFileURL, + .fileSystemItemNotFound, + .invalidApplicationBundleURL, + .invalidBundleIdentifier, + .installedApplicationNotFound, + .applicationBundleNotFound, + .requestRejected, + ] + let runtimeErrors: Set = [ + .invalidProcessIdentifier, + .invalidBundleIdentifier, + .applicationNotFound, + .applicationTerminated, + .applicationBundleIdentifierUnavailable, + .applicationBundleIdentifierMismatch, + .requestRejected, + ] + let openingOutcomes: Set> = [ + .requestAccepted, + .failed(.missingURLScheme), + .failed(.requestRejected), + ] + let runtimeOutcomes: Set> = [ + .requestAccepted, + .failed(.applicationNotFound), + .failed(.requestRejected), + ] + + XCTAssertTrue(openingErrors.contains(.requestRejected)) + XCTAssertTrue(runtimeErrors.contains(.requestRejected)) + XCTAssertTrue(openingOutcomes.contains(.requestAccepted)) + XCTAssertTrue(openingOutcomes.contains(.failed(.requestRejected))) + XCTAssertTrue(runtimeOutcomes.contains(.requestAccepted)) + XCTAssertTrue(runtimeOutcomes.contains(.failed(.requestRejected))) + } + + func testErrorAndSendableConformancesAreUsable() { + assertError(ApplicationOpeningError.requestRejected) + assertError(ApplicationRuntimeRequestError.requestRejected) + assertSendable(ApplicationOpeningError.requestRejected) + assertSendable(ApplicationRuntimeRequestError.requestRejected) + assertSendable(ApplicationRequestResult.requestAccepted) + assertSendable(ApplicationRequestResult.failed(.requestRejected)) + assertSendable(ApplicationRequestResult.requestAccepted) + assertSendable( + ApplicationRequestResult.failed(.requestRejected)) + } +} diff --git a/Tests/AppRuntimeKitTests/Runtime/ApplicationRuntimeEventTests.swift b/Tests/AppRuntimeKitTests/Runtime/ApplicationRuntimeEventTests.swift index 5742796..f8c1f2e 100644 --- a/Tests/AppRuntimeKitTests/Runtime/ApplicationRuntimeEventTests.swift +++ b/Tests/AppRuntimeKitTests/Runtime/ApplicationRuntimeEventTests.swift @@ -3,65 +3,57 @@ import XCTest import AppRuntimeKit final class ApplicationRuntimeEventTests: XCTestCase { - func testKindAllCasesListsLifecycleOrder() { + func testLifecycleAllCasesListsLifecycleOrder() { XCTAssertEqual( - ApplicationRuntimeEvent.Kind.allCases, + ApplicationRuntimeEvent.Lifecycle.allCases, [.launched, .terminated, .activated, .deactivated] ) } func testInitializerAssignsValues() { - let snapshot = Self.snapshot() + let snapshot = applicationProcessSnapshot() - let event = ApplicationRuntimeEvent(kind: .activated, application: snapshot) + let event = ApplicationRuntimeEvent(lifecycle: .activated, application: snapshot) - XCTAssertEqual(event.kind, .activated) + XCTAssertEqual(event.lifecycle, .activated) XCTAssertEqual(event.application, snapshot) } - func testEquatableDistinguishesKind() { - let snapshot = Self.snapshot() + func testEquatableDistinguishesLifecycle() { + let snapshot = applicationProcessSnapshot() XCTAssertNotEqual( - ApplicationRuntimeEvent(kind: .activated, application: snapshot), - ApplicationRuntimeEvent(kind: .deactivated, application: snapshot) + ApplicationRuntimeEvent(lifecycle: .activated, application: snapshot), + ApplicationRuntimeEvent(lifecycle: .deactivated, application: snapshot) ) } func testEquatableDistinguishesApplication() { XCTAssertNotEqual( - ApplicationRuntimeEvent(kind: .activated, application: Self.snapshot(processIdentifier: 1)), - ApplicationRuntimeEvent(kind: .activated, application: Self.snapshot(processIdentifier: 2)) + ApplicationRuntimeEvent( + lifecycle: .activated, application: applicationProcessSnapshot(processIdentifier: 1)), + ApplicationRuntimeEvent( + lifecycle: .activated, application: applicationProcessSnapshot(processIdentifier: 2)) ) } func testHashableUsesValueEquality() { - let snapshot = Self.snapshot() + let snapshot = applicationProcessSnapshot() let values: Set = [ - ApplicationRuntimeEvent(kind: .activated, application: snapshot), - ApplicationRuntimeEvent(kind: .activated, application: snapshot), - ApplicationRuntimeEvent(kind: .activated, application: Self.snapshot(processIdentifier: 456)), - ApplicationRuntimeEvent(kind: .deactivated, application: snapshot), + ApplicationRuntimeEvent(lifecycle: .activated, application: snapshot), + ApplicationRuntimeEvent(lifecycle: .activated, application: snapshot), + ApplicationRuntimeEvent( + lifecycle: .activated, application: applicationProcessSnapshot(processIdentifier: 456)), + ApplicationRuntimeEvent(lifecycle: .deactivated, application: snapshot), ] XCTAssertEqual(values.count, 3) } func testSendableConformancesAreUsable() { - assertSendable(ApplicationRuntimeEvent.Kind.activated) - assertSendable(ApplicationRuntimeEvent(kind: .activated, application: Self.snapshot())) + assertSendable(ApplicationRuntimeEvent.Lifecycle.activated) + assertSendable( + ApplicationRuntimeEvent(lifecycle: .activated, application: applicationProcessSnapshot())) } - private static func snapshot(processIdentifier: pid_t = 123) -> ApplicationProcessSnapshot { - ApplicationProcessSnapshot( - processIdentifier: processIdentifier, - bundleIdentifier: "com.example.app", - displayName: "Example", - isTerminated: false - ) - } -} - -private func assertSendable(_ value: T) { - _ = value } diff --git a/Tests/AppRuntimeKitTests/Runtime/ApplicationTerminationResultTests.swift b/Tests/AppRuntimeKitTests/Runtime/ApplicationTerminationResultTests.swift deleted file mode 100644 index 5dac992..0000000 --- a/Tests/AppRuntimeKitTests/Runtime/ApplicationTerminationResultTests.swift +++ /dev/null @@ -1,68 +0,0 @@ -import XCTest - -import AppRuntimeKit - -final class ApplicationTerminationResultTests: XCTestCase { - func testModeAllCasesListsRequestModes() { - XCTAssertEqual(ApplicationTerminationMode.allCases, [.graceful, .forced]) - } - - func testErrorAllCasesListsFailureReasons() { - XCTAssertEqual( - ApplicationTerminationError.allCases, - [.invalidBundleIdentifier, .applicationNotFound, .requestFailed] - ) - } - - func testRepresentsModeSuccessAndFailure() { - let gracefulMode: ApplicationTerminationMode = .graceful - let forcedMode: ApplicationTerminationMode = .forced - let success: ApplicationTerminationResult = .requested - let invalidBundleIdentifier: ApplicationTerminationResult = .failed(.invalidBundleIdentifier) - let applicationNotFound: ApplicationTerminationResult = .failed(.applicationNotFound) - let requestFailed: ApplicationTerminationResult = .failed(.requestFailed) - - XCTAssertEqual(gracefulMode, .graceful) - XCTAssertEqual(forcedMode, .forced) - XCTAssertEqual(success, .requested) - XCTAssertEqual(invalidBundleIdentifier, .failed(.invalidBundleIdentifier)) - XCTAssertEqual(applicationNotFound, .failed(.applicationNotFound)) - XCTAssertEqual(requestFailed, .failed(.requestFailed)) - } - - func testModeErrorAndResultAreHashable() { - let modes: Set = [.graceful, .forced] - let errors: Set = [ - .invalidBundleIdentifier, - .applicationNotFound, - .requestFailed, - ] - let results: Set = [ - .requested, - .failed(.invalidBundleIdentifier), - .failed(.applicationNotFound), - .failed(.requestFailed), - ] - - XCTAssertTrue(modes.contains(.forced)) - XCTAssertTrue(errors.contains(.requestFailed)) - XCTAssertTrue(results.contains(.requested)) - XCTAssertTrue(results.contains(.failed(.requestFailed))) - } - - func testErrorAndSendableConformancesAreUsable() { - assertError(ApplicationTerminationError.requestFailed) - assertSendable(ApplicationTerminationMode.forced) - assertSendable(ApplicationTerminationError.requestFailed) - assertSendable(ApplicationTerminationResult.requested) - assertSendable(ApplicationTerminationResult.failed(.requestFailed)) - } -} - -private func assertError(_ value: T) { - _ = value -} - -private func assertSendable(_ value: T) { - _ = value -} diff --git a/Tests/AppRuntimeKitTests/Support/ApplicationProcessSnapshotTestSupport.swift b/Tests/AppRuntimeKitTests/Support/ApplicationProcessSnapshotTestSupport.swift new file mode 100644 index 0000000..90142ea --- /dev/null +++ b/Tests/AppRuntimeKitTests/Support/ApplicationProcessSnapshotTestSupport.swift @@ -0,0 +1,17 @@ +import Darwin + +import AppRuntimeKit + +func applicationProcessSnapshot( + processIdentifier: pid_t = 1, + bundleIdentifier: String? = "com.example.app", + displayName: String? = "Example", + isTerminated: Bool = false +) -> ApplicationProcessSnapshot { + ApplicationProcessSnapshot( + processIdentifier: processIdentifier, + bundleIdentifier: bundleIdentifier, + displayName: displayName, + isTerminated: isTerminated + ) +} diff --git a/Tests/AppRuntimeKitTests/Support/ConformanceAssertions.swift b/Tests/AppRuntimeKitTests/Support/ConformanceAssertions.swift new file mode 100644 index 0000000..6ac12a4 --- /dev/null +++ b/Tests/AppRuntimeKitTests/Support/ConformanceAssertions.swift @@ -0,0 +1,37 @@ +import XCTest + +func assertError( + _ value: T, + file: StaticString = #filePath, + line: UInt = #line +) { + XCTAssertNotNil(value, file: file, line: line) +} + +func assertSendable( + _ value: T, + file: StaticString = #filePath, + line: UInt = #line +) { + XCTAssertNotNil(value, file: file, line: line) +} + +func assertHashableSendable( + _: T.Type, + _ value: T, + file: StaticString = #filePath, + line: UInt = #line +) where T: Hashable & Sendable { + XCTAssertEqual(value, value, file: file, line: line) + XCTAssertEqual(value.hashValue, value.hashValue, file: file, line: line) +} + +func assertErrorHashableSendable( + _: T.Type, + _ value: T, + file: StaticString = #filePath, + line: UInt = #line +) where T: Error & Hashable & Sendable { + assertError(value, file: file, line: line) + assertHashableSendable(T.self, value, file: file, line: line) +}