├── .editorconfig ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.yml │ └── config.yml └── workflows │ ├── ci.yml │ └── release.yml ├── .gitignore ├── .spi.yml ├── .swiftpm └── xcode │ └── package.xcworkspace │ └── contents.xcworkspacedata ├── Example ├── Compatibility │ ├── App.swift │ ├── Assets.xcassets │ │ ├── AccentColor.colorset │ │ │ └── Contents.json │ │ ├── AppIcon.appiconset │ │ │ └── Contents.json │ │ └── Contents.json │ ├── Compatibility.swift │ └── Preview Content │ │ └── Preview Assets.xcassets │ │ └── Contents.json ├── Example.xcodeproj │ ├── project.pbxproj │ ├── project.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata │ │ │ ├── IDEWorkspaceChecks.plist │ │ │ └── swiftpm │ │ │ └── Package.resolved │ └── xcshareddata │ │ └── xcschemes │ │ └── Example.xcscheme ├── Example │ ├── Assets.xcassets │ │ ├── .DS_Store │ │ ├── AccentColor.colorset │ │ │ └── Contents.json │ │ ├── AppIcon.appiconset │ │ │ └── Contents.json │ │ └── Contents.json │ ├── ContentView.swift │ ├── ExampleApp.swift │ └── Info.plist └── Package.swift ├── LICENSE ├── Makefile ├── Package.resolved ├── Package.swift ├── README.md ├── Sources ├── Perception │ ├── Exports.swift │ └── Macros.swift ├── PerceptionCore │ ├── Bindable.swift │ ├── Environment.swift │ ├── Internal │ │ ├── BetaChecking.swift │ │ ├── Exports.swift │ │ ├── Locking.swift │ │ ├── ThreadLocal.swift │ │ ├── Unchecked.swift │ │ └── _PerceptionRegistrar.swift │ ├── Perceptible.swift │ ├── PerceptionChecking.swift │ ├── PerceptionRegistrar.swift │ ├── PerceptionTracking.swift │ └── WithPerceptionTracking.swift └── PerceptionMacros │ ├── Availability.swift │ ├── Extensions.swift │ ├── PerceptibleMacro.swift │ └── Plugins.swift └── Tests ├── PerceptionMacrosTests └── PerceptionMacrosTests.swift └── PerceptionTests ├── ModifyTests.swift ├── PerceptionTrackingTests.swift ├── RuntimeWarningTests.swift └── WithPerceptionTrackingDSLTests.swift /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | indent_style = space 7 | indent_size = 2 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: Something isn't working as expected 3 | labels: [bug] 4 | body: 5 | - type: markdown 6 | attributes: 7 | value: | 8 | Thank you for contributing to Perception! 9 | 10 | Before you submit your issue, please complete each text area below with the relevant details for your bug, and complete the steps in the checklist 11 | - type: textarea 12 | attributes: 13 | label: Description 14 | description: | 15 | A short description of the incorrect behavior. 16 | 17 | If you think this issue has been recently introduced and did not occur in an earlier version, please note that. If possible, include the last version that the behavior was correct in addition to your current version. 18 | validations: 19 | required: true 20 | - type: checkboxes 21 | attributes: 22 | label: Checklist 23 | options: 24 | - label: I have determined that this bug is not reproducible using Swift's observation tools. If the bug is reproducible using the `@Observable` macro or another tool from the `Observation` framework, please [file it directly with Apple](https://github.com/apple/swift/issues/new/choose). 25 | required: false 26 | - label: If possible, I've reproduced the issue using the `main` branch of this package. 27 | required: false 28 | - label: This issue hasn't been addressed in an [existing GitHub issue](https://github.com/pointfreeco/swift-perception/issues) or [discussion](https://github.com/pointfreeco/swift-perception/discussions). 29 | required: true 30 | - type: textarea 31 | attributes: 32 | label: Expected behavior 33 | description: Describe what you expected to happen. 34 | validations: 35 | required: false 36 | - type: textarea 37 | attributes: 38 | label: Actual behavior 39 | description: Describe or copy/paste the behavior you observe. 40 | validations: 41 | required: false 42 | - type: textarea 43 | attributes: 44 | label: Steps to reproduce 45 | description: | 46 | Explanation of how to reproduce the incorrect behavior. 47 | 48 | This could include an attached project or link to code that is exhibiting the issue, and/or a screen recording. 49 | placeholder: | 50 | 1. ... 51 | validations: 52 | required: false 53 | - type: input 54 | attributes: 55 | label: Perception version information 56 | description: The version of Perception used to reproduce this issue. 57 | placeholder: "'1.0.0' for example, or a commit hash" 58 | - type: input 59 | attributes: 60 | label: Destination operating system 61 | description: The OS running your application. 62 | placeholder: "'iOS 13' for example" 63 | - type: input 64 | attributes: 65 | label: Xcode version information 66 | description: The version of Xcode used to reproduce this issue. 67 | placeholder: "The version displayed from 'Xcode 〉About Xcode'" 68 | - type: textarea 69 | attributes: 70 | label: Swift Compiler version information 71 | description: The version of Swift used to reproduce this issue. 72 | placeholder: Output from 'xcrun swiftc --version' 73 | render: shell 74 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | 3 | contact_links: 4 | - name: Project Discussion 5 | url: https://github.com/pointfreeco/swift-perception/discussions 6 | about: Perception Q&A, ideas, and more 7 | - name: Documentation 8 | url: https://pointfreeco.github.io/swift-perception/main/documentation/perception/ 9 | about: Read Perception's documentation 10 | - name: Videos 11 | url: https://www.pointfree.co/ 12 | about: Watch videos to get a behind-the-scenes look at how this library and others were motivated and built 13 | - name: Slack 14 | url: https://www.pointfree.co/slack-invite 15 | about: Community chat 16 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - '*' 10 | workflow_dispatch: 11 | 12 | concurrency: 13 | group: ci-${{ github.ref }} 14 | cancel-in-progress: true 15 | 16 | jobs: 17 | library: 18 | name: macOS 19 | strategy: 20 | matrix: 21 | xcode: ['15.4'] 22 | config: ['debug', 'release'] 23 | runs-on: macos-14 24 | steps: 25 | - uses: actions/checkout@v4 26 | - name: Select Xcode ${{ matrix.xcode }} 27 | run: sudo xcode-select -s /Applications/Xcode_${{ matrix.xcode }}.app 28 | - name: Run ${{ matrix.config }} tests 29 | run: swift test -c ${{ matrix.config }} 30 | - name: Run compatibility tests 31 | run: make test-compatibility 32 | if: ${{ matrix.config == 'debug' }} 33 | 34 | linux: 35 | name: Linux 36 | strategy: 37 | matrix: 38 | swift: 39 | - '6.0' 40 | runs-on: ubuntu-latest 41 | container: swift:${{ matrix.swift }} 42 | steps: 43 | - uses: actions/checkout@v4 44 | - name: Build 45 | run: swift build 46 | 47 | wasm: 48 | name: Wasm 49 | runs-on: ubuntu-latest 50 | steps: 51 | - uses: actions/checkout@v4 52 | - uses: bytecodealliance/actions/wasmtime/setup@v1 53 | - name: Install Swift and Swift SDK for WebAssembly 54 | run: | 55 | PREFIX=/opt/swift 56 | set -ex 57 | curl -f -o /tmp/swift.tar.gz "https://download.swift.org/swift-6.0.2-release/ubuntu2204/swift-6.0.2-RELEASE/swift-6.0.2-RELEASE-ubuntu22.04.tar.gz" 58 | sudo mkdir -p $PREFIX; sudo tar -xzf /tmp/swift.tar.gz -C $PREFIX --strip-component 1 59 | $PREFIX/usr/bin/swift sdk install https://github.com/swiftwasm/swift/releases/download/swift-wasm-6.0.2-RELEASE/swift-wasm-6.0.2-RELEASE-wasm32-unknown-wasi.artifactbundle.zip --checksum 6ffedb055cb9956395d9f435d03d53ebe9f6a8d45106b979d1b7f53358e1dcb4 60 | echo "$PREFIX/usr/bin" >> $GITHUB_PATH 61 | 62 | - name: Build 63 | run: swift build --swift-sdk wasm32-unknown-wasi -Xlinker -z -Xlinker stack-size=$((1024 * 1024)) 64 | 65 | check-macro-compatibility: 66 | name: Check Macro Compatibility 67 | runs-on: macos-latest 68 | steps: 69 | - name: Checkout repository 70 | uses: actions/checkout@v4 71 | - name: Run Swift Macro Compatibility Check 72 | uses: Matejkob/swift-macro-compatibility-check@v1 73 | with: 74 | run-tests: false 75 | major-versions-only: true 76 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | release: 4 | types: [published] 5 | workflow_dispatch: 6 | jobs: 7 | project-channel: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Dump Github context 11 | env: 12 | GITHUB_CONTEXT: ${{ toJSON(github) }} 13 | run: echo "$GITHUB_CONTEXT" 14 | - name: Slack Notification on SUCCESS 15 | if: success() 16 | uses: tokorom/action-slack-incoming-webhook@main 17 | env: 18 | INCOMING_WEBHOOK_URL: ${{ secrets.SLACK_PROJECT_CHANNEL_WEBHOOK_URL }} 19 | with: 20 | text: swift-perception ${{ github.event.release.tag_name }} has been released. 21 | blocks: | 22 | [ 23 | { 24 | "type": "header", 25 | "text": { 26 | "type": "plain_text", 27 | "text": "swift-perception ${{ github.event.release.tag_name}}" 28 | } 29 | }, 30 | { 31 | "type": "section", 32 | "text": { 33 | "type": "mrkdwn", 34 | "text": ${{ toJSON(github.event.release.body) }} 35 | } 36 | }, 37 | { 38 | "type": "section", 39 | "text": { 40 | "type": "mrkdwn", 41 | "text": "${{ github.event.release.html_url }}" 42 | } 43 | } 44 | ] 45 | 46 | releases-channel: 47 | runs-on: ubuntu-latest 48 | steps: 49 | - name: Dump Github context 50 | env: 51 | GITHUB_CONTEXT: ${{ toJSON(github) }} 52 | run: echo "$GITHUB_CONTEXT" 53 | - name: Slack Notification on SUCCESS 54 | if: success() 55 | uses: tokorom/action-slack-incoming-webhook@main 56 | env: 57 | INCOMING_WEBHOOK_URL: ${{ secrets.SLACK_RELEASES_WEBHOOK_URL }} 58 | with: 59 | text: swift-perception ${{ github.event.release.tag_name }} has been released. 60 | blocks: | 61 | [ 62 | { 63 | "type": "header", 64 | "text": { 65 | "type": "plain_text", 66 | "text": "swift-perception ${{ github.event.release.tag_name}}" 67 | } 68 | }, 69 | { 70 | "type": "section", 71 | "text": { 72 | "type": "mrkdwn", 73 | "text": ${{ toJSON(github.event.release.body) }} 74 | } 75 | }, 76 | { 77 | "type": "section", 78 | "text": { 79 | "type": "mrkdwn", 80 | "text": "${{ github.event.release.html_url }}" 81 | } 82 | } 83 | ] 84 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | ## User settings 6 | xcuserdata/ 7 | 8 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 9 | *.xcscmblueprint 10 | *.xccheckout 11 | 12 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 13 | build/ 14 | DerivedData/ 15 | *.moved-aside 16 | *.pbxuser 17 | !default.pbxuser 18 | *.mode1v3 19 | !default.mode1v3 20 | *.mode2v3 21 | !default.mode2v3 22 | *.perspectivev3 23 | !default.perspectivev3 24 | 25 | ## Obj-C/Swift specific 26 | *.hmap 27 | 28 | ## App packaging 29 | *.ipa 30 | *.dSYM.zip 31 | *.dSYM 32 | 33 | ## Playgrounds 34 | timeline.xctimeline 35 | playground.xcworkspace 36 | 37 | # Swift Package Manager 38 | # 39 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 40 | # Packages/ 41 | # Package.pins 42 | # Package.resolved 43 | # *.xcodeproj 44 | # 45 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 46 | # hence it is not needed unless you have added a package configuration file to your project 47 | .swiftpm 48 | 49 | .build/ 50 | 51 | # CocoaPods 52 | # 53 | # We recommend against adding the Pods directory to your .gitignore. However 54 | # you should judge for yourself, the pros and cons are mentioned at: 55 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 56 | # 57 | # Pods/ 58 | # 59 | # Add this line if you want to avoid checking in source code from the Xcode workspace 60 | # *.xcworkspace 61 | 62 | # Carthage 63 | # 64 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 65 | # Carthage/Checkouts 66 | 67 | Carthage/Build/ 68 | 69 | # Accio dependency management 70 | Dependencies/ 71 | .accio/ 72 | 73 | # fastlane 74 | # 75 | # It is recommended to not store the screenshots in the git repo. 76 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 77 | # For more information about the recommended setup visit: 78 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 79 | 80 | fastlane/report.xml 81 | fastlane/Preview.html 82 | fastlane/screenshots/**/*.png 83 | fastlane/test_output 84 | 85 | # Code Injection 86 | # 87 | # After new code Injection tools there's a generated folder /iOSInjectionProject 88 | # https://github.com/johnno1962/injectionforxcode 89 | 90 | iOSInjectionProject/ 91 | 92 | .DS_Store -------------------------------------------------------------------------------- /.spi.yml: -------------------------------------------------------------------------------- 1 | version: 1 2 | builder: 3 | configs: 4 | - documentation_targets: [PerceptionCore, Perception] 5 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Example/Compatibility/App.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | @main 4 | struct CompatibilityApp: App { 5 | var body: some Scene { 6 | WindowGroup { 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /Example/Compatibility/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Example/Compatibility/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "platform" : "ios", 6 | "size" : "1024x1024" 7 | } 8 | ], 9 | "info" : { 10 | "author" : "xcode", 11 | "version" : 1 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Example/Compatibility/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Example/Compatibility/Compatibility.swift: -------------------------------------------------------------------------------- 1 | import Perception 2 | import SwiftUI 3 | 4 | @Observable class Model {} 5 | 6 | func testUnqualifiedBindable() { 7 | @Bindable var model = Model() 8 | } 9 | -------------------------------------------------------------------------------- /Example/Compatibility/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Example/Example.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 56; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | CA114E912AE1FC29004844BE /* ExampleApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA114E902AE1FC29004844BE /* ExampleApp.swift */; }; 11 | CA114E932AE1FC29004844BE /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA114E922AE1FC29004844BE /* ContentView.swift */; }; 12 | CA114E952AE1FC2A004844BE /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = CA114E942AE1FC2A004844BE /* Assets.xcassets */; }; 13 | CA114EBE2AE1FCD7004844BE /* Perception in Frameworks */ = {isa = PBXBuildFile; productRef = CA114EBD2AE1FCD7004844BE /* Perception */; }; 14 | DC40DDAC2C0F98DF00B70F53 /* App.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC40DDAB2C0F98DF00B70F53 /* App.swift */; }; 15 | DC40DDAE2C0F98DF00B70F53 /* Compatibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC40DDAD2C0F98DF00B70F53 /* Compatibility.swift */; }; 16 | DC40DDB02C0F98E100B70F53 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DC40DDAF2C0F98E100B70F53 /* Assets.xcassets */; }; 17 | DC40DDB32C0F98E100B70F53 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DC40DDB22C0F98E100B70F53 /* Preview Assets.xcassets */; }; 18 | DC40DDD42C0F991D00B70F53 /* Perception in Frameworks */ = {isa = PBXBuildFile; productRef = DC40DDD32C0F991D00B70F53 /* Perception */; }; 19 | /* End PBXBuildFile section */ 20 | 21 | /* Begin PBXFileReference section */ 22 | CA114E8D2AE1FC29004844BE /* Example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Example.app; sourceTree = BUILT_PRODUCTS_DIR; }; 23 | CA114E902AE1FC29004844BE /* ExampleApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExampleApp.swift; sourceTree = ""; }; 24 | CA114E922AE1FC29004844BE /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; 25 | CA114E942AE1FC2A004844BE /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 26 | CA114EBA2AE1FC71004844BE /* swift-perception */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = "swift-perception"; path = ..; sourceTree = ""; }; 27 | CA114EBB2AE1FCCE004844BE /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; 28 | DC40DDA92C0F98DF00B70F53 /* Compatibility.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Compatibility.app; sourceTree = BUILT_PRODUCTS_DIR; }; 29 | DC40DDAB2C0F98DF00B70F53 /* App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App.swift; sourceTree = ""; }; 30 | DC40DDAD2C0F98DF00B70F53 /* Compatibility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Compatibility.swift; sourceTree = ""; }; 31 | DC40DDAF2C0F98E100B70F53 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 32 | DC40DDB22C0F98E100B70F53 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; 33 | /* End PBXFileReference section */ 34 | 35 | /* Begin PBXFrameworksBuildPhase section */ 36 | CA114E8A2AE1FC29004844BE /* Frameworks */ = { 37 | isa = PBXFrameworksBuildPhase; 38 | buildActionMask = 2147483647; 39 | files = ( 40 | CA114EBE2AE1FCD7004844BE /* Perception in Frameworks */, 41 | ); 42 | runOnlyForDeploymentPostprocessing = 0; 43 | }; 44 | DC40DDA62C0F98DF00B70F53 /* Frameworks */ = { 45 | isa = PBXFrameworksBuildPhase; 46 | buildActionMask = 2147483647; 47 | files = ( 48 | DC40DDD42C0F991D00B70F53 /* Perception in Frameworks */, 49 | ); 50 | runOnlyForDeploymentPostprocessing = 0; 51 | }; 52 | /* End PBXFrameworksBuildPhase section */ 53 | 54 | /* Begin PBXGroup section */ 55 | CA114E842AE1FC28004844BE = { 56 | isa = PBXGroup; 57 | children = ( 58 | CA114EBA2AE1FC71004844BE /* swift-perception */, 59 | CA114E8F2AE1FC29004844BE /* Example */, 60 | DC40DDAA2C0F98DF00B70F53 /* Compatibility */, 61 | CA114E8E2AE1FC29004844BE /* Products */, 62 | CA114EBC2AE1FCD7004844BE /* Frameworks */, 63 | ); 64 | sourceTree = ""; 65 | }; 66 | CA114E8E2AE1FC29004844BE /* Products */ = { 67 | isa = PBXGroup; 68 | children = ( 69 | CA114E8D2AE1FC29004844BE /* Example.app */, 70 | DC40DDA92C0F98DF00B70F53 /* Compatibility.app */, 71 | ); 72 | name = Products; 73 | sourceTree = ""; 74 | }; 75 | CA114E8F2AE1FC29004844BE /* Example */ = { 76 | isa = PBXGroup; 77 | children = ( 78 | CA114EBB2AE1FCCE004844BE /* Info.plist */, 79 | CA114E902AE1FC29004844BE /* ExampleApp.swift */, 80 | CA114E922AE1FC29004844BE /* ContentView.swift */, 81 | CA114E942AE1FC2A004844BE /* Assets.xcassets */, 82 | ); 83 | path = Example; 84 | sourceTree = ""; 85 | }; 86 | CA114EBC2AE1FCD7004844BE /* Frameworks */ = { 87 | isa = PBXGroup; 88 | children = ( 89 | ); 90 | name = Frameworks; 91 | sourceTree = ""; 92 | }; 93 | DC40DDAA2C0F98DF00B70F53 /* Compatibility */ = { 94 | isa = PBXGroup; 95 | children = ( 96 | DC40DDAB2C0F98DF00B70F53 /* App.swift */, 97 | DC40DDAD2C0F98DF00B70F53 /* Compatibility.swift */, 98 | DC40DDAF2C0F98E100B70F53 /* Assets.xcassets */, 99 | DC40DDB12C0F98E100B70F53 /* Preview Content */, 100 | ); 101 | path = Compatibility; 102 | sourceTree = ""; 103 | }; 104 | DC40DDB12C0F98E100B70F53 /* Preview Content */ = { 105 | isa = PBXGroup; 106 | children = ( 107 | DC40DDB22C0F98E100B70F53 /* Preview Assets.xcassets */, 108 | ); 109 | path = "Preview Content"; 110 | sourceTree = ""; 111 | }; 112 | /* End PBXGroup section */ 113 | 114 | /* Begin PBXNativeTarget section */ 115 | CA114E8C2AE1FC29004844BE /* Example */ = { 116 | isa = PBXNativeTarget; 117 | buildConfigurationList = CA114EB12AE1FC2A004844BE /* Build configuration list for PBXNativeTarget "Example" */; 118 | buildPhases = ( 119 | CA114E892AE1FC29004844BE /* Sources */, 120 | CA114E8A2AE1FC29004844BE /* Frameworks */, 121 | CA114E8B2AE1FC29004844BE /* Resources */, 122 | ); 123 | buildRules = ( 124 | ); 125 | dependencies = ( 126 | ); 127 | name = Example; 128 | packageProductDependencies = ( 129 | CA114EBD2AE1FCD7004844BE /* Perception */, 130 | ); 131 | productName = Example; 132 | productReference = CA114E8D2AE1FC29004844BE /* Example.app */; 133 | productType = "com.apple.product-type.application"; 134 | }; 135 | DC40DDA82C0F98DF00B70F53 /* Compatibility */ = { 136 | isa = PBXNativeTarget; 137 | buildConfigurationList = DC40DDD02C0F98E100B70F53 /* Build configuration list for PBXNativeTarget "Compatibility" */; 138 | buildPhases = ( 139 | DC40DDA52C0F98DF00B70F53 /* Sources */, 140 | DC40DDA62C0F98DF00B70F53 /* Frameworks */, 141 | DC40DDA72C0F98DF00B70F53 /* Resources */, 142 | ); 143 | buildRules = ( 144 | ); 145 | dependencies = ( 146 | ); 147 | name = Compatibility; 148 | packageProductDependencies = ( 149 | DC40DDD32C0F991D00B70F53 /* Perception */, 150 | ); 151 | productName = Compatibility; 152 | productReference = DC40DDA92C0F98DF00B70F53 /* Compatibility.app */; 153 | productType = "com.apple.product-type.application"; 154 | }; 155 | /* End PBXNativeTarget section */ 156 | 157 | /* Begin PBXProject section */ 158 | CA114E852AE1FC28004844BE /* Project object */ = { 159 | isa = PBXProject; 160 | attributes = { 161 | BuildIndependentTargetsInParallel = 1; 162 | LastSwiftUpdateCheck = 1540; 163 | LastUpgradeCheck = 1500; 164 | TargetAttributes = { 165 | CA114E8C2AE1FC29004844BE = { 166 | CreatedOnToolsVersion = 15.0; 167 | }; 168 | DC40DDA82C0F98DF00B70F53 = { 169 | CreatedOnToolsVersion = 15.4; 170 | }; 171 | }; 172 | }; 173 | buildConfigurationList = CA114E882AE1FC29004844BE /* Build configuration list for PBXProject "Example" */; 174 | compatibilityVersion = "Xcode 14.0"; 175 | developmentRegion = en; 176 | hasScannedForEncodings = 0; 177 | knownRegions = ( 178 | en, 179 | Base, 180 | ); 181 | mainGroup = CA114E842AE1FC28004844BE; 182 | productRefGroup = CA114E8E2AE1FC29004844BE /* Products */; 183 | projectDirPath = ""; 184 | projectRoot = ""; 185 | targets = ( 186 | CA114E8C2AE1FC29004844BE /* Example */, 187 | DC40DDA82C0F98DF00B70F53 /* Compatibility */, 188 | ); 189 | }; 190 | /* End PBXProject section */ 191 | 192 | /* Begin PBXResourcesBuildPhase section */ 193 | CA114E8B2AE1FC29004844BE /* Resources */ = { 194 | isa = PBXResourcesBuildPhase; 195 | buildActionMask = 2147483647; 196 | files = ( 197 | CA114E952AE1FC2A004844BE /* Assets.xcassets in Resources */, 198 | ); 199 | runOnlyForDeploymentPostprocessing = 0; 200 | }; 201 | DC40DDA72C0F98DF00B70F53 /* Resources */ = { 202 | isa = PBXResourcesBuildPhase; 203 | buildActionMask = 2147483647; 204 | files = ( 205 | DC40DDB32C0F98E100B70F53 /* Preview Assets.xcassets in Resources */, 206 | DC40DDB02C0F98E100B70F53 /* Assets.xcassets in Resources */, 207 | ); 208 | runOnlyForDeploymentPostprocessing = 0; 209 | }; 210 | /* End PBXResourcesBuildPhase section */ 211 | 212 | /* Begin PBXSourcesBuildPhase section */ 213 | CA114E892AE1FC29004844BE /* Sources */ = { 214 | isa = PBXSourcesBuildPhase; 215 | buildActionMask = 2147483647; 216 | files = ( 217 | CA114E932AE1FC29004844BE /* ContentView.swift in Sources */, 218 | CA114E912AE1FC29004844BE /* ExampleApp.swift in Sources */, 219 | ); 220 | runOnlyForDeploymentPostprocessing = 0; 221 | }; 222 | DC40DDA52C0F98DF00B70F53 /* Sources */ = { 223 | isa = PBXSourcesBuildPhase; 224 | buildActionMask = 2147483647; 225 | files = ( 226 | DC40DDAE2C0F98DF00B70F53 /* Compatibility.swift in Sources */, 227 | DC40DDAC2C0F98DF00B70F53 /* App.swift in Sources */, 228 | ); 229 | runOnlyForDeploymentPostprocessing = 0; 230 | }; 231 | /* End PBXSourcesBuildPhase section */ 232 | 233 | /* Begin XCBuildConfiguration section */ 234 | CA114EAF2AE1FC2A004844BE /* Debug */ = { 235 | isa = XCBuildConfiguration; 236 | buildSettings = { 237 | ALWAYS_SEARCH_USER_PATHS = NO; 238 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; 239 | CLANG_ANALYZER_NONNULL = YES; 240 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 241 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 242 | CLANG_ENABLE_MODULES = YES; 243 | CLANG_ENABLE_OBJC_ARC = YES; 244 | CLANG_ENABLE_OBJC_WEAK = YES; 245 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 246 | CLANG_WARN_BOOL_CONVERSION = YES; 247 | CLANG_WARN_COMMA = YES; 248 | CLANG_WARN_CONSTANT_CONVERSION = YES; 249 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 250 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 251 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 252 | CLANG_WARN_EMPTY_BODY = YES; 253 | CLANG_WARN_ENUM_CONVERSION = YES; 254 | CLANG_WARN_INFINITE_RECURSION = YES; 255 | CLANG_WARN_INT_CONVERSION = YES; 256 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 257 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 258 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 259 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 260 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 261 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 262 | CLANG_WARN_STRICT_PROTOTYPES = YES; 263 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 264 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 265 | CLANG_WARN_UNREACHABLE_CODE = YES; 266 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 267 | COPY_PHASE_STRIP = NO; 268 | DEBUG_INFORMATION_FORMAT = dwarf; 269 | ENABLE_STRICT_OBJC_MSGSEND = YES; 270 | ENABLE_TESTABILITY = YES; 271 | ENABLE_USER_SCRIPT_SANDBOXING = YES; 272 | GCC_C_LANGUAGE_STANDARD = gnu17; 273 | GCC_DYNAMIC_NO_PIC = NO; 274 | GCC_NO_COMMON_BLOCKS = YES; 275 | GCC_OPTIMIZATION_LEVEL = 0; 276 | GCC_PREPROCESSOR_DEFINITIONS = ( 277 | "DEBUG=1", 278 | "$(inherited)", 279 | ); 280 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 281 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 282 | GCC_WARN_UNDECLARED_SELECTOR = YES; 283 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 284 | GCC_WARN_UNUSED_FUNCTION = YES; 285 | GCC_WARN_UNUSED_VARIABLE = YES; 286 | IPHONEOS_DEPLOYMENT_TARGET = 14.0; 287 | LOCALIZATION_PREFERS_STRING_CATALOGS = YES; 288 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 289 | MTL_FAST_MATH = YES; 290 | ONLY_ACTIVE_ARCH = YES; 291 | SDKROOT = iphoneos; 292 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; 293 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 294 | SWIFT_STRICT_CONCURRENCY = complete; 295 | }; 296 | name = Debug; 297 | }; 298 | CA114EB02AE1FC2A004844BE /* Release */ = { 299 | isa = XCBuildConfiguration; 300 | buildSettings = { 301 | ALWAYS_SEARCH_USER_PATHS = NO; 302 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; 303 | CLANG_ANALYZER_NONNULL = YES; 304 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 305 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 306 | CLANG_ENABLE_MODULES = YES; 307 | CLANG_ENABLE_OBJC_ARC = YES; 308 | CLANG_ENABLE_OBJC_WEAK = YES; 309 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 310 | CLANG_WARN_BOOL_CONVERSION = YES; 311 | CLANG_WARN_COMMA = YES; 312 | CLANG_WARN_CONSTANT_CONVERSION = YES; 313 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 314 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 315 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 316 | CLANG_WARN_EMPTY_BODY = YES; 317 | CLANG_WARN_ENUM_CONVERSION = YES; 318 | CLANG_WARN_INFINITE_RECURSION = YES; 319 | CLANG_WARN_INT_CONVERSION = YES; 320 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 321 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 322 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 323 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 324 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 325 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 326 | CLANG_WARN_STRICT_PROTOTYPES = YES; 327 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 328 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 329 | CLANG_WARN_UNREACHABLE_CODE = YES; 330 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 331 | COPY_PHASE_STRIP = NO; 332 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 333 | ENABLE_NS_ASSERTIONS = NO; 334 | ENABLE_STRICT_OBJC_MSGSEND = YES; 335 | ENABLE_USER_SCRIPT_SANDBOXING = YES; 336 | GCC_C_LANGUAGE_STANDARD = gnu17; 337 | GCC_NO_COMMON_BLOCKS = YES; 338 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 339 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 340 | GCC_WARN_UNDECLARED_SELECTOR = YES; 341 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 342 | GCC_WARN_UNUSED_FUNCTION = YES; 343 | GCC_WARN_UNUSED_VARIABLE = YES; 344 | IPHONEOS_DEPLOYMENT_TARGET = 14.0; 345 | LOCALIZATION_PREFERS_STRING_CATALOGS = YES; 346 | MTL_ENABLE_DEBUG_INFO = NO; 347 | MTL_FAST_MATH = YES; 348 | SDKROOT = iphoneos; 349 | SWIFT_COMPILATION_MODE = wholemodule; 350 | SWIFT_STRICT_CONCURRENCY = complete; 351 | VALIDATE_PRODUCT = YES; 352 | }; 353 | name = Release; 354 | }; 355 | CA114EB22AE1FC2A004844BE /* Debug */ = { 356 | isa = XCBuildConfiguration; 357 | buildSettings = { 358 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 359 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 360 | CODE_SIGN_STYLE = Automatic; 361 | CURRENT_PROJECT_VERSION = 1; 362 | ENABLE_PREVIEWS = YES; 363 | GENERATE_INFOPLIST_FILE = YES; 364 | INFOPLIST_FILE = Example/Info.plist; 365 | INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; 366 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; 367 | INFOPLIST_KEY_UILaunchScreen_Generation = YES; 368 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 369 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 370 | LD_RUNPATH_SEARCH_PATHS = ( 371 | "$(inherited)", 372 | "@executable_path/Frameworks", 373 | ); 374 | MARKETING_VERSION = 1.0; 375 | PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.Example; 376 | PRODUCT_NAME = "$(TARGET_NAME)"; 377 | SWIFT_EMIT_LOC_STRINGS = YES; 378 | SWIFT_VERSION = 5.0; 379 | TARGETED_DEVICE_FAMILY = "1,2"; 380 | }; 381 | name = Debug; 382 | }; 383 | CA114EB32AE1FC2A004844BE /* Release */ = { 384 | isa = XCBuildConfiguration; 385 | buildSettings = { 386 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 387 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 388 | CODE_SIGN_STYLE = Automatic; 389 | CURRENT_PROJECT_VERSION = 1; 390 | ENABLE_PREVIEWS = YES; 391 | GENERATE_INFOPLIST_FILE = YES; 392 | INFOPLIST_FILE = Example/Info.plist; 393 | INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; 394 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; 395 | INFOPLIST_KEY_UILaunchScreen_Generation = YES; 396 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 397 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 398 | LD_RUNPATH_SEARCH_PATHS = ( 399 | "$(inherited)", 400 | "@executable_path/Frameworks", 401 | ); 402 | MARKETING_VERSION = 1.0; 403 | PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.Example; 404 | PRODUCT_NAME = "$(TARGET_NAME)"; 405 | SWIFT_EMIT_LOC_STRINGS = YES; 406 | SWIFT_VERSION = 5.0; 407 | TARGETED_DEVICE_FAMILY = "1,2"; 408 | }; 409 | name = Release; 410 | }; 411 | DC40DDCA2C0F98E100B70F53 /* Debug */ = { 412 | isa = XCBuildConfiguration; 413 | buildSettings = { 414 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 415 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 416 | CODE_SIGN_STYLE = Automatic; 417 | CURRENT_PROJECT_VERSION = 1; 418 | DEVELOPMENT_ASSET_PATHS = "\"Compatibility/Preview Content\""; 419 | ENABLE_PREVIEWS = YES; 420 | GENERATE_INFOPLIST_FILE = YES; 421 | INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; 422 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; 423 | INFOPLIST_KEY_UILaunchScreen_Generation = YES; 424 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 425 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 426 | IPHONEOS_DEPLOYMENT_TARGET = 17.0; 427 | LD_RUNPATH_SEARCH_PATHS = ( 428 | "$(inherited)", 429 | "@executable_path/Frameworks", 430 | ); 431 | MARKETING_VERSION = 1.0; 432 | PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.Compatibility; 433 | PRODUCT_NAME = "$(TARGET_NAME)"; 434 | SWIFT_EMIT_LOC_STRINGS = YES; 435 | SWIFT_VERSION = 5.0; 436 | TARGETED_DEVICE_FAMILY = "1,2"; 437 | }; 438 | name = Debug; 439 | }; 440 | DC40DDCB2C0F98E100B70F53 /* Release */ = { 441 | isa = XCBuildConfiguration; 442 | buildSettings = { 443 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 444 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 445 | CODE_SIGN_STYLE = Automatic; 446 | CURRENT_PROJECT_VERSION = 1; 447 | DEVELOPMENT_ASSET_PATHS = "\"Compatibility/Preview Content\""; 448 | ENABLE_PREVIEWS = YES; 449 | GENERATE_INFOPLIST_FILE = YES; 450 | INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; 451 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; 452 | INFOPLIST_KEY_UILaunchScreen_Generation = YES; 453 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 454 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 455 | IPHONEOS_DEPLOYMENT_TARGET = 17.0; 456 | LD_RUNPATH_SEARCH_PATHS = ( 457 | "$(inherited)", 458 | "@executable_path/Frameworks", 459 | ); 460 | MARKETING_VERSION = 1.0; 461 | PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.Compatibility; 462 | PRODUCT_NAME = "$(TARGET_NAME)"; 463 | SWIFT_EMIT_LOC_STRINGS = YES; 464 | SWIFT_VERSION = 5.0; 465 | TARGETED_DEVICE_FAMILY = "1,2"; 466 | }; 467 | name = Release; 468 | }; 469 | /* End XCBuildConfiguration section */ 470 | 471 | /* Begin XCConfigurationList section */ 472 | CA114E882AE1FC29004844BE /* Build configuration list for PBXProject "Example" */ = { 473 | isa = XCConfigurationList; 474 | buildConfigurations = ( 475 | CA114EAF2AE1FC2A004844BE /* Debug */, 476 | CA114EB02AE1FC2A004844BE /* Release */, 477 | ); 478 | defaultConfigurationIsVisible = 0; 479 | defaultConfigurationName = Release; 480 | }; 481 | CA114EB12AE1FC2A004844BE /* Build configuration list for PBXNativeTarget "Example" */ = { 482 | isa = XCConfigurationList; 483 | buildConfigurations = ( 484 | CA114EB22AE1FC2A004844BE /* Debug */, 485 | CA114EB32AE1FC2A004844BE /* Release */, 486 | ); 487 | defaultConfigurationIsVisible = 0; 488 | defaultConfigurationName = Release; 489 | }; 490 | DC40DDD02C0F98E100B70F53 /* Build configuration list for PBXNativeTarget "Compatibility" */ = { 491 | isa = XCConfigurationList; 492 | buildConfigurations = ( 493 | DC40DDCA2C0F98E100B70F53 /* Debug */, 494 | DC40DDCB2C0F98E100B70F53 /* Release */, 495 | ); 496 | defaultConfigurationIsVisible = 0; 497 | defaultConfigurationName = Release; 498 | }; 499 | /* End XCConfigurationList section */ 500 | 501 | /* Begin XCSwiftPackageProductDependency section */ 502 | CA114EBD2AE1FCD7004844BE /* Perception */ = { 503 | isa = XCSwiftPackageProductDependency; 504 | productName = Perception; 505 | }; 506 | DC40DDD32C0F991D00B70F53 /* Perception */ = { 507 | isa = XCSwiftPackageProductDependency; 508 | productName = Perception; 509 | }; 510 | /* End XCSwiftPackageProductDependency section */ 511 | }; 512 | rootObject = CA114E852AE1FC28004844BE /* Project object */; 513 | } 514 | -------------------------------------------------------------------------------- /Example/Example.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Example/Example.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Example/Example.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "pins" : [ 3 | { 4 | "identity" : "swift-macro-testing", 5 | "kind" : "remoteSourceControl", 6 | "location" : "https://github.com/pointfreeco/swift-macro-testing", 7 | "state" : { 8 | "revision" : "a35257b7e9ce44e92636447003a8eeefb77b145c", 9 | "version" : "0.5.1" 10 | } 11 | }, 12 | { 13 | "identity" : "swift-snapshot-testing", 14 | "kind" : "remoteSourceControl", 15 | "location" : "https://github.com/pointfreeco/swift-snapshot-testing", 16 | "state" : { 17 | "revision" : "c097f955b4e724690f0fc8ffb7a6d4b881c9c4e3", 18 | "version" : "1.17.2" 19 | } 20 | }, 21 | { 22 | "identity" : "swift-syntax", 23 | "kind" : "remoteSourceControl", 24 | "location" : "https://github.com/swiftlang/swift-syntax", 25 | "state" : { 26 | "revision" : "4c6cc0a3b9e8f14b3ae2307c5ccae4de6167ac2c", 27 | "version" : "600.0.0-prerelease-2024-06-12" 28 | } 29 | }, 30 | { 31 | "identity" : "xctest-dynamic-overlay", 32 | "kind" : "remoteSourceControl", 33 | "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", 34 | "state" : { 35 | "revision" : "357ca1e5dd31f613a1d43320870ebc219386a495", 36 | "version" : "1.2.2" 37 | } 38 | } 39 | ], 40 | "version" : 2 41 | } 42 | -------------------------------------------------------------------------------- /Example/Example.xcodeproj/xcshareddata/xcschemes/Example.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 31 | 32 | 35 | 41 | 42 | 43 | 46 | 52 | 53 | 54 | 57 | 63 | 64 | 65 | 68 | 74 | 75 | 76 | 77 | 78 | 88 | 90 | 96 | 97 | 98 | 99 | 105 | 107 | 113 | 114 | 115 | 116 | 118 | 119 | 122 | 123 | 124 | -------------------------------------------------------------------------------- /Example/Example/Assets.xcassets/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pointfreeco/swift-perception/d924c62a70fca5f43872f286dbd7cef0957f1c01/Example/Example/Assets.xcassets/.DS_Store -------------------------------------------------------------------------------- /Example/Example/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Example/Example/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "platform" : "ios", 6 | "size" : "1024x1024" 7 | } 8 | ], 9 | "info" : { 10 | "author" : "xcode", 11 | "version" : 1 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Example/Example/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Example/Example/ContentView.swift: -------------------------------------------------------------------------------- 1 | import Perception 2 | import SwiftUI 3 | 4 | @Perceptible 5 | class CounterModel { 6 | var count = 0 7 | var isDisplayingCount = true 8 | var isPresentingSheet = false 9 | var text = "" 10 | func decrementButtonTapped() { 11 | withAnimation { 12 | count -= 1 13 | } 14 | } 15 | func incrementButtonTapped() { 16 | withAnimation { 17 | count += 1 18 | } 19 | } 20 | func presentSheetButtonTapped() { 21 | isPresentingSheet = true 22 | } 23 | } 24 | 25 | struct ContentView: View { 26 | @Perception.Bindable var model: CounterModel 27 | 28 | var body: some View { 29 | WithPerceptionTracking { 30 | let _ = print("\(Self.self): tracked change.") 31 | Form { 32 | TextField("Text", text: $model.text) 33 | if model.isDisplayingCount { 34 | Text(model.count.description) 35 | .font(.largeTitle) 36 | } else { 37 | Text("Not tracking count") 38 | .font(.largeTitle) 39 | } 40 | Button("Decrement") { model.decrementButtonTapped() } 41 | Button("Increment") { model.incrementButtonTapped() } 42 | Toggle(isOn: $model.isDisplayingCount.animation()) { 43 | Text("Display count?") 44 | } 45 | Button("Present sheet") { 46 | model.presentSheetButtonTapped() 47 | } 48 | } 49 | .sheet(isPresented: $model.isPresentingSheet) { 50 | if #available(iOS 16.0, *) { 51 | WithPerceptionTracking { 52 | Form { 53 | Text(model.count.description) 54 | .font(.largeTitle) 55 | Button("Decrement") { model.decrementButtonTapped() } 56 | Button("Increment") { model.incrementButtonTapped() } 57 | } 58 | .presentationDetents([.medium]) 59 | } 60 | } else { 61 | WithPerceptionTracking { 62 | Form { 63 | Text(model.count.description) 64 | Button("Decrement") { model.decrementButtonTapped() } 65 | Button("Increment") { model.incrementButtonTapped() } 66 | } 67 | } 68 | } 69 | } 70 | } 71 | } 72 | } 73 | 74 | #Preview { 75 | ContentView(model: CounterModel()) 76 | } 77 | -------------------------------------------------------------------------------- /Example/Example/ExampleApp.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | @main 4 | struct ExampleApp: App { 5 | var body: some Scene { 6 | WindowGroup { 7 | ContentView(model: CounterModel()) 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /Example/Example/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /Example/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.9 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "blank", 7 | products: [], 8 | targets: [] 9 | ) 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Point-Free 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | PLATFORM_IOS = iOS Simulator,id=$(call udid_for,iOS 17.2,iPhone \d\+ Pro [^M]) 2 | 3 | test-compatibility: 4 | xcodebuild \ 5 | -skipMacroValidation \ 6 | -project Example/Example.xcodeproj \ 7 | -scheme Compatibility \ 8 | -destination generic/platform="$(PLATFORM_IOS)" 9 | 10 | format: 11 | find . \ 12 | -path '*/Documentation.docc' -prune -o \ 13 | -name '*.swift' \ 14 | -not -path '*/.*' -print0 \ 15 | | xargs -0 swift format --ignore-unparsable-files --in-place 16 | 17 | .PHONY: format test-compatibility 18 | 19 | define udid_for 20 | $(shell xcrun simctl list devices available '$(1)' | grep '$(2)' | sort -r | head -1 | awk -F '[()]' '{ print $$(NF-3) }') 21 | endef 22 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "pins" : [ 3 | { 4 | "identity" : "swift-macro-testing", 5 | "kind" : "remoteSourceControl", 6 | "location" : "https://github.com/pointfreeco/swift-macro-testing", 7 | "state" : { 8 | "revision" : "20c1a8f3b624fb5d1503eadcaa84743050c350f4", 9 | "version" : "0.5.2" 10 | } 11 | }, 12 | { 13 | "identity" : "swift-snapshot-testing", 14 | "kind" : "remoteSourceControl", 15 | "location" : "https://github.com/pointfreeco/swift-snapshot-testing", 16 | "state" : { 17 | "revision" : "42a086182681cf661f5c47c9b7dc3931de18c6d7", 18 | "version" : "1.17.6" 19 | } 20 | }, 21 | { 22 | "identity" : "swift-syntax", 23 | "kind" : "remoteSourceControl", 24 | "location" : "https://github.com/swiftlang/swift-syntax", 25 | "state" : { 26 | "revision" : "0687f71944021d616d34d922343dcef086855920", 27 | "version" : "600.0.1" 28 | } 29 | }, 30 | { 31 | "identity" : "xctest-dynamic-overlay", 32 | "kind" : "remoteSourceControl", 33 | "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", 34 | "state" : { 35 | "revision" : "770f990d3e4eececb57ac04a6076e22f8c97daeb", 36 | "version" : "1.4.2" 37 | } 38 | } 39 | ], 40 | "version" : 2 41 | } 42 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.9 2 | 3 | import CompilerPluginSupport 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "swift-perception", 8 | platforms: [ 9 | .iOS(.v13), 10 | .macOS(.v10_15), 11 | .tvOS(.v13), 12 | .watchOS(.v6), 13 | ], 14 | products: [ 15 | .library(name: "Perception", targets: ["Perception"]), 16 | .library(name: "PerceptionCore", targets: ["PerceptionCore"]), 17 | ], 18 | dependencies: [ 19 | .package(url: "https://github.com/pointfreeco/swift-macro-testing", from: "0.1.0"), 20 | .package(url: "https://github.com/swiftlang/swift-syntax", "509.0.0"..<"602.0.0"), 21 | .package(url: "https://github.com/pointfreeco/xctest-dynamic-overlay", from: "1.2.2"), 22 | ], 23 | targets: [ 24 | .target( 25 | name: "Perception", 26 | dependencies: [ 27 | "PerceptionCore", 28 | "PerceptionMacros", 29 | ] 30 | ), 31 | .target( 32 | name: "PerceptionCore", 33 | dependencies: [ 34 | .product(name: "IssueReporting", package: "xctest-dynamic-overlay"), 35 | ] 36 | ), 37 | .testTarget( 38 | name: "PerceptionTests", 39 | dependencies: ["Perception"] 40 | ), 41 | 42 | .macro( 43 | name: "PerceptionMacros", 44 | dependencies: [ 45 | .product(name: "SwiftSyntaxMacros", package: "swift-syntax"), 46 | .product(name: "SwiftCompilerPlugin", package: "swift-syntax"), 47 | ] 48 | ), 49 | .testTarget( 50 | name: "PerceptionMacrosTests", 51 | dependencies: [ 52 | "PerceptionMacros", 53 | .product(name: "MacroTesting", package: "swift-macro-testing"), 54 | ] 55 | ), 56 | ] 57 | ) 58 | 59 | for target in package.targets where target.type != .system { 60 | target.swiftSettings = target.swiftSettings ?? [] 61 | target.swiftSettings?.append(contentsOf: [ 62 | .enableExperimentalFeature("StrictConcurrency"), 63 | ]) 64 | } 65 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Perception 2 | 3 | [![CI](https://github.com/pointfreeco/swift-perception/actions/workflows/ci.yml/badge.svg)](https://github.com/pointfreeco/swift-perception/actions/workflows/ci.yml) 4 | [![Slack](https://img.shields.io/badge/slack-chat-informational.svg?label=Slack&logo=slack)](https://www.pointfree.co/slack-invite) 5 | [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fpointfreeco%2Fswift-perception%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/pointfreeco/swift-perception) 6 | [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fpointfreeco%2Fswift-perception%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/pointfreeco/swift-perception) 7 | 8 | Observation tools for platforms that do not officially support observation. 9 | 10 | ## Learn More 11 | 12 | This library was created by [Brandon Williams][mbrandonw] and [Stephen Celis][stephencelis], who 13 | host the [Point-Free][pointfreeco] video series which explores advanced Swift language concepts. 14 | 15 | 16 | video poster image 17 | 18 | 19 | ## Overview 20 | 21 | The Perception library provides tools that mimic `@Observable` and `withObservationTracking` in 22 | Swift 5.9, but they are backported to work all the way back to iOS 13, macOS 10.15, tvOS 13 and 23 | watchOS 6. This means you can start taking advantage of Swift 5.9's observation tools today, 24 | even if you can't drop support for older Apple platforms. Using this library's tools works almost 25 | exactly as using the official tools, but with one small exception. 26 | 27 | To begin, mark a class as being observable by using the `@Perceptible` macro instead of the 28 | `@Observable` macro: 29 | 30 | ```swift 31 | @Perceptible 32 | class FeatureModel { 33 | var count = 0 34 | } 35 | ``` 36 | 37 | Then you can hold onto a perceptible model in your view using a regular `let` property: 38 | 39 | ```swift 40 | struct FeatureView: View { 41 | let model: FeatureModel 42 | 43 | // ... 44 | } 45 | ``` 46 | 47 | And in the view's `body` you must wrap your content using the `WithPerceptionTracking` view in 48 | order for observation to be correctly hooked up: 49 | 50 | ```swift 51 | struct FeatureView: View { 52 | let model: FeatureModel 53 | 54 | var body: some View { 55 | WithPerceptionTracking { 56 | Form { 57 | Text(model.count.description) 58 | Button("Increment") { model.count += 1 } 59 | } 60 | } 61 | } 62 | } 63 | ``` 64 | 65 | It's unfortunate to have to wrap your view's content in `WithPerceptionTracking`, however if you 66 | forget then you will helpfully get a runtime warning letting you know that observation is not 67 | set up correctly: 68 | 69 | > 🟣 Runtime Warning: Perceptible state was accessed but is not being tracked. Track changes 70 | > to state by wrapping your view in a 'WithPerceptionTracking' view. This must also be done 71 | > for any escaping, trailing closures, such as 'GeometryReader', `LazyVStack` (and all lazy 72 | > views), navigation APIs ('sheet', 'popover', 'fullScreenCover', etc.), and others. 73 | 74 | ### Bindable 75 | 76 | SwiftUI's `@Bindable` property wrapper has also been backported to support perceptible objects. You 77 | can simply qualify the property wrapper with the `Perception` module: 78 | 79 | ```swift 80 | struct FeatureView: View { 81 | @Perception.Bindable var model: FeatureModel 82 | 83 | // ... 84 | } 85 | ``` 86 | 87 | ### Environment 88 | 89 | SwiftUI's `@Environment` property wrapper and `environment` view modifier's support for observation 90 | has also been backported to support perceptible objects using the exact same APIs: 91 | 92 | ```swift 93 | struct FeatureView: View { 94 | @Environment(Settings.self) var settings 95 | 96 | // ... 97 | } 98 | 99 | // In some parent view: 100 | .environment(settings) 101 | ``` 102 | 103 | ## Community 104 | 105 | If you want to discuss this library or have a question about how to use it to solve 106 | a particular problem, there are a number of places you can discuss with fellow 107 | [Point-Free](https://www.pointfree.co) enthusiasts: 108 | 109 | * For long-form discussions, we recommend the 110 | [discussions](https://github.com/pointfreeco/swift-perception/discussions) tab of this repo. 111 | * For casual chat, we recommend the [Point-Free Community Slack](https://pointfree.co/slack-invite). 112 | 113 | ## Documentation 114 | 115 | The latest documentation for the Perception APIs is available [here][docs]. 116 | 117 | ## License 118 | 119 | This library is released under the MIT license. See [LICENSE](LICENSE) for details. 120 | 121 | [pointfreeco]: https://www.pointfree.co 122 | [mbrandonw]: https://twitter.com/mbrandonw 123 | [stephencelis]: https://twitter.com/stephencelis 124 | [docs]: https://swiftpackageindex.com/pointfreeco/swift-perception/main/documentation/perception 125 | -------------------------------------------------------------------------------- /Sources/Perception/Exports.swift: -------------------------------------------------------------------------------- 1 | @_exported import PerceptionCore 2 | -------------------------------------------------------------------------------- /Sources/Perception/Macros.swift: -------------------------------------------------------------------------------- 1 | //===----------------------------------------------------------------------===// 2 | // 3 | // This source file is part of the Swift.org open source project 4 | // 5 | // Copyright (c) 2023 Apple Inc. and the Swift project authors 6 | // Licensed under Apache License v2.0 with Runtime Library Exception 7 | // 8 | // See https://swift.org/LICENSE.txt for license information 9 | // 10 | //===----------------------------------------------------------------------===// 11 | 12 | @available(iOS, deprecated: 17, renamed: "Observable") 13 | @available(macOS, deprecated: 14, renamed: "Observable") 14 | @available(watchOS, deprecated: 10, renamed: "Observable") 15 | @available(tvOS, deprecated: 17, renamed: "Observable") 16 | @attached( 17 | member, names: named(_$id), named(_$perceptionRegistrar), named(access), named(withMutation)) 18 | @attached(memberAttribute) 19 | @attached(extension, conformances: Perceptible, Observable) 20 | public macro Perceptible() = 21 | #externalMacro(module: "PerceptionMacros", type: "PerceptibleMacro") 22 | 23 | @available(iOS, deprecated: 17, renamed: "ObservationTracked") 24 | @available(macOS, deprecated: 14, renamed: "ObservationTracked") 25 | @available(watchOS, deprecated: 10, renamed: "ObservationTracked") 26 | @available(tvOS, deprecated: 17, renamed: "ObservationTracked") 27 | @attached(accessor, names: named(init), named(get), named(set), named(_modify)) 28 | @attached(peer, names: prefixed(_)) 29 | public macro PerceptionTracked() = 30 | #externalMacro(module: "PerceptionMacros", type: "PerceptionTrackedMacro") 31 | 32 | @available(iOS, deprecated: 17, renamed: "ObservationIgnored") 33 | @available(macOS, deprecated: 14, renamed: "ObservationIgnored") 34 | @available(watchOS, deprecated: 10, renamed: "ObservationIgnored") 35 | @available(tvOS, deprecated: 17, renamed: "ObservationIgnored") 36 | @attached(accessor, names: named(willSet)) 37 | public macro PerceptionIgnored() = 38 | #externalMacro(module: "PerceptionMacros", type: "PerceptionIgnoredMacro") 39 | -------------------------------------------------------------------------------- /Sources/PerceptionCore/Bindable.swift: -------------------------------------------------------------------------------- 1 | #if canImport(SwiftUI) 2 | import SwiftUI 3 | 4 | /// A property wrapper type that supports creating bindings to the mutable properties of 5 | /// perceptible objects. 6 | /// 7 | /// A backport of SwiftUI's `Bindable` property wrapper. 8 | @available(iOS, introduced: 13, obsoleted: 17, message: "Use @Bindable without the 'Perception.' prefix.") 9 | @available(macOS, introduced: 10.15, obsoleted: 14, message: "Use @Bindable without the 'Perception.' prefix.") 10 | @available(tvOS, introduced: 13, obsoleted: 17, message: "Use @Bindable without the 'Perception.' prefix.") 11 | @available(watchOS, introduced: 6, obsoleted: 10, message: "Use @Bindable without the 'Perception.' prefix.") 12 | @available(visionOS, unavailable) 13 | @dynamicMemberLookup 14 | @propertyWrapper 15 | public struct Bindable { 16 | @ObservedObject fileprivate var observer: Observer 17 | 18 | /// The wrapped object. 19 | public var wrappedValue: Value { 20 | get { self.observer.object } 21 | set { self.observer.object = newValue } 22 | } 23 | 24 | /// The bindable wrapper for the object that creates bindings to its properties using dynamic 25 | /// member lookup. 26 | public var projectedValue: Bindable { 27 | self 28 | } 29 | 30 | /// Returns a binding to the value of a given key path. 31 | public subscript( 32 | dynamicMember keyPath: ReferenceWritableKeyPath 33 | ) -> Binding where Value: AnyObject { 34 | withPerceptionTracking { 35 | self.$observer[dynamicMember: (\Observer.object).appending(path: keyPath)] 36 | } onChange: { [send = UncheckedSendable(self.observer.objectWillChange.send)] in 37 | send.value() 38 | } 39 | } 40 | 41 | /// Creates a bindable object from an observable object. 42 | public init(wrappedValue: Value) where Value: AnyObject & Perceptible { 43 | self.observer = Observer(wrappedValue) 44 | } 45 | 46 | /// Creates a bindable object from an observable object. 47 | public init(_ wrappedValue: Value) where Value: AnyObject & Perceptible { 48 | self.init(wrappedValue: wrappedValue) 49 | } 50 | 51 | /// Creates a bindable from the value of another bindable. 52 | public init(projectedValue: Bindable) where Value: AnyObject & Perceptible { 53 | self = projectedValue 54 | } 55 | } 56 | 57 | @available(visionOS, unavailable) 58 | extension Bindable: Identifiable where Value: Identifiable { 59 | public var id: Value.ID { 60 | wrappedValue.id 61 | } 62 | } 63 | 64 | @available(visionOS, unavailable) 65 | extension Bindable: Sendable where Value: Sendable {} 66 | 67 | private final class Observer: ObservableObject { 68 | var object: Object 69 | init(_ object: Object) { 70 | self.object = object 71 | } 72 | } 73 | 74 | extension Observer: Equatable where Object: AnyObject { 75 | static func == (lhs: Observer, rhs: Observer) -> Bool { 76 | lhs.object === rhs.object 77 | } 78 | } 79 | #endif 80 | -------------------------------------------------------------------------------- /Sources/PerceptionCore/Environment.swift: -------------------------------------------------------------------------------- 1 | #if canImport(SwiftUI) 2 | import SwiftUI 3 | 4 | extension Environment { 5 | /// Creates an environment property to read a perceptible object from the environment. 6 | /// 7 | /// A backport of SwiftUI's `Environment.init` that takes an observable object. 8 | /// 9 | /// - Parameter objectType: The type of the `Perceptible` object to read from the environment. 10 | @_disfavoredOverload 11 | public init(_ objectType: Value.Type) where Value: AnyObject & Perceptible { 12 | self.init(\.[unwrap: \Value.self]) 13 | } 14 | 15 | /// Creates an environment property to read a perceptible object from the environment, returning 16 | /// `nil` if no corresponding object has been set in the current view's environment. 17 | /// 18 | /// A backport of SwiftUI's `Environment.init` that takes an observable object. 19 | /// 20 | /// - Parameter objectType: The type of the `Perceptible` object to read from the environment. 21 | @_disfavoredOverload 22 | public init(_ objectType: T.Type) where Value == T? { 23 | self.init(\.[\T.self]) 24 | } 25 | } 26 | 27 | extension View { 28 | /// Places a perceptible object in the view’s environment. 29 | /// 30 | /// A backport of SwiftUI's `View.environment` that takes an observable object. 31 | /// 32 | /// - Parameter object: The object to set for this object's type in the environment, or `nil` to 33 | /// clear an object of this type from the environment. 34 | /// - Returns: A view that has the specified object in its environment. 35 | @_disfavoredOverload 36 | public func environment(_ object: T?) -> some View { 37 | self.environment(\.[\T.self], object) 38 | } 39 | } 40 | 41 | private struct PerceptibleKey: EnvironmentKey { 42 | static var defaultValue: T? { nil } 43 | } 44 | 45 | extension EnvironmentValues { 46 | fileprivate subscript(_: KeyPath) -> T? { 47 | get { self[PerceptibleKey.self] } 48 | set { self[PerceptibleKey.self] = newValue } 49 | } 50 | 51 | fileprivate subscript(unwrap _: KeyPath) -> T { 52 | get { 53 | guard let object = self[\T.self] else { 54 | fatalError( 55 | """ 56 | No perceptible object of type \(T.self) found. A View.environment(_:) for \(T.self) may \ 57 | be missing as an ancestor of this view. 58 | """ 59 | ) 60 | } 61 | return object 62 | } 63 | set { self[\T.self] = newValue } 64 | } 65 | } 66 | #endif 67 | -------------------------------------------------------------------------------- /Sources/PerceptionCore/Internal/BetaChecking.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | // NB: This boolean is used to work around a crash experienced by beta users of Observation when 4 | // `Observable` was still a marker protocol and we attempt to dynamically cast to 5 | // `any Observable`. 6 | let isObservationBeta: Bool = { 7 | #if os(iOS) || os(tvOS) || os(watchOS) 8 | let os = ProcessInfo.processInfo.operatingSystemVersion 9 | #if os(iOS) || os(tvOS) 10 | if (os.majorVersion, os.minorVersion, os.patchVersion) != (17, 0, 0) { 11 | return false 12 | } 13 | #elseif os(watchOS) 14 | if (os.majorVersion, os.minorVersion, os.patchVersion) != (10, 0, 0) { 15 | return false 16 | } 17 | #endif 18 | var size = 0 19 | sysctlbyname("kern.osversion", nil, &size, nil, 0) 20 | var version = [CChar](repeating: 0, count: size) 21 | let ret = sysctlbyname("kern.osversion", &version, &size, nil, 0) 22 | // NB: Beta builds end with a lowercase character (_e.g._, '21A5277j') 23 | return ret == 0 ? String(cString: version).last?.isLowercase == true : false 24 | #else 25 | return false 26 | #endif 27 | }() 28 | -------------------------------------------------------------------------------- /Sources/PerceptionCore/Internal/Exports.swift: -------------------------------------------------------------------------------- 1 | #if canImport(Observation) 2 | @_exported import Observation 3 | #else 4 | @available(macOS 14, iOS 17, watchOS 10, tvOS 17, *) 5 | public protocol Observable {} 6 | #endif 7 | -------------------------------------------------------------------------------- /Sources/PerceptionCore/Internal/Locking.swift: -------------------------------------------------------------------------------- 1 | //===----------------------------------------------------------------------===// 2 | // 3 | // This source file is part of the Swift.org open source project 4 | // 5 | // Copyright (c) 2023 Apple Inc. and the Swift project authors 6 | // Licensed under Apache License v2.0 with Runtime Library Exception 7 | // 8 | // See https://swift.org/LICENSE.txt for license information 9 | // 10 | //===----------------------------------------------------------------------===// 11 | 12 | import Foundation 13 | 14 | internal struct _ManagedCriticalState { 15 | private let lock = NSLock() 16 | final private class LockedBuffer: ManagedBuffer {} 17 | 18 | private let buffer: ManagedBuffer 19 | 20 | internal init(_ buffer: ManagedBuffer) { 21 | self.buffer = buffer 22 | } 23 | 24 | internal init(_ initial: State) { 25 | let roundedSize = 26 | (MemoryLayout.size - 1) / MemoryLayout.size 27 | self.init( 28 | LockedBuffer.create(minimumCapacity: Swift.max(roundedSize, 1)) { buffer in 29 | return initial 30 | }) 31 | } 32 | 33 | internal func withCriticalRegion( 34 | _ critical: (inout State) throws -> R 35 | ) rethrows -> R { 36 | try buffer.withUnsafeMutablePointers { header, lock in 37 | self.lock.lock() 38 | defer { 39 | self.lock.unlock() 40 | } 41 | return try critical(&header.pointee) 42 | } 43 | } 44 | } 45 | 46 | extension _ManagedCriticalState: @unchecked Sendable where State: Sendable {} 47 | 48 | extension _ManagedCriticalState: Identifiable { 49 | internal var id: ObjectIdentifier { 50 | ObjectIdentifier(buffer) 51 | } 52 | } 53 | 54 | extension NSLock { 55 | @inlinable @discardableResult 56 | @_spi(Internals) public func sync(work: () -> R) -> R { 57 | self.lock() 58 | defer { self.unlock() } 59 | return work() 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /Sources/PerceptionCore/Internal/ThreadLocal.swift: -------------------------------------------------------------------------------- 1 | //===----------------------------------------------------------------------===// 2 | // 3 | // This source file is part of the Swift.org open source project 4 | // 5 | // Copyright (c) 2022 Apple Inc. and the Swift project authors 6 | // Licensed under Apache License v2.0 with Runtime Library Exception 7 | // 8 | // See https://swift.org/LICENSE.txt for license information 9 | // 10 | //===----------------------------------------------------------------------===// 11 | 12 | import Foundation 13 | 14 | struct _ThreadLocal { 15 | #if os(WASI) 16 | // NB: This can simply be 'nonisolated(unsafe)' when we drop support for Swift 5.9 17 | static var value: UnsafeMutableRawPointer? { 18 | get { _value.value } 19 | set { _value.value = newValue } 20 | } 21 | private static let _value = UncheckedBox(nil) 22 | #else 23 | static var value: UnsafeMutableRawPointer? { 24 | get { Thread.current.threadDictionary[Key()] as! UnsafeMutableRawPointer? } 25 | set { Thread.current.threadDictionary[Key()] = newValue } 26 | } 27 | private struct Key: Hashable {} 28 | #endif 29 | } 30 | -------------------------------------------------------------------------------- /Sources/PerceptionCore/Internal/Unchecked.swift: -------------------------------------------------------------------------------- 1 | @usableFromInline final class UncheckedBox: @unchecked Sendable { 2 | @usableFromInline var value: Value 3 | @usableFromInline init(_ value: Value) { 4 | self.value = value 5 | } 6 | } 7 | @usableFromInline struct UncheckedSendable: @unchecked Sendable { 8 | @usableFromInline let value: Value 9 | @usableFromInline init(_ value: Value) { 10 | self.value = value 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Sources/PerceptionCore/Internal/_PerceptionRegistrar.swift: -------------------------------------------------------------------------------- 1 | //===----------------------------------------------------------------------===// 2 | // 3 | // This source file is part of the Swift.org open source project 4 | // 5 | // Copyright (c) 2023 Apple Inc. and the Swift project authors 6 | // Licensed under Apache License v2.0 with Runtime Library Exception 7 | // 8 | // See https://swift.org/LICENSE.txt for license information 9 | // 10 | //===----------------------------------------------------------------------===// 11 | 12 | struct _PerceptionRegistrar: Sendable { 13 | internal class ValuePerceptionStorage { 14 | func emit(_ element: Element) -> Bool { return false } 15 | func cancel() {} 16 | } 17 | 18 | private struct ValuesPerceptor { 19 | private let storage: ValuePerceptionStorage 20 | 21 | internal init(storage: ValuePerceptionStorage) { 22 | self.storage = storage 23 | } 24 | 25 | internal func emit(_ element: Element) -> Bool { 26 | storage.emit(element) 27 | } 28 | 29 | internal func cancel() { 30 | storage.cancel() 31 | } 32 | } 33 | 34 | private struct State: @unchecked Sendable { 35 | private enum PerceptionKind { 36 | case willSetTracking(@Sendable () -> Void) 37 | case didSetTracking(@Sendable () -> Void) 38 | case computed(@Sendable (Any) -> Void) 39 | case values(ValuesPerceptor) 40 | } 41 | 42 | private struct Perception { 43 | private var kind: PerceptionKind 44 | internal var properties: Set 45 | 46 | internal init(kind: PerceptionKind, properties: Set) { 47 | self.kind = kind 48 | self.properties = properties 49 | } 50 | 51 | var willSetTracker: (@Sendable () -> Void)? { 52 | switch kind { 53 | case .willSetTracking(let tracker): 54 | return tracker 55 | default: 56 | return nil 57 | } 58 | } 59 | 60 | var didSetTracker: (@Sendable () -> Void)? { 61 | switch kind { 62 | case .didSetTracking(let tracker): 63 | return tracker 64 | default: 65 | return nil 66 | } 67 | } 68 | 69 | var perceptor: (@Sendable (Any) -> Void)? { 70 | switch kind { 71 | case .computed(let perceptor): 72 | return perceptor 73 | default: 74 | return nil 75 | } 76 | } 77 | 78 | var isValuePerceptor: Bool { 79 | switch kind { 80 | case .values: 81 | return true 82 | default: 83 | return false 84 | } 85 | } 86 | 87 | func emit(_ value: Element) -> Bool { 88 | switch kind { 89 | case .values(let perceptor): 90 | return perceptor.emit(value) 91 | default: 92 | return false 93 | } 94 | } 95 | 96 | func cancel() { 97 | switch kind { 98 | case .values(let perceptor): 99 | perceptor.cancel() 100 | default: 101 | break 102 | } 103 | } 104 | } 105 | 106 | private var id = 0 107 | private var perceptions = [Int: Perception]() 108 | private var lookups = [AnyKeyPath: Set]() 109 | 110 | internal mutating func generateId() -> Int { 111 | defer { id &+= 1 } 112 | return id 113 | } 114 | 115 | internal mutating func registerTracking( 116 | for properties: Set, willSet perceptor: @Sendable @escaping () -> Void 117 | ) -> Int { 118 | let id = generateId() 119 | perceptions[id] = Perception(kind: .willSetTracking(perceptor), properties: properties) 120 | for keyPath in properties { 121 | lookups[keyPath, default: []].insert(id) 122 | } 123 | return id 124 | } 125 | 126 | internal mutating func registerTracking( 127 | for properties: Set, didSet perceptor: @Sendable @escaping () -> Void 128 | ) -> Int { 129 | let id = generateId() 130 | perceptions[id] = Perception(kind: .didSetTracking(perceptor), properties: properties) 131 | for keyPath in properties { 132 | lookups[keyPath, default: []].insert(id) 133 | } 134 | return id 135 | } 136 | 137 | internal mutating func registerComputedValues( 138 | for properties: Set, perceptor: @Sendable @escaping (Any) -> Void 139 | ) -> Int { 140 | let id = generateId() 141 | perceptions[id] = Perception(kind: .computed(perceptor), properties: properties) 142 | for keyPath in properties { 143 | lookups[keyPath, default: []].insert(id) 144 | } 145 | return id 146 | } 147 | 148 | internal mutating func registerValues( 149 | for properties: Set, storage: ValuePerceptionStorage 150 | ) -> Int { 151 | let id = generateId() 152 | perceptions[id] = Perception( 153 | kind: .values(ValuesPerceptor(storage: storage)), properties: properties) 154 | for keyPath in properties { 155 | lookups[keyPath, default: []].insert(id) 156 | } 157 | return id 158 | } 159 | 160 | internal func valuePerceptors(for keyPath: AnyKeyPath) -> Set { 161 | guard let ids = lookups[keyPath] else { 162 | return [] 163 | } 164 | return ids.filter { perceptions[$0]?.isValuePerceptor == true } 165 | } 166 | 167 | internal mutating func cancel(_ id: Int) { 168 | if let perception = perceptions.removeValue(forKey: id) { 169 | for keyPath in perception.properties { 170 | if var ids = lookups[keyPath] { 171 | ids.remove(id) 172 | if ids.count == 0 { 173 | lookups.removeValue(forKey: keyPath) 174 | } else { 175 | lookups[keyPath] = ids 176 | } 177 | } 178 | } 179 | perception.cancel() 180 | } 181 | } 182 | 183 | internal mutating func cancelAll() { 184 | for perception in perceptions.values { 185 | perception.cancel() 186 | } 187 | perceptions.removeAll() 188 | lookups.removeAll() 189 | } 190 | 191 | internal mutating func willSet(keyPath: AnyKeyPath) -> [@Sendable () -> Void] { 192 | var trackers = [@Sendable () -> Void]() 193 | if let ids = lookups[keyPath] { 194 | for id in ids { 195 | if let tracker = perceptions[id]?.willSetTracker { 196 | trackers.append(tracker) 197 | } 198 | } 199 | } 200 | return trackers 201 | } 202 | 203 | internal mutating func didSet(keyPath: KeyPath) 204 | -> ([@Sendable (Any) -> Void], [@Sendable () -> Void]) 205 | { 206 | var perceptors = [@Sendable (Any) -> Void]() 207 | var trackers = [@Sendable () -> Void]() 208 | if let ids = lookups[keyPath] { 209 | for id in ids { 210 | if let perceptor = perceptions[id]?.perceptor { 211 | perceptors.append(perceptor) 212 | cancel(id) 213 | } 214 | if let tracker = perceptions[id]?.didSetTracker { 215 | trackers.append(tracker) 216 | } 217 | } 218 | } 219 | return (perceptors, trackers) 220 | } 221 | 222 | internal mutating func emit(_ value: Element, ids: Set) { 223 | for id in ids { 224 | if perceptions[id]?.emit(value) == true { 225 | cancel(id) 226 | } 227 | } 228 | } 229 | } 230 | 231 | internal struct Context: Sendable { 232 | private let state = _ManagedCriticalState(State()) 233 | 234 | internal var id: ObjectIdentifier { state.id } 235 | 236 | internal func registerTracking( 237 | for properties: Set, willSet perceptor: @Sendable @escaping () -> Void 238 | ) -> Int { 239 | state.withCriticalRegion { $0.registerTracking(for: properties, willSet: perceptor) } 240 | } 241 | 242 | internal func registerTracking( 243 | for properties: Set, didSet perceptor: @Sendable @escaping () -> Void 244 | ) -> Int { 245 | state.withCriticalRegion { $0.registerTracking(for: properties, didSet: perceptor) } 246 | } 247 | 248 | internal func registerComputedValues( 249 | for properties: Set, perceptor: @Sendable @escaping (Any) -> Void 250 | ) -> Int { 251 | state.withCriticalRegion { $0.registerComputedValues(for: properties, perceptor: perceptor) } 252 | } 253 | 254 | internal func registerValues(for properties: Set, storage: ValuePerceptionStorage) 255 | -> Int 256 | { 257 | state.withCriticalRegion { $0.registerValues(for: properties, storage: storage) } 258 | } 259 | 260 | internal func cancel(_ id: Int) { 261 | state.withCriticalRegion { $0.cancel(id) } 262 | } 263 | 264 | internal func cancelAll() { 265 | state.withCriticalRegion { $0.cancelAll() } 266 | } 267 | 268 | internal func willSet( 269 | _ subject: Subject, 270 | keyPath: KeyPath 271 | ) { 272 | let tracking = state.withCriticalRegion { $0.willSet(keyPath: keyPath) } 273 | for action in tracking { 274 | action() 275 | } 276 | } 277 | 278 | internal func didSet( 279 | _ subject: Subject, 280 | keyPath: KeyPath 281 | ) { 282 | let (ids, (actions, tracking)) = state.withCriticalRegion { 283 | ($0.valuePerceptors(for: keyPath), $0.didSet(keyPath: keyPath)) 284 | } 285 | if !ids.isEmpty { 286 | let value = subject[keyPath: keyPath] 287 | state.withCriticalRegion { $0.emit(value, ids: ids) } 288 | } 289 | for action in tracking { 290 | action() 291 | } 292 | for action in actions { 293 | action(subject) 294 | } 295 | } 296 | } 297 | 298 | private final class Extent: @unchecked Sendable { 299 | let context = Context() 300 | 301 | init() { 302 | } 303 | 304 | deinit { 305 | context.cancelAll() 306 | } 307 | } 308 | 309 | internal var context: Context { 310 | return extent.context 311 | } 312 | 313 | private var extent = Extent() 314 | 315 | init() { 316 | } 317 | 318 | /// Registers access to a specific property for observation. 319 | /// 320 | /// - Parameters: 321 | /// - subject: An instance of an observable type. 322 | /// - keyPath: The key path of an observed property. 323 | func access( 324 | _ subject: Subject, 325 | keyPath: KeyPath 326 | ) { 327 | if let trackingPtr = _ThreadLocal.value? 328 | .assumingMemoryBound(to: PerceptionTracking._AccessList?.self) 329 | { 330 | if trackingPtr.pointee == nil { 331 | trackingPtr.pointee = PerceptionTracking._AccessList() 332 | } 333 | trackingPtr.pointee?.addAccess(keyPath: keyPath, context: context) 334 | } 335 | } 336 | 337 | /// A property observation called before setting the value of the subject. 338 | /// 339 | /// - Parameters: 340 | /// - subject: An instance of an observable type. 341 | /// - keyPath: The key path of an observed property. 342 | func willSet( 343 | _ subject: Subject, 344 | keyPath: KeyPath 345 | ) { 346 | context.willSet(subject, keyPath: keyPath) 347 | } 348 | 349 | /// A property observation called after setting the value of the subject. 350 | /// 351 | /// - Parameters: 352 | /// - subject: An instance of an observable type. 353 | /// - keyPath: The key path of an observed property. 354 | func didSet( 355 | _ subject: Subject, 356 | keyPath: KeyPath 357 | ) { 358 | context.didSet(subject, keyPath: keyPath) 359 | } 360 | 361 | /// Identifies mutations to the transactions registered for observers. 362 | /// 363 | /// This method calls ``willset(_:keypath:)`` before the mutation. Then it 364 | /// calls ``didset(_:keypath:)`` after the mutation. 365 | /// - Parameters: 366 | /// - of: An instance of an observable type. 367 | /// - keyPath: The key path of an observed property. 368 | func withMutation( 369 | of subject: Subject, 370 | keyPath: KeyPath, 371 | _ mutation: () throws -> T 372 | ) rethrows -> T { 373 | willSet(subject, keyPath: keyPath) 374 | defer { didSet(subject, keyPath: keyPath) } 375 | return try mutation() 376 | } 377 | } 378 | -------------------------------------------------------------------------------- /Sources/PerceptionCore/Perceptible.swift: -------------------------------------------------------------------------------- 1 | //===----------------------------------------------------------------------===// 2 | // 3 | // This source file is part of the Swift.org open source project 4 | // 5 | // Copyright (c) 2023 Apple Inc. and the Swift project authors 6 | // Licensed under Apache License v2.0 with Runtime Library Exception 7 | // 8 | // See https://swift.org/LICENSE.txt for license information 9 | // 10 | //===----------------------------------------------------------------------===// 11 | 12 | /// A type that emits notifications to observers when underlying data changes. 13 | /// 14 | /// Conforming to this protocol signals to other APIs that the type supports 15 | /// observation. However, applying the `Perceptible` protocol by itself to a 16 | /// type doesn't add observation functionality to the type. Instead, always use 17 | /// the ``Perception/Perceptible()`` macro when adding observation 18 | /// support to a type. 19 | @available(iOS, deprecated: 17, renamed: "Observable") 20 | @available(macOS, deprecated: 14, renamed: "Observable") 21 | @available(watchOS, deprecated: 10, renamed: "Observable") 22 | @available(tvOS, deprecated: 17, renamed: "Observable") 23 | public protocol Perceptible {} 24 | -------------------------------------------------------------------------------- /Sources/PerceptionCore/PerceptionChecking.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Globally enable or disable perception checks. 4 | /// 5 | /// The library performs certain runtime checks to make sure that the tools are being used 6 | /// correctly. In particular, view bodies must be wrapped in the ``WithPerceptionTracking`` view 7 | /// in order for observation to be properly tracked. If the library detects state is accessed 8 | /// without being inside ``WithPerceptionTracking``, a runtime warning is triggered to let you 9 | /// know there is something to fix. 10 | /// 11 | /// This check only happens in `DEBUG` builds, and so does not affect App Store releases of your 12 | /// app. However, the checks can sometimes be costly and slow down your app in development. If 13 | /// you wish to fully disable the checks, you can set this boolean to `false.` 14 | @available( 15 | iOS, deprecated: 17, message: "'isPerceptionCheckingEnabled' is no longer needed in iOS 17+" 16 | ) 17 | @available( 18 | macOS, deprecated: 14, message: "'isPerceptionCheckingEnabled' is no longer needed in macOS 14+" 19 | ) 20 | @available( 21 | watchOS, deprecated: 10, 22 | message: "'isPerceptionCheckingEnabled' is no longer needed in watchOS 10+" 23 | ) 24 | @available( 25 | tvOS, deprecated: 17, message: "'isPerceptionCheckingEnabled' is no longer needed in tvOS 17+" 26 | ) 27 | public var isPerceptionCheckingEnabled: Bool { 28 | get { perceptionChecking.isPerceptionCheckingEnabled } 29 | set { perceptionChecking.isPerceptionCheckingEnabled = newValue } 30 | } 31 | 32 | @available(iOS, deprecated: 17, message: "'_PerceptionLocals' is no longer needed in iOS 17+") 33 | @available(macOS, deprecated: 14, message: "'_PerceptionLocals' is no longer needed in macOS 14+") 34 | @available( 35 | watchOS, deprecated: 10, message: "'_PerceptionLocals' is no longer needed in watchOS 10+" 36 | ) 37 | @available(tvOS, deprecated: 17, message: "'_PerceptionLocals' is no longer needed in tvOS 17+") 38 | public enum _PerceptionLocals { 39 | @TaskLocal public static var isInPerceptionTracking = false 40 | @TaskLocal public static var skipPerceptionChecking = false 41 | } 42 | 43 | private let perceptionChecking = PerceptionChecking() 44 | 45 | private class PerceptionChecking: @unchecked Sendable { 46 | var isPerceptionCheckingEnabled: Bool { 47 | get { 48 | lock.lock() 49 | defer { lock.unlock() } 50 | return _isPerceptionCheckingEnabled 51 | } 52 | set { 53 | lock.lock() 54 | defer { lock.unlock() } 55 | _isPerceptionCheckingEnabled = newValue 56 | } 57 | } 58 | let lock = NSLock() 59 | #if DEBUG 60 | var _isPerceptionCheckingEnabled = true 61 | #else 62 | var _isPerceptionCheckingEnabled = false 63 | #endif 64 | } 65 | -------------------------------------------------------------------------------- /Sources/PerceptionCore/PerceptionRegistrar.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import IssueReporting 3 | 4 | /// Provides storage for tracking and access to data changes. 5 | /// 6 | /// You don't need to create an instance of `PerceptionRegistrar` when using 7 | /// the ``Perception/Perceptible()`` macro to indicate observability of a type. 8 | @available(iOS, deprecated: 17, message: "Use 'ObservationRegistrar' instead.") 9 | @available(macOS, deprecated: 14, message: "Use 'ObservationRegistrar' instead.") 10 | @available(watchOS, deprecated: 10, message: "Use 'ObservationRegistrar' instead.") 11 | @available(tvOS, deprecated: 17, message: "Use 'ObservationRegistrar' instead.") 12 | public struct PerceptionRegistrar: Sendable { 13 | private let _rawValue: any Sendable 14 | #if DEBUG 15 | private let isPerceptionCheckingEnabled: Bool 16 | fileprivate let perceptionChecks = _ManagedCriticalState<[Location: Bool]>([:]) 17 | #endif 18 | 19 | /// Creates an instance of the observation registrar. 20 | /// 21 | /// You don't need to create an instance of 22 | /// ``PerceptionRegistrar`` when using the 23 | /// ``Perception/Perceptible()`` macro to indicate observably 24 | /// of a type. 25 | public init(isPerceptionCheckingEnabled: Bool = PerceptionCore.isPerceptionCheckingEnabled) { 26 | if #available(iOS 17, macOS 14, tvOS 17, watchOS 10, *), !isObservationBeta { 27 | #if canImport(Observation) 28 | self._rawValue = ObservationRegistrar() 29 | #else 30 | self._rawValue = _PerceptionRegistrar() 31 | #endif 32 | } else { 33 | self._rawValue = _PerceptionRegistrar() 34 | } 35 | #if DEBUG 36 | self.isPerceptionCheckingEnabled = isPerceptionCheckingEnabled 37 | #endif 38 | } 39 | 40 | #if canImport(Observation) 41 | @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) 42 | private var registrar: ObservationRegistrar { 43 | self._rawValue as! ObservationRegistrar 44 | } 45 | #endif 46 | 47 | private var perceptionRegistrar: _PerceptionRegistrar { 48 | self._rawValue as! _PerceptionRegistrar 49 | } 50 | } 51 | 52 | #if canImport(Observation) 53 | @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) 54 | extension PerceptionRegistrar { 55 | public func access( 56 | _ subject: Subject, 57 | keyPath: KeyPath, 58 | fileID: StaticString = #fileID, 59 | filePath: StaticString = #filePath, 60 | line: UInt = #line, 61 | column: UInt = #column 62 | ) { 63 | self.registrar.access(subject, keyPath: keyPath) 64 | } 65 | 66 | public func withMutation( 67 | of subject: Subject, keyPath: KeyPath, _ mutation: () throws -> T 68 | ) rethrows -> T { 69 | try self.registrar.withMutation(of: subject, keyPath: keyPath, mutation) 70 | } 71 | 72 | public func willSet( 73 | _ subject: Subject, keyPath: KeyPath 74 | ) { 75 | self.registrar.willSet(subject, keyPath: keyPath) 76 | } 77 | 78 | public func didSet( 79 | _ subject: Subject, keyPath: KeyPath 80 | ) { 81 | self.registrar.didSet(subject, keyPath: keyPath) 82 | } 83 | } 84 | #endif 85 | 86 | extension PerceptionRegistrar { 87 | @_disfavoredOverload 88 | public func access( 89 | _ subject: Subject, 90 | keyPath: KeyPath, 91 | fileID: StaticString = #fileID, 92 | filePath: StaticString = #filePath, 93 | line: UInt = #line, 94 | column: UInt = #column 95 | ) { 96 | #if DEBUG && canImport(SwiftUI) 97 | self.perceptionCheck( 98 | fileID: fileID, 99 | filePath: filePath, 100 | line: line, 101 | column: column 102 | ) 103 | #endif 104 | #if canImport(Observation) 105 | if #available(iOS 17, macOS 14, tvOS 17, watchOS 10, *), !isObservationBeta { 106 | func `open`(_ subject: T) { 107 | self.registrar.access( 108 | subject, 109 | keyPath: unsafeDowncast(keyPath, to: KeyPath.self) 110 | ) 111 | } 112 | if let subject = subject as? any Observable { 113 | return open(subject) 114 | } 115 | } 116 | #endif 117 | self.perceptionRegistrar.access(subject, keyPath: keyPath) 118 | } 119 | 120 | @_disfavoredOverload 121 | public func withMutation( 122 | of subject: Subject, 123 | keyPath: KeyPath, 124 | _ mutation: () throws -> T 125 | ) rethrows -> T { 126 | #if canImport(Observation) 127 | if #available(iOS 17, macOS 14, tvOS 17, watchOS 10, *), !isObservationBeta, 128 | let subject = subject as? any Observable 129 | { 130 | func `open`(_ subject: S) throws -> T { 131 | return try self.registrar.withMutation( 132 | of: subject, 133 | keyPath: unsafeDowncast(keyPath, to: KeyPath.self), 134 | mutation 135 | ) 136 | } 137 | return try open(subject) 138 | } 139 | #endif 140 | return try self.perceptionRegistrar.withMutation(of: subject, keyPath: keyPath, mutation) 141 | } 142 | 143 | @_disfavoredOverload 144 | public func willSet( 145 | _ subject: Subject, 146 | keyPath: KeyPath 147 | ) { 148 | #if canImport(Observation) 149 | if #available(iOS 17, macOS 14, tvOS 17, watchOS 10, *), !isObservationBeta, 150 | let subject = subject as? any Observable 151 | { 152 | func `open`(_ subject: S) { 153 | return self.registrar.willSet( 154 | subject, 155 | keyPath: unsafeDowncast(keyPath, to: KeyPath.self) 156 | ) 157 | } 158 | return open(subject) 159 | } 160 | #endif 161 | return self.perceptionRegistrar.willSet(subject, keyPath: keyPath) 162 | } 163 | 164 | @_disfavoredOverload 165 | public func didSet( 166 | _ subject: Subject, 167 | keyPath: KeyPath 168 | ) { 169 | #if canImport(Observation) 170 | if #available(iOS 17, macOS 14, tvOS 17, watchOS 10, *), !isObservationBeta, 171 | let subject = subject as? any Observable 172 | { 173 | func `open`(_ subject: S) { 174 | return self.registrar.didSet( 175 | subject, 176 | keyPath: unsafeDowncast(keyPath, to: KeyPath.self) 177 | ) 178 | } 179 | return open(subject) 180 | } 181 | #endif 182 | return self.perceptionRegistrar.didSet(subject, keyPath: keyPath) 183 | } 184 | } 185 | 186 | extension PerceptionRegistrar: Codable { 187 | public init(from decoder: any Decoder) throws { 188 | self.init() 189 | } 190 | 191 | public func encode(to encoder: any Encoder) { 192 | // Don't encode a registrar's transient state. 193 | } 194 | } 195 | 196 | extension PerceptionRegistrar: Hashable { 197 | public static func == (lhs: Self, rhs: Self) -> Bool { 198 | // A registrar should be ignored for the purposes of determining its 199 | // parent type's equality. 200 | return true 201 | } 202 | 203 | public func hash(into hasher: inout Hasher) { 204 | // Don't include a registrar's transient state in its parent type's 205 | // hash value. 206 | } 207 | } 208 | 209 | #if DEBUG && canImport(SwiftUI) 210 | extension PerceptionRegistrar { 211 | fileprivate func perceptionCheck( 212 | fileID: StaticString, 213 | filePath: StaticString, 214 | line: UInt, 215 | column: UInt 216 | ) { 217 | if self.isPerceptionCheckingEnabled, 218 | PerceptionCore.isPerceptionCheckingEnabled, 219 | !_PerceptionLocals.isInPerceptionTracking, 220 | !_PerceptionLocals.skipPerceptionChecking, 221 | self.isInSwiftUIBody(file: filePath, line: line) 222 | { 223 | reportIssue( 224 | """ 225 | Perceptible state was accessed but is not being tracked. Track changes to state by \ 226 | wrapping your view in a 'WithPerceptionTracking' view. This must also be done for any \ 227 | escaping, trailing closures, such as 'GeometryReader', `LazyVStack` (and all lazy \ 228 | views), navigation APIs ('sheet', 'popover', 'fullScreenCover', etc.), and others. 229 | """ 230 | ) 231 | } 232 | } 233 | 234 | fileprivate func isInSwiftUIBody(file: StaticString, line: UInt) -> Bool { 235 | self.perceptionChecks.withCriticalRegion { perceptionChecks in 236 | if let result = perceptionChecks[Location(file: file, line: line)] { 237 | return result 238 | } 239 | for callStackSymbol in Thread.callStackSymbols { 240 | let mangledSymbol = callStackSymbol.utf8 241 | .drop(while: { $0 != .init(ascii: "$") }) 242 | .prefix(while: { $0 != .init(ascii: " ") }) 243 | guard let demangled = String(Substring(mangledSymbol)).demangled 244 | else { 245 | continue 246 | } 247 | if demangled.isGeometryTrailingClosure { 248 | return !(demangled.isSuspendingClosure || demangled.isActionClosure) 249 | } 250 | guard 251 | mangledSymbol.isMangledViewBodyGetter, 252 | !demangled.isSuspendingClosure, 253 | !demangled.isActionClosure 254 | else { 255 | continue 256 | } 257 | return true 258 | } 259 | perceptionChecks[Location(file: file, line: line)] = false 260 | return false 261 | } 262 | } 263 | } 264 | 265 | extension String { 266 | var isGeometryTrailingClosure: Bool { 267 | self.contains("(SwiftUI.GeometryProxy) -> ") 268 | } 269 | 270 | fileprivate var isSuspendingClosure: Bool { 271 | let fragment = self.utf8.drop(while: { $0 != .init(ascii: ")") }).dropFirst() 272 | return fragment.starts( 273 | with: " suspend resume partial function for closure".utf8 274 | ) 275 | || fragment.starts( 276 | with: " suspend resume partial function for implicit closure".utf8 277 | ) 278 | || fragment.starts( 279 | with: " await resume partial function for partial apply forwarder for closure".utf8 280 | ) 281 | || fragment.starts( 282 | with: " await resume partial function for partial apply forwarder for implicit closure" 283 | .utf8 284 | ) 285 | || fragment.starts( 286 | with: " await resume partial function for implicit closure".utf8 287 | ) 288 | } 289 | fileprivate var isActionClosure: Bool { 290 | var view = self[...].utf8 291 | view = view.drop(while: { $0 != .init(ascii: "#") }) 292 | view = view.dropFirst() 293 | view = view.drop(while: { $0 >= .init(ascii: "0") && $0 <= .init(ascii: "9") }) 294 | view = view.drop(while: { $0 != .init(ascii: "-") }) 295 | return view.starts(with: "-> () in ".utf8) 296 | } 297 | fileprivate var demangled: String? { 298 | return self.utf8CString.withUnsafeBufferPointer { mangledNameUTF8CStr in 299 | let demangledNamePtr = swift_demangle( 300 | mangledName: mangledNameUTF8CStr.baseAddress, 301 | mangledNameLength: UInt(mangledNameUTF8CStr.count - 1), 302 | outputBuffer: nil, 303 | outputBufferSize: nil, 304 | flags: 0 305 | ) 306 | if let demangledNamePtr = demangledNamePtr { 307 | let demangledName = String(cString: demangledNamePtr) 308 | free(demangledNamePtr) 309 | return demangledName 310 | } 311 | return nil 312 | } 313 | } 314 | } 315 | 316 | @_silgen_name("swift_demangle") 317 | private func swift_demangle( 318 | mangledName: UnsafePointer?, 319 | mangledNameLength: UInt, 320 | outputBuffer: UnsafeMutablePointer?, 321 | outputBufferSize: UnsafeMutablePointer?, 322 | flags: UInt32 323 | ) -> UnsafeMutablePointer? 324 | #endif 325 | 326 | #if DEBUG 327 | public func _withoutPerceptionChecking( 328 | _ apply: () -> T 329 | ) -> T { 330 | return _PerceptionLocals.$skipPerceptionChecking.withValue(true) { 331 | apply() 332 | } 333 | } 334 | #else 335 | @_transparent 336 | @inline(__always) 337 | public func _withoutPerceptionChecking( 338 | _ apply: () -> T 339 | ) -> T { 340 | apply() 341 | } 342 | #endif 343 | 344 | #if DEBUG 345 | extension Substring.UTF8View { 346 | fileprivate var isMangledViewBodyGetter: Bool { 347 | self._contains("V4bodyQrvg".utf8) 348 | } 349 | fileprivate func _contains(_ other: String.UTF8View) -> Bool { 350 | guard let first = other.first 351 | else { return false } 352 | let otherCount = other.count 353 | var input = self 354 | while let index = input.firstIndex(where: { first == $0 }) { 355 | input = input[index...] 356 | if input.count >= otherCount, 357 | zip(input, other).allSatisfy(==) 358 | { 359 | return true 360 | } 361 | input.removeFirst() 362 | } 363 | return false 364 | } 365 | } 366 | 367 | private struct Location: Hashable { 368 | let file: String 369 | let line: UInt 370 | init(file: StaticString, line: UInt) { 371 | self.file = file.description 372 | self.line = line 373 | } 374 | } 375 | #endif 376 | -------------------------------------------------------------------------------- /Sources/PerceptionCore/PerceptionTracking.swift: -------------------------------------------------------------------------------- 1 | //===----------------------------------------------------------------------===// 2 | // 3 | // This source file is part of the Swift.org open source project 4 | // 5 | // Copyright (c) 2023 Apple Inc. and the Swift project authors 6 | // Licensed under Apache License v2.0 with Runtime Library Exception 7 | // 8 | // See https://swift.org/LICENSE.txt for license information 9 | // 10 | //===----------------------------------------------------------------------===// 11 | 12 | @_spi(SwiftUI) 13 | public struct PerceptionTracking: Sendable { 14 | enum Id { 15 | case willSet(Int) 16 | case didSet(Int) 17 | case full(Int, Int) 18 | } 19 | 20 | struct Entry: @unchecked Sendable { 21 | let context: _PerceptionRegistrar.Context 22 | 23 | var properties: Set 24 | 25 | init(_ context: _PerceptionRegistrar.Context, properties: Set = []) { 26 | self.context = context 27 | self.properties = properties 28 | } 29 | 30 | func addWillSetPerceptor(_ changed: @Sendable @escaping () -> Void) -> Int { 31 | return context.registerTracking(for: properties, willSet: changed) 32 | } 33 | 34 | func addDidSetPerceptor(_ changed: @Sendable @escaping () -> Void) -> Int { 35 | return context.registerTracking(for: properties, didSet: changed) 36 | } 37 | 38 | func removePerceptor(_ token: Int) { 39 | context.cancel(token) 40 | } 41 | 42 | mutating func insert(_ keyPath: AnyKeyPath) { 43 | properties.insert(keyPath) 44 | } 45 | 46 | func union(_ entry: Entry) -> Entry { 47 | Entry(context, properties: properties.union(entry.properties)) 48 | } 49 | } 50 | 51 | @_spi(SwiftUI) 52 | public struct _AccessList: Sendable { 53 | internal var entries = [ObjectIdentifier: Entry]() 54 | 55 | internal init() {} 56 | 57 | internal mutating func addAccess( 58 | keyPath: PartialKeyPath, 59 | context: _PerceptionRegistrar.Context 60 | ) { 61 | entries[context.id, default: Entry(context)].insert(keyPath) 62 | } 63 | 64 | internal mutating func merge(_ other: _AccessList) { 65 | entries.merge(other.entries) { existing, entry in 66 | existing.union(entry) 67 | } 68 | } 69 | } 70 | 71 | @_spi(SwiftUI) 72 | public static func _installTracking( 73 | _ tracking: PerceptionTracking, 74 | willSet: (@Sendable (PerceptionTracking) -> Void)? = nil, 75 | didSet: (@Sendable (PerceptionTracking) -> Void)? = nil 76 | ) { 77 | let values = tracking.list.entries.mapValues { 78 | switch (willSet, didSet) { 79 | case (.some(let willSetPerceptor), .some(let didSetPerceptor)): 80 | return Id.full( 81 | $0.addWillSetPerceptor { 82 | willSetPerceptor(tracking) 83 | }, 84 | $0.addDidSetPerceptor { 85 | didSetPerceptor(tracking) 86 | }) 87 | case (.some(let willSetPerceptor), .none): 88 | return Id.willSet( 89 | $0.addWillSetPerceptor { 90 | willSetPerceptor(tracking) 91 | }) 92 | case (.none, .some(let didSetPerceptor)): 93 | return Id.didSet( 94 | $0.addDidSetPerceptor { 95 | didSetPerceptor(tracking) 96 | }) 97 | case (.none, .none): 98 | fatalError() 99 | } 100 | } 101 | 102 | tracking.install(values) 103 | } 104 | 105 | @_spi(SwiftUI) 106 | public static func _installTracking( 107 | _ list: _AccessList, 108 | onChange: @escaping @Sendable () -> Void 109 | ) { 110 | let tracking = PerceptionTracking(list) 111 | _installTracking( 112 | tracking, 113 | willSet: { _ in 114 | onChange() 115 | tracking.cancel() 116 | }) 117 | } 118 | 119 | struct State { 120 | var values = [ObjectIdentifier: PerceptionTracking.Id]() 121 | var cancelled = false 122 | } 123 | 124 | private let state = _ManagedCriticalState(State()) 125 | private let list: _AccessList 126 | 127 | @_spi(SwiftUI) 128 | public init(_ list: _AccessList?) { 129 | self.list = list ?? _AccessList() 130 | } 131 | 132 | internal func install(_ values: [ObjectIdentifier: PerceptionTracking.Id]) { 133 | state.withCriticalRegion { 134 | if !$0.cancelled { 135 | $0.values = values 136 | } 137 | } 138 | } 139 | 140 | public func cancel() { 141 | let values = state.withCriticalRegion { 142 | $0.cancelled = true 143 | let values = $0.values 144 | $0.values = [:] 145 | return values 146 | } 147 | for (id, perceptionId) in values { 148 | switch perceptionId { 149 | case .willSet(let token): 150 | list.entries[id]?.removePerceptor(token) 151 | case .didSet(let token): 152 | list.entries[id]?.removePerceptor(token) 153 | case .full(let willSetToken, let didSetToken): 154 | list.entries[id]?.removePerceptor(willSetToken) 155 | list.entries[id]?.removePerceptor(didSetToken) 156 | } 157 | } 158 | } 159 | } 160 | 161 | private func generateAccessList(_ apply: () -> T) -> (T, PerceptionTracking._AccessList?) { 162 | var accessList: PerceptionTracking._AccessList? 163 | let result = withUnsafeMutablePointer(to: &accessList) { ptr in 164 | let previous = _ThreadLocal.value 165 | _ThreadLocal.value = UnsafeMutableRawPointer(ptr) 166 | defer { 167 | if let scoped = ptr.pointee, let previous { 168 | if var prevList = previous.assumingMemoryBound(to: PerceptionTracking._AccessList?.self) 169 | .pointee 170 | { 171 | prevList.merge(scoped) 172 | previous.assumingMemoryBound(to: PerceptionTracking._AccessList?.self).pointee = prevList 173 | } else { 174 | previous.assumingMemoryBound(to: PerceptionTracking._AccessList?.self).pointee = scoped 175 | } 176 | } 177 | _ThreadLocal.value = previous 178 | } 179 | return apply() 180 | } 181 | return (result, accessList) 182 | } 183 | 184 | /// Tracks access to properties. 185 | /// 186 | /// This method tracks access to any property within the `apply` closure, and 187 | /// informs the caller of value changes made to participating properties by way 188 | /// of the `onChange` closure. For example, the following code tracks changes 189 | /// to the name of cars, but it doesn't track changes to any other property of 190 | /// `Car`: 191 | /// 192 | /// func render() { 193 | /// withPerceptionTracking { 194 | /// for car in cars { 195 | /// print(car.name) 196 | /// } 197 | /// } onChange: { 198 | /// print("Schedule renderer.") 199 | /// } 200 | /// } 201 | /// 202 | /// - Parameters: 203 | /// - apply: A closure that contains properties to track. 204 | /// - onChange: The closure invoked when the value of a property changes. 205 | /// 206 | /// - Returns: The value that the `apply` closure returns if it has a return 207 | /// value; otherwise, there is no return value. 208 | public func withPerceptionTracking( 209 | _ apply: () -> T, 210 | onChange: @autoclosure () -> @Sendable () -> Void 211 | ) -> T { 212 | #if canImport(Observation) 213 | if #available(iOS 17, macOS 14, tvOS 17, watchOS 10, *), !isObservationBeta { 214 | return withObservationTracking(apply, onChange: onChange()) 215 | } 216 | #endif 217 | let (result, accessList) = generateAccessList(apply) 218 | if let accessList { 219 | PerceptionTracking._installTracking(accessList, onChange: onChange()) 220 | } 221 | return result 222 | } 223 | -------------------------------------------------------------------------------- /Sources/PerceptionCore/WithPerceptionTracking.swift: -------------------------------------------------------------------------------- 1 | #if canImport(SwiftUI) 2 | import SwiftUI 3 | 4 | /// Observes changes to perceptible models. 5 | /// 6 | /// Use this view to automatically subscribe to the changes of any fields in ``Perceptible()`` 7 | /// models used in the view. Typically you will install this view at the root of your view like 8 | /// so: 9 | /// 10 | /// ```swift 11 | /// struct FeatureView: View { 12 | /// let model: FeatureModel 13 | /// 14 | /// var body: some View { 15 | /// WithPerceptionTracking { 16 | /// // ... 17 | /// } 18 | /// } 19 | /// } 20 | /// ``` 21 | /// 22 | /// You will also need to use ``WithPerceptionTracking`` in any escaping, trailing closures used 23 | /// in SwiftUI's various navigation APIs, such as the sheet modifier: 24 | /// 25 | /// ```swift 26 | /// .sheet(isPresented: $isPresented) { 27 | /// WithPerceptionTracking { 28 | /// // Access to `model` in here will be properly observed. 29 | /// } 30 | /// } 31 | /// ``` 32 | /// 33 | /// > Note: Other common escaping closures to be aware of: 34 | /// > * Reader views, such as `GeometryReader`, ScrollViewReader`, etc. 35 | /// > * Lazy views such as `LazyVStack`, `LazyVGrid`, etc. 36 | /// > * Navigation APIs, such as `sheet`, `popover`, `fullScreenCover`, `navigationDestination`, 37 | /// etc. 38 | /// 39 | /// If a field of a `@Perceptible` model is accessed in a view while _not_ inside 40 | /// ``WithPerceptionTracking``, then a runtime warning will helpfully be triggered: 41 | /// 42 | /// > 🟣 Runtime Warning: Perceptible state was accessed but is not being tracked. Track changes 43 | /// > to state by wrapping your view in a 'WithPerceptionTracking' view. This must also be done 44 | /// > for any escaping, trailing closures, such as 'GeometryReader', `LazyVStack` (and all lazy 45 | /// > views), navigation APIs ('sheet', 'popover', 'fullScreenCover', etc.), and others. 46 | /// 47 | /// To debug this, expand the warning in the Issue Navigator of Xcode (cmd+5), and click through 48 | /// the stack frames displayed to find the line in your view where you are accessing state without 49 | /// being inside ``WithPerceptionTracking``. 50 | /// 51 | /// > Important: In iOS 17+, etc., `WithPerceptionTracking` is a no-op and SwiftUI will use the 52 | /// > Observation framework instead. Be sure to test your application in all supported deployment 53 | /// > targets to catch potential differences in observation behavior. 54 | @available( 55 | iOS, deprecated: 17, message: "'WithPerceptionTracking' is no longer needed in iOS 17+" 56 | ) 57 | @available( 58 | macOS, deprecated: 14, message: "'WithPerceptionTracking' is no longer needed in macOS 14+" 59 | ) 60 | @available( 61 | watchOS, deprecated: 10, message: "'WithPerceptionTracking' is no longer needed in watchOS 10+" 62 | ) 63 | @available( 64 | tvOS, deprecated: 17, message: "'WithPerceptionTracking' is no longer needed in tvOS 17+" 65 | ) 66 | 67 | enum _WithPerceptionTrackingContent { 68 | case direct(Content) 69 | case instrumented(() -> Content) 70 | case tracked(() -> Content) 71 | 72 | init(_ content: @escaping () -> Content) { 73 | if #available(iOS 17, macOS 14, tvOS 17, watchOS 10, *), !isObservationBeta { 74 | #if DEBUG 75 | self = .instrumented(content) 76 | #else 77 | self = .direct(content()) 78 | #endif 79 | } else { 80 | self = .tracked(content) 81 | } 82 | } 83 | } 84 | 85 | public struct WithPerceptionTracking { 86 | @State var id = 0 87 | let content: _WithPerceptionTrackingContent 88 | 89 | public var body: Content { 90 | switch content { 91 | case .direct(let content): 92 | return content 93 | 94 | case .instrumented(let content): 95 | return instrumentedBody(content) 96 | 97 | case .tracked(let content): 98 | let _ = self.id 99 | return withPerceptionTracking { 100 | self.instrumentedBody(content) 101 | } onChange: { [_id = UncheckedSendable(self._id)] in 102 | _id.value.wrappedValue &+= 1 103 | } 104 | } 105 | } 106 | 107 | public init(content: @escaping @autoclosure () -> Content) { 108 | self.content = _WithPerceptionTrackingContent(content) 109 | } 110 | 111 | @_transparent 112 | @inline(__always) 113 | private func instrumentedBody(_ content: () -> Content) -> Content { 114 | #if DEBUG 115 | return _PerceptionLocals.$isInPerceptionTracking.withValue(true) { 116 | content() 117 | } 118 | #else 119 | return content() 120 | #endif 121 | } 122 | } 123 | 124 | @available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) 125 | extension WithPerceptionTracking: AccessibilityRotorContent 126 | where Content: AccessibilityRotorContent { 127 | public init(@AccessibilityRotorContentBuilder content: @escaping () -> Content) { 128 | self.content = _WithPerceptionTrackingContent(content) 129 | } 130 | } 131 | 132 | @available(iOS 14, macOS 11, *) 133 | @available(tvOS, unavailable) 134 | @available(watchOS, unavailable) 135 | extension WithPerceptionTracking: Commands where Content: Commands { 136 | public init(@CommandsBuilder content: @escaping () -> Content) { 137 | self.content = _WithPerceptionTrackingContent(content) 138 | } 139 | } 140 | 141 | @available(iOS 14, macOS 11, tvOS 14, watchOS 7, *) 142 | extension WithPerceptionTracking: CustomizableToolbarContent 143 | where Content: CustomizableToolbarContent { 144 | } 145 | 146 | @available(iOS 14, macOS 11, tvOS 14, watchOS 7, *) 147 | extension WithPerceptionTracking: Scene where Content: Scene { 148 | public init(@SceneBuilder content: @escaping () -> Content) { 149 | self.content = _WithPerceptionTrackingContent(content) 150 | } 151 | } 152 | 153 | @available(iOS 16, macOS 12, *) 154 | @available(tvOS, unavailable) 155 | @available(watchOS, unavailable) 156 | extension WithPerceptionTracking: TableColumnContent where Content: TableColumnContent { 157 | public typealias TableRowValue = Content.TableRowValue 158 | public typealias TableColumnSortComparator = Content.TableColumnSortComparator 159 | public typealias TableColumnBody = Never 160 | 161 | public init(@TableColumnBuilder content: @escaping () -> Content) 162 | where R == Content.TableRowValue, C == Content.TableColumnSortComparator { 163 | self.content = _WithPerceptionTrackingContent(content) 164 | } 165 | 166 | nonisolated public var tableColumnBody: Never { 167 | fatalError() 168 | } 169 | 170 | nonisolated public static func _makeContent( 171 | content: _GraphValue>, inputs: _TableColumnInputs 172 | ) -> _TableColumnOutputs { 173 | Content._makeContent(content: content[\.body], inputs: inputs) 174 | } 175 | } 176 | 177 | @available(iOS 16, macOS 12, *) 178 | @available(tvOS, unavailable) 179 | @available(watchOS, unavailable) 180 | extension WithPerceptionTracking: TableRowContent where Content: TableRowContent { 181 | public typealias TableRowValue = Content.TableRowValue 182 | public typealias TableRowBody = Never 183 | 184 | public init(@TableRowBuilder content: @escaping () -> Content) 185 | where R == Content.TableRowValue { 186 | self.content = _WithPerceptionTrackingContent(content) 187 | } 188 | 189 | nonisolated public var tableRowBody: Never { 190 | fatalError() 191 | } 192 | } 193 | 194 | @available(iOS 14, macOS 11, tvOS 14, watchOS 7, *) 195 | extension WithPerceptionTracking: ToolbarContent where Content: ToolbarContent { 196 | public init(@ToolbarContentBuilder content: @escaping () -> Content) { 197 | self.content = _WithPerceptionTrackingContent(content) 198 | } 199 | } 200 | 201 | extension WithPerceptionTracking: View where Content: View { 202 | public init(@ViewBuilder content: @escaping () -> Content) { 203 | self.content = _WithPerceptionTrackingContent(content) 204 | } 205 | } 206 | 207 | #if canImport(Charts) 208 | import Charts 209 | 210 | @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) 211 | extension WithPerceptionTracking: ChartContent where Content: ChartContent { 212 | public init(@ChartContentBuilder content: @escaping () -> Content) { 213 | self.content = _WithPerceptionTrackingContent(content) 214 | } 215 | } 216 | #endif 217 | #endif 218 | -------------------------------------------------------------------------------- /Sources/PerceptionMacros/Availability.swift: -------------------------------------------------------------------------------- 1 | //===----------------------------------------------------------------------===// 2 | // 3 | // This source file is part of the Swift.org open source project 4 | // 5 | // Copyright (c) 2023 Apple Inc. and the Swift project authors 6 | // Licensed under Apache License v2.0 with Runtime Library Exception 7 | // 8 | // See https://swift.org/LICENSE.txt for license information 9 | // 10 | //===----------------------------------------------------------------------===// 11 | 12 | import SwiftDiagnostics 13 | import SwiftOperators 14 | import SwiftSyntax 15 | import SwiftSyntaxBuilder 16 | import SwiftSyntaxMacros 17 | 18 | extension AttributeSyntax { 19 | var availability: AttributeSyntax? { 20 | if attributeName.identifier == "available" { 21 | return self 22 | } else { 23 | return nil 24 | } 25 | } 26 | } 27 | 28 | extension IfConfigClauseSyntax.Elements { 29 | var availability: IfConfigClauseSyntax.Elements? { 30 | switch self { 31 | case .attributes(let attributes): 32 | if let availability = attributes.availability { 33 | return .attributes(availability) 34 | } else { 35 | return nil 36 | } 37 | default: 38 | return nil 39 | } 40 | } 41 | } 42 | 43 | extension IfConfigClauseSyntax { 44 | var availability: IfConfigClauseSyntax? { 45 | if let availability = elements?.availability { 46 | return with(\.elements, availability) 47 | } else { 48 | return nil 49 | } 50 | } 51 | 52 | var clonedAsIf: IfConfigClauseSyntax { 53 | detached.with(\.poundKeyword, .poundIfToken()) 54 | } 55 | } 56 | 57 | extension IfConfigDeclSyntax { 58 | var availability: IfConfigDeclSyntax? { 59 | var elements = [IfConfigClauseListSyntax.Element]() 60 | for clause in clauses { 61 | if let availability = clause.availability { 62 | if elements.isEmpty { 63 | elements.append(availability.clonedAsIf) 64 | } else { 65 | elements.append(availability) 66 | } 67 | } 68 | } 69 | if elements.isEmpty { 70 | return nil 71 | } else { 72 | return with(\.clauses, IfConfigClauseListSyntax(elements)) 73 | } 74 | 75 | } 76 | } 77 | 78 | extension AttributeListSyntax.Element { 79 | var availability: AttributeListSyntax.Element? { 80 | switch self { 81 | case .attribute(let attribute): 82 | if let availability = attribute.availability { 83 | return .attribute(availability) 84 | } 85 | case .ifConfigDecl(let ifConfig): 86 | if let availability = ifConfig.availability { 87 | return .ifConfigDecl(availability) 88 | } 89 | } 90 | return nil 91 | } 92 | } 93 | 94 | extension AttributeListSyntax { 95 | var availability: AttributeListSyntax? { 96 | var elements = [AttributeListSyntax.Element]() 97 | for element in self { 98 | if let availability = element.availability { 99 | elements.append(availability) 100 | } 101 | } 102 | if elements.isEmpty { 103 | return nil 104 | } 105 | return AttributeListSyntax(elements) 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /Sources/PerceptionMacros/Extensions.swift: -------------------------------------------------------------------------------- 1 | //===----------------------------------------------------------------------===// 2 | // 3 | // This source file is part of the Swift.org open source project 4 | // 5 | // Copyright (c) 2023 Apple Inc. and the Swift project authors 6 | // Licensed under Apache License v2.0 with Runtime Library Exception 7 | // 8 | // See https://swift.org/LICENSE.txt for license information 9 | // 10 | //===----------------------------------------------------------------------===// 11 | 12 | import SwiftDiagnostics 13 | import SwiftOperators 14 | import SwiftSyntax 15 | import SwiftSyntaxBuilder 16 | import SwiftSyntaxMacros 17 | 18 | extension VariableDeclSyntax { 19 | var identifierPattern: IdentifierPatternSyntax? { 20 | bindings.first?.pattern.as(IdentifierPatternSyntax.self) 21 | } 22 | 23 | var isInstance: Bool { 24 | for modifier in modifiers { 25 | for token in modifier.tokens(viewMode: .all) { 26 | if token.tokenKind == .keyword(.static) || token.tokenKind == .keyword(.class) { 27 | return false 28 | } 29 | } 30 | } 31 | return true 32 | } 33 | 34 | var identifier: TokenSyntax? { 35 | identifierPattern?.identifier 36 | } 37 | 38 | var type: TypeSyntax? { 39 | bindings.first?.typeAnnotation?.type 40 | } 41 | 42 | func accessorsMatching(_ predicate: (TokenKind) -> Bool) -> [AccessorDeclSyntax] { 43 | let accessors: [AccessorDeclListSyntax.Element] = bindings.compactMap { patternBinding in 44 | switch patternBinding.accessorBlock?.accessors { 45 | case .accessors(let accessors): 46 | return accessors 47 | default: 48 | return nil 49 | } 50 | }.flatMap { $0 } 51 | return accessors.compactMap { accessor in 52 | predicate(accessor.accessorSpecifier.tokenKind) ? accessor : nil 53 | } 54 | } 55 | 56 | var willSetAccessors: [AccessorDeclSyntax] { 57 | accessorsMatching { $0 == .keyword(.willSet) } 58 | } 59 | var didSetAccessors: [AccessorDeclSyntax] { 60 | accessorsMatching { $0 == .keyword(.didSet) } 61 | } 62 | 63 | var isComputed: Bool { 64 | if accessorsMatching({ $0 == .keyword(.get) }).count > 0 { 65 | return true 66 | } else { 67 | return bindings.contains { binding in 68 | if case .getter = binding.accessorBlock?.accessors { 69 | return true 70 | } else { 71 | return false 72 | } 73 | } 74 | } 75 | } 76 | 77 | var isImmutable: Bool { 78 | return bindingSpecifier.tokenKind == .keyword(.let) 79 | } 80 | 81 | func isEquivalent(to other: VariableDeclSyntax) -> Bool { 82 | if isInstance != other.isInstance { 83 | return false 84 | } 85 | return identifier?.text == other.identifier?.text 86 | } 87 | 88 | var initializer: InitializerClauseSyntax? { 89 | bindings.first?.initializer 90 | } 91 | 92 | func hasMacroApplication(_ name: String) -> Bool { 93 | for attribute in attributes { 94 | switch attribute { 95 | case .attribute(let attr): 96 | if attr.attributeName.tokens(viewMode: .all).map({ $0.tokenKind }) == [.identifier(name)] { 97 | return true 98 | } 99 | default: 100 | break 101 | } 102 | } 103 | return false 104 | } 105 | } 106 | 107 | extension TypeSyntax { 108 | var identifier: String? { 109 | for token in tokens(viewMode: .all) { 110 | switch token.tokenKind { 111 | case .identifier(let identifier): 112 | return identifier 113 | default: 114 | break 115 | } 116 | } 117 | return nil 118 | } 119 | 120 | func genericSubstitution(_ parameters: GenericParameterListSyntax?) -> String? { 121 | var genericParameters = [String: TypeSyntax?]() 122 | if let parameters { 123 | for parameter in parameters { 124 | genericParameters[parameter.name.text] = parameter.inheritedType 125 | } 126 | } 127 | var iterator = self.asProtocol(TypeSyntaxProtocol.self).tokens(viewMode: .sourceAccurate) 128 | .makeIterator() 129 | guard let base = iterator.next() else { 130 | return nil 131 | } 132 | 133 | if let genericBase = genericParameters[base.text] { 134 | if let text = genericBase?.identifier { 135 | return "some " + text 136 | } else { 137 | return nil 138 | } 139 | } 140 | var substituted = base.text 141 | 142 | while let token = iterator.next() { 143 | switch token.tokenKind { 144 | case .leftAngle: 145 | substituted += "<" 146 | case .rightAngle: 147 | substituted += ">" 148 | case .comma: 149 | substituted += "," 150 | case .identifier(let identifier): 151 | let type: TypeSyntax = "\(raw: identifier)" 152 | guard let substitutedType = type.genericSubstitution(parameters) else { 153 | return nil 154 | } 155 | substituted += substitutedType 156 | break 157 | default: 158 | // ignore? 159 | break 160 | } 161 | } 162 | 163 | return substituted 164 | } 165 | } 166 | 167 | extension FunctionDeclSyntax { 168 | var isInstance: Bool { 169 | for modifier in modifiers { 170 | for token in modifier.tokens(viewMode: .all) { 171 | if token.tokenKind == .keyword(.static) || token.tokenKind == .keyword(.class) { 172 | return false 173 | } 174 | } 175 | } 176 | return true 177 | } 178 | 179 | struct SignatureStandin: Equatable { 180 | var isInstance: Bool 181 | var identifier: String 182 | var parameters: [String] 183 | var returnType: String 184 | } 185 | 186 | var signatureStandin: SignatureStandin { 187 | var parameters = [String]() 188 | for parameter in signature.parameterClause.parameters { 189 | parameters.append( 190 | parameter.firstName.text + ":" 191 | + (parameter.type.genericSubstitution(genericParameterClause?.parameters) ?? "")) 192 | } 193 | let returnType = 194 | signature.returnClause?.type.genericSubstitution(genericParameterClause?.parameters) ?? "Void" 195 | return SignatureStandin( 196 | isInstance: isInstance, identifier: name.text, parameters: parameters, returnType: returnType) 197 | } 198 | 199 | func isEquivalent(to other: FunctionDeclSyntax) -> Bool { 200 | return signatureStandin == other.signatureStandin 201 | } 202 | } 203 | 204 | extension DeclGroupSyntax { 205 | var memberFunctionStandins: [FunctionDeclSyntax.SignatureStandin] { 206 | var standins = [FunctionDeclSyntax.SignatureStandin]() 207 | for member in memberBlock.members { 208 | if let function = member.decl.as(FunctionDeclSyntax.self) { 209 | standins.append(function.signatureStandin) 210 | } 211 | } 212 | return standins 213 | } 214 | 215 | func hasMemberFunction(equivalentTo other: FunctionDeclSyntax) -> Bool { 216 | for member in memberBlock.members { 217 | if let function = member.decl.as(FunctionDeclSyntax.self) { 218 | if function.isEquivalent(to: other) { 219 | return true 220 | } 221 | } 222 | } 223 | return false 224 | } 225 | 226 | func hasMemberProperty(equivalentTo other: VariableDeclSyntax) -> Bool { 227 | for member in memberBlock.members { 228 | if let variable = member.decl.as(VariableDeclSyntax.self) { 229 | if variable.isEquivalent(to: other) { 230 | return true 231 | } 232 | } 233 | } 234 | return false 235 | } 236 | 237 | var definedVariables: [VariableDeclSyntax] { 238 | memberBlock.members.compactMap { member in 239 | if let variableDecl = member.decl.as(VariableDeclSyntax.self) 240 | { 241 | return variableDecl 242 | } 243 | return nil 244 | } 245 | } 246 | 247 | func addIfNeeded(_ decl: DeclSyntax?, to declarations: inout [DeclSyntax]) { 248 | guard let decl else { return } 249 | if let fn = decl.as(FunctionDeclSyntax.self) { 250 | if !hasMemberFunction(equivalentTo: fn) { 251 | declarations.append(decl) 252 | } 253 | } else if let property = decl.as(VariableDeclSyntax.self) { 254 | if !hasMemberProperty(equivalentTo: property) { 255 | declarations.append(decl) 256 | } 257 | } 258 | } 259 | 260 | var isClass: Bool { 261 | return self.is(ClassDeclSyntax.self) 262 | } 263 | 264 | var isActor: Bool { 265 | return self.is(ActorDeclSyntax.self) 266 | } 267 | 268 | var isEnum: Bool { 269 | return self.is(EnumDeclSyntax.self) 270 | } 271 | 272 | var isStruct: Bool { 273 | return self.is(StructDeclSyntax.self) 274 | } 275 | } 276 | -------------------------------------------------------------------------------- /Sources/PerceptionMacros/PerceptibleMacro.swift: -------------------------------------------------------------------------------- 1 | //===----------------------------------------------------------------------===// 2 | // 3 | // This source file is part of the Swift.org open source project 4 | // 5 | // Copyright (c) 2023 Apple Inc. and the Swift project authors 6 | // Licensed under Apache License v2.0 with Runtime Library Exception 7 | // 8 | // See https://swift.org/LICENSE.txt for license information 9 | // 10 | //===----------------------------------------------------------------------===// 11 | 12 | import SwiftDiagnostics 13 | import SwiftOperators 14 | import SwiftSyntax 15 | import SwiftSyntaxBuilder 16 | import SwiftSyntaxMacros 17 | 18 | public struct PerceptibleMacro { 19 | static let moduleName = "Perception" 20 | 21 | static let conformanceName = "Perceptible" 22 | static var qualifiedConformanceName: String { 23 | return "\(moduleName).\(conformanceName)" 24 | } 25 | 26 | static var perceptibleConformanceType: TypeSyntax { 27 | "\(raw: qualifiedConformanceName)" 28 | } 29 | 30 | static let registrarTypeName = "PerceptionRegistrar" 31 | static var qualifiedRegistrarTypeName: String { 32 | return "\(moduleName).\(registrarTypeName)" 33 | } 34 | 35 | static let trackedMacroName = "PerceptionTracked" 36 | static let ignoredMacroName = "PerceptionIgnored" 37 | 38 | static let registrarVariableName = "_$perceptionRegistrar" 39 | 40 | static func registrarVariable(_ perceptibleType: TokenSyntax) -> DeclSyntax { 41 | return 42 | """ 43 | @\(raw: ignoredMacroName) private let \(raw: registrarVariableName) = \(raw: qualifiedRegistrarTypeName)() 44 | """ 45 | } 46 | 47 | static func accessFunction(_ perceptibleType: TokenSyntax) -> DeclSyntax { 48 | return 49 | """ 50 | internal nonisolated func access( 51 | keyPath: KeyPath<\(perceptibleType), Member>, 52 | fileID: StaticString = #fileID, 53 | filePath: StaticString = #filePath, 54 | line: UInt = #line, 55 | column: UInt = #column 56 | ) { 57 | \(raw: registrarVariableName).access( 58 | self, 59 | keyPath: keyPath, 60 | fileID: fileID, 61 | filePath: filePath, 62 | line: line, 63 | column: column 64 | ) 65 | } 66 | """ 67 | } 68 | 69 | static func withMutationFunction(_ perceptibleType: TokenSyntax) -> DeclSyntax { 70 | return 71 | """ 72 | internal nonisolated func withMutation( 73 | keyPath: KeyPath<\(perceptibleType), Member>, 74 | _ mutation: () throws -> MutationResult 75 | ) rethrows -> MutationResult { 76 | try \(raw: registrarVariableName).withMutation(of: self, keyPath: keyPath, mutation) 77 | } 78 | """ 79 | } 80 | 81 | static var ignoredAttribute: AttributeSyntax { 82 | AttributeSyntax( 83 | leadingTrivia: .space, 84 | atSign: .atSignToken(), 85 | attributeName: IdentifierTypeSyntax(name: .identifier(ignoredMacroName)), 86 | trailingTrivia: .space 87 | ) 88 | } 89 | } 90 | 91 | struct PerceptionDiagnostic: DiagnosticMessage { 92 | enum ID: String { 93 | case invalidApplication = "invalid type" 94 | case missingInitializer = "missing initializer" 95 | } 96 | 97 | var message: String 98 | var diagnosticID: MessageID 99 | var severity: DiagnosticSeverity 100 | 101 | init( 102 | message: String, diagnosticID: SwiftDiagnostics.MessageID, 103 | severity: SwiftDiagnostics.DiagnosticSeverity = .error 104 | ) { 105 | self.message = message 106 | self.diagnosticID = diagnosticID 107 | self.severity = severity 108 | } 109 | 110 | init( 111 | message: String, domain: String, id: ID, severity: SwiftDiagnostics.DiagnosticSeverity = .error 112 | ) { 113 | self.message = message 114 | self.diagnosticID = MessageID(domain: domain, id: id.rawValue) 115 | self.severity = severity 116 | } 117 | } 118 | 119 | extension DiagnosticsError { 120 | init( 121 | syntax: S, message: String, domain: String = "Perception", id: PerceptionDiagnostic.ID, 122 | severity: SwiftDiagnostics.DiagnosticSeverity = .error 123 | ) { 124 | self.init(diagnostics: [ 125 | Diagnostic( 126 | node: Syntax(syntax), 127 | message: PerceptionDiagnostic(message: message, domain: domain, id: id, severity: severity)) 128 | ]) 129 | } 130 | } 131 | 132 | extension DeclModifierListSyntax { 133 | func privatePrefixed(_ prefix: String) -> DeclModifierListSyntax { 134 | let modifier: DeclModifierSyntax = DeclModifierSyntax(name: "private", trailingTrivia: .space) 135 | return [modifier] 136 | + filter { 137 | switch $0.name.tokenKind { 138 | case .keyword(let keyword): 139 | switch keyword { 140 | case .fileprivate, .private, .internal, .package, .public: 141 | return false 142 | default: 143 | return true 144 | } 145 | default: 146 | return true 147 | } 148 | } 149 | } 150 | 151 | init(keyword: Keyword) { 152 | self.init([DeclModifierSyntax(name: .keyword(keyword))]) 153 | } 154 | } 155 | 156 | extension TokenSyntax { 157 | func privatePrefixed(_ prefix: String) -> TokenSyntax { 158 | switch tokenKind { 159 | case .identifier(let identifier): 160 | return TokenSyntax( 161 | .identifier(prefix + identifier), leadingTrivia: leadingTrivia, 162 | trailingTrivia: trailingTrivia, presence: presence) 163 | default: 164 | return self 165 | } 166 | } 167 | } 168 | 169 | extension PatternBindingListSyntax { 170 | func privatePrefixed(_ prefix: String) -> PatternBindingListSyntax { 171 | var bindings = self.map { $0 } 172 | for index in 0.. VariableDeclSyntax 198 | { 199 | let newAttributes = attributes + [.attribute(attribute)] 200 | return VariableDeclSyntax( 201 | leadingTrivia: leadingTrivia, 202 | attributes: newAttributes, 203 | modifiers: modifiers.privatePrefixed(prefix), 204 | bindingSpecifier: TokenSyntax( 205 | bindingSpecifier.tokenKind, leadingTrivia: .space, trailingTrivia: .space, 206 | presence: .present), 207 | bindings: bindings.privatePrefixed(prefix), 208 | trailingTrivia: trailingTrivia 209 | ) 210 | } 211 | 212 | var isValidForPerception: Bool { 213 | !isComputed && isInstance && !isImmutable && identifier != nil 214 | } 215 | } 216 | 217 | extension PerceptibleMacro: MemberMacro { 218 | public static func expansion< 219 | Declaration: DeclGroupSyntax, 220 | Context: MacroExpansionContext 221 | >( 222 | of node: AttributeSyntax, 223 | providingMembersOf declaration: Declaration, 224 | in context: Context 225 | ) throws -> [DeclSyntax] { 226 | guard let identified = declaration.asProtocol(NamedDeclSyntax.self) else { 227 | return [] 228 | } 229 | 230 | let perceptibleType = identified.name.trimmed 231 | 232 | if declaration.isEnum { 233 | // enumerations cannot store properties 234 | throw DiagnosticsError( 235 | syntax: node, 236 | message: "'@Perceptible' cannot be applied to enumeration type '\(perceptibleType.text)'", 237 | id: .invalidApplication) 238 | } 239 | if declaration.isStruct { 240 | // structs are not yet supported; copying/mutation semantics tbd 241 | throw DiagnosticsError( 242 | syntax: node, 243 | message: "'@Perceptible' cannot be applied to struct type '\(perceptibleType.text)'", 244 | id: .invalidApplication) 245 | } 246 | if declaration.isActor { 247 | // actors cannot yet be supported for their isolation 248 | throw DiagnosticsError( 249 | syntax: node, 250 | message: "'@Perceptible' cannot be applied to actor type '\(perceptibleType.text)'", 251 | id: .invalidApplication) 252 | } 253 | 254 | var declarations = [DeclSyntax]() 255 | 256 | declaration.addIfNeeded(PerceptibleMacro.registrarVariable(perceptibleType), to: &declarations) 257 | declaration.addIfNeeded(PerceptibleMacro.accessFunction(perceptibleType), to: &declarations) 258 | declaration.addIfNeeded( 259 | PerceptibleMacro.withMutationFunction(perceptibleType), to: &declarations) 260 | 261 | return declarations 262 | } 263 | } 264 | 265 | extension PerceptibleMacro: MemberAttributeMacro { 266 | public static func expansion< 267 | Declaration: DeclGroupSyntax, 268 | MemberDeclaration: DeclSyntaxProtocol, 269 | Context: MacroExpansionContext 270 | >( 271 | of node: AttributeSyntax, 272 | attachedTo declaration: Declaration, 273 | providingAttributesFor member: MemberDeclaration, 274 | in context: Context 275 | ) throws -> [AttributeSyntax] { 276 | guard let property = member.as(VariableDeclSyntax.self), property.isValidForPerception, 277 | property.identifier != nil 278 | else { 279 | return [] 280 | } 281 | 282 | // dont apply to ignored properties or properties that are already flagged as tracked 283 | if property.hasMacroApplication(PerceptibleMacro.ignoredMacroName) 284 | || property.hasMacroApplication(PerceptibleMacro.trackedMacroName) 285 | { 286 | return [] 287 | } 288 | 289 | return [ 290 | AttributeSyntax( 291 | attributeName: IdentifierTypeSyntax(name: .identifier(PerceptibleMacro.trackedMacroName))) 292 | ] 293 | } 294 | } 295 | 296 | extension PerceptibleMacro: ExtensionMacro { 297 | public static func expansion( 298 | of node: AttributeSyntax, 299 | attachedTo declaration: some DeclGroupSyntax, 300 | providingExtensionsOf type: some TypeSyntaxProtocol, 301 | conformingTo protocols: [TypeSyntax], 302 | in context: some MacroExpansionContext 303 | ) throws -> [ExtensionDeclSyntax] { 304 | // This method can be called twice - first with an empty `protocols` when 305 | // no conformance is needed, and second with a `MissingTypeSyntax` instance. 306 | if protocols.isEmpty { 307 | return [] 308 | } 309 | 310 | let decl: DeclSyntax = """ 311 | extension \(raw: type.trimmedDescription): \(raw: qualifiedConformanceName), Observation.Observable {} 312 | """ 313 | let ext = decl.cast(ExtensionDeclSyntax.self) 314 | 315 | if let availability = declaration.attributes.availability { 316 | return [ 317 | ext.with(\.attributes, availability), 318 | ] 319 | } else { 320 | return [ 321 | ext, 322 | ] 323 | } 324 | } 325 | } 326 | 327 | public struct PerceptionTrackedMacro: AccessorMacro { 328 | public static func expansion< 329 | Context: MacroExpansionContext, 330 | Declaration: DeclSyntaxProtocol 331 | >( 332 | of node: AttributeSyntax, 333 | providingAccessorsOf declaration: Declaration, 334 | in context: Context 335 | ) throws -> [AccessorDeclSyntax] { 336 | guard let property = declaration.as(VariableDeclSyntax.self), 337 | property.isValidForPerception, 338 | let identifier = property.identifier?.trimmed 339 | else { 340 | return [] 341 | } 342 | 343 | if property.hasMacroApplication(PerceptibleMacro.ignoredMacroName) { 344 | return [] 345 | } 346 | 347 | let initAccessor: AccessorDeclSyntax = 348 | """ 349 | @storageRestrictions(initializes: _\(identifier)) 350 | init(initialValue) { 351 | _\(identifier) = initialValue 352 | } 353 | """ 354 | 355 | let getAccessor: AccessorDeclSyntax = 356 | """ 357 | get { 358 | access(keyPath: \\.\(identifier)) 359 | return _\(identifier) 360 | } 361 | """ 362 | 363 | let setAccessor: AccessorDeclSyntax = 364 | """ 365 | set { 366 | withMutation(keyPath: \\.\(identifier)) { 367 | _\(identifier) = newValue 368 | } 369 | } 370 | """ 371 | 372 | let modifyAccessor: AccessorDeclSyntax = 373 | """ 374 | _modify { 375 | access(keyPath: \\.\(identifier)) 376 | \(raw: PerceptibleMacro.registrarVariableName).willSet(self, keyPath: \\.\(identifier)) 377 | defer { \(raw: PerceptibleMacro.registrarVariableName).didSet(self, keyPath: \\.\(identifier)) } 378 | yield &_\(identifier) 379 | } 380 | """ 381 | 382 | return [initAccessor, getAccessor, setAccessor, modifyAccessor] 383 | } 384 | } 385 | 386 | extension PerceptionTrackedMacro: PeerMacro { 387 | public static func expansion< 388 | Context: MacroExpansionContext, 389 | Declaration: DeclSyntaxProtocol 390 | >( 391 | of node: SwiftSyntax.AttributeSyntax, 392 | providingPeersOf declaration: Declaration, 393 | in context: Context 394 | ) throws -> [DeclSyntax] { 395 | guard let property = declaration.as(VariableDeclSyntax.self), 396 | property.isValidForPerception 397 | else { 398 | return [] 399 | } 400 | 401 | if property.hasMacroApplication(PerceptibleMacro.ignoredMacroName) 402 | || property.hasMacroApplication(PerceptibleMacro.trackedMacroName) 403 | { 404 | return [] 405 | } 406 | 407 | let storage = DeclSyntax( 408 | property.privatePrefixed("_", addingAttribute: PerceptibleMacro.ignoredAttribute)) 409 | return [storage] 410 | } 411 | } 412 | 413 | public struct PerceptionIgnoredMacro: AccessorMacro { 414 | public static func expansion< 415 | Context: MacroExpansionContext, 416 | Declaration: DeclSyntaxProtocol 417 | >( 418 | of node: AttributeSyntax, 419 | providingAccessorsOf declaration: Declaration, 420 | in context: Context 421 | ) throws -> [AccessorDeclSyntax] { 422 | return [] 423 | } 424 | } 425 | -------------------------------------------------------------------------------- /Sources/PerceptionMacros/Plugins.swift: -------------------------------------------------------------------------------- 1 | import SwiftCompilerPlugin 2 | import SwiftSyntaxMacros 3 | 4 | @main 5 | struct MacrosPlugin: CompilerPlugin { 6 | let providingMacros: [Macro.Type] = [ 7 | PerceptibleMacro.self, 8 | PerceptionTrackedMacro.self, 9 | PerceptionIgnoredMacro.self, 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /Tests/PerceptionMacrosTests/PerceptionMacrosTests.swift: -------------------------------------------------------------------------------- 1 | #if canImport(PerceptionMacros) 2 | import MacroTesting 3 | import PerceptionMacros 4 | import XCTest 5 | 6 | class PerceptionMacroTests: XCTestCase { 7 | override func invokeTest() { 8 | withMacroTesting( 9 | // isRecording: true, 10 | macros: [ 11 | PerceptibleMacro.self, 12 | PerceptionTrackedMacro.self, 13 | PerceptionIgnoredMacro.self, 14 | ] 15 | ) { 16 | super.invokeTest() 17 | } 18 | } 19 | 20 | func testPerceptible() { 21 | assertMacro { 22 | """ 23 | @Perceptible 24 | class Feature { 25 | var count = 0 26 | } 27 | """ 28 | } expansion: { 29 | #""" 30 | class Feature { 31 | var count { 32 | @storageRestrictions(initializes: _count) 33 | init(initialValue) { 34 | _count = initialValue 35 | } 36 | get { 37 | access(keyPath: \.count) 38 | return _count 39 | } 40 | set { 41 | withMutation(keyPath: \.count) { 42 | _count = newValue 43 | } 44 | } 45 | _modify { 46 | access(keyPath: \.count) 47 | _$perceptionRegistrar.willSet(self, keyPath: \.count) 48 | defer { 49 | _$perceptionRegistrar.didSet(self, keyPath: \.count) 50 | } 51 | yield &_count 52 | } 53 | } 54 | 55 | private let _$perceptionRegistrar = Perception.PerceptionRegistrar() 56 | 57 | internal nonisolated func access( 58 | keyPath: KeyPath, 59 | fileID: StaticString = #fileID, 60 | filePath: StaticString = #filePath, 61 | line: UInt = #line, 62 | column: UInt = #column 63 | ) { 64 | _$perceptionRegistrar.access( 65 | self, 66 | keyPath: keyPath, 67 | fileID: fileID, 68 | filePath: filePath, 69 | line: line, 70 | column: column 71 | ) 72 | } 73 | 74 | internal nonisolated func withMutation( 75 | keyPath: KeyPath, 76 | _ mutation: () throws -> MutationResult 77 | ) rethrows -> MutationResult { 78 | try _$perceptionRegistrar.withMutation(of: self, keyPath: keyPath, mutation) 79 | } 80 | } 81 | """# 82 | } 83 | } 84 | } 85 | #endif 86 | -------------------------------------------------------------------------------- /Tests/PerceptionTests/ModifyTests.swift: -------------------------------------------------------------------------------- 1 | import Perception 2 | import XCTest 3 | 4 | final class ModifyTests: XCTestCase { 5 | func testCOW() { 6 | let subject = CowTest() 7 | let startId = subject.container.id 8 | XCTAssertEqual(subject.container.id, startId) 9 | subject.container.mutate() 10 | XCTAssertEqual(subject.container.id, startId) 11 | } 12 | } 13 | 14 | struct CowContainer { 15 | final class Contents { } 16 | var contents = Contents() 17 | mutating func mutate() { 18 | if !isKnownUniquelyReferenced(&contents) { 19 | contents = Contents() 20 | } 21 | } 22 | var id: ObjectIdentifier { 23 | ObjectIdentifier(contents) 24 | } 25 | } 26 | 27 | @Perceptible 28 | final class CowTest { 29 | var container = CowContainer() 30 | } 31 | -------------------------------------------------------------------------------- /Tests/PerceptionTests/PerceptionTrackingTests.swift: -------------------------------------------------------------------------------- 1 | import Perception 2 | import XCTest 3 | 4 | final class PerceptionTrackingTests: XCTestCase { 5 | func testMutateAccessedField() { 6 | let model = Model() 7 | 8 | let expectation = self.expectation(description: "count1 changed") 9 | withPerceptionTracking { 10 | _ = model.count1 11 | } onChange: { 12 | expectation.fulfill() 13 | } 14 | model.count1 += 1 15 | XCTAssertEqual(model.count1, 1) 16 | XCTAssertEqual(model.count2, 0) 17 | self.wait(for: [expectation], timeout: 0) 18 | } 19 | 20 | func testMutateNonAccessedField() { 21 | let model = Model() 22 | withPerceptionTracking { 23 | _ = model.count1 24 | } onChange: { 25 | XCTFail("count1 should not have changed.") 26 | } 27 | model.count2 += 1 28 | XCTAssertEqual(model.count1, 0) 29 | XCTAssertEqual(model.count2, 1) 30 | } 31 | } 32 | 33 | @Perceptible 34 | private class Model { 35 | var count1 = 0 36 | var count2 = 0 37 | } 38 | -------------------------------------------------------------------------------- /Tests/PerceptionTests/RuntimeWarningTests.swift: -------------------------------------------------------------------------------- 1 | #if DEBUG && canImport(SwiftUI) 2 | import Combine 3 | import Perception 4 | import SwiftUI 5 | import XCTest 6 | 7 | @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) 8 | final class RuntimeWarningTests: XCTestCase { 9 | @MainActor 10 | func testNotInPerceptionBody() { 11 | let model = Model() 12 | model.count += 1 13 | XCTAssertEqual(model.count, 1) 14 | } 15 | 16 | @MainActor 17 | func testInPerceptionBody_NotInSwiftUIBody() { 18 | let model = Model() 19 | _PerceptionLocals.$isInPerceptionTracking.withValue(true) { 20 | _ = model.count 21 | } 22 | } 23 | 24 | @MainActor 25 | func testNotInPerceptionBody_InSwiftUIBody() async throws { 26 | struct FeatureView: View { 27 | let model = Model() 28 | var body: some View { 29 | Text(expectRuntimeWarning { self.model.count }.description) 30 | } 31 | } 32 | try await self.render(FeatureView()) 33 | } 34 | 35 | @MainActor 36 | func testNotInPerceptionBody_InSwiftUIBody_Wrapper() async throws { 37 | struct FeatureView: View { 38 | let model = Model() 39 | var body: some View { 40 | Wrapper { 41 | Text(expectRuntimeWarning { self.model.count }.description) 42 | } 43 | } 44 | } 45 | try await self.render(FeatureView()) 46 | } 47 | 48 | @MainActor 49 | func testInPerceptionBody_InSwiftUIBody_Wrapper() async throws { 50 | struct FeatureView: View { 51 | let model = Model() 52 | var body: some View { 53 | WithPerceptionTracking { 54 | Wrapper { 55 | Text(self.model.count.description) 56 | } 57 | } 58 | } 59 | } 60 | try await self.render(FeatureView()) 61 | } 62 | 63 | @MainActor 64 | func testInPerceptionBody_InSwiftUIBody() async throws { 65 | struct FeatureView: View { 66 | let model = Model() 67 | var body: some View { 68 | WithPerceptionTracking { 69 | Text(self.model.count.description) 70 | } 71 | } 72 | } 73 | try await self.render(FeatureView()) 74 | } 75 | 76 | @MainActor 77 | func testNotInPerceptionBody_SwiftUIBinding() async throws { 78 | struct FeatureView: View { 79 | @State var model = Model() 80 | var body: some View { 81 | Form { 82 | TextField("", text: expectRuntimeWarning { self.$model.text }) 83 | } 84 | } 85 | } 86 | try await self.render(FeatureView()) 87 | } 88 | 89 | @MainActor 90 | func testInPerceptionBody_SwiftUIBinding() async throws { 91 | struct FeatureView: View { 92 | @State var model = Model() 93 | var body: some View { 94 | WithPerceptionTracking { 95 | TextField("", text: self.$model.text) 96 | } 97 | } 98 | } 99 | try await self.render(FeatureView()) 100 | } 101 | 102 | @MainActor 103 | func testNotInPerceptionBody_ForEach() async throws { 104 | struct FeatureView: View { 105 | @State var model = Model( 106 | list: [ 107 | Model(count: 1), 108 | Model(count: 2), 109 | Model(count: 3), 110 | ] 111 | ) 112 | var body: some View { 113 | ForEach(expectRuntimeWarning { model.list }) { model in 114 | Text(expectRuntimeWarning { model.count }.description) 115 | } 116 | } 117 | } 118 | 119 | try await self.render(FeatureView()) 120 | } 121 | 122 | @MainActor 123 | func testInnerInPerceptionBody_ForEach() async throws { 124 | struct FeatureView: View { 125 | @State var model = Model( 126 | list: [ 127 | Model(count: 1), 128 | Model(count: 2), 129 | Model(count: 3), 130 | ] 131 | ) 132 | var body: some View { 133 | ForEach(expectRuntimeWarning { model.list }) { model in 134 | WithPerceptionTracking { 135 | Text(model.count.description) 136 | } 137 | } 138 | } 139 | } 140 | 141 | try await self.render(FeatureView()) 142 | } 143 | 144 | @MainActor 145 | func testOuterInPerceptionBody_ForEach() async throws { 146 | struct FeatureView: View { 147 | @State var model = Model( 148 | list: [ 149 | Model(count: 1), 150 | Model(count: 2), 151 | Model(count: 3), 152 | ] 153 | ) 154 | var body: some View { 155 | WithPerceptionTracking { 156 | ForEach(model.list) { model in 157 | Text(expectRuntimeWarning { model.count }.description) 158 | } 159 | } 160 | } 161 | } 162 | 163 | try await self.render(FeatureView()) 164 | } 165 | 166 | @MainActor 167 | func testOuterAndInnerInPerceptionBody_ForEach() async throws { 168 | struct FeatureView: View { 169 | @State var model = Model( 170 | list: [ 171 | Model(count: 1), 172 | Model(count: 2), 173 | Model(count: 3), 174 | ] 175 | ) 176 | var body: some View { 177 | WithPerceptionTracking { 178 | ForEach(model.list) { model in 179 | WithPerceptionTracking { 180 | Text(model.count.description) 181 | } 182 | } 183 | } 184 | } 185 | } 186 | 187 | try await self.render(FeatureView()) 188 | } 189 | 190 | @MainActor 191 | func testNotInPerceptionBody_Sheet() async throws { 192 | struct FeatureView: View { 193 | @State var model = Model(child: Model()) 194 | var body: some View { 195 | Text("Parent") 196 | .sheet(item: expectRuntimeWarning { $model.child }) { child in 197 | Text(expectRuntimeWarning { child.count }.description) 198 | } 199 | } 200 | } 201 | 202 | try await self.render(FeatureView()) 203 | } 204 | 205 | @MainActor 206 | func testInnerInPerceptionBody_Sheet() async throws { 207 | struct FeatureView: View { 208 | @State var model = Model(child: Model()) 209 | var body: some View { 210 | Text("Parent") 211 | .sheet(item: expectRuntimeWarning { $model.child }) { child in 212 | WithPerceptionTracking { 213 | Text(child.count.description) 214 | } 215 | } 216 | } 217 | } 218 | 219 | try await self.render(FeatureView()) 220 | } 221 | 222 | @MainActor 223 | func testOuterInPerceptionBody_Sheet() async throws { 224 | struct FeatureView: View { 225 | @State var model = Model(child: Model()) 226 | var body: some View { 227 | WithPerceptionTracking { 228 | Text("Parent") 229 | .sheet(item: $model.child) { child in 230 | Text(expectRuntimeWarning { child.count }.description) 231 | } 232 | } 233 | } 234 | } 235 | 236 | try await self.render(FeatureView()) 237 | } 238 | 239 | @MainActor 240 | func testOuterAndInnerInPerceptionBody_Sheet() async throws { 241 | struct FeatureView: View { 242 | @State var model = Model(child: Model()) 243 | var body: some View { 244 | WithPerceptionTracking { 245 | Text("Parent") 246 | .sheet(item: $model.child) { child in 247 | WithPerceptionTracking { 248 | Text(child.count.description) 249 | } 250 | } 251 | } 252 | } 253 | } 254 | 255 | try await self.render(FeatureView()) 256 | } 257 | 258 | @MainActor 259 | func testActionClosure() async throws { 260 | struct FeatureView: View { 261 | @State var model = Model() 262 | var body: some View { 263 | Text("Hi") 264 | .onAppear { _ = self.model.count } 265 | } 266 | } 267 | 268 | try await self.render(FeatureView()) 269 | } 270 | 271 | @MainActor 272 | func testActionClosure_CallMethodWithArguments() async throws { 273 | struct FeatureView: View { 274 | @State var model = Model() 275 | var body: some View { 276 | Text("Hi") 277 | .onAppear { _ = foo(42) } 278 | } 279 | func foo(_: Int) -> Bool { 280 | _ = self.model.count 281 | return true 282 | } 283 | } 284 | 285 | try await self.render(FeatureView()) 286 | } 287 | 288 | @MainActor 289 | func testActionClosure_WithArguments() async throws { 290 | struct FeatureView: View { 291 | @State var model = Model() 292 | var body: some View { 293 | Text("Hi") 294 | .onReceive(Just(1)) { _ in 295 | _ = self.model.count 296 | } 297 | } 298 | } 299 | 300 | try await self.render(FeatureView()) 301 | } 302 | 303 | @MainActor 304 | func testActionClosure_WithArguments_ImplicitClosure() async throws { 305 | struct FeatureView: View { 306 | @State var model = Model() 307 | var body: some View { 308 | Text("Hi") 309 | .onReceive(Just(1), perform: self.foo) 310 | } 311 | func foo(_: Int) { 312 | _ = self.model.count 313 | } 314 | } 315 | 316 | try await self.render(FeatureView()) 317 | } 318 | 319 | @MainActor 320 | func testImplicitActionClosure() async throws { 321 | struct FeatureView: View { 322 | @State var model = Model() 323 | var body: some View { 324 | Text("Hi") 325 | .onAppear(perform: foo) 326 | } 327 | func foo() { 328 | _ = self.model.count 329 | } 330 | } 331 | 332 | try await self.render(FeatureView()) 333 | } 334 | 335 | @MainActor 336 | func testRegistrarDisablePerceptionTracking() async throws { 337 | struct FeatureView: View { 338 | let model = Model() 339 | let registrar = PerceptionRegistrar(isPerceptionCheckingEnabled: false) 340 | var body: some View { 341 | let _ = registrar.access(model, keyPath: \.count) 342 | Text("Hi") 343 | } 344 | } 345 | try await self.render(FeatureView()) 346 | } 347 | 348 | @MainActor 349 | func testGlobalDisablePerceptionTracking() async throws { 350 | let previous = Perception.isPerceptionCheckingEnabled 351 | Perception.isPerceptionCheckingEnabled = false 352 | defer { Perception.isPerceptionCheckingEnabled = previous } 353 | 354 | struct FeatureView: View { 355 | let model = Model() 356 | var body: some View { 357 | Text(model.count.description) 358 | } 359 | } 360 | try await self.render(FeatureView()) 361 | } 362 | 363 | @MainActor 364 | func testParentAccessingChildState_ParentNotObserving_ChildObserving() async throws { 365 | struct ChildView: View { 366 | let model: Model 367 | var body: some View { 368 | WithPerceptionTracking { 369 | Text(model.count.description) 370 | .onAppear { let _ = model.count } 371 | } 372 | } 373 | } 374 | struct FeatureView: View { 375 | let model: Model 376 | let childModel: Model 377 | init() { 378 | self.childModel = Model() 379 | self.model = Model(list: [self.childModel]) 380 | } 381 | var body: some View { 382 | VStack { 383 | ChildView(model: self.childModel) 384 | Text(expectRuntimeWarning { childModel.count }.description) 385 | } 386 | .onAppear { let _ = childModel.count } 387 | } 388 | } 389 | 390 | try await self.render(FeatureView()) 391 | } 392 | 393 | @MainActor 394 | func testParentAccessingChildState_ParentObserving_ChildNotObserving() async throws { 395 | struct ChildView: View { 396 | let model: Model 397 | var body: some View { 398 | Text(expectRuntimeWarning { model.count }.description) 399 | .onAppear { let _ = model.count } 400 | } 401 | } 402 | struct FeatureView: View { 403 | let model: Model 404 | let childModel: Model 405 | init() { 406 | self.childModel = Model() 407 | self.model = Model(list: [self.childModel]) 408 | } 409 | var body: some View { 410 | WithPerceptionTracking { 411 | ChildView(model: self.childModel) 412 | Text(childModel.count.description) 413 | } 414 | .onAppear { let _ = childModel.count } 415 | } 416 | } 417 | 418 | try await self.render(FeatureView()) 419 | } 420 | 421 | @MainActor 422 | func testParentAccessingChildState_ParentNotObserving_ChildNotObserving() async throws { 423 | struct ChildView: View { 424 | let model: Model 425 | var body: some View { 426 | Text(expectRuntimeWarning { model.count }.description) 427 | .onAppear { let _ = model.count } 428 | } 429 | } 430 | struct FeatureView: View { 431 | let model: Model 432 | let childModel: Model 433 | init() { 434 | self.childModel = Model() 435 | self.model = Model(list: [self.childModel]) 436 | } 437 | var body: some View { 438 | VStack { 439 | ChildView(model: self.childModel) 440 | Text(expectRuntimeWarning { childModel.count }.description) 441 | } 442 | .onAppear { let _ = childModel.count } 443 | } 444 | } 445 | 446 | try await self.render(FeatureView()) 447 | } 448 | 449 | @MainActor 450 | func testParentAccessingChildState_ParentObserving_ChildObserving() async throws { 451 | struct ChildView: View { 452 | let model: Model 453 | var body: some View { 454 | WithPerceptionTracking { 455 | Text(model.count.description) 456 | .onAppear { let _ = model.count } 457 | } 458 | } 459 | } 460 | struct FeatureView: View { 461 | let model: Model 462 | let childModel: Model 463 | init() { 464 | self.childModel = Model() 465 | self.model = Model(list: [self.childModel]) 466 | } 467 | var body: some View { 468 | WithPerceptionTracking { 469 | ChildView(model: self.childModel) 470 | Text(childModel.count.description) 471 | } 472 | .onAppear { let _ = childModel.count } 473 | } 474 | } 475 | 476 | try await self.render(FeatureView()) 477 | } 478 | 479 | @MainActor 480 | func testAccessInOnAppearWithAsyncTask() async throws { 481 | @MainActor 482 | struct FeatureView: View { 483 | let model = Model() 484 | var body: some View { 485 | Text("Hi") 486 | .onAppear { 487 | Task { @MainActor in _ = model.count } 488 | } 489 | } 490 | } 491 | try await self.render(FeatureView()) 492 | } 493 | 494 | @MainActor 495 | func testAccessInOnAppearWithAsyncTask_Implicit() async throws { 496 | @MainActor 497 | struct FeatureView: View { 498 | let model = Model() 499 | var body: some View { 500 | Text("Hi") 501 | .onAppear { 502 | Task(operation: self.perform) 503 | } 504 | } 505 | @Sendable 506 | func perform() async throws { 507 | _ = model.count 508 | } 509 | } 510 | try await self.render(FeatureView()) 511 | } 512 | 513 | @MainActor 514 | func testAccessInTask() async throws { 515 | @MainActor 516 | struct FeatureView: View { 517 | let model = Model() 518 | var body: some View { 519 | Text("Hi") 520 | .task { @MainActor in 521 | _ = model.count 522 | } 523 | } 524 | } 525 | try await self.render(FeatureView()) 526 | } 527 | 528 | @MainActor 529 | func testGeometryReader_WithoutPerceptionTracking() async throws { 530 | struct FeatureView: View { 531 | let model = Model() 532 | var body: some View { 533 | WithPerceptionTracking { 534 | GeometryReader { _ in 535 | Text(expectRuntimeWarning { self.model.count }.description) 536 | } 537 | } 538 | } 539 | } 540 | try await self.render(FeatureView()) 541 | } 542 | 543 | @MainActor 544 | func testGeometryReader_WithProperPerceptionTracking() async throws { 545 | struct FeatureView: View { 546 | let model = Model() 547 | var body: some View { 548 | GeometryReader { _ in 549 | WithPerceptionTracking { 550 | Text(self.model.count.description) 551 | } 552 | } 553 | } 554 | } 555 | try await self.render(FeatureView()) 556 | } 557 | 558 | @MainActor 559 | func testGeometryReader_ComputedProperty_ImproperPerceptionTracking() async throws { 560 | struct FeatureView: View { 561 | let model = Model() 562 | var body: some View { 563 | WithPerceptionTracking { 564 | content 565 | } 566 | } 567 | var content: some View { 568 | GeometryReader { _ in 569 | Text(expectRuntimeWarning { self.model.count }.description) 570 | } 571 | } 572 | } 573 | try await self.render(FeatureView()) 574 | } 575 | 576 | @MainActor 577 | func testGeometryReader_NestedSuspendingClosure_ImproperPerceptionTracking() async throws { 578 | struct FeatureView: View { 579 | let model = Model() 580 | var body: some View { 581 | GeometryReader { _ in 582 | ZStack {} 583 | .task { @MainActor in _ = model.count } 584 | } 585 | } 586 | } 587 | try await self.render(FeatureView()) 588 | } 589 | 590 | @MainActor 591 | func testGeometryReader_NestedActionClosure_ImproperPerceptionTracking() async throws { 592 | struct FeatureView: View { 593 | let model = Model() 594 | var body: some View { 595 | GeometryReader { _ in 596 | ZStack {} 597 | .onAppear { let _ = model.count } 598 | } 599 | } 600 | } 601 | try await self.render(FeatureView()) 602 | } 603 | 604 | @MainActor 605 | private func render(_ view: some View) async throws { 606 | let image = ImageRenderer(content: view).cgImage 607 | _ = image 608 | try await Task.sleep(for: .seconds(0.1)) 609 | } 610 | } 611 | 612 | private func expectRuntimeWarning(failingBlock: () -> R) -> R { 613 | XCTExpectFailure(failingBlock: failingBlock) { 614 | $0.compactDescription == """ 615 | failed - Perceptible state was accessed but is not being tracked. Track changes to state \ 616 | by wrapping your view in a 'WithPerceptionTracking' view. This must also be done for any \ 617 | escaping, trailing closures, such as 'GeometryReader', `LazyVStack` (and all lazy \ 618 | views), navigation APIs ('sheet', 'popover', 'fullScreenCover', etc.), and others. 619 | """ 620 | } 621 | } 622 | 623 | @Perceptible 624 | private class Model: Identifiable { 625 | var child: Model? 626 | var count: Int 627 | var list: [Model] 628 | var text: String 629 | 630 | init( 631 | child: Model? = nil, 632 | count: Int = 0, 633 | list: [Model] = [], 634 | text: String = "" 635 | ) { 636 | self.child = child 637 | self.count = count 638 | self.list = list 639 | self.text = text 640 | } 641 | } 642 | 643 | struct Wrapper: View { 644 | @ViewBuilder var content: Content 645 | var body: some View { 646 | self.content 647 | } 648 | } 649 | #endif 650 | -------------------------------------------------------------------------------- /Tests/PerceptionTests/WithPerceptionTrackingDSLTests.swift: -------------------------------------------------------------------------------- 1 | #if canImport(SwiftUI) 2 | import Perception 3 | import SwiftUI 4 | import XCTest 5 | 6 | final class WithPerceptionTrackingDSLTests: XCTestCase { 7 | 8 | @available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) 9 | func testBuildsAccessibilityRotorContent() { 10 | _ = WithPerceptionTracking { 11 | AccessibilityRotorEntry("foo", id: "bar") 12 | } 13 | } 14 | 15 | @available(iOS 14, macOS 11, *) 16 | @available(tvOS, unavailable) 17 | @available(watchOS, unavailable) 18 | func testBuildsCommandContent() { 19 | _ = WithPerceptionTracking { 20 | EmptyCommands() 21 | } 22 | } 23 | 24 | @available(iOS 14, macOS 11, tvOS 14, watchOS 7, *) 25 | func testBuildsCustomizableToolbarContent() { 26 | _ = WithPerceptionTracking { 27 | ToolbarItem { 28 | EmptyView() 29 | } 30 | } 31 | } 32 | 33 | @available(iOS 14, macOS 11, tvOS 14, watchOS 7, *) 34 | func testBuildsScene() { 35 | _ = WithPerceptionTracking { 36 | WindowGroup { 37 | EmptyView() 38 | } 39 | } 40 | } 41 | 42 | @available(iOS 16, macOS 12, *) 43 | @available(tvOS, unavailable) 44 | @available(watchOS, unavailable) 45 | func testBuildsTableColumnContent() { 46 | _ = WithPerceptionTracking { 47 | TableColumn("Foo", value: \IdentifiableMock.id) 48 | } 49 | } 50 | 51 | @available(iOS 16, macOS 12, *) 52 | @available(tvOS, unavailable) 53 | @available(watchOS, unavailable) 54 | func testBuildsTableRowContent() { 55 | _ = WithPerceptionTracking { 56 | TableRow(IdentifiableMock(id: "")) 57 | } 58 | } 59 | 60 | @available(iOS 14, macOS 11, tvOS 14, watchOS 7, *) 61 | func testBuildsToolbarContent() { 62 | _ = WithPerceptionTracking { 63 | ToolbarItemGroup { 64 | EmptyView() 65 | } 66 | } 67 | } 68 | 69 | func testBuildsSwiftUIView() { 70 | _ = WithPerceptionTracking { 71 | EmptyView() 72 | } 73 | } 74 | 75 | func testBuildsCustomDSL() { 76 | _ = WithPerceptionTracking { 77 | CustomDSL() 78 | } 79 | } 80 | 81 | } 82 | 83 | private struct IdentifiableMock: Identifiable { 84 | var id: String 85 | } 86 | 87 | private protocol CustomDSLContent {} 88 | private struct CustomDSL: CustomDSLContent {} 89 | 90 | @resultBuilder 91 | private struct CustomDSLContentBuilder { 92 | /// Builds an expression within the map content builder. 93 | public static func buildBlock(_ content: Content) -> Content where Content: CustomDSLContent { 94 | content 95 | } 96 | } 97 | 98 | extension WithPerceptionTracking: CustomDSLContent where Content: CustomDSLContent { 99 | init(@CustomDSLContentBuilder content: @escaping () -> Content) { 100 | self.init(content: content()) 101 | } 102 | } 103 | 104 | #endif 105 | --------------------------------------------------------------------------------