diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..aebb2b6 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,2 @@ +# Default reviewers for every PR. +* @snailer-team diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..e65630e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,59 @@ +name: Bug report +description: Something in SpaceZ doesn't work as documented +labels: [bug] +body: + - type: textarea + id: what + attributes: + label: What happened? + description: What did you expect, and what happened instead? + validations: + required: true + - type: textarea + id: repro + attributes: + label: Reproduction + description: > + Minimal steps or code. If the bug involves a specific hierarchy shape, + an export from `GET /snapshot.json` (redacted by default) is the most + useful attachment. + placeholder: | + 1. SpaceZDebugger.start() + 2. … + validations: + required: true + - type: input + id: version + attributes: + label: SpaceZ version + placeholder: "1.0.0" + validations: + required: true + - type: input + id: environment + attributes: + label: Environment + description: iOS version, Xcode version, device or simulator + placeholder: "iOS 18.2, Xcode 26.4, iPhone 15 Pro simulator" + validations: + required: true + - type: dropdown + id: area + attributes: + label: Area + options: + - Capture engine / snapshots + - In-app overlay + - Remote inspector (server / web client) + - Rules / diagnostics + - SwiftUI semantic tree + - Performance + - Other + validations: + required: true + - type: textarea + id: logs + attributes: + label: Logs + description: Console output (subsystem `io.spacez`), if relevant. + render: text diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..3ba13e0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1 @@ +blank_issues_enabled: false diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..4555797 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,33 @@ +name: Feature request +description: Propose new functionality for SpaceZ +labels: [enhancement] +body: + - type: textarea + id: problem + attributes: + label: Problem + description: > + What debugging problem can't you solve today? Concrete scenarios + ("QA reported X and I couldn't see Y") carry more weight than + abstract capabilities. + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposed solution + description: > + How would it work? If it's a new diagnostic, consider whether it can be + a custom `UIRule` (registerable today) before proposing core changes. + validations: + required: true + - type: textarea + id: constraints + attributes: + label: Design constraints check + description: > + SpaceZ holds three invariants — how does your proposal interact with + them? (1) main-thread capture budget, (2) redaction-by-default over + transport, (3) zero third-party dependencies. + validations: + required: false diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..71f6542 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,54 @@ + + +## What & Why + + + +## How + + + +## Performance impact (required if you touched the capture path) + + + +## Breaking changes + + + +## How was this tested? + + + +## Checklist + +- [ ] Tests added or updated for the change +- [ ] `swiftlint --strict` passes +- [ ] All tests pass locally (`bash Scripts/ci_test.sh`) +- [ ] Public API has doc comments +- [ ] No new third-party dependencies (SpaceZ ships dependency-free) +- [ ] DEBUG-only guarantees preserved (nothing starts the server or overlay in release builds) +- [ ] Redaction is not weakened (no new path sends raw text/user content off-device by default) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..199555f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,51 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + lint: + name: SwiftLint + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + - name: Install SwiftLint + run: brew install swiftlint + - name: Lint (strict) + run: swiftlint --strict + + test: + name: Build & Test (iOS Simulator) + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + - name: Show toolchain + run: | + xcodebuild -version + swift --version + - name: Run package tests + # Includes the capture performance gate: a 5,000-node capture p95 + # above SPACEZ_CAPTURE_BUDGET_MS fails the suite. + run: bash Scripts/ci_test.sh + env: + SPACEZ_CAPTURE_BUDGET_MS: "50" + + demo-app: + name: Build DemoApp + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + - name: Build example app against the local package + run: | + xcodebuild \ + -project Examples/DemoApp/DemoApp.xcodeproj \ + -scheme DemoApp \ + -destination 'generic/platform=iOS Simulator' \ + CODE_SIGNING_ALLOWED=NO \ + build diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..c515172 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,47 @@ +name: Release + +# SPM distribution is the git tag itself — this workflow re-verifies the tagged +# commit and publishes a GitHub Release with the matching CHANGELOG section. +on: + push: + tags: + - "v*.*.*" + +permissions: + contents: write + +jobs: + verify: + name: Verify tagged commit + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + - name: Run full test suite + run: bash Scripts/ci_test.sh + env: + SPACEZ_CAPTURE_BUDGET_MS: "50" + + release: + name: Publish GitHub Release + needs: verify + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Extract changelog section for this version + id: changelog + run: | + VERSION="${GITHUB_REF_NAME#v}" + # Pull the "## [X.Y.Z]" section out of CHANGELOG.md. + awk -v ver="$VERSION" ' + $0 ~ "^## \\[" ver "\\]" { capture = 1; next } + capture && /^## \[/ { exit } + capture { print } + ' CHANGELOG.md > release-notes.md + if [ ! -s release-notes.md ]; then + echo "No CHANGELOG section found for $VERSION" > release-notes.md + fi + - name: Create release + uses: softprops/action-gh-release@v2 + with: + body_path: release-notes.md + generate_release_notes: true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..12b0f2c --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +.DS_Store +.build/ +DerivedData/ +*.xcuserstate +xcuserdata/ +*.xcscmblueprint +*.xccheckout +.swiftpm/ +Package.resolved diff --git a/.swiftlint.yml b/.swiftlint.yml new file mode 100644 index 0000000..5613bfb --- /dev/null +++ b/.swiftlint.yml @@ -0,0 +1,41 @@ +included: + - Sources + - Tests + - Examples/DemoApp/DemoApp + +line_length: + warning: 110 + error: 140 + +identifier_name: + min_length: 2 + max_length: 50 + excluded: [x, y, w, h, id, ws, ok] + +type_body_length: + warning: 400 + +file_length: + warning: 600 + +function_body_length: + warning: 80 + +cyclomatic_complexity: + warning: 15 + +nesting: + type_level: 3 + +disabled_rules: + # Trailing-closure and multiline styles follow the surrounding code. + - opening_brace + - todo + # Trailing commas in multiline literals keep diffs one-line. + - trailing_comma + +opt_in_rules: + - empty_count + - closure_spacing + - contains_over_first_not_nil + - redundant_nil_coalescing diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..48118e1 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,46 @@ +# Changelog + +All notable changes to SpaceZ are documented here. The format follows +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions follow +[Semantic Versioning](https://semver.org). + +## [Unreleased] + +## [1.0.0] - 2026-08-31 + +Initial release. + +### Added + +- **Capture engine** (`SpaceZCore`): main-thread value-copy capture into an + immutable, normalized node store (`NodeID → InspectorNode`, children as + `[NodeID]`), iterative traversal safe for 10,000-deep hierarchies, + session-stable identity IDs via a weak-keyed registry. +- **Live updates**: run-loop-observer change detection with throttling and + trailing-edge capture, fingerprint-based no-op suppression, latest-state-wins + backpressure, subtree diffing to invalidation sets. +- **UIKit adapter** (`SpaceZUIKit`): full `UIView` tree with per-class extras + (labels, buttons, text fields, scroll/stack views), window-coordinate frames, + on-device highlight overlay. +- **SwiftUI semantic tree** (`SpaceZSwiftUI`): best-effort Mirror reflection of + hosted SwiftUI values — `ProfileCard → VStack → Text/Button` style trees with + modifier notes, graceful fallback to the UIKit subtree when reflection fails. +- **Rule engine** (`SpaceZRules`): 6 built-in diagnostics + (invisible-interaction, missing-accessibility-label, deep-hierarchy, + massive-siblings, clipped-child, hidden-subtree) plus a public `UIRule` + extension point. +- **In-app overlay** (`SpaceZOverlay`): floating entry button, tree browser + with search, property inspector, issues list, tap-to-select, all in a + touch-passthrough window excluded from capture. +- **Remote inspector** (`SpaceZRemote`): zero-dependency HTTP + WebSocket + server (Network.framework), token-gated, Bonjour-advertised; embedded + single-file web client with live tree, canvas view, search, property editing + (allowlisted keys only), and issues; invalidation-based partial-fetch + protocol; strict redaction of user content by default. +- **DemoApp** example project with intentionally planted bugs for each + built-in rule. +- CI (lint + tests + perf gate + example build), release workflow, contribution + guide, PR/issue templates. + +[Unreleased]: https://github.com/snailer-team/SpaceZ/compare/v1.0.0...HEAD +[1.0.0]: https://github.com/snailer-team/SpaceZ/releases/tag/v1.0.0 diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..e1c753d --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,58 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity and +orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment: + +- Demonstrating empathy and kindness toward other people +- Being respectful of differing opinions, viewpoints, and experiences +- Giving and gracefully accepting constructive feedback +- Accepting responsibility and apologizing to those affected by our mistakes +- Focusing on what is best for the overall community + +Examples of unacceptable behavior: + +- The use of sexualized language or imagery, and sexual attention or advances +- Trolling, insulting or derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information without their explicit permission +- Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement by opening an +issue or contacting the maintainers. All complaints will be reviewed and +investigated promptly and fairly. + +## Attribution + +This Code of Conduct is adapted from the +[Contributor Covenant](https://www.contributor-covenant.org), version 2.1, +available at +https://www.contributor-covenant.org/version/2/1/code_of_conduct.html. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..6d4e01f --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,133 @@ +# Contributing to SpaceZ + +Thanks for your interest! SpaceZ is a runtime UI debugger, which means +contributions are held to two unusual standards: **the debugger must never +distort the app it observes**, and **UI data must never leak off-device by +accident**. This document explains how to work within those constraints. + +## Development setup + +- Xcode 16 or newer (CI runs the latest stable Xcode on `macos-latest`) +- iOS 16+ simulator +- [SwiftLint](https://github.com/realm/SwiftLint) (`brew install swiftlint`) + +```bash +git clone https://github.com/snailer-team/SpaceZ.git +cd SpaceZ + +# Run the full test suite (auto-picks a simulator): +bash Scripts/ci_test.sh + +# Lint: +swiftlint --strict + +# Try your change live: +open Examples/DemoApp/DemoApp.xcodeproj # run, then open the printed inspector URL +``` + +## Architecture in five sentences + +1. **`SpaceZCore`** captures the UI on the main thread into an immutable, + normalized `Snapshot` (`NodeID → InspectorNode`, children as IDs) and hands + it to a background pipeline (`latest state wins`). +2. **Descriptors** (`SpaceZUIKit`, `SpaceZSwiftUI`, or yours) teach the engine + one framework each; the core never imports UIKit. +3. **`SpaceZRules`** evaluates diagnostics over snapshots, decoupled from + capture. +4. **`SpaceZRemote`** redacts, diffs, and serves snapshots over an + invalidation-based WebSocket protocol plus an embedded web client. +5. **`SpaceZOverlay`** is the on-device inspector UI; **`SpaceZ`** wires it all + together behind `SpaceZDebugger.start()`. + +A full walkthrough lives in [README.md](README.md#architecture). + +## The three invariants + +PRs that violate these will be asked to change, regardless of the feature: + +1. **Main-thread budget.** One capture pass over 5,000 nodes targets + p50 < 2 ms / p95 < 5 ms, hard ceiling 8 ms (release build, device-class + hardware). Only value copying happens on the main thread — diffing, + encoding, rule evaluation, and transport stay off it. If you touch the + capture path, your PR must include before/after numbers from the + `CapturePerformanceTests` output (the PR template shows how). +2. **Redaction by default.** Anything crossing the process boundary goes + through `Redactor` first. New captured properties that may contain user + content must be added to `RedactionPolicy.defaultSensitiveKeys` or redacted + structurally. The on-device overlay may show raw values; the network may not. +3. **Zero dependencies, DEBUG only.** SpaceZ ships with no third-party + dependencies, and nothing may start the server or overlay in a release + build (`SpaceZDebugger.start()` is compiled to a no-op outside DEBUG). + +## Adding support for a new UI framework + +This is the extension point the architecture is built around — no core changes +needed: + +```swift +final class MyFrameworkDescriptor: NodeDescriptor { + func supports(_ object: AnyObject) -> Bool { object is MyComponent } + func capture(_ object: AnyObject) -> CapturedNodeContent { ... } + func children(of object: AnyObject) -> [AnyObject] { ... } +} + +SpaceZDebugger.register(descriptor: MyFrameworkDescriptor()) +``` + +Rules for descriptors: +- `capture` copies primitive values only — never retain the object or return + anything that references it. +- Children must come back in z-order (back to front). +- Synthetic nodes (like the SwiftUI semantic tree) need stable object identity + across captures, or every capture will churn NodeIDs and spam invalidations. + See `SwiftUISemanticElement`'s path-keyed cache for the pattern. + +## Adding a diagnostic rule + +```swift +struct MyRule: UIRule { + let id = "my-rule" + func inspect(node: InspectorNode, context: InspectionContext) -> [Issue] { ... } +} +``` + +- Use `InspectionContext`'s precomputed `depths`/`parents` indexes; a rule must + not walk the whole tree per node (keeps evaluation O(nodes × rules)). +- Report a problem **once**, at the node where it's actionable, not for every + descendant (see `DeepHierarchyRule`). +- Include a `suggestion` when there's a concrete fix. +- Built-ins must have zero false positives on a plain UIKit template app; + noisy rules get reverted. + +## Protocol changes + +The wire protocol (`InspectorMessage.swift` ↔ `inspector.html`) is versioned +by snapshot, not by message schema. When you add a message: +- Add it to both the Swift codec and the web client. +- Unknown message types must remain non-fatal on both sides. +- Add a round-trip test in `InspectorProtocolTests`. + +## Style + +- `swiftlint --strict` must pass; configuration is in `.swiftlint.yml`. +- Comments explain *why*, not *what*. Numbers in comments need a source or a + calculation. +- Public API requires doc comments. + +## Commits & PRs + +- Branch from `main`; one logical change per PR. +- Fill in every section of the PR template — especially **Performance impact** + when relevant. PRs with an empty template are returned unreviewed. +- CI must be green (lint, tests incl. the perf gate, DemoApp build). + +## Releases (maintainers) + +1. Update `CHANGELOG.md` with a `## [X.Y.Z]` section. +2. Tag: `git tag vX.Y.Z && git push --tags`. +3. The release workflow re-runs tests and publishes the GitHub Release. + SemVer: breaking public API → major, features → minor, fixes → patch. + +## Code of Conduct + +This project follows the [Contributor Covenant](CODE_OF_CONDUCT.md). diff --git a/Examples/DemoApp/DemoApp.xcodeproj/project.pbxproj b/Examples/DemoApp/DemoApp.xcodeproj/project.pbxproj new file mode 100644 index 0000000..f787c52 --- /dev/null +++ b/Examples/DemoApp/DemoApp.xcodeproj/project.pbxproj @@ -0,0 +1,260 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + AA0000000000000000000101 /* SpaceZ in Frameworks */ = {isa = PBXBuildFile; productRef = AA0000000000000000000201 /* SpaceZ */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + AA0000000000000000000301 /* DemoApp */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = DemoApp; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFileReference section */ + AA0000000000000000000401 /* DemoApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = DemoApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + AA0000000000000000000501 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + AA0000000000000000000101 /* SpaceZ in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + AA0000000000000000000601 = { + isa = PBXGroup; + children = ( + AA0000000000000000000301 /* DemoApp */, + AA0000000000000000000701 /* Products */, + ); + sourceTree = ""; + }; + AA0000000000000000000701 /* Products */ = { + isa = PBXGroup; + children = ( + AA0000000000000000000401 /* DemoApp.app */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + AA0000000000000000000801 /* DemoApp */ = { + isa = PBXNativeTarget; + buildConfigurationList = AA0000000000000000000901 /* Build configuration list for PBXNativeTarget "DemoApp" */; + buildPhases = ( + AA0000000000000000000A01 /* Sources */, + AA0000000000000000000501 /* Frameworks */, + AA0000000000000000000B01 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + AA0000000000000000000301 /* DemoApp */, + ); + name = DemoApp; + packageProductDependencies = ( + AA0000000000000000000201 /* SpaceZ */, + ); + productName = DemoApp; + productReference = AA0000000000000000000401 /* DemoApp.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + AA0000000000000000000C01 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastUpgradeCheck = 1600; + TargetAttributes = { + AA0000000000000000000801 = { + CreatedOnToolsVersion = 16.0; + }; + }; + }; + buildConfigurationList = AA0000000000000000000D01 /* Build configuration list for PBXProject "DemoApp" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = AA0000000000000000000601; + minimizedProjectReferenceProxies = 1; + packageReferences = ( + AA0000000000000000000E01 /* XCLocalSwiftPackageReference "../.." */, + ); + preferredProjectObjectVersion = 77; + productRefGroup = AA0000000000000000000701 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + AA0000000000000000000801 /* DemoApp */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + AA0000000000000000000B01 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + AA0000000000000000000A01 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + AA0000000000000000000F01 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_TESTABILITY = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + AA0000000000000000000F02 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + AA0000000000000000001001 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = io.spacez.DemoApp; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + AA0000000000000000001002 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = io.spacez.DemoApp; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + AA0000000000000000000D01 /* Build configuration list for PBXProject "DemoApp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + AA0000000000000000000F01 /* Debug */, + AA0000000000000000000F02 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + AA0000000000000000000901 /* Build configuration list for PBXNativeTarget "DemoApp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + AA0000000000000000001001 /* Debug */, + AA0000000000000000001002 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + AA0000000000000000000E01 /* XCLocalSwiftPackageReference "../.." */ = { + isa = XCLocalSwiftPackageReference; + relativePath = "../.."; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + AA0000000000000000000201 /* SpaceZ */ = { + isa = XCSwiftPackageProductDependency; + productName = SpaceZ; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = AA0000000000000000000C01 /* Project object */; +} diff --git a/Examples/DemoApp/DemoApp/AppDelegate.swift b/Examples/DemoApp/DemoApp/AppDelegate.swift new file mode 100644 index 0000000..66a8e7a --- /dev/null +++ b/Examples/DemoApp/DemoApp/AppDelegate.swift @@ -0,0 +1,65 @@ +import SpaceZ +import UIKit + +@main +final class AppDelegate: UIResponder, UIApplicationDelegate { + func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + // The entire integration: one call in a debug build. + #if DEBUG + SpaceZDebugger.start() + #endif + return true + } + + func application( + _ application: UIApplication, + configurationForConnecting connectingSceneSession: UISceneSession, + options: UIScene.ConnectionOptions + ) -> UISceneConfiguration { + let configuration = UISceneConfiguration( + name: "Default", sessionRole: connectingSceneSession.role + ) + configuration.delegateClass = SceneDelegate.self + return configuration + } +} + +final class SceneDelegate: UIResponder, UIWindowSceneDelegate { + var window: UIWindow? + + func scene( + _ scene: UIScene, + willConnectTo session: UISceneSession, + options connectionOptions: UIScene.ConnectionOptions + ) { + guard let windowScene = scene as? UIWindowScene else { return } + let window = UIWindow(windowScene: windowScene) + + let tabs = UITabBarController() + let checkout = UINavigationController(rootViewController: CheckoutViewController()) + checkout.tabBarItem = UITabBarItem( + title: "Checkout", image: UIImage(systemName: "creditcard"), tag: 0 + ) + let swiftUI = UINavigationController(rootViewController: SwiftUIDemoViewController()) + swiftUI.tabBarItem = UITabBarItem( + title: "SwiftUI", image: UIImage(systemName: "swift"), tag: 1 + ) + let stress = UINavigationController(rootViewController: StressViewController()) + stress.tabBarItem = UITabBarItem( + title: "Stress", image: UIImage(systemName: "square.stack.3d.up"), tag: 2 + ) + tabs.viewControllers = [checkout, swiftUI, stress] + // Launch argument for automation: -initialTab 1 opens the SwiftUI tab. + if let index = UserDefaults.standard.string(forKey: "initialTab").flatMap(Int.init), + (0..<3).contains(index) { + tabs.selectedIndex = index + } + + window.rootViewController = tabs + window.makeKeyAndVisible() + self.window = window + } +} diff --git a/Examples/DemoApp/DemoApp/CheckoutViewController.swift b/Examples/DemoApp/DemoApp/CheckoutViewController.swift new file mode 100644 index 0000000..3785ef2 --- /dev/null +++ b/Examples/DemoApp/DemoApp/CheckoutViewController.swift @@ -0,0 +1,73 @@ +import UIKit + +/// The lecture's canonical bug, reproduced on purpose: +/// "결제 버튼이 화면에는 보이는데 눌리지 않습니다." +/// +/// A LoadingOverlay with alpha 0.01 covers the whole screen and swallows every +/// touch. A screenshot looks fine; the SpaceZ issues tab flags it as +/// `invisible-interaction`, and the tree shows the overlay above PayButton. +final class CheckoutViewController: UIViewController { + override func viewDidLoad() { + super.viewDidLoad() + title = "Checkout" + view.backgroundColor = .systemBackground + + let priceLabel = UILabel() + priceLabel.text = "₩1,299,000" + priceLabel.font = .systemFont(ofSize: 34, weight: .bold) + priceLabel.accessibilityIdentifier = "PriceLabel" + + // Bug 2: clipped text — container too small + clipsToBounds. + let badgeContainer = UIView() + badgeContainer.backgroundColor = .systemYellow.withAlphaComponent(0.3) + badgeContainer.clipsToBounds = true + badgeContainer.accessibilityIdentifier = "BadgeContainer" + let badgeLabel = UILabel() + badgeLabel.text = "Limited launch discount — today only!" + badgeLabel.font = .systemFont(ofSize: 14) + badgeContainer.addSubview(badgeLabel) + badgeLabel.frame = CGRect(x: 8, y: 4, width: 280, height: 20) + + let payButton = UIButton(configuration: .filled()) + payButton.configuration?.title = "Pay Now" + payButton.accessibilityIdentifier = "PayButton" + payButton.addAction( + UIAction { [weak self] _ in + let alert = UIAlertController( + title: "Paid!", message: "The button worked.", preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: "OK", style: .default)) + self?.present(alert, animated: true) + }, + for: .touchUpInside + ) + + // Bug 3: icon-only button with no accessibility label. + let helpButton = UIButton(type: .system) + helpButton.setImage(UIImage(systemName: "questionmark.circle"), for: .normal) + + let stack = UIStackView(arrangedSubviews: [priceLabel, badgeContainer, payButton, helpButton]) + stack.axis = .vertical + stack.spacing = 20 + stack.alignment = .fill + stack.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(stack) + NSLayoutConstraint.activate([ + stack.centerYAnchor.constraint(equalTo: view.centerYAnchor), + stack.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 32), + stack.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -32), + badgeContainer.heightAnchor.constraint(equalToConstant: 24), + ]) + + // Bug 1: the invisible touch-eating overlay. It was "temporarily" + // faded to 0.01 instead of removed — the classic. + let loadingOverlay = UIView() + loadingOverlay.accessibilityIdentifier = "LoadingOverlay" + loadingOverlay.backgroundColor = .black + loadingOverlay.alpha = 0.01 + loadingOverlay.isUserInteractionEnabled = true + loadingOverlay.frame = view.bounds + loadingOverlay.autoresizingMask = [.flexibleWidth, .flexibleHeight] + view.addSubview(loadingOverlay) + } +} diff --git a/Examples/DemoApp/DemoApp/StressViewController.swift b/Examples/DemoApp/DemoApp/StressViewController.swift new file mode 100644 index 0000000..a319c7e --- /dev/null +++ b/Examples/DemoApp/DemoApp/StressViewController.swift @@ -0,0 +1,76 @@ +import UIKit + +/// Structural smells for the rule engine and a live-update playground: +/// - a 30-level wrapper chain (`deep-hierarchy`) +/// - 80 direct children in one container (`massive-siblings`) +/// - a hidden subtree with dozens of live views (`hidden-subtree`) +/// - a timer that mutates a view every second, to watch incremental +/// invalidation flow into the web inspector. +final class StressViewController: UIViewController { + private let pulseView = UIView() + private var timer: Timer? + + override func viewDidLoad() { + super.viewDidLoad() + title = "Stress" + view.backgroundColor = .systemBackground + + // Deep wrapper chain. + var current = view! + for depth in 0..<30 { + let wrapper = UIView() + wrapper.accessibilityIdentifier = "Wrapper\(depth)" + wrapper.frame = current.bounds.insetBy(dx: 1, dy: 1) + wrapper.autoresizingMask = [.flexibleWidth, .flexibleHeight] + current.addSubview(wrapper) + current = wrapper + } + + // Sibling flood. + let crowd = UIStackView() + crowd.accessibilityIdentifier = "SiblingFlood" + crowd.axis = .vertical + for index in 0..<80 { + let row = UILabel() + row.text = "row \(index)" + row.font = .monospacedSystemFont(ofSize: 10, weight: .regular) + crowd.addArrangedSubview(row) + } + crowd.frame = CGRect(x: 16, y: 120, width: 120, height: 400) + crowd.clipsToBounds = true + current.addSubview(crowd) + + // Hidden but alive. + let ghost = UIView() + ghost.accessibilityIdentifier = "GhostContainer" + ghost.isHidden = true + for _ in 0..<25 { + ghost.addSubview(UIView()) + } + current.addSubview(ghost) + + // Live mutation source. + pulseView.accessibilityIdentifier = "PulseView" + pulseView.backgroundColor = .systemTeal + pulseView.frame = CGRect(x: 180, y: 160, width: 120, height: 120) + pulseView.layer.cornerRadius = 12 + current.addSubview(pulseView) + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in + MainActor.assumeIsolated { + guard let self else { return } + self.pulseView.alpha = self.pulseView.alpha > 0.5 ? 0.3 : 1.0 + self.pulseView.frame.origin.y = self.pulseView.frame.origin.y > 200 ? 160 : 240 + } + } + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + timer?.invalidate() + timer = nil + } +} diff --git a/Examples/DemoApp/DemoApp/SwiftUIDemoViewController.swift b/Examples/DemoApp/DemoApp/SwiftUIDemoViewController.swift new file mode 100644 index 0000000..0f09563 --- /dev/null +++ b/Examples/DemoApp/DemoApp/SwiftUIDemoViewController.swift @@ -0,0 +1,47 @@ +import SwiftUI +import UIKit + +/// UIKit + SwiftUI mixed screen: the inspector shows the hosting view's real +/// UIView subtree *and* the reflected `SwiftUI:*` semantic tree side by side. +final class SwiftUIDemoViewController: UIViewController { + override func viewDidLoad() { + super.viewDidLoad() + title = "SwiftUI" + view.backgroundColor = .systemBackground + + let hosting = UIHostingController(rootView: ProfileCard()) + addChild(hosting) + hosting.view.frame = view.bounds + hosting.view.autoresizingMask = [.flexibleWidth, .flexibleHeight] + view.addSubview(hosting.view) + hosting.didMove(toParent: self) + } +} + +struct ProfileCard: View { + @State private var isFollowing = false + + var body: some View { + VStack(spacing: 16) { + Image(systemName: "person.crop.circle.fill") + .font(.system(size: 72)) + .foregroundStyle(.tint) + Text("SpaceZ Astronaut") + .font(.title2.bold()) + Text("Debugging UI since 2026") + .font(.subheadline) + .foregroundStyle(.secondary) + HStack(spacing: 12) { + Button(isFollowing ? "Following" : "Follow") { + isFollowing.toggle() + } + .buttonStyle(.borderedProminent) + Button("Message") {} + .buttonStyle(.bordered) + } + } + .padding(24) + .background(RoundedRectangle(cornerRadius: 20).fill(.quaternary.opacity(0.4))) + .padding() + } +} diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..b3b668a --- /dev/null +++ b/Package.swift @@ -0,0 +1,67 @@ +// swift-tools-version: 6.0 +import PackageDescription + +let package = Package( + name: "SpaceZ", + defaultLocalization: "en", + platforms: [ + .iOS(.v16), + ], + products: [ + // Umbrella product: capture + rules + overlay + remote inspector. + .library(name: "SpaceZ", targets: ["SpaceZ"]), + // Core-only product for hosts that ship a custom UI or transport. + .library(name: "SpaceZCore", targets: ["SpaceZCore"]), + ], + targets: [ + .target( + name: "SpaceZCore" + ), + .target( + name: "SpaceZUIKit", + dependencies: ["SpaceZCore"] + ), + .target( + name: "SpaceZSwiftUI", + dependencies: ["SpaceZCore", "SpaceZUIKit"] + ), + .target( + name: "SpaceZRules", + dependencies: ["SpaceZCore"] + ), + .target( + name: "SpaceZRemote", + dependencies: ["SpaceZCore", "SpaceZRules"], + resources: [ + .copy("Resources/inspector.html"), + ] + ), + .target( + name: "SpaceZOverlay", + dependencies: ["SpaceZCore", "SpaceZUIKit", "SpaceZRules"] + ), + .target( + name: "SpaceZ", + dependencies: [ + "SpaceZCore", + "SpaceZUIKit", + "SpaceZSwiftUI", + "SpaceZRules", + "SpaceZRemote", + "SpaceZOverlay", + ] + ), + .testTarget( + name: "SpaceZCoreTests", + dependencies: ["SpaceZCore", "SpaceZUIKit"] + ), + .testTarget( + name: "SpaceZRulesTests", + dependencies: ["SpaceZRules"] + ), + .testTarget( + name: "SpaceZRemoteTests", + dependencies: ["SpaceZRemote"] + ), + ] +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..dffe0ed --- /dev/null +++ b/README.md @@ -0,0 +1,200 @@ +# SpaceZ + +**A Flipper-class UI debugging platform for iOS, in one SPM package.** + +[![CI](https://github.com/snailer-team/SpaceZ/actions/workflows/ci.yml/badge.svg)](https://github.com/snailer-team/SpaceZ/actions/workflows/ci.yml) +![Swift 6](https://img.shields.io/badge/Swift-6-orange) +![iOS 16+](https://img.shields.io/badge/iOS-16%2B-blue) +![License: MIT](https://img.shields.io/badge/License-MIT-green) +![Dependencies: 0](https://img.shields.io/badge/dependencies-0-brightgreen) + +QA reports: *"The Pay button is visible but doesn't respond."* +The screenshot looks fine. `isHidden` is false, `alpha` is 1. On device, taps go nowhere. + +SpaceZ answers the question a screenshot can't: **what objects is this screen +actually made of, and what state is each one in?** + +``` +🔴 invisible-interaction + LoadingOverlay is nearly invisible (alpha=0.01) but still receives + touches over 393×852pt. + → Set isUserInteractionEnabled = false while invisible, or remove + the view instead of fading it out. +``` + +## What you get + +- **In-app inspector** — floating button → live hierarchy tree, property + inspector, search (`PayButton`, `alpha=0`, `accessibilityLabel=nil`), + tap-to-select, on-screen highlight. +- **Remote inspector** — your app serves a web inspector; open a URL in any + browser on the same network. Live tree with incremental updates, 2D frame + canvas, property editing, device↔browser two-way selection. No desktop app + to install — the "developer tool" is one embedded HTML file. +- **Automated diagnostics** — a rule engine that flags invisible + touch-eaters, missing accessibility labels, clipped content, wrapper-view + creep, sibling floods, and hidden-but-alive subtrees. Add your own rules + (deprecated design-system components, experiment misconfigurations, …). +- **SwiftUI semantic tree** — best-effort reflection shows + `ProfileCard → VStack → Text/Button` alongside the real UIKit render tree. +- **Zero dependencies. DEBUG-only by construction.** + +## Quick start + +**Xcode**: File → Add Package Dependencies → `https://github.com/snailer-team/SpaceZ.git` + +Or in `Package.swift`: + +```swift +.package(url: "https://github.com/snailer-team/SpaceZ.git", from: "1.0.0") +``` + +Then one call: + +```swift +#if DEBUG +import SpaceZ + +// In application(_:didFinishLaunchingWithOptions:) or App.init: +SpaceZDebugger.start() +// Console: [SpaceZ] Inspector → http://192.168.0.42:9394/?token=a1b2c3… +#endif +``` + +- Tap the floating **Z** button for the on-device inspector + (long-press = tap-to-select). +- Open the printed URL in a browser on the same Wi-Fi for the remote + inspector. On the simulator, `localhost` works from the same Mac. + +Options: + +```swift +SpaceZDebugger.start(configuration: .overlayOnly) // no network server + +var config = SpaceZConfiguration.default +config.captureThrottle = 0.5 // fewer captures +config.remoteRedaction = .none // ⚠️ raw text over the network +config.writablePropertyKeys = ["alpha"] // shrink the write API +SpaceZDebugger.start(configuration: config) + +SpaceZDebugger.register(rule: MyDeprecatedComponentRule()) // custom diagnostics +SpaceZDebugger.register(descriptor: MyFrameworkDescriptor()) // custom UI framework +``` + +Try it immediately: `open Examples/DemoApp/DemoApp.xcodeproj` — a demo app with +every bug class planted on purpose. + +## Architecture + +The library is the lecture-series design +(`DFS → immutable snapshot → normalized node store → partial fetch/invalidation +→ adapter → diagnostics platform`) implemented end to end: + +``` +┌───────────────────────── DEVICE / APP ─────────────────────────┐ +│ UIKit / SwiftUI / your framework │ +│ │ │ +│ ┌─────▼──────────┐ NodeDescriptor adapters — core never │ +│ │ Descriptors │ imports a UI framework │ +│ └─────┬──────────┘ │ +│ MAIN │ value copy only · p50 < 2ms for 5,000 nodes │ +│ ┌─────▼──────────┐ │ +│ │ CaptureEngine │──► immutable Snapshot (NodeID → node, │ +│ └─────┬──────────┘ children as [NodeID]) │ +│ ═══════╪═════════════ thread boundary ═══════════════════════ │ +│ BACKGROUND │ +│ ┌─────▼──────────┐ ┌────────────┐ ┌──────────────┐ │ +│ │ Pipeline │─►│ Diff │ │ Rule Engine │ │ +│ │ (latest wins) │ │ →invalidate│ │ →issues │ │ +│ └─────┬──────────┘ └─────┬──────┘ └──────┬───────┘ │ +│ │ ┌─────▼───────────────▼───────┐ │ +│ ┌─────▼─────────┐ │ Redactor → WebSocket server │ │ +│ │ Overlay (UI) │ │ (HTTP serves web client) │ │ +│ └───────────────┘ └─────────────┬───────────────┘ │ +└────────────────────────────────────┼───────────────────────────┘ + │ getRoot / getNodes(ids) / + │ invalidate / search / + │ highlight / setProperty + ┌──────────▼──────────┐ + │ Browser inspector │ + │ tree·canvas·props │ + └─────────────────────┘ +``` + +### The five design decisions that matter + +1. **Main thread copies values; everything else runs behind it.** UIKit is + main-thread-only (correctness), and the debugger must not distort what it + measures (performance). Budget: p50 < 2 ms / p95 < 5 ms / hard 8 ms per + 5,000-node capture. Measured on CI every run; captures that exceed the + budget log a warning at runtime. +2. **Normalized node store, not a nested tree.** `nodes[id]` is O(1); + children are `[NodeID]`. That's what makes partial fetch, search indexes, + and subtree invalidation cheap — the same shape Flipper's protocol used. +3. **Identity IDs + invalidation, not tree diffing.** A full snapshot is + ~1.5 MB (5,000 × 300 B); at 10 Hz that's 15 MB/s. An invalidation is tens of + bytes, and the client refetches only what it shows. Stable NodeIDs + (object-identity-keyed, weak) turn "diff two trees" into "which IDs changed" + — a cache-invalidation problem, solved in O(N) dictionary walks. +4. **Latest state wins.** If the UI mutates faster than a consumer drains, + intermediate snapshots are dropped (`bufferingNewest(1)`). A debugger wants + *now*, not a replay. (A future recording mode is a different consistency + policy — see roadmap.) +5. **Adapters own framework knowledge.** Supporting a new UI framework is one + `NodeDescriptor` conformance, registered at runtime. The SwiftUI semantic + tree is "just another descriptor" — proof the abstraction holds. + +### Performance, honestly + +Measured by `CapturePerformanceTests` (5,551-node synthetic tree, iPhone 15 Pro +simulator, **debug build -Onone**): capture p50 ≈ 10.5 ms, p95 ≈ 12 ms. At the +default 250 ms throttle that is ~4% of main-thread time while debugging. +Release-optimized builds are substantially faster; the 2/5/8 ms budget targets +release-config numbers, and CI fails any PR whose p95 crosses 50 ms on shared +runners. Capture-path PRs must include before/after numbers (see the PR +template). + +## Security model + +A UI inspector is a data-exfiltration surface if you're careless. SpaceZ isn't: + +| Layer | Guarantee | +|---|---| +| Build | `SpaceZDebugger.start()` compiles to a **no-op outside DEBUG** — the server can't ship on by accident | +| Session | Per-launch random 64-bit token required on every HTTP route and as the first WebSocket message | +| Data | **Redaction on by default** for anything leaving the device: text, titles, placeholders, accessibility values → `[REDACTED]`. `accessibilityIdentifier` survives (developer-authored, needed for search) | +| Writes | `setProperty` accepts an explicit allowlist only (`alpha`, `isHidden`, `backgroundColor` by default) | +| On-device | The overlay shows raw values — the developer already sees the screen | + +## SwiftUI support: what "best-effort" means + +The semantic tree comes from `Mirror` reflection over the hosted view values — +no private symbols are linked. App-defined views are expanded through `body` +(outside SwiftUI's render context, `@Environment` yields defaults; structure is +accurate, resolved values may not be). When extraction fails on some OS +release, the hosting view's real UIKit subtree is still complete and the node +carries `swiftUISemantics: unavailable on this OS version`. Breakage on new OS +versions is treated as a normal state with a graceful fallback, not a crash. + +## Requirements + +- iOS 16.0+ +- Swift 6 toolchain (Xcode 16+) + +## Roadmap + +- Snapshot diffing across app versions (UI regression detection) +- Recording mode (every-event consistency, for animation debugging) +- Swizzle-based dirty tracking as opt-in instrumentation +- Accessibility audit rule pack +- Historical snapshots attached to bug reports + +## Contributing + +See [CONTRIBUTING.md](CONTRIBUTING.md) — including the three invariants every +PR must hold (main-thread budget, redaction-by-default, zero dependencies) and +how to add framework adapters and diagnostic rules. + +## License + +[MIT](LICENSE) © SnailerLab diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..01a0495 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,40 @@ +# Security Policy + +SpaceZ is a **debug-build-only** tool, but it opens a LAN port and serializes +UI state, so we treat security reports seriously. + +## Supported versions + +| Version | Supported | +|---|---| +| latest 1.x | ✅ | +| older | ❌ — upgrade to the latest release | + +## Reporting a vulnerability + +Please **do not open a public issue** for security problems. Instead, use +[GitHub private vulnerability reporting](https://github.com/snailer-team/SpaceZ/security/advisories/new) +on this repository. You should receive a response within 7 days. + +Reports we consider in scope: + +- Bypassing the session token on the HTTP or WebSocket endpoints +- Any path that sends unredacted user content off-device while + `RedactionPolicy.strict` (the default) is active +- Escaping the `setProperty` allowlist (arbitrary mutation of a live app) +- Any way `SpaceZDebugger.start()` can activate capture, overlay, or the + server in a non-DEBUG build + +Out of scope: + +- Attacks requiring the attacker to already have the session token URL +- Behavior of builds that explicitly opt out of protections + (`RedactionPolicy.none`, custom allowlists) +- Denial of service against the debug server itself (it is a development tool + on a trusted network; killing it does not affect the host app) + +## Design-level guarantees + +The invariants the codebase maintains — useful context when assessing a +report — are documented in [CONTRIBUTING.md](CONTRIBUTING.md#the-three-invariants) +and the [README security model](README.md#security-model). diff --git a/Scripts/ci_test.sh b/Scripts/ci_test.sh new file mode 100755 index 0000000..08acd2e --- /dev/null +++ b/Scripts/ci_test.sh @@ -0,0 +1,40 @@ +#!/bin/bash +# Runs the package test suite on the first available iPhone simulator. +# Runner images ship different simulator sets, so the device is discovered, +# never hardcoded. +set -euo pipefail + +DEVICE_ID=$(xcrun simctl list devices available --json \ + | python3 -c " +import json, sys +data = json.load(sys.stdin) +for runtime, devices in data['devices'].items(): + for device in devices: + if device['name'].startswith('iPhone'): + print(device['udid']) + sys.exit(0) +sys.exit(1) +") + +echo "Using simulator: $DEVICE_ID" + +# SPACEZ_CAPTURE_BUDGET_MS: perf-test ceiling. 50 ms is an order of magnitude +# above the 5 ms design budget — generous enough for noisy shared runners, +# tight enough that a real main-thread capture regression still fails CI. +# +# The verdict is xcodebuild's own exit code — never scraped from its output, +# which varies across Xcode versions and greps. +set +e +xcodebuild \ + -scheme SpaceZ-Package \ + -destination "platform=iOS Simulator,id=$DEVICE_ID" \ + SPACEZ_CAPTURE_BUDGET_MS="${SPACEZ_CAPTURE_BUDGET_MS:-50}" \ + test > /tmp/xcodebuild-test.log 2>&1 +STATUS=$? +set -e + +grep -E "Test Suite '.*' (started|passed|failed)|Executed .* tests|error:|SpaceZ perf" \ + /tmp/xcodebuild-test.log || true + +echo "xcodebuild exit status: $STATUS" +exit "$STATUS" diff --git a/Sources/SpaceZ/SpaceZDebugger.swift b/Sources/SpaceZ/SpaceZDebugger.swift new file mode 100644 index 0000000..6f255d2 --- /dev/null +++ b/Sources/SpaceZ/SpaceZDebugger.swift @@ -0,0 +1,247 @@ +import Foundation +import OSLog +@_exported import SpaceZCore +@_exported import SpaceZRules +import SpaceZOverlay +import SpaceZRemote +import SpaceZSwiftUI +import SpaceZUIKit +import UIKit + +/// Public entry point. +/// +/// ```swift +/// #if DEBUG +/// import SpaceZ +/// SpaceZDebugger.start() +/// #endif +/// ``` +/// +/// `start()` is a hard no-op in non-DEBUG builds: the capture engine, overlay +/// and — most importantly — the network server never come up in a release +/// binary even if the call ships by accident. A production UI inspector is a +/// data-exfiltration surface, so "off in release" is enforced here, not left +/// to documentation. +@MainActor +public enum SpaceZDebugger { + static let logger = Logger(subsystem: "io.spacez", category: "SpaceZ") + + public private(set) static var session: SpaceZSession? + + /// The URL to open in a browser on the same network, once started. + public static var inspectorURL: URL? { session?.inspectorURL } + + /// Boots capture + overlay + remote server. Safe to call from + /// `application(_:didFinishLaunchingWithOptions:)` or the SwiftUI + /// `App.init` — the overlay attaches itself when the first scene activates. + public static func start(configuration: SpaceZConfiguration = .default) { + #if DEBUG + guard session == nil else { + logger.warning("SpaceZDebugger.start() called twice; ignoring.") + return + } + let session = SpaceZSession(configuration: configuration) + self.session = session + session.start() + #else + // Deliberately empty. See the type documentation. + #endif + } + + public static func stop() { + session?.stop() + session = nil + } + + /// One immediate capture, bypassing the throttle. Also published to the + /// pipeline, so overlay/remote update too. + @discardableResult + public static func captureNow() -> Snapshot? { + session?.captureNow() + } + + /// Signal that something changed outside normal run loop activity. + public static func setNeedsCapture() { + session?.detector.markDirty() + } + + /// Register a custom framework adapter (consulted before built-ins). + public static func register(descriptor: NodeDescriptor) { + session?.engine.descriptors.register(descriptor, prepend: true) + } + + /// Register an additional diagnostic rule. + public static func register(rule: any UIRule) { + session?.ruleEngine.register(rule) + } +} + +/// Wires all modules together for one debug session. +@MainActor +public final class SpaceZSession { + let configuration: SpaceZConfiguration + let engine: CaptureEngine + let detector: ChangeDetector + let pipeline = SnapshotPipeline() + let store = NodeStore() + var ruleEngine = RuleEngine() + let highlight = HighlightController() + + private var overlay: OverlayCoordinator? + private var bridge: RemoteBridge? + private var server: DebugServer? + public private(set) var inspectorURL: URL? + + init(configuration: SpaceZConfiguration) { + self.configuration = configuration + self.engine = CaptureEngine() + self.detector = ChangeDetector(throttle: configuration.captureThrottle) + } + + func start() { + // Descriptor order: SwiftUI first (it claims only hosting views), + // UIKit as the general case, core fallback for everything else. + engine.descriptors.register(SwiftUIHostingDescriptor()) + engine.descriptors.register(UIKitViewDescriptor()) + engine.addRootProvider(UIKitRootProvider()) + + detector.onCaptureNeeded = { [weak self] in + self?.captureAndPublish() + } + + if configuration.overlayEnabled { + let context = OverlayContext( + pipeline: pipeline, + registry: engine.registry, + highlight: highlight, + ruleEngine: ruleEngine, + onSelect: { [weak self] nodeID in + guard let bridge = self?.bridge else { return } + Task { await bridge.pushSelection(nodeID) } + } + ) + let overlay = OverlayCoordinator(context: context) + overlay.show() + self.overlay = overlay + } + + if configuration.remoteEnabled { + startRemote() + } + + detector.start() + detector.markDirty() + } + + func stop() { + detector.stop() + overlay?.hide() + highlight.clear() + let server = self.server + Task { await server?.stop() } + self.server = nil + bridge = nil + } + + @discardableResult + func captureNow() -> Snapshot? { + captureAndPublish() + } + + @discardableResult + private func captureAndPublish() -> Snapshot { + let snapshot = engine.captureSnapshot(maxDepth: configuration.maxDepth) + + if snapshot.captureDuration > configuration.captureBudget { + SpaceZDebugger.logger.warning( + """ + Capture exceeded budget: \(Int(snapshot.captureDuration * 1000))ms \ + for \(snapshot.nodeCount) nodes (budget \ + \(Int(self.configuration.captureBudget * 1000))ms). Consider raising \ + captureThrottle or reporting this with the hierarchy shape. + """ + ) + } + + let bridge = self.bridge + Task.detached(priority: .utility) { [pipeline, store] in + await store.update(snapshot) + await pipeline.publish(snapshot) + await bridge?.ingest(snapshot) + } + return snapshot + } + + private func startRemote() { + let registry = engine.registry + let descriptors = engine.descriptors + let highlight = self.highlight + let writableKeys = configuration.writablePropertyKeys + + let handlers = RemoteUIHandlers( + highlight: { nodeID in + await MainActor.run { + guard let nodeID, + let view = registry.object(for: nodeID) as? UIView, + let window = view.window + else { + highlight.clear() + return + } + let frame = view.convert(view.bounds, to: window) + highlight.highlight( + frame: frame, + caption: "\(String(describing: type(of: view))) \(nodeID)" + ) + } + }, + setProperty: { nodeID, key, value in + await MainActor.run { + guard let object = registry.object(for: nodeID) else { return false } + let descriptor = descriptors.descriptor(for: object) + return descriptor.setProperty(key, to: value, on: object) + } + } + ) + + let bridge = RemoteBridge( + redaction: configuration.remoteRedaction, + writableKeys: writableKeys, + ruleEngine: ruleEngine, + handlers: handlers + ) + self.bridge = bridge + + let server = DebugServer(httpPort: configuration.httpPort, bridge: bridge) + self.server = server + + let host = NetworkInterfaces.primaryIPv4Address() ?? "localhost" + let url = URL(string: "http://\(host):\(configuration.httpPort)/?token=\(server.token)") + let port = configuration.httpPort + + Task { [weak self] in + do { + try await server.start() + // Only advertise the URL once both listeners are actually + // bound — printing it earlier would hand out a dead link when + // the port is taken. + await MainActor.run { self?.inspectorURL = url } + SpaceZDebugger.logger.info( + "Remote inspector ready → \(url?.absoluteString ?? "?", privacy: .public)" + ) + // Also print: the console is where developers will look first. + print("[SpaceZ] Inspector → \(url?.absoluteString ?? "?")") + } catch { + SpaceZDebugger.logger.error( + "Remote inspector failed to start on port \(port): \(error, privacy: .public)" + ) + print( + "[SpaceZ] Remote inspector failed to start on port \(port) " + + "(\(error)). Another app may hold it — set " + + "SpaceZConfiguration.httpPort to a free port. " + + "The in-app overlay still works." + ) + } + } + } +} diff --git a/Sources/SpaceZCore/CaptureEngine.swift b/Sources/SpaceZCore/CaptureEngine.swift new file mode 100644 index 0000000..10e7e68 --- /dev/null +++ b/Sources/SpaceZCore/CaptureEngine.swift @@ -0,0 +1,152 @@ +import Foundation + +/// Walks the live UI and produces an immutable ``Snapshot``. +/// +/// This is the only component that touches UI objects, and it runs entirely on +/// the main actor. Its job ends at "copy primitive values out"; everything +/// else (diff, rules, redaction, encoding, transport) happens off-main on +/// snapshot values. That split is a correctness constraint (UIKit is +/// main-thread-only), not just an optimization. +@MainActor +public final class CaptureEngine { + public let registry: NodeIDRegistry + public let descriptors: DescriptorRegistry + + private var rootProviders: [RootProvider] = [] + private var version: UInt64 = 0 + // Rolling size estimate so the node dictionary doesn't rehash its way up + // from empty on every capture (~5,000 inserts otherwise rehash ~12 times). + private var lastNodeCount = 256 + + public init( + registry: NodeIDRegistry = NodeIDRegistry(), + descriptors: DescriptorRegistry = DescriptorRegistry() + ) { + self.registry = registry + self.descriptors = descriptors + } + + public func addRootProvider(_ provider: RootProvider) { + rootProviders.append(provider) + } + + /// Captures the full hierarchy. Iterative traversal (explicit stack): a + /// pathological 10,000-deep hierarchy must not overflow the real stack — + /// the debugger surviving broken UIs is the whole point. + public func captureSnapshot(maxDepth: Int = 512) -> Snapshot { + let start = ContinuousClock.now + version += 1 + + var nodes: [NodeID: InspectorNode] = [:] + nodes.reserveCapacity(lastNodeCount + lastNodeCount / 4) + var rootIDs: [NodeID] = [] + + for provider in rootProviders { + for root in provider.rootObjects() { + let id = capture(object: root, into: &nodes, maxDepth: maxDepth) + rootIDs.append(id) + } + } + + lastNodeCount = nodes.count + let elapsed = start.duration(to: .now) + return Snapshot( + version: version, + rootIDs: rootIDs, + nodes: nodes, + captureDuration: Double(elapsed.components.seconds) + + Double(elapsed.components.attoseconds) / 1e18 + ) + } + + /// Captures a single subtree rooted at the object behind `id`. + /// Used by the live-update path: after an invalidation only the dirty + /// subtree is re-read, not the whole hierarchy. + public func captureSubtree(of id: NodeID, maxDepth: Int = 512) -> [NodeID: InspectorNode]? { + guard let object = registry.object(for: id) else { return nil } + var nodes: [NodeID: InspectorNode] = [:] + _ = capture(object: object, into: &nodes, maxDepth: maxDepth) + return nodes + } + + private func capture( + object rootObject: AnyObject, + into nodes: inout [NodeID: InspectorNode], + maxDepth: Int + ) -> NodeID { + // Post-order via explicit stack: children must be captured before the + // parent node is finalized, because the parent stores child IDs. + struct Frame { + let object: AnyObject + let id: NodeID + let depth: Int + var pendingChildren: [AnyObject] + var childIDs: [NodeID] = [] + } + + // pendingChildren is kept reversed so the next child comes from + // popLast() (O(1)) instead of removeFirst() (O(n) per sibling). + let rootID = registry.id(for: rootObject) + let rootDescriptor = descriptors.descriptor(for: rootObject) + var stack: [Frame] = [ + Frame( + object: rootObject, + id: rootID, + depth: 0, + pendingChildren: rootDescriptor.children(of: rootObject).reversed() + ) + ] + + while !stack.isEmpty { + if stack[stack.count - 1].pendingChildren.isEmpty { + // All children done → finalize this node. + let frame = stack.removeLast() + let descriptor = descriptors.descriptor(for: frame.object) + let content = descriptor.capture(frame.object) + nodes[frame.id] = InspectorNode( + id: frame.id, + type: content.type, + identifier: content.identifier, + label: content.label, + frame: content.frame, + alpha: content.alpha, + isHidden: content.isHidden, + clipsToBounds: content.clipsToBounds, + isUserInteractionEnabled: content.isUserInteractionEnabled, + accessibilityLabel: content.accessibilityLabel, + children: frame.childIDs, + properties: content.properties + ) + if !stack.isEmpty { + stack[stack.count - 1].childIDs.append(frame.id) + } + } else { + let child = stack[stack.count - 1].pendingChildren.removeLast() + let depth = stack[stack.count - 1].depth + 1 + let childID = registry.id(for: child) + if depth >= maxDepth { + // Truncate instead of recursing forever on cyclic or + // absurdly deep hierarchies. + nodes[childID] = InspectorNode( + id: childID, + type: String(describing: Swift.type(of: child)), + label: "⚠ truncated at depth \(maxDepth)" + ) + stack[stack.count - 1].childIDs.append(childID) + continue + } + let descriptor = descriptors.descriptor(for: child) + stack.append( + Frame( + object: child, + id: childID, + depth: depth, + pendingChildren: descriptor.children(of: child).reversed() + ) + ) + } + } + + return rootID + } +} diff --git a/Sources/SpaceZCore/ChangeDetector.swift b/Sources/SpaceZCore/ChangeDetector.swift new file mode 100644 index 0000000..c83f98b --- /dev/null +++ b/Sources/SpaceZCore/ChangeDetector.swift @@ -0,0 +1,81 @@ +import Foundation + +/// Decides *when* to capture without instrumenting the host framework. +/// +/// Strategy (hybrid, per design decision D3): +/// - A `CFRunLoopObserver` on `.beforeWaiting` fires after every main-thread +/// work item — any UI mutation wakes the run loop, so nothing is missed. +/// - A throttle (default 250 ms) collapses bursts: 50 layout passes inside one +/// scroll animation become at most 4 captures, not 50. +/// - The publisher upstream drops snapshots whose fingerprint didn't change, +/// so an idle-but-awake run loop (timers, network) costs one capture per +/// throttle window at worst, and nothing is sent downstream. +/// +/// Swizzling-based dirty tracking is deliberately not the default: a debug +/// tool that rewrites `addSubview` on every app that links it trades host-app +/// stability for precision we don't need at 250 ms granularity. +@MainActor +public final class ChangeDetector { + public var onCaptureNeeded: (() -> Void)? + + private var observer: CFRunLoopObserver? + private var throttle: TimeInterval + private var lastFire = ContinuousClock.now + private var trailingScheduled = false + + public init(throttle: TimeInterval = 0.25) { + self.throttle = throttle + } + + public func start() { + guard observer == nil else { return } + let observer = CFRunLoopObserverCreateWithHandler( + kCFAllocatorDefault, + CFRunLoopActivity.beforeWaiting.rawValue, + true, + 0 + ) { [weak self] _, _ in + // The observer fires on the main run loop, which is the main actor. + MainActor.assumeIsolated { + self?.runLoopDidTick() + } + } + self.observer = observer + CFRunLoopAddObserver(CFRunLoopGetMain(), observer, .commonModes) + } + + public func stop() { + if let observer { + CFRunLoopRemoveObserver(CFRunLoopGetMain(), observer, .commonModes) + } + observer = nil + } + + /// Manual dirty signal for hosts that want captures outside run loop + /// activity (e.g. after an off-screen render). + public func markDirty() { + runLoopDidTick() + } + + private func runLoopDidTick() { + let now = ContinuousClock.now + let sinceLast = lastFire.duration(to: now) + if sinceLast >= .seconds(throttle) { + lastFire = now + onCaptureNeeded?() + } else if !trailingScheduled { + // Trailing edge: guarantee one capture after the burst settles, + // otherwise the last mutation in a burst would go unseen until + // the next unrelated run loop tick. + trailingScheduled = true + let delay = throttle - Double(sinceLast.components.seconds) + - Double(sinceLast.components.attoseconds) / 1e18 + DispatchQueue.main.asyncAfter(deadline: .now() + max(delay, 0.01)) { [weak self] in + guard let self else { return } + self.trailingScheduled = false + self.lastFire = ContinuousClock.now + self.onCaptureNeeded?() + } + } + } +} diff --git a/Sources/SpaceZCore/Configuration.swift b/Sources/SpaceZCore/Configuration.swift new file mode 100644 index 0000000..268db7b --- /dev/null +++ b/Sources/SpaceZCore/Configuration.swift @@ -0,0 +1,59 @@ +import Foundation + +/// All the knobs, with defaults chosen for "drop into a debug build and go". +public struct SpaceZConfiguration: Sendable { + /// Minimum interval between live captures. 250 ms ≈ 4 captures/s: fast + /// enough to follow interactive debugging, and with a ~2 ms capture cost + /// (5,000 nodes) it stays under 1% of main-thread time. + public var captureThrottle: TimeInterval + + /// Hard traversal depth limit — guards against cyclic descriptors. + public var maxDepth: Int + + /// Show the floating in-app inspector button. + public var overlayEnabled: Bool + + /// Start the HTTP + WebSocket server for the browser inspector. + public var remoteEnabled: Bool + + /// HTTP port serving the web client. WebSocket uses `httpPort + 1`. + public var httpPort: UInt16 + + /// Applied to every snapshot before it leaves the process. + /// The in-app overlay always shows unredacted values. + public var remoteRedaction: RedactionPolicy + + /// Property keys the remote `setProperty` command may mutate. + /// A remote write API into a live app must be an allowlist, never open. + public var writablePropertyKeys: Set + + /// Warn-level budget for one main-thread capture pass. Exceeding it logs + /// a warning with the node count so regressions surface immediately. + public var captureBudget: TimeInterval + + public init( + captureThrottle: TimeInterval = 0.25, + maxDepth: Int = 512, + overlayEnabled: Bool = true, + remoteEnabled: Bool = true, + httpPort: UInt16 = 9394, + remoteRedaction: RedactionPolicy = .strict, + writablePropertyKeys: Set = ["alpha", "isHidden", "backgroundColor"], + captureBudget: TimeInterval = 0.008 + ) { + self.captureThrottle = captureThrottle + self.maxDepth = maxDepth + self.overlayEnabled = overlayEnabled + self.remoteEnabled = remoteEnabled + self.httpPort = httpPort + self.remoteRedaction = remoteRedaction + self.writablePropertyKeys = writablePropertyKeys + self.captureBudget = captureBudget + } + + public static let `default` = SpaceZConfiguration() + + /// Overlay only, no server — for environments where opening a port is + /// unacceptable (e.g. dogfood builds). + public static let overlayOnly = SpaceZConfiguration(remoteEnabled: false) +} diff --git a/Sources/SpaceZCore/InspectorNode.swift b/Sources/SpaceZCore/InspectorNode.swift new file mode 100644 index 0000000..c476446 --- /dev/null +++ b/Sources/SpaceZCore/InspectorNode.swift @@ -0,0 +1,116 @@ +import CoreGraphics +import Foundation + +/// One captured UI object, normalized for the node store. +/// +/// Children are stored as IDs (`children: [NodeID]`), never as nested nodes. +/// This is what makes O(1) lookup, partial fetch, and subtree invalidation +/// possible — the same normalization Flipper's Layout Inspector protocol uses. +public struct InspectorNode: Sendable, Codable, Equatable, Identifiable { + public let id: NodeID + /// Short type name, e.g. `UILabel` or `SwiftUI:VStack`. + public let type: String + /// Developer-authored identifier (`accessibilityIdentifier`). Not user + /// content, so redaction leaves it alone — remote search by identifier + /// ("PayButton") must keep working with strict redaction on. + public let identifier: String? + /// Human hint derived from content (text/title preview). User data — + /// redacted before leaving the device. + public let label: String? + /// Frame converted to window coordinates. + public let frame: CGRect + public let alpha: Double + public let isHidden: Bool + public let clipsToBounds: Bool + public let isUserInteractionEnabled: Bool + public let accessibilityLabel: String? + public let children: [NodeID] + /// Framework-specific extras, keyed by property name. + public let properties: [String: InspectorValue] + + public init( + id: NodeID, + type: String, + identifier: String? = nil, + label: String? = nil, + frame: CGRect = .zero, + alpha: Double = 1, + isHidden: Bool = false, + clipsToBounds: Bool = false, + isUserInteractionEnabled: Bool = false, + accessibilityLabel: String? = nil, + children: [NodeID] = [], + properties: [String: InspectorValue] = [:] + ) { + self.id = id + self.type = type + self.identifier = identifier + self.label = label + self.frame = frame + self.alpha = alpha + self.isHidden = isHidden + self.clipsToBounds = clipsToBounds + self.isUserInteractionEnabled = isUserInteractionEnabled + self.accessibilityLabel = accessibilityLabel + self.children = children + self.properties = properties + } + + /// Cheap content hash used by the change detector to drop no-op captures + /// and by the differ to find changed nodes without deep Equatable walks + /// over the `properties` dictionary ordering. + public var contentFingerprint: UInt64 { + var hasher = FNV1a() + hasher.combine(id.rawValue) + hasher.combine(type) + hasher.combine(identifier ?? "") + hasher.combine(label ?? "") + hasher.combine(frame) + hasher.combine(alpha.bitPattern) + hasher.combine(isHidden ? 1 : 0) + hasher.combine(clipsToBounds ? 1 : 0) + hasher.combine(isUserInteractionEnabled ? 1 : 0) + hasher.combine(accessibilityLabel ?? "") + for child in children { hasher.combine(child.rawValue) } + // Properties participate via sorted keys so dictionary order is stable. + for key in properties.keys.sorted() { + hasher.combine(key) + hasher.combine(properties[key]?.displayString ?? "") + } + return hasher.value + } +} + +/// FNV-1a: deterministic across launches, unlike Swift's seeded `Hasher`. +/// Determinism matters because fingerprints are compared across snapshots +/// and may be logged for debugging. +public struct FNV1a: Sendable { + public private(set) var value: UInt64 = 0xcbf29ce484222325 + + public init() {} + + public mutating func combine(_ int: UInt64) { + for shift in stride(from: 0, to: 64, by: 8) { + value ^= (int >> shift) & 0xFF + value = value &* 0x100000001b3 + } + } + + public mutating func combine(_ int: Int) { + combine(UInt64(bitPattern: Int64(int))) + } + + public mutating func combine(_ string: String) { + for byte in string.utf8 { + value ^= UInt64(byte) + value = value &* 0x100000001b3 + } + } + + public mutating func combine(_ rect: CGRect) { + combine(rect.origin.x.native.bitPattern) + combine(rect.origin.y.native.bitPattern) + combine(rect.size.width.native.bitPattern) + combine(rect.size.height.native.bitPattern) + } +} diff --git a/Sources/SpaceZCore/InspectorValue.swift b/Sources/SpaceZCore/InspectorValue.swift new file mode 100644 index 0000000..bafc5c6 --- /dev/null +++ b/Sources/SpaceZCore/InspectorValue.swift @@ -0,0 +1,107 @@ +import CoreGraphics +import Foundation + +/// A property value captured from a UI object. +/// +/// Values are plain data so a snapshot can cross the main-thread boundary and +/// the process boundary (JSON) without retaining any UI object. +public enum InspectorValue: Sendable, Equatable { + case string(String) + case number(Double) + case bool(Bool) + case rect(CGRect) + case size(CGSize) + case point(CGPoint) + /// CSS-style color, e.g. `#RRGGBBAA`. + case color(String) + /// A value that was removed by the redaction policy before transport. + case redacted + case null + + public var displayString: String { + switch self { + case .string(let value): return value + case .number(let value): + return value == value.rounded() && abs(value) < 1e12 + ? String(Int64(value)) + : String(format: "%.2f", value) + case .bool(let value): return value ? "true" : "false" + case .rect(let rect): + return String( + format: "(%.1f, %.1f, %.1f, %.1f)", + rect.origin.x, rect.origin.y, rect.size.width, rect.size.height + ) + case .size(let size): return String(format: "(%.1f, %.1f)", size.width, size.height) + case .point(let point): return String(format: "(%.1f, %.1f)", point.x, point.y) + case .color(let hex): return hex + case .redacted: return "[REDACTED]" + case .null: return "nil" + } + } +} + +extension InspectorValue: Codable { + private enum Kind: String, Codable { + case string, number, bool, rect, size, point, color, redacted, null + } + + private enum CodingKeys: String, CodingKey { + case kind = "k" + case value = "v" + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + switch try container.decode(Kind.self, forKey: .kind) { + case .string: self = .string(try container.decode(String.self, forKey: .value)) + case .number: self = .number(try container.decode(Double.self, forKey: .value)) + case .bool: self = .bool(try container.decode(Bool.self, forKey: .value)) + case .rect: + let values = try container.decode([Double].self, forKey: .value) + self = .rect(CGRect(x: values[0], y: values[1], width: values[2], height: values[3])) + case .size: + let values = try container.decode([Double].self, forKey: .value) + self = .size(CGSize(width: values[0], height: values[1])) + case .point: + let values = try container.decode([Double].self, forKey: .value) + self = .point(CGPoint(x: values[0], y: values[1])) + case .color: self = .color(try container.decode(String.self, forKey: .value)) + case .redacted: self = .redacted + case .null: self = .null + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case .string(let value): + try container.encode(Kind.string, forKey: .kind) + try container.encode(value, forKey: .value) + case .number(let value): + try container.encode(Kind.number, forKey: .kind) + try container.encode(value, forKey: .value) + case .bool(let value): + try container.encode(Kind.bool, forKey: .kind) + try container.encode(value, forKey: .value) + case .rect(let rect): + try container.encode(Kind.rect, forKey: .kind) + try container.encode( + [rect.origin.x, rect.origin.y, rect.size.width, rect.size.height], + forKey: .value + ) + case .size(let size): + try container.encode(Kind.size, forKey: .kind) + try container.encode([size.width, size.height], forKey: .value) + case .point(let point): + try container.encode(Kind.point, forKey: .kind) + try container.encode([point.x, point.y], forKey: .value) + case .color(let hex): + try container.encode(Kind.color, forKey: .kind) + try container.encode(hex, forKey: .value) + case .redacted: + try container.encode(Kind.redacted, forKey: .kind) + case .null: + try container.encode(Kind.null, forKey: .kind) + } + } +} diff --git a/Sources/SpaceZCore/NodeDescriptor.swift b/Sources/SpaceZCore/NodeDescriptor.swift new file mode 100644 index 0000000..51b3a72 --- /dev/null +++ b/Sources/SpaceZCore/NodeDescriptor.swift @@ -0,0 +1,109 @@ +import CoreGraphics +import Foundation + +/// Everything a descriptor reads from one UI object, as plain values. +public struct CapturedNodeContent: Sendable { + public var type: String + /// Developer-authored identifier — survives redaction. + public var identifier: String? + /// Content-derived preview — redacted before transport. + public var label: String? + public var frame: CGRect + public var alpha: Double + public var isHidden: Bool + public var clipsToBounds: Bool + public var isUserInteractionEnabled: Bool + public var accessibilityLabel: String? + public var properties: [String: InspectorValue] + + public init( + type: String, + identifier: String? = nil, + label: String? = nil, + frame: CGRect = .zero, + alpha: Double = 1, + isHidden: Bool = false, + clipsToBounds: Bool = false, + isUserInteractionEnabled: Bool = false, + accessibilityLabel: String? = nil, + properties: [String: InspectorValue] = [:] + ) { + self.type = type + self.identifier = identifier + self.label = label + self.frame = frame + self.alpha = alpha + self.isHidden = isHidden + self.clipsToBounds = clipsToBounds + self.isUserInteractionEnabled = isUserInteractionEnabled + self.accessibilityLabel = accessibilityLabel + self.properties = properties + } +} + +/// Adapter that teaches the capture engine one UI framework. +/// +/// The engine itself never imports UIKit/SwiftUI — supporting a new framework +/// (or a company-internal one) is implementing this protocol and registering +/// it. Runs on the main actor because UI objects may only be read there. +@MainActor +public protocol NodeDescriptor: AnyObject { + /// First descriptor (in registration order) that returns true wins. + func supports(_ object: AnyObject) -> Bool + /// Read the object's displayable state as plain values. Must not retain + /// `object` or any of its properties beyond the call. + func capture(_ object: AnyObject) -> CapturedNodeContent + /// Child objects in z-order (back to front). + func children(of object: AnyObject) -> [AnyObject] + /// Best-effort mutation for the property-editing API. Only allowlisted + /// keys ever reach this. Return false if the key is unsupported. + func setProperty(_ key: String, to value: InspectorValue, on object: AnyObject) -> Bool +} + +extension NodeDescriptor { + public func setProperty(_ key: String, to value: InspectorValue, on object: AnyObject) -> Bool { + false + } +} + +/// Ordered registry. Custom descriptors are consulted before built-ins so a +/// host app can override how its own component types are presented. +@MainActor +public final class DescriptorRegistry { + private var descriptors: [NodeDescriptor] = [] + private let fallback = FallbackDescriptor() + + public init() {} + + public func register(_ descriptor: NodeDescriptor, prepend: Bool = false) { + if prepend { + descriptors.insert(descriptor, at: 0) + } else { + descriptors.append(descriptor) + } + } + + public func descriptor(for object: AnyObject) -> NodeDescriptor { + descriptors.first { $0.supports(object) } ?? fallback + } +} + +/// Used when no registered descriptor claims an object: shows the type name +/// and stops traversal. Keeps unknown objects visible instead of crashing or +/// silently dropping the subtree. +@MainActor +final class FallbackDescriptor: NodeDescriptor { + func supports(_ object: AnyObject) -> Bool { true } + + func capture(_ object: AnyObject) -> CapturedNodeContent { + CapturedNodeContent(type: String(describing: Swift.type(of: object))) + } + + func children(of object: AnyObject) -> [AnyObject] { [] } +} + +/// Supplies the root objects of a capture (the app's windows on UIKit). +@MainActor +public protocol RootProvider: AnyObject { + func rootObjects() -> [AnyObject] +} diff --git a/Sources/SpaceZCore/NodeID.swift b/Sources/SpaceZCore/NodeID.swift new file mode 100644 index 0000000..336a4cd --- /dev/null +++ b/Sources/SpaceZCore/NodeID.swift @@ -0,0 +1,25 @@ +import Foundation + +/// Stable identifier for a UI object within one debug session. +/// +/// IDs are assigned by ``NodeIDRegistry`` per live object identity, so the same +/// view keeps the same ID across captures. IDs are **not** persistent across +/// process launches. +public struct NodeID: Hashable, Codable, Sendable, CustomStringConvertible { + public let rawValue: UInt64 + + public init(rawValue: UInt64) { + self.rawValue = rawValue + } + + public var description: String { "#\(rawValue)" } + + public init(from decoder: Decoder) throws { + rawValue = try decoder.singleValueContainer().decode(UInt64.self) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(rawValue) + } +} diff --git a/Sources/SpaceZCore/NodeIDRegistry.swift b/Sources/SpaceZCore/NodeIDRegistry.swift new file mode 100644 index 0000000..5a68099 --- /dev/null +++ b/Sources/SpaceZCore/NodeIDRegistry.swift @@ -0,0 +1,51 @@ +import Foundation + +/// Assigns session-stable ``NodeID``s to live object identities. +/// +/// Position-derived IDs ("0.1.3") shift when a sibling is inserted; identity +/// IDs survive any reordering, which is what makes cross-snapshot diffing and +/// subtree invalidation possible. +/// +/// Both maps hold the UI object weakly, so the registry never extends an +/// object's lifetime and entries vanish when views are deallocated. +@MainActor +public final class NodeIDRegistry { + // object → id. Weak keys: entries are purged by NSMapTable after dealloc. + private let idsByObject = NSMapTable( + keyOptions: [.weakMemory, .objectPointerPersonality], + valueOptions: .strongMemory + ) + // id → object. Weak values for the same reason; used by highlight and + // setProperty to get back from a NodeID to the live view. + private let objectsByID = NSMapTable( + keyOptions: .strongMemory, + valueOptions: [.weakMemory, .objectPointerPersonality] + ) + private var nextID: UInt64 = 1 + + public init() {} + + /// Returns the stable ID for `object`, minting one on first sight. + public func id(for object: AnyObject) -> NodeID { + if let existing = idsByObject.object(forKey: object) { + return NodeID(rawValue: existing.uint64Value) + } + let id = NodeID(rawValue: nextID) + nextID += 1 + let boxed = NSNumber(value: id.rawValue) + idsByObject.setObject(boxed, forKey: object) + objectsByID.setObject(object, forKey: boxed) + return id + } + + /// The live object for an ID, or nil if it was deallocated. + public func object(for id: NodeID) -> AnyObject? { + objectsByID.object(forKey: NSNumber(value: id.rawValue)) + } + + public func reset() { + idsByObject.removeAllObjects() + objectsByID.removeAllObjects() + nextID = 1 + } +} diff --git a/Sources/SpaceZCore/NodeStore.swift b/Sources/SpaceZCore/NodeStore.swift new file mode 100644 index 0000000..6eb9b24 --- /dev/null +++ b/Sources/SpaceZCore/NodeStore.swift @@ -0,0 +1,79 @@ +import Foundation + +/// Off-main holder of the current snapshot with query support. +/// +/// The store answers the inspector protocol's read side: node lookup by ID +/// (O(1)), partial fetch, and search — without ever touching a live view. +public actor NodeStore { + public private(set) var current: Snapshot? + + public init() {} + + public func update(_ snapshot: Snapshot) { + current = snapshot + } + + public func node(_ id: NodeID) -> InspectorNode? { + current?.nodes[id] + } + + public func nodes(_ ids: [NodeID]) -> [InspectorNode] { + guard let current else { return [] } + return ids.compactMap { current.nodes[$0] } + } + + public func search(_ query: String) -> [NodeID] { + current?.search(query) ?? [] + } +} + +extension Snapshot { + /// Search syntax: + /// - plain text → case-insensitive match on type, label, accessibilityLabel + /// - `key=value` → attribute match, e.g. `alpha=0`, `accessibilityLabel=nil`, + /// `hidden=true`, or any captured property key. + public func search(_ query: String) -> [NodeID] { + let trimmed = query.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty else { return [] } + + if let separator = trimmed.firstIndex(of: "=") { + let key = String(trimmed[.. Bool { + let lowered = value.lowercased() + switch key.lowercased() { + case "alpha": + return Double(value).map { abs(node.alpha - $0) < 0.001 } ?? false + case "hidden", "ishidden": + return node.isHidden == (lowered == "true") + case "interaction", "isuserinteractionenabled": + return node.isUserInteractionEnabled == (lowered == "true") + case "accessibilitylabel": + if lowered == "nil" { return node.accessibilityLabel == nil } + return node.accessibilityLabel?.lowercased().contains(lowered) == true + case "type": + return node.type.lowercased().contains(lowered) + default: + // "key=nil" matches nodes where the property is absent. + guard let property = node.properties[key] else { return lowered == "nil" } + return property.displayString.lowercased().contains(lowered) + } + } +} diff --git a/Sources/SpaceZCore/Redactor.swift b/Sources/SpaceZCore/Redactor.swift new file mode 100644 index 0000000..eabd581 --- /dev/null +++ b/Sources/SpaceZCore/Redactor.swift @@ -0,0 +1,84 @@ +import Foundation + +/// Strips user content from snapshots before they leave the device. +/// +/// A UI hierarchy carries more PII than it looks: `UILabel.text`, +/// `UITextField.text`, `accessibilityLabel` can hold card numbers, emails, +/// medical text. The on-device overlay shows raw values (the developer already +/// sees the screen); anything crossing the process boundary goes through this +/// first, and remote transport redacts **by default** — opting out is the +/// explicit act. +public struct RedactionPolicy: Sendable { + /// Property keys whose values are replaced with `.redacted`. + public var sensitiveKeys: Set + /// Also blank the free-text node fields (`label`, `accessibilityLabel`). + public var redactsTextualFields: Bool + + public init( + sensitiveKeys: Set = RedactionPolicy.defaultSensitiveKeys, + redactsTextualFields: Bool = true + ) { + self.sensitiveKeys = sensitiveKeys + self.redactsTextualFields = redactsTextualFields + } + + public static let defaultSensitiveKeys: Set = [ + "text", "attributedText", "placeholder", "title", "accessibilityValue", + ] + + /// Redact everything textual. Default for remote transport. + public static let strict = RedactionPolicy() + + /// No redaction. Only reasonable for a developer's own debug build on a + /// trusted network — never make this a library default. + public static let none = RedactionPolicy(sensitiveKeys: [], redactsTextualFields: false) +} + +public enum Redactor { + public static func redact(_ snapshot: Snapshot, policy: RedactionPolicy) -> Snapshot { + guard policy.redactsTextualFields || !policy.sensitiveKeys.isEmpty else { + return snapshot + } + var redactedNodes: [NodeID: InspectorNode] = [:] + redactedNodes.reserveCapacity(snapshot.nodes.count) + for (id, node) in snapshot.nodes { + redactedNodes[id] = redact(node, policy: policy) + } + return Snapshot( + version: snapshot.version, + capturedAt: snapshot.capturedAt, + rootIDs: snapshot.rootIDs, + nodes: redactedNodes, + captureDuration: snapshot.captureDuration + ) + } + + public static func redact(_ node: InspectorNode, policy: RedactionPolicy) -> InspectorNode { + var properties = node.properties + for key in policy.sensitiveKeys where properties[key] != nil { + properties[key] = .redacted + } + return InspectorNode( + id: node.id, + type: node.type, + // `identifier` is developer-authored (accessibilityIdentifier), + // not user content — it survives redaction so remote search by + // identifier keeps working. + identifier: node.identifier, + // `label` embeds content previews, so it falls under textual + // fields. nil-ness is preserved — "has no accessibility label" is + // itself diagnostic data and not sensitive. + label: policy.redactsTextualFields ? node.label.map { _ in "***" } : node.label, + frame: node.frame, + alpha: node.alpha, + isHidden: node.isHidden, + clipsToBounds: node.clipsToBounds, + isUserInteractionEnabled: node.isUserInteractionEnabled, + accessibilityLabel: policy.redactsTextualFields + ? node.accessibilityLabel.map { _ in "***" } + : node.accessibilityLabel, + children: node.children, + properties: properties + ) + } +} diff --git a/Sources/SpaceZCore/Snapshot.swift b/Sources/SpaceZCore/Snapshot.swift new file mode 100644 index 0000000..b5c2f09 --- /dev/null +++ b/Sources/SpaceZCore/Snapshot.swift @@ -0,0 +1,78 @@ +import Foundation + +/// Immutable capture of the whole UI at one instant. +/// +/// This is the value that crosses the main-thread boundary: everything past +/// the capture engine (diff, rules, redaction, encoding, transport) works on +/// `Snapshot` and never touches a live UI object. +public struct Snapshot: Sendable { + /// Monotonically increasing per session. Lets a remote client detect that + /// nodes fetched at different times belong to different UI states. + public let version: UInt64 + public let capturedAt: Date + /// Root nodes — one per window, in back-to-front order. + public let rootIDs: [NodeID] + public let nodes: [NodeID: InspectorNode] + /// Wall-clock cost of the main-thread capture pass, for budget tracking. + public let captureDuration: TimeInterval + + public init( + version: UInt64, + capturedAt: Date = Date(), + rootIDs: [NodeID], + nodes: [NodeID: InspectorNode], + captureDuration: TimeInterval = 0 + ) { + self.version = version + self.capturedAt = capturedAt + self.rootIDs = rootIDs + self.nodes = nodes + self.captureDuration = captureDuration + } + + public var nodeCount: Int { nodes.count } + + public subscript(id: NodeID) -> InspectorNode? { nodes[id] } + + /// Combined fingerprint of every node. Two snapshots with equal + /// fingerprints render identically, so publishing the newer one is a no-op. + public var fingerprint: UInt64 { + // XOR keeps the fold order-independent so we can iterate the dictionary + // without sorting 5,000 keys on every capture. + var combined: UInt64 = 0 + for node in nodes.values { + combined ^= node.contentFingerprint + } + var hasher = FNV1a() + hasher.combine(combined) + for root in rootIDs { hasher.combine(root.rawValue) } + return hasher.value + } + + /// Depth of a node from its root, walking the parent index lazily. + public func depths() -> [NodeID: Int] { + var result: [NodeID: Int] = [:] + result.reserveCapacity(nodes.count) + var stack: [(NodeID, Int)] = rootIDs.map { ($0, 0) } + while let (id, depth) = stack.popLast() { + result[id] = depth + guard let node = nodes[id] else { continue } + for child in node.children { + stack.append((child, depth + 1)) + } + } + return result + } + + /// Parent lookup table built from the children lists. + public func parentIndex() -> [NodeID: NodeID] { + var result: [NodeID: NodeID] = [:] + result.reserveCapacity(nodes.count) + for node in nodes.values { + for child in node.children { + result[child] = node.id + } + } + return result + } +} diff --git a/Sources/SpaceZCore/SnapshotDiff.swift b/Sources/SpaceZCore/SnapshotDiff.swift new file mode 100644 index 0000000..8b37f9d --- /dev/null +++ b/Sources/SpaceZCore/SnapshotDiff.swift @@ -0,0 +1,51 @@ +import Foundation + +/// Which nodes changed between two snapshots. +/// +/// Because NodeIDs are identity-stable, diffing is a dictionary walk — +/// O(N) with a small constant — not a tree-edit-distance computation. The +/// debugger doesn't need the minimal edit script, only "which IDs should the +/// client refetch", which turns the tree-diff problem into cache invalidation. +public struct SnapshotChangeSet: Sendable, Equatable { + public let fromVersion: UInt64 + public let toVersion: UInt64 + public let added: Set + public let removed: Set + public let changed: Set + + public var isEmpty: Bool { added.isEmpty && removed.isEmpty && changed.isEmpty } + + /// All IDs a remote client should invalidate in its cache. + public var invalidatedIDs: Set { + added.union(removed).union(changed) + } +} + +public enum SnapshotDiff { + public static func changes(from old: Snapshot, to new: Snapshot) -> SnapshotChangeSet { + var added: Set = [] + var removed: Set = [] + var changed: Set = [] + + for (id, newNode) in new.nodes { + if let oldNode = old.nodes[id] { + if oldNode.contentFingerprint != newNode.contentFingerprint { + changed.insert(id) + } + } else { + added.insert(id) + } + } + for id in old.nodes.keys where new.nodes[id] == nil { + removed.insert(id) + } + + return SnapshotChangeSet( + fromVersion: old.version, + toVersion: new.version, + added: added, + removed: removed, + changed: changed + ) + } +} diff --git a/Sources/SpaceZCore/SnapshotPipeline.swift b/Sources/SpaceZCore/SnapshotPipeline.swift new file mode 100644 index 0000000..3d03bfa --- /dev/null +++ b/Sources/SpaceZCore/SnapshotPipeline.swift @@ -0,0 +1,73 @@ +import Foundation + +/// Fan-out hub between the capture engine and its consumers (overlay, rules, +/// remote server), with backpressure built in. +/// +/// Policy: **latest state wins**. Consumers subscribe with +/// `AsyncStream(bufferingNewest: 1)` — if a consumer is slower than the UI +/// mutates, intermediate snapshots are dropped and it always resumes at the +/// current state. A debugger wants "what does the UI look like now", not a +/// replay of every frame (recording mode is a different consistency policy, +/// out of scope for v1). +public actor SnapshotPipeline { + private var continuations: [UUID: AsyncStream.Continuation] = [:] + private var latest: Snapshot? + private var lastPublishedFingerprint: UInt64? + + /// Count of snapshots dropped because their fingerprint matched the + /// previous publish. Exported for observability ("is the detector firing + /// without real changes?"). + public private(set) var suppressedNoOpCount = 0 + + public init() {} + + public var latestSnapshot: Snapshot? { latest } + + /// Publishes a snapshot to every subscriber, unless nothing changed. + /// Returns whether the snapshot was actually forwarded. + @discardableResult + public func publish(_ snapshot: Snapshot) -> Bool { + latest = snapshot + let fingerprint = snapshot.fingerprint + if fingerprint == lastPublishedFingerprint { + suppressedNoOpCount += 1 + return false + } + lastPublishedFingerprint = fingerprint + for continuation in continuations.values { + continuation.yield(snapshot) + } + return true + } + + /// Subscribe. The stream immediately yields the latest snapshot (if any), + /// then only meaningful updates, keeping at most the newest one buffered. + public func subscribe() -> AsyncStream { + let id = UUID() + let (stream, continuation) = AsyncStream.makeStream( + of: Snapshot.self, + bufferingPolicy: .bufferingNewest(1) + ) + continuations[id] = continuation + continuation.onTermination = { [weak self] _ in + Task { [weak self] in + await self?.removeSubscriber(id) + } + } + if let latest { + continuation.yield(latest) + } + return stream + } + + private func removeSubscriber(_ id: UUID) { + continuations[id] = nil + } + + public func finish() { + for continuation in continuations.values { + continuation.finish() + } + continuations.removeAll() + } +} diff --git a/Sources/SpaceZOverlay/FloatingButton.swift b/Sources/SpaceZOverlay/FloatingButton.swift new file mode 100644 index 0000000..87e1713 --- /dev/null +++ b/Sources/SpaceZOverlay/FloatingButton.swift @@ -0,0 +1,65 @@ +import UIKit + +/// Draggable round entry point. Tap opens the panel, long-press starts +/// tap-to-select. +@MainActor +final class FloatingButton: UIView { + var onTap: (() -> Void)? + var onLongPress: (() -> Void)? + + private let label = UILabel() + + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = UIColor.black.withAlphaComponent(0.72) + layer.cornerRadius = 24 + layer.shadowColor = UIColor.black.cgColor + layer.shadowOpacity = 0.3 + layer.shadowRadius = 6 + layer.shadowOffset = CGSize(width: 0, height: 2) + + label.text = "Z" + label.textColor = .white + label.font = .monospacedSystemFont(ofSize: 20, weight: .bold) + label.textAlignment = .center + addSubview(label) + + addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(handleTap))) + addGestureRecognizer( + UILongPressGestureRecognizer(target: self, action: #selector(handleLongPress)) + ) + addGestureRecognizer(UIPanGestureRecognizer(target: self, action: #selector(handlePan))) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError("unsupported") } + + override func layoutSubviews() { + super.layoutSubviews() + label.frame = bounds + } + + @objc private func handleTap() { + onTap?() + } + + @objc private func handleLongPress(_ gesture: UILongPressGestureRecognizer) { + guard gesture.state == .began else { return } + onLongPress?() + } + + @objc private func handlePan(_ gesture: UIPanGestureRecognizer) { + guard let superview else { return } + let translation = gesture.translation(in: superview) + center = CGPoint(x: center.x + translation.x, y: center.y + translation.y) + gesture.setTranslation(.zero, in: superview) + if gesture.state == .ended { + // Snap inside safe bounds so the button can't be dragged offscreen. + let bounds = superview.bounds.insetBy(dx: 28, dy: 60) + center = CGPoint( + x: min(max(center.x, bounds.minX), bounds.maxX), + y: min(max(center.y, bounds.minY), bounds.maxY) + ) + } + } +} diff --git a/Sources/SpaceZOverlay/InspectorPanelViewController.swift b/Sources/SpaceZOverlay/InspectorPanelViewController.swift new file mode 100644 index 0000000..fc4f49b --- /dev/null +++ b/Sources/SpaceZOverlay/InspectorPanelViewController.swift @@ -0,0 +1,71 @@ +import SpaceZCore +import UIKit + +/// Sheet container: [Tree | Issues] segmented switcher. +@MainActor +final class InspectorPanelViewController: UIViewController { + var onDismiss: (() -> Void)? + + private let context: OverlayContext + private let segmented = UISegmentedControl(items: ["Tree", "Issues"]) + private lazy var treeController = TreeViewController(context: context) + private lazy var issuesController = IssuesViewController(context: context) + private var currentChild: UIViewController? + + init(context: OverlayContext) { + self.context = context + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError("unsupported") } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .systemBackground + navigationItem.titleView = segmented + navigationItem.rightBarButtonItem = UIBarButtonItem( + barButtonSystemItem: .close, + target: self, + action: #selector(closeTapped) + ) + segmented.selectedSegmentIndex = 0 + segmented.addTarget(self, action: #selector(segmentChanged), for: .valueChanged) + showChild(treeController) + } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + if isBeingDismissed || navigationController?.isBeingDismissed == true { + onDismiss?() + } + } + + func reveal(_ nodeID: NodeID) { + segmented.selectedSegmentIndex = 0 + showChild(treeController) + treeController.reveal(nodeID) + } + + @objc private func segmentChanged() { + showChild(segmented.selectedSegmentIndex == 0 ? treeController : issuesController) + } + + @objc private func closeTapped() { + dismiss(animated: true) + } + + private func showChild(_ child: UIViewController) { + guard currentChild !== child else { return } + currentChild?.willMove(toParent: nil) + currentChild?.view.removeFromSuperview() + currentChild?.removeFromParent() + + addChild(child) + child.view.frame = view.bounds + child.view.autoresizingMask = [.flexibleWidth, .flexibleHeight] + view.addSubview(child.view) + child.didMove(toParent: self) + currentChild = child + } +} diff --git a/Sources/SpaceZOverlay/IssuesViewController.swift b/Sources/SpaceZOverlay/IssuesViewController.swift new file mode 100644 index 0000000..9bd6431 --- /dev/null +++ b/Sources/SpaceZOverlay/IssuesViewController.swift @@ -0,0 +1,100 @@ +import SpaceZCore +import SpaceZRules +import UIKit + +/// Live diagnostics list. Rule evaluation runs off-main on the snapshot; the +/// table only renders results. +@MainActor +final class IssuesViewController: UIViewController, UITableViewDataSource, UITableViewDelegate { + private let context: OverlayContext + private let tableView = UITableView() + private var issues: [Issue] = [] + private var snapshot: Snapshot? + private var subscription: Task? + + init(context: OverlayContext) { + self.context = context + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError("unsupported") } + + deinit { + subscription?.cancel() + } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .systemBackground + tableView.dataSource = self + tableView.delegate = self + tableView.register(UITableViewCell.self, forCellReuseIdentifier: "issue") + tableView.frame = view.bounds + tableView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + view.addSubview(tableView) + + let ruleEngine = context.ruleEngine + subscription = Task { [weak self] in + guard let pipeline = self?.context.pipeline else { return } + for await snapshot in await pipeline.subscribe() { + // Rule evaluation is O(nodes × rules); keep it off the main + // actor so a 5,000-node pass can't stutter the app. + let evaluated = await Task.detached(priority: .utility) { + ruleEngine.evaluate(snapshot) + }.value + guard let self else { return } + self.snapshot = snapshot + self.issues = evaluated + self.tableView.reloadData() + } + } + } + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + max(issues.count, 1) + } + + func tableView( + _ tableView: UITableView, cellForRowAt indexPath: IndexPath + ) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: "issue", for: indexPath) + var configuration = cell.defaultContentConfiguration() + + if issues.isEmpty { + configuration.text = "No issues found" + configuration.textProperties.color = .secondaryLabel + cell.contentConfiguration = configuration + cell.selectionStyle = .none + return cell + } + + let issue = issues[indexPath.row] + let badge: String + switch issue.severity { + case .error: badge = "🔴" + case .warning: badge = "🟠" + case .info: badge = "🔵" + } + configuration.text = "\(badge) \(issue.ruleID)" + configuration.textProperties.font = .monospacedSystemFont(ofSize: 12, weight: .semibold) + configuration.secondaryText = issue.message + configuration.secondaryTextProperties.font = .systemFont(ofSize: 12) + configuration.secondaryTextProperties.numberOfLines = 0 + cell.contentConfiguration = configuration + cell.selectionStyle = .default + return cell + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: false) + guard !issues.isEmpty, let snapshot else { return } + let issue = issues[indexPath.row] + guard let node = snapshot.nodes[issue.nodeID] else { return } + context.highlight.highlight( + frame: node.frame, + caption: "\(node.type) \(node.id)" + ) + context.onSelect(node.id) + } +} diff --git a/Sources/SpaceZOverlay/NodeDetailViewController.swift b/Sources/SpaceZOverlay/NodeDetailViewController.swift new file mode 100644 index 0000000..807a8cf --- /dev/null +++ b/Sources/SpaceZOverlay/NodeDetailViewController.swift @@ -0,0 +1,113 @@ +import SpaceZCore +import SpaceZRules +import UIKit + +/// Property inspector for one node: core attributes, captured properties, and +/// any rule issues attached to it. Shows unredacted values — this screen never +/// leaves the device. +@MainActor +final class NodeDetailViewController: UIViewController, UITableViewDataSource { + private struct Entry { + let key: String + let value: String + } + + private struct Section { + let title: String + let entries: [Entry] + } + + private let node: InspectorNode + private let sections: [Section] + private let tableView = UITableView(frame: .zero, style: .insetGrouped) + + init(node: InspectorNode, snapshot: Snapshot, context: OverlayContext) { + self.node = node + + var attributeEntries: [Entry] = [ + Entry(key: "id", value: node.id.description), + Entry(key: "type", value: node.type), + Entry(key: "identifier", value: node.identifier ?? "nil"), + Entry(key: "frame", value: InspectorValue.rect(node.frame).displayString), + Entry(key: "alpha", value: String(format: "%.2f", node.alpha)), + Entry(key: "hidden", value: node.isHidden ? "true" : "false"), + Entry(key: "clipsToBounds", value: node.clipsToBounds ? "true" : "false"), + Entry( + key: "userInteractionEnabled", + value: node.isUserInteractionEnabled ? "true" : "false" + ), + Entry(key: "accessibilityLabel", value: node.accessibilityLabel ?? "nil"), + Entry(key: "children", value: String(node.children.count)), + ] + if let label = node.label { + attributeEntries.insert(Entry(key: "label", value: label), at: 2) + } + + let propertyEntries = node.properties.keys.sorted().map { key in + Entry(key: key, value: node.properties[key]?.displayString ?? "nil") + } + + // Inspect only this node — no need to evaluate the whole snapshot to + // render one detail screen. + let inspection = InspectionContext(snapshot: snapshot) + let issues = context.ruleEngine.rules.flatMap { + $0.inspect(node: node, context: inspection) + } + let issueEntries = issues.map { issue in + Entry(key: "⚠ \(issue.ruleID)", value: issue.message) + } + + var built: [Section] = [Section(title: "Attributes", entries: attributeEntries)] + if !propertyEntries.isEmpty { + built.append(Section(title: "Properties", entries: propertyEntries)) + } + if !issueEntries.isEmpty { + built.append(Section(title: "Issues", entries: issueEntries)) + } + self.sections = built + + super.init(nibName: nil, bundle: nil) + title = node.type + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError("unsupported") } + + override func viewDidLoad() { + super.viewDidLoad() + tableView.dataSource = self + tableView.register(UITableViewCell.self, forCellReuseIdentifier: "entry") + tableView.frame = view.bounds + tableView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + view.addSubview(tableView) + } + + func numberOfSections(in tableView: UITableView) -> Int { + sections.count + } + + func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { + sections[section].title + } + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + sections[section].entries.count + } + + func tableView( + _ tableView: UITableView, cellForRowAt indexPath: IndexPath + ) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: "entry", for: indexPath) + let entry = sections[indexPath.section].entries[indexPath.row] + var configuration = cell.defaultContentConfiguration() + configuration.text = entry.key + configuration.textProperties.font = .monospacedSystemFont(ofSize: 12, weight: .semibold) + configuration.secondaryText = entry.value + configuration.secondaryTextProperties.font = .monospacedSystemFont( + ofSize: 12, weight: .regular + ) + cell.contentConfiguration = configuration + cell.selectionStyle = .none + return cell + } +} diff --git a/Sources/SpaceZOverlay/OverlayCoordinator.swift b/Sources/SpaceZOverlay/OverlayCoordinator.swift new file mode 100644 index 0000000..5e67271 --- /dev/null +++ b/Sources/SpaceZOverlay/OverlayCoordinator.swift @@ -0,0 +1,171 @@ +import SpaceZCore +import SpaceZRules +import SpaceZUIKit +import UIKit + +/// Everything the overlay needs from the session, injected once. +@MainActor +public struct OverlayContext { + public let pipeline: SnapshotPipeline + public let registry: NodeIDRegistry + public let highlight: HighlightController + public let ruleEngine: RuleEngine + /// Called when the user picks a node on-device (tree tap or select mode), + /// so the session can forward the selection to remote inspectors. + public let onSelect: (NodeID) -> Void + + public init( + pipeline: SnapshotPipeline, + registry: NodeIDRegistry, + highlight: HighlightController, + ruleEngine: RuleEngine, + onSelect: @escaping (NodeID) -> Void + ) { + self.pipeline = pipeline + self.registry = registry + self.highlight = highlight + self.ruleEngine = ruleEngine + self.onSelect = onSelect + } +} + +/// Owns the floating entry button and the inspector panel. +/// +/// Lives in its own window (marked ``SpaceZInternalWindow`` → excluded from +/// capture). The window passes every touch through except over the button or +/// an open panel, so the debugger never steals a tap from the app. +@MainActor +public final class OverlayCoordinator { + private final class PassthroughWindow: UIWindow, SpaceZInternalWindow { + var interactiveWhenPanelOpen = false + + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + guard let hit = super.hitTest(point, with: event) else { return nil } + if interactiveWhenPanelOpen { return hit } + // Only the floating button (and its subviews) are interactive. + return hit.spacezIsInteractiveOverlayElement ? hit : nil + } + } + + private let context: OverlayContext + private var window: PassthroughWindow? + private var button: FloatingButton? + private var selectMode: SelectModeController? + private var sceneObserver: NSObjectProtocol? + + public init(context: OverlayContext) { + self.context = context + } + + public func show() { + if attachToActiveScene() { return } + // App may still be launching; wait for the first scene activation. + sceneObserver = NotificationCenter.default.addObserver( + forName: UIScene.didActivateNotification, + object: nil, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + guard let self, self.window == nil else { return } + if self.attachToActiveScene(), let observer = self.sceneObserver { + NotificationCenter.default.removeObserver(observer) + self.sceneObserver = nil + } + } + } + } + + public func hide() { + window?.isHidden = true + window = nil + button = nil + } + + private func attachToActiveScene() -> Bool { + guard let scene = UIApplication.shared.connectedScenes + .compactMap({ $0 as? UIWindowScene }) + .first(where: { $0.activationState == .foregroundActive }) + else { return false } + + let window = PassthroughWindow(windowScene: scene) + window.windowLevel = .statusBar + 100 + window.backgroundColor = .clear + let root = UIViewController() + root.view.backgroundColor = .clear + window.rootViewController = root + window.isHidden = false + + let button = FloatingButton() + button.onTap = { [weak self] in self?.presentPanel() } + button.onLongPress = { [weak self] in self?.enterSelectMode() } + root.view.addSubview(button) + button.frame = CGRect( + x: root.view.bounds.maxX - 68, + y: root.view.bounds.midY - 100, + width: 48, + height: 48 + ) + + self.window = window + self.button = button + return true + } + + private func presentPanel() { + guard let window, let root = window.rootViewController, + root.presentedViewController == nil else { return } + window.interactiveWhenPanelOpen = true + + let panel = InspectorPanelViewController(context: context) + panel.onDismiss = { [weak self] in + self?.window?.interactiveWhenPanelOpen = false + } + let navigation = UINavigationController(rootViewController: panel) + navigation.modalPresentationStyle = .pageSheet + if let sheet = navigation.sheetPresentationController { + sheet.detents = [.medium(), .large()] + // Keep the app visible above the sheet — inspecting while seeing + // the UI is the point. + sheet.largestUndimmedDetentIdentifier = .medium + } + root.present(navigation, animated: true) + } + + private func enterSelectMode() { + guard let window, selectMode == nil else { return } + window.interactiveWhenPanelOpen = true + let controller = SelectModeController( + window: window, + registry: context.registry, + highlight: context.highlight + ) + controller.onPicked = { [weak self] nodeID in + guard let self else { return } + self.exitSelectMode() + self.context.onSelect(nodeID) + self.presentPanel() + } + controller.onCancelled = { [weak self] in self?.exitSelectMode() } + controller.begin() + selectMode = controller + } + + private func exitSelectMode() { + selectMode?.end() + selectMode = nil + window?.interactiveWhenPanelOpen = false + } +} + +extension UIView { + /// Marks views that should receive touches while the overlay window is in + /// passthrough mode. + @objc var spacezIsInteractiveOverlayElement: Bool { + var current: UIView? = self + while let view = current { + if view is FloatingButton || view is SelectModeView { return true } + current = view.superview + } + return false + } +} diff --git a/Sources/SpaceZOverlay/SelectModeController.swift b/Sources/SpaceZOverlay/SelectModeController.swift new file mode 100644 index 0000000..ea69bea --- /dev/null +++ b/Sources/SpaceZOverlay/SelectModeController.swift @@ -0,0 +1,106 @@ +import SpaceZCore +import SpaceZUIKit +import UIKit + +/// Full-screen transparent layer that captures one tap and resolves it to the +/// deepest inspectable view at that point (device → tool selection). +@MainActor +final class SelectModeView: UIView {} + +@MainActor +final class SelectModeController { + var onPicked: ((NodeID) -> Void)? + var onCancelled: (() -> Void)? + + private weak var window: UIWindow? + private let registry: NodeIDRegistry + private let highlight: HighlightController + private var selectView: SelectModeView? + + init(window: UIWindow, registry: NodeIDRegistry, highlight: HighlightController) { + self.window = window + self.registry = registry + self.highlight = highlight + } + + func begin() { + guard let window, let root = window.rootViewController?.view else { return } + let view = SelectModeView(frame: root.bounds) + view.backgroundColor = UIColor.systemBlue.withAlphaComponent(0.06) + view.addGestureRecognizer( + UITapGestureRecognizer(target: self, action: #selector(handleTap)) + ) + + let hint = UILabel() + hint.text = "Tap a view to inspect · tap here to cancel" + hint.font = .systemFont(ofSize: 13, weight: .semibold) + hint.textColor = .white + hint.backgroundColor = UIColor.black.withAlphaComponent(0.75) + hint.textAlignment = .center + hint.layer.cornerRadius = 8 + hint.layer.masksToBounds = true + hint.frame = CGRect(x: 40, y: root.safeAreaInsets.top + 8, + width: root.bounds.width - 80, height: 32) + hint.autoresizingMask = [.flexibleWidth] + hint.tag = 0xCA7CE1 + view.addSubview(hint) + + root.addSubview(view) + selectView = view + } + + func end() { + selectView?.removeFromSuperview() + selectView = nil + highlight.clear() + } + + @objc private func handleTap(_ gesture: UITapGestureRecognizer) { + guard let selectView, let window else { return } + let point = gesture.location(in: selectView) + + if let hint = selectView.viewWithTag(0xCA7CE1), hint.frame.contains(point) { + onCancelled?() + return + } + + guard let scene = window.windowScene else { + onCancelled?() + return + } + // Hit-test the app's windows front-to-back, skipping our own. + let appWindows = scene.windows + .filter { !($0 is SpaceZInternalWindow) && !$0.isHidden } + .sorted { $0.windowLevel > $1.windowLevel } + + for appWindow in appWindows { + let converted = appWindow.convert(point, from: window) + if let hit = deepestHit(in: appWindow, at: converted) { + let nodeID = registry.id(for: hit) + let frame = hit.convert(hit.bounds, to: appWindow) + highlight.highlight( + frame: frame, + caption: "\(String(describing: type(of: hit))) \(nodeID)" + ) + onPicked?(nodeID) + return + } + } + onCancelled?() + } + + /// `hitTest` skips non-interactive views, which are exactly the ones we + /// often need to inspect — so walk the tree manually, deepest-last-subview + /// first (top of z-order), ignoring only hidden/zero-alpha branches. + private func deepestHit(in view: UIView, at point: CGPoint) -> UIView? { + guard !view.isHidden, view.alpha > 0.01, view.point(inside: point, with: nil) + else { return nil } + for subview in view.subviews.reversed() { + let converted = subview.convert(point, from: view) + if let hit = deepestHit(in: subview, at: converted) { + return hit + } + } + return view + } +} diff --git a/Sources/SpaceZOverlay/TreeViewController.swift b/Sources/SpaceZOverlay/TreeViewController.swift new file mode 100644 index 0000000..4f704ea --- /dev/null +++ b/Sources/SpaceZOverlay/TreeViewController.swift @@ -0,0 +1,237 @@ +import SpaceZCore +import UIKit + +/// The on-device hierarchy browser. +/// +/// Renders the *snapshot*, never live views — the table can safely reload on a +/// background-published update because it only reads immutable values. Rows +/// are the flattened visible portion of the tree (expanded nodes only), so a +/// 5,000-node hierarchy costs table rows proportional to what's open, not N. +@MainActor +final class TreeViewController: UIViewController, UITableViewDataSource, UITableViewDelegate, + UISearchBarDelegate { + private struct Row { + let node: InspectorNode + let depth: Int + let hasChildren: Bool + let isExpanded: Bool + } + + private let context: OverlayContext + private let tableView = UITableView() + private let searchBar = UISearchBar() + + private var snapshot: Snapshot? + private var expanded: Set = [] + private var rows: [Row] = [] + private var selectedID: NodeID? + private var searchResults: [NodeID]? + private var subscription: Task? + + init(context: OverlayContext) { + self.context = context + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError("unsupported") } + + deinit { + subscription?.cancel() + } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .systemBackground + + searchBar.placeholder = "PayButton, alpha=0, accessibilityLabel=nil…" + searchBar.delegate = self + searchBar.autocapitalizationType = .none + searchBar.frame = CGRect(x: 0, y: 0, width: view.bounds.width, height: 52) + searchBar.autoresizingMask = [.flexibleWidth] + + tableView.dataSource = self + tableView.delegate = self + tableView.register(UITableViewCell.self, forCellReuseIdentifier: "node") + tableView.tableHeaderView = searchBar + tableView.frame = view.bounds + tableView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + view.addSubview(tableView) + + subscription = Task { [weak self] in + guard let pipeline = self?.context.pipeline else { return } + for await snapshot in await pipeline.subscribe() { + guard let self else { return } + self.apply(snapshot) + } + } + } + + private func apply(_ snapshot: Snapshot) { + self.snapshot = snapshot + if expanded.isEmpty { + // First snapshot: open the top levels so the tree isn't a bare root. + for rootID in snapshot.rootIDs { + expanded.insert(rootID) + if let root = snapshot.nodes[rootID] { + expanded.formUnion(root.children) + } + } + } + rebuildRows() + } + + private func rebuildRows() { + guard let snapshot else { return } + + if let searchResults { + rows = searchResults.compactMap { id in + snapshot.nodes[id].map { + Row(node: $0, depth: 0, hasChildren: false, isExpanded: false) + } + } + tableView.reloadData() + return + } + + var result: [Row] = [] + // Depth-first over expanded nodes only. + var stack: [(NodeID, Int)] = snapshot.rootIDs.reversed().map { ($0, 0) } + while let (id, depth) = stack.popLast() { + guard let node = snapshot.nodes[id] else { continue } + let isExpanded = expanded.contains(id) + result.append(Row( + node: node, + depth: depth, + hasChildren: !node.children.isEmpty, + isExpanded: isExpanded + )) + if isExpanded { + for child in node.children.reversed() { + stack.append((child, depth + 1)) + } + } + } + rows = result + tableView.reloadData() + } + + func reveal(_ nodeID: NodeID) { + guard let snapshot else { return } + searchResults = nil + searchBar.text = nil + // Expand every ancestor so the node's row exists. + let parents = snapshot.parentIndex() + var current: NodeID? = nodeID + while let id = current { + expanded.insert(id) + current = parents[id] + } + selectedID = nodeID + rebuildRows() + if let index = rows.firstIndex(where: { $0.node.id == nodeID }) { + tableView.scrollToRow( + at: IndexPath(row: index, section: 0), at: .middle, animated: false + ) + } + } + + // MARK: - Table + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + rows.count + } + + func tableView( + _ tableView: UITableView, cellForRowAt indexPath: IndexPath + ) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: "node", for: indexPath) + let row = rows[indexPath.row] + let node = row.node + + var text = "" + if row.hasChildren { + text += row.isExpanded ? "▾ " : "▸ " + } else { + text += " " + } + text += node.type + if let identifier = node.identifier { + text += " [\(identifier)]" + } + if let label = node.label { + text += " \(label)" + } + + var configuration = cell.defaultContentConfiguration() + configuration.text = text + configuration.textProperties.font = .monospacedSystemFont(ofSize: 12, weight: .regular) + configuration.textProperties.color = node.isHidden || node.alpha < 0.05 + ? .tertiaryLabel + : .label + configuration.secondaryText = String( + format: "%@ · (%.0f, %.0f, %.0f×%.0f)", + node.id.description, + node.frame.origin.x, node.frame.origin.y, node.frame.width, node.frame.height + ) + configuration.secondaryTextProperties.font = .monospacedSystemFont( + ofSize: 10, weight: .regular + ) + configuration.secondaryTextProperties.color = .secondaryLabel + cell.contentConfiguration = configuration + + cell.indentationLevel = row.depth + cell.indentationWidth = 12 + cell.accessoryType = .detailButton + cell.backgroundColor = node.id == selectedID + ? UIColor.systemBlue.withAlphaComponent(0.12) + : .clear + return cell + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: false) + let row = rows[indexPath.row] + let node = row.node + + selectedID = node.id + if row.hasChildren, searchResults == nil { + if expanded.contains(node.id) { + expanded.remove(node.id) + } else { + expanded.insert(node.id) + } + } + context.highlight.highlight( + frame: node.frame, + caption: "\(node.type) \(node.id)" + ) + context.onSelect(node.id) + rebuildRows() + } + + func tableView( + _ tableView: UITableView, accessoryButtonTappedForRowWith indexPath: IndexPath + ) { + guard let snapshot else { return } + let node = rows[indexPath.row].node + let detail = NodeDetailViewController(node: node, snapshot: snapshot, context: context) + navigationController?.pushViewController(detail, animated: true) + } + + // MARK: - Search + + func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) { + let query = searchText.trimmingCharacters(in: .whitespaces) + if query.isEmpty { + searchResults = nil + } else { + searchResults = snapshot?.search(query) ?? [] + } + rebuildRows() + } + + func searchBarSearchButtonClicked(_ searchBar: UISearchBar) { + searchBar.resignFirstResponder() + } +} diff --git a/Sources/SpaceZRemote/DebugServer.swift b/Sources/SpaceZRemote/DebugServer.swift new file mode 100644 index 0000000..d768cc6 --- /dev/null +++ b/Sources/SpaceZRemote/DebugServer.swift @@ -0,0 +1,278 @@ +import Foundation +import Network +import SpaceZCore + +/// The device-side endpoint of the remote inspector. +/// +/// Two listeners on adjacent ports, both plain Network.framework (zero +/// third-party dependencies): +/// - `httpPort`: serves the embedded single-file web client and +/// `/snapshot.json` (redacted export). +/// - `httpPort + 1`: WebSocket carrying ``InspectorRequest`` / +/// ``InspectorResponse`` JSON. +/// +/// Security posture (this is a *debug* tool, and it opens a LAN port): +/// - The umbrella target's `SpaceZDebugger.start()` is a no-op outside DEBUG +/// builds, so in a release binary this server is never started. +/// - Every HTTP route and the first WebSocket message require the per-launch +/// random session token. Without the token URL printed to the console, a +/// peer on the network gets 403 and the socket closes. +/// - Snapshots arrive here already redacted by ``RemoteBridge``. +public actor DebugServer { + public nonisolated let httpPort: UInt16 + public nonisolated let token: String + + private let bridge: RemoteBridge + private var httpListener: NWListener? + private var wsListener: NWListener? + private var connections: [ObjectIdentifier: NWConnection] = [:] + private var authenticated: Set = [] + + public init(httpPort: UInt16, bridge: RemoteBridge) { + self.httpPort = httpPort + self.bridge = bridge + // 16 hex chars ≈ 64 bits — unguessable over a LAN session lifetime. + self.token = String(format: "%08x%08x", UInt32.random(in: .min ... .max), + UInt32.random(in: .min ... .max)) + } + + public var webSocketPort: UInt16 { httpPort + 1 } + + public func start() async throws { + guard httpListener == nil else { return } + + await bridge.setBroadcast { [weak self] response in + Task { await self?.broadcastToClients(response) } + } + + let http = try NWListener(using: .tcp, on: NWEndpoint.Port(rawValue: httpPort)!) + http.newConnectionHandler = { [weak self] connection in + Task { await self?.acceptHTTP(connection) } + } + // Bonjour lets tools discover the app without typing an IP. + http.service = NWListener.Service(name: nil, type: "_spacez._tcp") + + let wsParams = NWParameters.tcp + let wsOptions = NWProtocolWebSocket.Options() + wsOptions.autoReplyPing = true + wsParams.defaultProtocolStack.applicationProtocols.insert(wsOptions, at: 0) + let ws = try NWListener(using: wsParams, on: NWEndpoint.Port(rawValue: webSocketPort)!) + ws.newConnectionHandler = { [weak self] connection in + Task { await self?.acceptWebSocket(connection) } + } + + // NWListener reports bind failures (EADDRINUSE etc.) asynchronously + // through its state, not from start(). Without waiting for .ready, + // start() would "succeed" against an occupied port and the caller + // would print an inspector URL that can never work. + do { + try await startAndAwaitReady(http) + httpListener = http + try await startAndAwaitReady(ws) + wsListener = ws + } catch { + http.cancel() + ws.cancel() + httpListener = nil + wsListener = nil + throw error + } + } + + private func startAndAwaitReady(_ listener: NWListener) async throws { + // The state handler keeps firing after readiness; the guard makes the + // continuation one-shot. + let once = OneShot() + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + listener.stateUpdateHandler = { state in + switch state { + case .ready: + if once.claim() { continuation.resume() } + case .failed(let error): + if once.claim() { continuation.resume(throwing: error) } + case .cancelled: + if once.claim() { continuation.resume(throwing: CancellationError()) } + default: + break + } + } + listener.start(queue: .global(qos: .utility)) + } + } + + public func stop() { + httpListener?.cancel() + wsListener?.cancel() + httpListener = nil + wsListener = nil + for connection in connections.values { + connection.cancel() + } + connections.removeAll() + authenticated.removeAll() + } + + // MARK: - HTTP + + private func acceptHTTP(_ connection: NWConnection) { + connection.start(queue: .global(qos: .utility)) + receiveHTTPHead(connection, buffered: Data()) + } + + private nonisolated func receiveHTTPHead(_ connection: NWConnection, buffered: Data) { + connection.receive( + minimumIncompleteLength: 1, + maximumLength: 16 * 1024 + ) { [weak self] data, _, isComplete, error in + guard let self, error == nil, let data, !data.isEmpty else { + connection.cancel() + return + } + let combined = buffered + data + if let request = HTTPRequest.parse(combined) { + Task { await self.respondHTTP(request, on: connection) } + } else if isComplete || combined.count > 64 * 1024 { + connection.cancel() + } else { + self.receiveHTTPHead(connection, buffered: combined) + } + } + } + + private func respondHTTP(_ request: HTTPRequest, on connection: NWConnection) async { + let response: Data + switch (request.method, request.path) { + case ("GET", "/"), ("GET", "/index.html"): + if request.query["token"] != token { + response = HTTPResponse.status( + 403, "Forbidden", + body: "SpaceZ: open the exact URL printed in the app's console " + + "(it includes the session token)." + ) + } else if let page = Self.webClientHTML(wsPort: webSocketPort, token: token) { + response = HTTPResponse.ok(contentType: "text/html; charset=utf-8", body: page) + } else { + response = HTTPResponse.status(500, "Internal Server Error", + body: "inspector.html missing from bundle") + } + case ("GET", "/snapshot.json"): + if request.query["token"] != token { + response = HTTPResponse.status(403, "Forbidden") + } else { + let json = (try? await bridge.currentSnapshotJSON()) ?? Data("{}".utf8) + response = HTTPResponse.ok(contentType: "application/json", body: json) + } + default: + response = HTTPResponse.status(404, "Not Found") + } + connection.send(content: response, completion: .contentProcessed { _ in + connection.cancel() + }) + } + + private static func webClientHTML(wsPort: UInt16, token: String) -> Data? { + guard let url = Bundle.module.url(forResource: "inspector", withExtension: "html"), + var html = try? String(contentsOf: url, encoding: .utf8) + else { return nil } + html = html.replacingOccurrences(of: "__SPACEZ_WS_PORT__", with: String(wsPort)) + html = html.replacingOccurrences(of: "__SPACEZ_TOKEN__", with: token) + return Data(html.utf8) + } + + // MARK: - WebSocket + + private func acceptWebSocket(_ connection: NWConnection) { + let id = ObjectIdentifier(connection) + connections[id] = connection + connection.stateUpdateHandler = { [weak self] state in + if case .failed = state { + Task { await self?.dropConnection(id) } + } else if case .cancelled = state { + Task { await self?.dropConnection(id) } + } + } + connection.start(queue: .global(qos: .utility)) + receiveWSMessage(connection, id: id) + } + + private nonisolated func receiveWSMessage(_ connection: NWConnection, id: ObjectIdentifier) { + connection.receiveMessage { [weak self] data, _, _, error in + guard let self, error == nil, let data else { + connection.cancel() + return + } + Task { + await self.handleWSMessage(data, connection: connection, id: id) + self.receiveWSMessage(connection, id: id) + } + } + } + + private func handleWSMessage( + _ data: Data, connection: NWConnection, id: ObjectIdentifier + ) async { + guard let request = try? InspectorCodec.decodeRequest(data) else { + send(.error(message: "Malformed request"), over: connection) + return + } + + // First message must be a valid hello; everything else before + // authentication kills the connection. + guard authenticated.contains(id) else { + if case .hello(let clientToken) = request, clientToken == token { + authenticated.insert(id) + send(await bridge.handle(request), over: connection) + } else { + send(.error(message: "Unauthorized"), over: connection) + connection.cancel() + } + return + } + + send(await bridge.handle(request), over: connection) + } + + private func broadcastToClients(_ response: InspectorResponse) { + guard !authenticated.isEmpty, let data = try? InspectorCodec.encode(response) else { + return + } + for (id, connection) in connections where authenticated.contains(id) { + sendRaw(data, over: connection) + } + } + + private func send(_ response: InspectorResponse, over connection: NWConnection) { + guard let data = try? InspectorCodec.encode(response) else { return } + sendRaw(data, over: connection) + } + + private nonisolated func sendRaw(_ data: Data, over connection: NWConnection) { + let metadata = NWProtocolWebSocket.Metadata(opcode: .text) + let context = NWConnection.ContentContext(identifier: "spacez", metadata: [metadata]) + connection.send( + content: data, + contentContext: context, + isComplete: true, + completion: .contentProcessed { _ in } + ) + } + + private func dropConnection(_ id: ObjectIdentifier) { + connections[id] = nil + authenticated.remove(id) + } +} + +/// Lock-guarded single-claim flag, usable from any queue. +private final class OneShot: @unchecked Sendable { + private let lock = NSLock() + private var claimed = false + + func claim() -> Bool { + lock.lock() + defer { lock.unlock() } + if claimed { return false } + claimed = true + return true + } +} diff --git a/Sources/SpaceZRemote/HTTPRequest.swift b/Sources/SpaceZRemote/HTTPRequest.swift new file mode 100644 index 0000000..6ce8c41 --- /dev/null +++ b/Sources/SpaceZRemote/HTTPRequest.swift @@ -0,0 +1,57 @@ +import Foundation + +/// Minimal HTTP/1.1 request head parser — just enough to serve the embedded +/// web client and a snapshot export. Not a general web server and must never +/// become one; anything more belongs behind the WebSocket protocol. +struct HTTPRequest { + let method: String + let path: String + let query: [String: String] + + /// Parses the request head. Returns nil until a full head + /// (terminated by CRLFCRLF) is present. + static func parse(_ data: Data) -> HTTPRequest? { + guard let headEnd = data.range(of: Data("\r\n\r\n".utf8)) else { return nil } + guard let head = String(data: data[..= 2 else { return nil } + let method = String(parts[0]) + let target = String(parts[1]) + + let pathAndQuery = target.split(separator: "?", maxSplits: 1) + let path = pathAndQuery.first.map(String.init) ?? "/" + var query: [String: String] = [:] + if pathAndQuery.count == 2 { + for pair in pathAndQuery[1].split(separator: "&") { + let keyValue = pair.split(separator: "=", maxSplits: 1) + guard let key = keyValue.first else { continue } + let value = keyValue.count == 2 ? String(keyValue[1]) : "" + query[String(key)] = value.removingPercentEncoding ?? value + } + } + return HTTPRequest(method: method, path: path, query: query) + } +} + +enum HTTPResponse { + static func ok(contentType: String, body: Data) -> Data { + var response = "HTTP/1.1 200 OK\r\n" + response += "Content-Type: \(contentType)\r\n" + response += "Content-Length: \(body.count)\r\n" + response += "Cache-Control: no-store\r\n" + response += "Connection: close\r\n\r\n" + return Data(response.utf8) + body + } + + static func status(_ code: Int, _ reason: String, body: String = "") -> Data { + let content = Data(body.utf8) + var response = "HTTP/1.1 \(code) \(reason)\r\n" + response += "Content-Type: text/plain; charset=utf-8\r\n" + response += "Content-Length: \(content.count)\r\n" + response += "Connection: close\r\n\r\n" + return Data(response.utf8) + content + } +} diff --git a/Sources/SpaceZRemote/InspectorMessage.swift b/Sources/SpaceZRemote/InspectorMessage.swift new file mode 100644 index 0000000..0cc8825 --- /dev/null +++ b/Sources/SpaceZRemote/InspectorMessage.swift @@ -0,0 +1,237 @@ +import Foundation +import SpaceZCore +import SpaceZRules + +// The wire protocol between the device SDK and the browser inspector. +// +// Transport-agnostic by design: these are plain Codable values, currently +// carried over WebSocket JSON. Swapping the transport (USB, socket file) +// must never require touching inspector logic. +// +// The read side follows the partial-fetch model: `getRoot` + `getNodes(ids)` + +// pushed `invalidate` — never "stream the whole tree on every change" +// (5,000 nodes × 300 B × 10 Hz ≈ 15 MB/s; invalidation is ~tens of bytes). + +// MARK: - Client → Device + +public enum InspectorRequest: Sendable, Equatable { + /// Must be the first message; carries the session token. + case hello(token: String) + case getRoot + case getNodes(ids: [NodeID]) + /// Full tree in one response — initial load and manual refresh. + case getTree + case search(query: String) + case highlight(id: NodeID?) + case setProperty(id: NodeID, key: String, value: InspectorValue) + case getIssues +} + +extension InspectorRequest: Codable { + private enum CodingKeys: String, CodingKey { + case type, token, ids, query, id, key, value + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let type = try container.decode(String.self, forKey: .type) + switch type { + case "hello": + self = .hello(token: try container.decode(String.self, forKey: .token)) + case "getRoot": + self = .getRoot + case "getNodes": + self = .getNodes(ids: try container.decode([NodeID].self, forKey: .ids)) + case "getTree": + self = .getTree + case "search": + self = .search(query: try container.decode(String.self, forKey: .query)) + case "highlight": + self = .highlight(id: try container.decodeIfPresent(NodeID.self, forKey: .id)) + case "setProperty": + self = .setProperty( + id: try container.decode(NodeID.self, forKey: .id), + key: try container.decode(String.self, forKey: .key), + value: try container.decode(InspectorValue.self, forKey: .value) + ) + case "getIssues": + self = .getIssues + default: + throw DecodingError.dataCorruptedError( + forKey: .type, in: container, debugDescription: "Unknown request type '\(type)'" + ) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case .hello(let token): + try container.encode("hello", forKey: .type) + try container.encode(token, forKey: .token) + case .getRoot: + try container.encode("getRoot", forKey: .type) + case .getNodes(let ids): + try container.encode("getNodes", forKey: .type) + try container.encode(ids, forKey: .ids) + case .getTree: + try container.encode("getTree", forKey: .type) + case .search(let query): + try container.encode("search", forKey: .type) + try container.encode(query, forKey: .query) + case .highlight(let id): + try container.encode("highlight", forKey: .type) + try container.encodeIfPresent(id, forKey: .id) + case .setProperty(let id, let key, let value): + try container.encode("setProperty", forKey: .type) + try container.encode(id, forKey: .id) + try container.encode(key, forKey: .key) + try container.encode(value, forKey: .value) + case .getIssues: + try container.encode("getIssues", forKey: .type) + } + } +} + +// MARK: - Device → Client + +public enum InspectorResponse: Sendable { + case helloAck(appName: String, version: UInt64) + case root(version: UInt64, rootIDs: [NodeID], nodeCount: Int) + case nodes(version: UInt64, nodes: [InspectorNode]) + /// Whole tree: roots plus every node. Initial load / refresh only. + case tree(version: UInt64, rootIDs: [NodeID], nodes: [InspectorNode]) + /// The listed IDs changed (added/changed/removed); cached copies are + /// stale. `removed` may be pruned locally without a refetch. + case invalidate(version: UInt64, invalidated: [NodeID], removed: [NodeID], rootIDs: [NodeID]) + case searchResults(version: UInt64, ids: [NodeID]) + case issues(version: UInt64, issues: [Issue]) + /// Device-initiated selection (tap-to-select on the phone). + case select(id: NodeID) + case ack(ok: Bool, detail: String?) + case error(message: String) +} + +extension InspectorResponse: Codable { + private enum CodingKeys: String, CodingKey { + case type, appName, version, rootIds, nodeCount, nodes, invalidated, removed + case ids, issues, id, ok, detail, message + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let type = try container.decode(String.self, forKey: .type) + switch type { + case "helloAck": + self = .helloAck( + appName: try container.decode(String.self, forKey: .appName), + version: try container.decode(UInt64.self, forKey: .version) + ) + case "root": + self = .root( + version: try container.decode(UInt64.self, forKey: .version), + rootIDs: try container.decode([NodeID].self, forKey: .rootIds), + nodeCount: try container.decode(Int.self, forKey: .nodeCount) + ) + case "nodes": + self = .nodes( + version: try container.decode(UInt64.self, forKey: .version), + nodes: try container.decode([InspectorNode].self, forKey: .nodes) + ) + case "tree": + self = .tree( + version: try container.decode(UInt64.self, forKey: .version), + rootIDs: try container.decode([NodeID].self, forKey: .rootIds), + nodes: try container.decode([InspectorNode].self, forKey: .nodes) + ) + case "invalidate": + self = .invalidate( + version: try container.decode(UInt64.self, forKey: .version), + invalidated: try container.decode([NodeID].self, forKey: .invalidated), + removed: try container.decode([NodeID].self, forKey: .removed), + rootIDs: try container.decode([NodeID].self, forKey: .rootIds) + ) + case "searchResults": + self = .searchResults( + version: try container.decode(UInt64.self, forKey: .version), + ids: try container.decode([NodeID].self, forKey: .ids) + ) + case "issues": + self = .issues( + version: try container.decode(UInt64.self, forKey: .version), + issues: try container.decode([Issue].self, forKey: .issues) + ) + case "select": + self = .select(id: try container.decode(NodeID.self, forKey: .id)) + case "ack": + self = .ack( + ok: try container.decode(Bool.self, forKey: .ok), + detail: try container.decodeIfPresent(String.self, forKey: .detail) + ) + case "error": + self = .error(message: try container.decode(String.self, forKey: .message)) + default: + throw DecodingError.dataCorruptedError( + forKey: .type, in: container, debugDescription: "Unknown response type '\(type)'" + ) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case .helloAck(let appName, let version): + try container.encode("helloAck", forKey: .type) + try container.encode(appName, forKey: .appName) + try container.encode(version, forKey: .version) + case .root(let version, let rootIDs, let nodeCount): + try container.encode("root", forKey: .type) + try container.encode(version, forKey: .version) + try container.encode(rootIDs, forKey: .rootIds) + try container.encode(nodeCount, forKey: .nodeCount) + case .nodes(let version, let nodes): + try container.encode("nodes", forKey: .type) + try container.encode(version, forKey: .version) + try container.encode(nodes, forKey: .nodes) + case .tree(let version, let rootIDs, let nodes): + try container.encode("tree", forKey: .type) + try container.encode(version, forKey: .version) + try container.encode(rootIDs, forKey: .rootIds) + try container.encode(nodes, forKey: .nodes) + case .invalidate(let version, let invalidated, let removed, let rootIDs): + try container.encode("invalidate", forKey: .type) + try container.encode(version, forKey: .version) + try container.encode(invalidated, forKey: .invalidated) + try container.encode(removed, forKey: .removed) + try container.encode(rootIDs, forKey: .rootIds) + case .searchResults(let version, let ids): + try container.encode("searchResults", forKey: .type) + try container.encode(version, forKey: .version) + try container.encode(ids, forKey: .ids) + case .issues(let version, let issues): + try container.encode("issues", forKey: .type) + try container.encode(version, forKey: .version) + try container.encode(issues, forKey: .issues) + case .select(let id): + try container.encode("select", forKey: .type) + try container.encode(id, forKey: .id) + case .ack(let ok, let detail): + try container.encode("ack", forKey: .type) + try container.encode(ok, forKey: .ok) + try container.encodeIfPresent(detail, forKey: .detail) + case .error(let message): + try container.encode("error", forKey: .type) + try container.encode(message, forKey: .message) + } + } +} + +public enum InspectorCodec { + public static func encode(_ response: InspectorResponse) throws -> Data { + try JSONEncoder().encode(response) + } + + public static func decodeRequest(_ data: Data) throws -> InspectorRequest { + try JSONDecoder().decode(InspectorRequest.self, from: data) + } +} diff --git a/Sources/SpaceZRemote/NetworkInterfaces.swift b/Sources/SpaceZRemote/NetworkInterfaces.swift new file mode 100644 index 0000000..6f727ab --- /dev/null +++ b/Sources/SpaceZRemote/NetworkInterfaces.swift @@ -0,0 +1,37 @@ +import Foundation + +/// Best-effort LAN IPv4 address, used only to print a copy-pasteable +/// inspector URL. `en0` is Wi-Fi on iPhone; the simulator shares the Mac's +/// interfaces, where `localhost` also works. +public enum NetworkInterfaces { + public static func primaryIPv4Address() -> String? { + var addresses: [(name: String, address: String)] = [] + var interfaceList: UnsafeMutablePointer? + guard getifaddrs(&interfaceList) == 0, let first = interfaceList else { return nil } + defer { freeifaddrs(interfaceList) } + + var pointer: UnsafeMutablePointer? = first + while let interface = pointer { + defer { pointer = interface.pointee.ifa_next } + guard let socketAddress = interface.pointee.ifa_addr, + socketAddress.pointee.sa_family == UInt8(AF_INET) + else { continue } + var host = [CChar](repeating: 0, count: Int(NI_MAXHOST)) + guard getnameinfo( + socketAddress, + socklen_t(socketAddress.pointee.sa_len), + &host, + socklen_t(host.count), + nil, + 0, + NI_NUMERICHOST + ) == 0 else { continue } + let name = String(cString: interface.pointee.ifa_name) + let address = String(cString: host) + guard !address.hasPrefix("127."), !address.hasPrefix("169.254.") else { continue } + addresses.append((name, address)) + } + + return addresses.first { $0.name == "en0" }?.address ?? addresses.first?.address + } +} diff --git a/Sources/SpaceZRemote/RemoteBridge.swift b/Sources/SpaceZRemote/RemoteBridge.swift new file mode 100644 index 0000000..44e4740 --- /dev/null +++ b/Sources/SpaceZRemote/RemoteBridge.swift @@ -0,0 +1,160 @@ +import Foundation +import SpaceZCore +import SpaceZRules + +/// Handlers that must run against live UI objects. Injected by the host +/// (umbrella target) so this module stays free of UIKit. +public struct RemoteUIHandlers: Sendable { + /// Show/clear the on-device highlight for a node. + public var highlight: @Sendable (NodeID?) async -> Void + /// Apply an allowlisted property write. Returns success. + public var setProperty: @Sendable (NodeID, String, InspectorValue) async -> Bool + + public init( + highlight: @escaping @Sendable (NodeID?) async -> Void, + setProperty: @escaping @Sendable (NodeID, String, InspectorValue) async -> Bool + ) { + self.highlight = highlight + self.setProperty = setProperty + } +} + +/// Inspector logic between the snapshot pipeline and the transport. +/// +/// Owns the *redacted* copy of the world: snapshots enter through +/// `ingest(_:)`, get redacted once, diffed against the previous redacted +/// snapshot, and only then talk to clients. Raw values never reach this actor, +/// so a transport bug can't leak what redaction removed. +public actor RemoteBridge { + private let redaction: RedactionPolicy + private let writableKeys: Set + private let ruleEngine: RuleEngine + private let handlers: RemoteUIHandlers + private let appName: String + + private var current: Snapshot? + private var broadcast: (@Sendable (InspectorResponse) -> Void)? + + public init( + redaction: RedactionPolicy, + writableKeys: Set, + ruleEngine: RuleEngine = RuleEngine(), + handlers: RemoteUIHandlers, + appName: String = ProcessInfo.processInfo.processName + ) { + self.redaction = redaction + self.writableKeys = writableKeys + self.ruleEngine = ruleEngine + self.handlers = handlers + self.appName = appName + } + + /// Wire the push channel (server broadcast). Called once by the server. + public func setBroadcast(_ send: @escaping @Sendable (InspectorResponse) -> Void) { + broadcast = send + } + + // MARK: - Snapshot ingestion (pipeline consumer) + + public func ingest(_ raw: Snapshot) { + let redacted = Redactor.redact(raw, policy: redaction) + defer { current = redacted } + + guard let previous = current else { + // First snapshot of the session: clients that already connected + // learn about it through the root push. + broadcast?(.root( + version: redacted.version, + rootIDs: redacted.rootIDs, + nodeCount: redacted.nodeCount + )) + return + } + + let changes = SnapshotDiff.changes(from: previous, to: redacted) + guard !changes.isEmpty else { return } + // Push invalidation, not content: clients refetch only what they + // display. added+changed are refetchable; removed is a local prune. + broadcast?(.invalidate( + version: redacted.version, + invalidated: changes.added.union(changes.changed).sorted { $0.rawValue < $1.rawValue }, + removed: changes.removed.sorted { $0.rawValue < $1.rawValue }, + rootIDs: redacted.rootIDs + )) + } + + /// Device-side selection (tap-to-select) forwarded to connected tools. + public func pushSelection(_ id: NodeID) { + broadcast?(.select(id: id)) + } + + // MARK: - Request handling + + public func handle(_ request: InspectorRequest) async -> InspectorResponse { + switch request { + case .hello: + // Token verification happens in the server layer before requests + // reach the bridge; hello here just acknowledges. + return .helloAck(appName: appName, version: current?.version ?? 0) + + case .getRoot: + guard let current else { return .error(message: "No snapshot captured yet") } + return .root( + version: current.version, + rootIDs: current.rootIDs, + nodeCount: current.nodeCount + ) + + case .getNodes(let ids): + guard let current else { return .error(message: "No snapshot captured yet") } + return .nodes(version: current.version, nodes: ids.compactMap { current.nodes[$0] }) + + case .getTree: + guard let current else { return .error(message: "No snapshot captured yet") } + return .tree( + version: current.version, + rootIDs: current.rootIDs, + nodes: Array(current.nodes.values) + ) + + case .search(let query): + guard let current else { return .error(message: "No snapshot captured yet") } + return .searchResults(version: current.version, ids: current.search(query)) + + case .highlight(let id): + await handlers.highlight(id) + return .ack(ok: true, detail: nil) + + case .setProperty(let id, let key, let value): + // The allowlist is the security boundary for the write API: a remote + // tool must never gain arbitrary mutation over a live app. + guard writableKeys.contains(key) else { + return .ack(ok: false, detail: "Property '\(key)' is not writable") + } + let ok = await handlers.setProperty(id, key, value) + return .ack(ok: ok, detail: ok ? nil : "Set failed (node gone or unsupported)") + + case .getIssues: + guard let current else { return .error(message: "No snapshot captured yet") } + return .issues(version: current.version, issues: ruleEngine.evaluate(current)) + } + } + + public func currentSnapshotJSON() throws -> Data { + struct Export: Encodable { + let version: UInt64 + let capturedAt: Date + let rootIds: [NodeID] + let nodes: [InspectorNode] + } + guard let current else { return Data("{}".utf8) } + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + return try encoder.encode(Export( + version: current.version, + capturedAt: current.capturedAt, + rootIds: current.rootIDs, + nodes: Array(current.nodes.values) + )) + } +} diff --git a/Sources/SpaceZRemote/Resources/inspector.html b/Sources/SpaceZRemote/Resources/inspector.html new file mode 100644 index 0000000..65e559d --- /dev/null +++ b/Sources/SpaceZRemote/Resources/inspector.html @@ -0,0 +1,539 @@ + + + + + +SpaceZ Inspector + + + +
+

SpaceZ Inspector

+ connecting… + + +
+
+
+
+ +
click a rect to select · selection highlights on device
+
+
+
+ + +
+
+
+
+ + + diff --git a/Sources/SpaceZRules/BuiltInRules.swift b/Sources/SpaceZRules/BuiltInRules.swift new file mode 100644 index 0000000..4814834 --- /dev/null +++ b/Sources/SpaceZRules/BuiltInRules.swift @@ -0,0 +1,205 @@ +import CoreGraphics +import Foundation +import SpaceZCore + +/// The classic "button visible but not tappable" culprit: a nearly transparent +/// view that still consumes touches. A screenshot can't show it; the hierarchy +/// can. +public struct InvisibleInteractionRule: UIRule { + public let id = "invisible-interaction" + /// Below this alpha a view is effectively invisible to users. + public var alphaThreshold: Double + /// Ignore tiny views — a 10×10 invisible hotspot is usually intentional. + public var minimumArea: Double + /// System chrome that legitimately sits at alpha 0 with interaction on + /// (e.g. the blur background of UIKit bars). Flagging those is pure noise. + public var exemptTypes: Set + + public init( + alphaThreshold: Double = 0.05, + minimumArea: Double = 44 * 44, + exemptTypes: Set = ["UIVisualEffectView", "_UIBarBackground"] + ) { + self.alphaThreshold = alphaThreshold + self.minimumArea = minimumArea + self.exemptTypes = exemptTypes + } + + public func inspect(node: InspectorNode, context: InspectionContext) -> [Issue] { + guard + node.isUserInteractionEnabled, + !node.isHidden, + node.alpha <= alphaThreshold, + node.frame.width * node.frame.height >= minimumArea, + !exemptTypes.contains(node.type) + else { return [] } + return [ + Issue( + ruleID: id, + nodeID: node.id, + severity: .error, + message: "\(node.type) is nearly invisible (alpha=\(node.alpha)) " + + "but still receives touches over \(Int(node.frame.width))×" + + "\(Int(node.frame.height))pt.", + suggestion: "Set isUserInteractionEnabled = false while invisible, " + + "or remove the view instead of fading it out." + ) + ] + } +} + +/// Interactive element without an accessibility label — invisible to VoiceOver. +public struct MissingAccessibilityLabelRule: UIRule { + public let id = "missing-accessibility-label" + + /// Types that are interactive by design. + public var interactiveTypes: Set + + public init(interactiveTypes: Set = ["UIButton", "UISwitch", "UISegmentedControl"]) { + self.interactiveTypes = interactiveTypes + } + + public func inspect(node: InspectorNode, context: InspectionContext) -> [Issue] { + let isInteractiveType = interactiveTypes.contains { node.type.hasPrefix($0) } + guard + isInteractiveType, + !node.isHidden, + node.accessibilityLabel == nil, + // A button whose title is set gets a derived label from UIKit; + // flag only when there's no textual content at all. + node.properties["title"] == nil || node.properties["title"] == .null + else { return [] } + return [ + Issue( + ruleID: id, + nodeID: node.id, + severity: .warning, + message: "\(node.type) is interactive but has no accessibility label.", + suggestion: "Set accessibilityLabel so assistive technologies can name it." + ) + ] + } +} + +/// Deep nesting slows layout and usually indicates wrapper-view creep. +public struct DeepHierarchyRule: UIRule { + public let id = "deep-hierarchy" + public var maxDepth: Int + + public init(maxDepth: Int = 25) { + self.maxDepth = maxDepth + } + + public func inspect(node: InspectorNode, context: InspectionContext) -> [Issue] { + // Report once, at the node that crosses the threshold, not for every + // descendant below it — one problem, one issue. + guard context.depths[node.id] == maxDepth else { return [] } + return [ + Issue( + ruleID: id, + nodeID: node.id, + severity: .warning, + message: "Hierarchy reaches depth \(maxDepth) at \(node.type).", + suggestion: "Flatten wrapper views; each level adds layout and rendering cost." + ) + ] + } +} + +/// Hundreds of siblings usually means cells are being added, not reused. +public struct MassiveSiblingsRule: UIRule { + public let id = "massive-siblings" + public var maxChildren: Int + + public init(maxChildren: Int = 50) { + self.maxChildren = maxChildren + } + + public func inspect(node: InspectorNode, context: InspectionContext) -> [Issue] { + guard node.children.count > maxChildren else { return [] } + return [ + Issue( + ruleID: id, + nodeID: node.id, + severity: .warning, + message: "\(node.type) has \(node.children.count) direct children.", + suggestion: "If these are list items, check that cell reuse is working." + ) + ] + } +} + +/// Child extends outside a clipping parent — content is silently cut off. +public struct ClippedChildRule: UIRule { + public let id = "clipped-child" + /// Overhang below this many points is ignored (rounding, shadows). + public var tolerance: Double + + public init(tolerance: Double = 2) { + self.tolerance = tolerance + } + + public func inspect(node: InspectorNode, context: InspectionContext) -> [Issue] { + guard + let parent = context.parent(of: node.id), + parent.clipsToBounds, + !node.isHidden, !parent.isHidden, + !node.frame.isEmpty, !parent.frame.isEmpty + else { return [] } + // Scroll views clip by design; their children live outside the bounds. + guard !parent.type.contains("ScrollView"), + !parent.type.contains("TableView"), + !parent.type.contains("CollectionView") + else { return [] } + + let inset = parent.frame.insetBy(dx: -tolerance, dy: -tolerance) + guard !inset.contains(node.frame) else { return [] } + return [ + Issue( + ruleID: id, + nodeID: node.id, + severity: .warning, + message: "\(node.type) extends outside its clipping parent \(parent.type); " + + "part of it is not rendered.", + suggestion: "Check constraints/intrinsic size, or disable clipsToBounds on the parent." + ) + ] + } +} + +/// A hidden view still holding a big live subtree wastes memory and often +/// signals "we hide instead of removing". +public struct HiddenSubtreeRule: UIRule { + public let id = "hidden-subtree" + public var minimumDescendants: Int + + public init(minimumDescendants: Int = 20) { + self.minimumDescendants = minimumDescendants + } + + public func inspect(node: InspectorNode, context: InspectionContext) -> [Issue] { + guard node.isHidden else { return [] } + // Only flag the topmost hidden node of a hidden region. + if let parent = context.parent(of: node.id), parent.isHidden { return [] } + + var count = 0 + var stack = node.children + while let id = stack.popLast() { + count += 1 + if count >= minimumDescendants { break } + if let child = context.snapshot.nodes[id] { + stack.append(contentsOf: child.children) + } + } + guard count >= minimumDescendants else { return [] } + return [ + Issue( + ruleID: id, + nodeID: node.id, + severity: .info, + message: "\(node.type) is hidden but keeps \(minimumDescendants)+ live descendants.", + suggestion: "Remove the subtree instead of hiding it if it stays hidden for long." + ) + ] + } +} diff --git a/Sources/SpaceZRules/Issue.swift b/Sources/SpaceZRules/Issue.swift new file mode 100644 index 0000000..1459303 --- /dev/null +++ b/Sources/SpaceZRules/Issue.swift @@ -0,0 +1,38 @@ +import Foundation +import SpaceZCore + +/// One problem a rule found in a snapshot. +public struct Issue: Sendable, Codable, Equatable, Identifiable { + public enum Severity: String, Sendable, Codable, Comparable { + case info, warning, error + + public static func < (lhs: Severity, rhs: Severity) -> Bool { + let order: [Severity] = [.info, .warning, .error] + return order.firstIndex(of: lhs)! < order.firstIndex(of: rhs)! + } + } + + public let id: String + /// Rule that produced this issue (e.g. `invisible-interaction`). + public let ruleID: String + public let nodeID: NodeID + public let severity: Severity + public let message: String + /// Suggested fix, when the rule can offer one. + public let suggestion: String? + + public init( + ruleID: String, + nodeID: NodeID, + severity: Severity, + message: String, + suggestion: String? = nil + ) { + self.id = "\(ruleID)/\(nodeID.rawValue)" + self.ruleID = ruleID + self.nodeID = nodeID + self.severity = severity + self.message = message + self.suggestion = suggestion + } +} diff --git a/Sources/SpaceZRules/UIRule.swift b/Sources/SpaceZRules/UIRule.swift new file mode 100644 index 0000000..872e9fa --- /dev/null +++ b/Sources/SpaceZRules/UIRule.swift @@ -0,0 +1,66 @@ +import Foundation +import SpaceZCore + +/// Context handed to rules alongside each node: precomputed indexes so a rule +/// never walks the whole tree itself (N rules × N nodes must stay O(N·rules), +/// not O(N²)). +public struct InspectionContext: Sendable { + public let snapshot: Snapshot + public let depths: [NodeID: Int] + public let parents: [NodeID: NodeID] + + public init(snapshot: Snapshot) { + self.snapshot = snapshot + self.depths = snapshot.depths() + self.parents = snapshot.parentIndex() + } + + public func parent(of id: NodeID) -> InspectorNode? { + parents[id].flatMap { snapshot.nodes[$0] } + } +} + +/// A diagnostic rule. Rules see snapshot values only — they run off-main and +/// are decoupled from the collector, so adding a rule never touches capture. +public protocol UIRule: Sendable { + var id: String { get } + func inspect(node: InspectorNode, context: InspectionContext) -> [Issue] +} + +/// Evaluates all registered rules against a snapshot. +public struct RuleEngine: Sendable { + public private(set) var rules: [any UIRule] + + public init(rules: [any UIRule] = RuleEngine.builtInRules) { + self.rules = rules + } + + public static var builtInRules: [any UIRule] { + [ + InvisibleInteractionRule(), + MissingAccessibilityLabelRule(), + DeepHierarchyRule(), + MassiveSiblingsRule(), + ClippedChildRule(), + HiddenSubtreeRule(), + ] + } + + public mutating func register(_ rule: any UIRule) { + rules.append(rule) + } + + public func evaluate(_ snapshot: Snapshot) -> [Issue] { + let context = InspectionContext(snapshot: snapshot) + var issues: [Issue] = [] + for node in snapshot.nodes.values { + for rule in rules { + issues.append(contentsOf: rule.inspect(node: node, context: context)) + } + } + // Stable, severity-first ordering for display. + return issues.sorted { + $0.severity == $1.severity ? $0.id < $1.id : $0.severity > $1.severity + } + } +} diff --git a/Sources/SpaceZSwiftUI/SwiftUIHostingDescriptor.swift b/Sources/SpaceZSwiftUI/SwiftUIHostingDescriptor.swift new file mode 100644 index 0000000..f4cc7d5 --- /dev/null +++ b/Sources/SpaceZSwiftUI/SwiftUIHostingDescriptor.swift @@ -0,0 +1,321 @@ +import SpaceZCore +import SpaceZUIKit +import SwiftUI +import UIKit + +/// Best-effort SwiftUI semantic tree, exposed *alongside* the UIKit render +/// tree. +/// +/// SwiftUI's declaration tree (`VStack { Text; Button }`) and the UIView tree +/// under `UIHostingController` are different abstractions; pretending one is +/// the other produces nonsense. So the hosting view keeps its real UIView +/// children, and additionally gets synthetic `SwiftUI:*` children extracted by +/// reflecting the hosted root value with `Mirror`. +/// +/// Reflection over SwiftUI internals is inherently version-fragile. The +/// contract here is graceful degradation, treated as a normal state, not an +/// error: when extraction fails on some OS release, the UIKit subtree is still +/// complete and a `swiftUISemantics: unavailable` property says why. No +/// private symbols are linked — `Mirror` only. +@MainActor +public final class SwiftUIHostingDescriptor: NodeDescriptor { + /// Caps keep a pathological SwiftUI value graph from blowing up capture + /// time; 400 semantic nodes ≫ anything a single screen legitimately shows. + private let maxElements = 400 + private let maxDepth = 40 + + private let uikit = UIKitViewDescriptor() + + /// Wrapper identity cache: hosting view → (semantic path → wrapper). + /// Reusing the wrapper object for the same path keeps NodeIDs stable + /// across captures, which keeps remote invalidation quiet while the + /// SwiftUI structure is unchanged. + private let wrapperCache = NSMapTable( + keyOptions: [.weakMemory, .objectPointerPersonality], + valueOptions: .strongMemory + ) + + public init() {} + + public func supports(_ object: AnyObject) -> Bool { + if object is SwiftUISemanticElement { return true } + guard let view = object as? UIView else { return false } + return String(describing: type(of: view)).contains("HostingView") + } + + public func capture(_ object: AnyObject) -> CapturedNodeContent { + if let element = object as? SwiftUISemanticElement { + var properties: [String: InspectorValue] = [ + "source": .string("SwiftUI semantic tree (best-effort reflection)"), + ] + if !element.modifierNames.isEmpty { + properties["modifiers"] = .string(element.modifierNames.joined(separator: ", ")) + } + return CapturedNodeContent( + type: "SwiftUI:\(element.displayType)", + label: element.detail, + properties: properties + ) + } + + var content = uikit.capture(object) + guard let view = object as? UIView else { return content } + // Cheap availability probe only — the actual extraction happens once, + // in children(of:), not twice per capture pass. + content.properties["swiftUISemantics"] = hostedRootValue(of: view) == nil + ? .string("unavailable on this OS version") + : .string("attached as SwiftUI:* children") + return content + } + + public func children(of object: AnyObject) -> [AnyObject] { + if let element = object as? SwiftUISemanticElement { + return element.children + } + guard let view = object as? UIView else { return [] } + let semantic = semanticRoots(for: view) ?? [] + return uikit.children(of: view) + semantic + } + + public func setProperty( + _ key: String, to value: InspectorValue, on object: AnyObject + ) -> Bool { + // Semantic nodes are value reflections — there is nothing to mutate. + guard object is UIView else { return false } + return uikit.setProperty(key, to: value, on: object) + } + + // MARK: - Reflection + + private func semanticRoots(for view: UIView) -> [SwiftUISemanticElement]? { + guard let rootValue = hostedRootValue(of: view) else { return nil } + let cache = cacheDictionary(for: view) + var budget = maxElements + let roots = buildElements( + from: rootValue, + path: "root", + depth: 0, + cache: cache, + budget: &budget + ) + return roots.isEmpty ? nil : roots + } + + /// Finds the hosted SwiftUI root value by walking the hosting view's + /// Mirror (including superclasses) for a `_rootView`-shaped field. + private func hostedRootValue(of view: UIView) -> Any? { + var mirror: Mirror? = Mirror(reflecting: view) + while let current = mirror { + for child in current.children { + if child.label == "_rootView" || child.label == "rootView" { + return child.value + } + // Newer OS versions hang the root view off an intermediate + // content storage box. + if child.label == "_base" || child.label == "content" { + let inner = Mirror(reflecting: child.value) + for innerChild in inner.children + where innerChild.label == "_rootView" || innerChild.label == "rootView" { + return innerChild.value + } + } + } + mirror = current.superclassMirror + } + return nil + } + + private func cacheDictionary(for view: UIView) -> NSMutableDictionary { + if let existing = wrapperCache.object(forKey: view) { return existing } + let created = NSMutableDictionary() + wrapperCache.setObject(created, forKey: view) + return created + } + + /// SwiftUI plumbing types that carry structure but no meaning to the + /// reader — flattened away so `VStack → Text` reads like the source, not + /// like the runtime's wrapper soup. + private static let transparentTypes: Set = [ + "TupleView", "Tree", "Group", "_ConditionalContent", "AnyView", + "ResolvedButtonStyle", "_VariadicView", + ] + + /// Builds semantic elements for one value. Returns a *list* because + /// transparent containers and tuples dissolve into their children. + private func buildElements( + from value: Any, + path: String, + depth: Int, + cache: NSMutableDictionary, + budget: inout Int + ) -> [SwiftUISemanticElement] { + guard budget > 0, depth < maxDepth else { return [] } + + var current = value + var modifiers: [String] = [] + // Unwrap ModifiedContent chains: `Text("x").padding().opacity(...)` + // is ModifiedContent, _Opacity>. The + // interesting node is the innermost content; modifiers become a note. + var unwrapIterations = 0 + while Self.fullTypeName(of: current).hasPrefix("SwiftUI.ModifiedContent<"), + unwrapIterations < 32 { + unwrapIterations += 1 + let mirror = Mirror(reflecting: current) + guard let content = mirror.children.first(where: { $0.label == "content" }) + else { break } + if let modifier = mirror.children.first(where: { $0.label == "modifier" }) { + let name = Self.simplifiedTypeName(of: modifier.value) + // Internal bookkeeping modifiers add noise, not information. + if !name.hasPrefix("Static"), !name.hasPrefix("_") || name.contains("Padding") + || name.contains("Background") || name.contains("Foreground") { + modifiers.append(name) + } + } + current = content.value + } + + let mirror = Mirror(reflecting: current) + let isView = current is any View + let displayType = Self.simplifiedTypeName(of: current) + + // Tuples, transparent wrappers, and non-view storage boxes dissolve. + if mirror.displayStyle == .tuple + || (isView && Self.transparentTypes.contains(displayType)) + || (!isView && Self.containsViews(mirror)) { + var flattened: [SwiftUISemanticElement] = [] + var index = 0 + for child in mirror.children { + let built = buildElements( + from: child.value, + path: "\(path)/\(displayType)[\(index)]", + depth: depth + 1, + cache: cache, + budget: &budget + ) + if !built.isEmpty { + flattened.append(contentsOf: built) + index += 1 + } + } + return flattened + } + + guard isView, !(current is EmptyView) else { return [] } + + budget -= 1 + let elementPath = "\(path)/\(displayType)" + let element: SwiftUISemanticElement + if let cached = cache[elementPath] as? SwiftUISemanticElement { + element = cached + } else { + element = SwiftUISemanticElement() + cache[elementPath] = element + } + element.displayType = displayType + element.modifierNames = modifiers + element.detail = Self.detailText(of: current) + + // An app-defined `View` declares its children in `body`, not in + // stored properties — Mirror alone would only show its @State. SwiftUI's + // own types are never body-expanded (their bodies recurse into style + // resolution wrappers forever); their children live in storage, which + // Mirror reaches. + let childValues: [Any] + if !Self.isSwiftUIType(current), let body = Self.expandBody(of: current) { + childValues = [body] + } else { + childValues = mirror.children.map(\.value) + } + + var children: [SwiftUISemanticElement] = [] + var index = 0 + for child in childValues { + let built = buildElements( + from: child, + path: "\(elementPath)[\(index)]", + depth: depth + 1, + cache: cache, + budget: &budget + ) + if !built.isEmpty { + children.append(contentsOf: built) + index += 1 + } + } + element.children = children + return [element] + } + + /// Calls `body` on a view with a real body. Best-effort by construction: + /// outside SwiftUI's render context, @Environment yields defaults and + /// @State yields its current storage — good enough for structure. + private static func expandBody(of value: Any) -> Any? { + func open(_ view: V) -> Any? { + guard V.Body.self != Never.self else { return nil } + return view.body + } + guard let view = value as? any View else { return nil } + return open(view) + } + + /// One-level probe: does this non-view container hold views (directly or + /// inside a tuple)? Storage boxes that do are made transparent; everything + /// else (Ints, Strings, closures, app model values) is data and must not + /// be walked — both for signal and for privacy. + private static func containsViews(_ mirror: Mirror) -> Bool { + mirror.children.contains { child in + if child.value is any View { return true } + let inner = Mirror(reflecting: child.value) + if inner.displayStyle == .tuple || inner.displayStyle == .struct { + return inner.children.contains { $0.value is any View } + } + return false + } + } + + private static func isSwiftUIType(_ value: Any) -> Bool { + String(reflecting: type(of: value)).hasPrefix("SwiftUI.") + } + + private static func fullTypeName(of value: Any) -> String { + String(reflecting: type(of: value)) + } + + private static func simplifiedTypeName(of value: Any) -> String { + let full = String(describing: type(of: value)) + // `VStack)>>` → `VStack` + if let angleBracket = full.firstIndex(of: "<") { + return String(full[.. String? { + guard simplifiedTypeName(of: value) == "Text" else { return nil } + // Text storage nests the literal a few mirrors deep; look for a String. + var queue: [Any] = [value] + var hops = 0 + while !queue.isEmpty, hops < 24 { + hops += 1 + let current = queue.removeFirst() + if let string = current as? String { + return "\"\(string.spacezTruncated(40))\"" + } + queue.append(contentsOf: Mirror(reflecting: current).children.map(\.value)) + } + return nil + } +} + +/// Identity anchor for one SwiftUI semantic node. Class (not struct) because +/// the ``NodeIDRegistry`` keys IDs off object identity; the cache in the +/// descriptor keeps the same instance alive for the same semantic path. +@MainActor +public final class SwiftUISemanticElement { + var displayType: String = "" + var detail: String? + var modifierNames: [String] = [] + var children: [SwiftUISemanticElement] = [] +} diff --git a/Sources/SpaceZUIKit/HighlightController.swift b/Sources/SpaceZUIKit/HighlightController.swift new file mode 100644 index 0000000..89e72bc --- /dev/null +++ b/Sources/SpaceZUIKit/HighlightController.swift @@ -0,0 +1,69 @@ +import SpaceZCore +import UIKit + +/// Draws the selection rectangle over the app, Flipper/Xcode style. +/// +/// Implemented as its own non-interactive window above everything: it can +/// never affect the inspected hierarchy's layout, and it is excluded from +/// capture via ``SpaceZInternalWindow``. +@MainActor +public final class HighlightController { + private final class HighlightWindow: UIWindow, SpaceZInternalWindow { + // Fully transparent to touches — the highlight must never eat a tap. + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { nil } + } + + private var window: HighlightWindow? + private let frameView = UIView() + private let infoLabel = UILabel() + + public init() {} + + /// Shows a highlight box at `frame` (window coordinates) with a caption. + public func highlight(frame: CGRect, caption: String) { + guard let scene = UIApplication.shared.connectedScenes + .compactMap({ $0 as? UIWindowScene }) + .first(where: { $0.activationState == .foregroundActive }) + ?? UIApplication.shared.connectedScenes.compactMap({ $0 as? UIWindowScene }).first + else { return } + + let window = self.window ?? makeWindow(scene: scene) + window.isHidden = false + + frameView.frame = frame + infoLabel.text = " \(caption) " + infoLabel.sizeToFit() + var labelFrame = infoLabel.frame + labelFrame.origin.x = max(4, frame.minX) + labelFrame.origin.y = frame.minY >= labelFrame.height + 4 + ? frame.minY - labelFrame.height - 2 + : frame.maxY + 2 + infoLabel.frame = labelFrame + } + + public func clear() { + window?.isHidden = true + } + + private func makeWindow(scene: UIWindowScene) -> HighlightWindow { + let window = HighlightWindow(windowScene: scene) + window.windowLevel = .statusBar + 90 + window.backgroundColor = .clear + window.isUserInteractionEnabled = false + + frameView.backgroundColor = UIColor.systemBlue.withAlphaComponent(0.18) + frameView.layer.borderColor = UIColor.systemBlue.cgColor + frameView.layer.borderWidth = 1.5 + + infoLabel.font = .monospacedSystemFont(ofSize: 11, weight: .semibold) + infoLabel.textColor = .white + infoLabel.backgroundColor = UIColor.systemBlue + infoLabel.layer.cornerRadius = 3 + infoLabel.layer.masksToBounds = true + + window.addSubview(frameView) + window.addSubview(infoLabel) + self.window = window + return window + } +} diff --git a/Sources/SpaceZUIKit/SpaceZInternalWindow.swift b/Sources/SpaceZUIKit/SpaceZInternalWindow.swift new file mode 100644 index 0000000..98cfb27 --- /dev/null +++ b/Sources/SpaceZUIKit/SpaceZInternalWindow.swift @@ -0,0 +1,7 @@ +import UIKit + +/// Marker for windows the debugger itself creates (overlay, highlight). +/// The root provider skips them so the inspector never inspects itself — +/// otherwise every capture would differ (the overlay reacts to snapshots, +/// which would trigger captures, forever). +public protocol SpaceZInternalWindow: AnyObject {} diff --git a/Sources/SpaceZUIKit/UIKitRootProvider.swift b/Sources/SpaceZUIKit/UIKitRootProvider.swift new file mode 100644 index 0000000..069ffa5 --- /dev/null +++ b/Sources/SpaceZUIKit/UIKitRootProvider.swift @@ -0,0 +1,18 @@ +import SpaceZCore +import UIKit + +/// Roots = every window of every connected scene, back to front. +/// Multi-window iPad apps produce multiple roots; the snapshot models that as +/// `rootIDs: [NodeID]` rather than inventing a fake super-root. +@MainActor +public final class UIKitRootProvider: RootProvider { + public init() {} + + public func rootObjects() -> [AnyObject] { + UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .flatMap(\.windows) + .filter { !($0 is SpaceZInternalWindow) } + .sorted { $0.windowLevel < $1.windowLevel } + } +} diff --git a/Sources/SpaceZUIKit/UIKitViewDescriptor.swift b/Sources/SpaceZUIKit/UIKitViewDescriptor.swift new file mode 100644 index 0000000..a07c409 --- /dev/null +++ b/Sources/SpaceZUIKit/UIKitViewDescriptor.swift @@ -0,0 +1,194 @@ +import SpaceZCore +import UIKit + +/// Teaches the capture engine about `UIView`. +/// +/// Reads only primitive values and copies them out — the returned +/// `CapturedNodeContent` holds no reference to the view. Cost per node is a +/// dozen objc property reads (~tens of ns each), which is what keeps a +/// 5,000-node capture around the 2 ms design budget. +@MainActor +public final class UIKitViewDescriptor: NodeDescriptor { + public init() {} + + public func supports(_ object: AnyObject) -> Bool { + object is UIView + } + + public func children(of object: AnyObject) -> [AnyObject] { + guard let view = object as? UIView else { return [] } + return view.subviews + } + + public func capture(_ object: AnyObject) -> CapturedNodeContent { + guard let view = object as? UIView else { + return CapturedNodeContent(type: String(describing: type(of: object))) + } + + var properties: [String: InspectorValue] = [:] + + // Frame in window coordinates so the desktop canvas and the on-device + // highlight agree on geometry regardless of nesting. + let frameInWindow: CGRect + if let window = view.window { + frameInWindow = view.convert(view.bounds, to: window) + } else { + frameInWindow = view.frame + } + + properties["bounds"] = .rect(view.bounds) + properties["frame"] = .rect(view.frame) + properties["tag"] = .number(Double(view.tag)) + if let backgroundColor = view.backgroundColor { + properties["backgroundColor"] = .color(backgroundColor.spacezHexString) + } + properties["layer.zPosition"] = .number(Double(view.layer.zPosition)) + properties["contentMode"] = .string(view.contentMode.spacezName) + + var identifier: String? + if let accessibilityIdentifier = view.accessibilityIdentifier, + !accessibilityIdentifier.isEmpty { + identifier = accessibilityIdentifier + } + var label: String? + + // Type-specific extras. Kept inline (not per-class descriptors) while + // the list is this short; split when a class needs real logic. + switch view { + case let labelView as UILabel: + properties["text"] = labelView.text.map(InspectorValue.string) ?? .null + properties["font"] = .string(labelView.font.spacezDescription) + properties["numberOfLines"] = .number(Double(labelView.numberOfLines)) + label = labelView.text.map { "\"\($0.spacezTruncated(40))\"" } + case let button as UIButton: + let title = button.title(for: .normal) ?? button.configuration?.title + properties["title"] = title.map(InspectorValue.string) ?? .null + properties["isEnabled"] = .bool(button.isEnabled) + label = title.map { "\"\($0.spacezTruncated(40))\"" } + case let textField as UITextField: + properties["text"] = textField.text.map(InspectorValue.string) ?? .null + properties["placeholder"] = textField.placeholder.map(InspectorValue.string) ?? .null + properties["isSecureTextEntry"] = .bool(textField.isSecureTextEntry) + case let imageView as UIImageView: + properties["imageSize"] = imageView.image.map { .size($0.size) } ?? .null + case let scrollView as UIScrollView: + properties["contentOffset"] = .point(scrollView.contentOffset) + properties["contentSize"] = .size(scrollView.contentSize) + case let stack as UIStackView: + properties["axis"] = .string(stack.axis == .vertical ? "vertical" : "horizontal") + properties["spacing"] = .number(Double(stack.spacing)) + default: + break + } + + if !view.gestureRecognizers.isNilOrEmpty { + properties["gestureRecognizers"] = .string( + (view.gestureRecognizers ?? []) + .map { String(describing: type(of: $0)) } + .joined(separator: ", ") + ) + } + + return CapturedNodeContent( + type: String(describing: type(of: view)), + identifier: identifier, + label: label, + frame: frameInWindow, + alpha: Double(view.alpha), + isHidden: view.isHidden, + clipsToBounds: view.clipsToBounds, + isUserInteractionEnabled: view.isUserInteractionEnabled, + accessibilityLabel: view.accessibilityLabel, + properties: properties + ) + } + + public func setProperty( + _ key: String, to value: InspectorValue, on object: AnyObject + ) -> Bool { + guard let view = object as? UIView else { return false } + switch (key, value) { + case ("alpha", .number(let number)): + view.alpha = CGFloat(number) + case ("isHidden", .bool(let flag)): + view.isHidden = flag + case ("backgroundColor", .color(let hex)), ("backgroundColor", .string(let hex)): + guard let color = UIColor(spacezHex: hex) else { return false } + view.backgroundColor = color + default: + return false + } + return true + } +} + +extension Optional where Wrapped: Collection { + var isNilOrEmpty: Bool { + self?.isEmpty ?? true + } +} + +extension String { + package func spacezTruncated(_ limit: Int) -> String { + count <= limit ? self : String(prefix(limit)) + "…" + } +} + +extension UIFont { + var spacezDescription: String { + "\(fontName) \(String(format: "%.1f", pointSize))pt" + } +} + +extension UIView.ContentMode { + var spacezName: String { + switch self { + case .scaleToFill: return "scaleToFill" + case .scaleAspectFit: return "scaleAspectFit" + case .scaleAspectFill: return "scaleAspectFill" + case .redraw: return "redraw" + case .center: return "center" + case .top: return "top" + case .bottom: return "bottom" + case .left: return "left" + case .right: return "right" + case .topLeft: return "topLeft" + case .topRight: return "topRight" + case .bottomLeft: return "bottomLeft" + case .bottomRight: return "bottomRight" + @unknown default: return "unknown" + } + } +} + +extension UIColor { + public var spacezHexString: String { + var red: CGFloat = 0, green: CGFloat = 0, blue: CGFloat = 0, alpha: CGFloat = 0 + getRed(&red, green: &green, blue: &blue, alpha: &alpha) + func clamp(_ component: CGFloat) -> Int { + Int((max(0, min(1, component)) * 255).rounded()) + } + return String( + format: "#%02X%02X%02X%02X", + clamp(red), clamp(green), clamp(blue), clamp(alpha) + ) + } + + public convenience init?(spacezHex: String) { + var hex = spacezHex.trimmingCharacters(in: .whitespaces) + if hex.hasPrefix("#") { hex.removeFirst() } + guard hex.count == 6 || hex.count == 8, let value = UInt64(hex, radix: 16) else { + return nil + } + let hasAlpha = hex.count == 8 + let divisor: CGFloat = 255 + let alpha = hasAlpha ? CGFloat(value & 0xFF) / divisor : 1 + let shift = hasAlpha ? 8 : 0 + self.init( + red: CGFloat((value >> (16 + shift)) & 0xFF) / divisor, + green: CGFloat((value >> (8 + shift)) & 0xFF) / divisor, + blue: CGFloat((value >> shift) & 0xFF) / divisor, + alpha: alpha + ) + } +} diff --git a/Tests/.swiftlint.yml b/Tests/.swiftlint.yml new file mode 100644 index 0000000..5cff037 --- /dev/null +++ b/Tests/.swiftlint.yml @@ -0,0 +1,6 @@ +# Test-only relaxations: force casts on known JSON shapes and one-letter +# fixture names are idiomatic in tests, not code smells. +disabled_rules: + - force_cast + - force_try + - identifier_name diff --git a/Tests/SpaceZCoreTests/CaptureEngineTests.swift b/Tests/SpaceZCoreTests/CaptureEngineTests.swift new file mode 100644 index 0000000..e5a4c6d --- /dev/null +++ b/Tests/SpaceZCoreTests/CaptureEngineTests.swift @@ -0,0 +1,123 @@ +import XCTest +@testable import SpaceZCore + +@MainActor +final class CaptureEngineTests: XCTestCase { + func testCapturesFullTreeNormalized() { + // root ── a ── (b, c) + d + let b = TestElement("b") + let c = TestElement("c") + let a = TestElement("a", children: [b, c]) + let d = TestElement("d") + let root = TestElement("root", children: [a, d]) + let engine = makeEngine(roots: [root]) + + let snapshot = engine.captureSnapshot() + + XCTAssertEqual(snapshot.nodeCount, 5) + XCTAssertEqual(snapshot.rootIDs.count, 1) + let rootNode = snapshot[snapshot.rootIDs[0]]! + XCTAssertEqual(rootNode.type, "root") + XCTAssertEqual(rootNode.children.count, 2) + // Children stored as IDs, in declaration (z) order. + let childTypes = rootNode.children.compactMap { snapshot[$0]?.type } + XCTAssertEqual(childTypes, ["a", "d"]) + let aNode = snapshot[rootNode.children[0]]! + XCTAssertEqual(aNode.children.compactMap { snapshot[$0]?.type }, ["b", "c"]) + } + + func testNodeIDsAreStableAcrossCaptures() { + let child = TestElement("child") + let root = TestElement("root", children: [child]) + let engine = makeEngine(roots: [root]) + + let first = engine.captureSnapshot() + let second = engine.captureSnapshot() + + XCTAssertEqual(first.rootIDs, second.rootIDs) + XCTAssertEqual( + Set(first.nodes.keys), + Set(second.nodes.keys), + "Same objects must keep the same NodeIDs across captures" + ) + } + + func testInsertedSiblingDoesNotShiftExistingIDs() { + let existing = TestElement("existing") + let root = TestElement("root", children: [existing]) + let engine = makeEngine(roots: [root]) + let before = engine.captureSnapshot() + let existingID = before[before.rootIDs[0]]!.children[0] + + root.children.insert(TestElement("inserted"), at: 0) + let after = engine.captureSnapshot() + + let childIDs = after[after.rootIDs[0]]!.children + XCTAssertEqual(childIDs.count, 2) + XCTAssertEqual(childIDs[1], existingID, "existing sibling keeps its ID") + XCTAssertEqual(after[childIDs[0]]?.type, "inserted") + } + + func testDeepHierarchyDoesNotOverflowStack() { + // 10,000 levels would crash a recursive traversal. + var current = TestElement("leaf") + for index in 0..<10_000 { + current = TestElement("n\(index)", children: [current]) + } + let engine = makeEngine(roots: [current]) + let snapshot = engine.captureSnapshot(maxDepth: 20_000) + XCTAssertEqual(snapshot.nodeCount, 10_001) + } + + func testMaxDepthTruncates() { + var current = TestElement("leaf") + for index in 0..<10 { + current = TestElement("n\(index)", children: [current]) + } + let engine = makeEngine(roots: [current]) + let snapshot = engine.captureSnapshot(maxDepth: 3) + let depths = snapshot.depths() + XCTAssertEqual(depths.values.max(), 3) + // The truncated node is visibly marked, not silently dropped. + XCTAssertTrue(snapshot.nodes.values.contains { $0.label?.contains("truncated") == true }) + } + + func testVersionIncreasesMonotonically() { + let engine = makeEngine(roots: [TestElement("root")]) + let v1 = engine.captureSnapshot().version + let v2 = engine.captureSnapshot().version + XCTAssertGreaterThan(v2, v1) + } + + func testSubtreeCaptureReturnsOnlySubtree() { + let grandchild = TestElement("grandchild") + let child = TestElement("child", children: [grandchild]) + let root = TestElement("root", children: [child, TestElement("other")]) + let engine = makeEngine(roots: [root]) + let full = engine.captureSnapshot() + let childID = full[full.rootIDs[0]]!.children[0] + + let subtree = engine.captureSubtree(of: childID)! + + XCTAssertEqual(subtree.count, 2) + XCTAssertEqual(Set(subtree.values.map(\.type)), ["child", "grandchild"]) + } + + func testFingerprintStableWhenNothingChanges() { + let root = TestElement("root", children: [TestElement("a"), TestElement("b")]) + let engine = makeEngine(roots: [root]) + let first = engine.captureSnapshot() + let second = engine.captureSnapshot() + XCTAssertEqual(first.fingerprint, second.fingerprint) + } + + func testFingerprintChangesWhenPropertyChanges() { + let child = TestElement("child") + let root = TestElement("root", children: [child]) + let engine = makeEngine(roots: [root]) + let before = engine.captureSnapshot() + child.alpha = 0.5 + let after = engine.captureSnapshot() + XCTAssertNotEqual(before.fingerprint, after.fingerprint) + } +} diff --git a/Tests/SpaceZCoreTests/CapturePerformanceTests.swift b/Tests/SpaceZCoreTests/CapturePerformanceTests.swift new file mode 100644 index 0000000..288dfa2 --- /dev/null +++ b/Tests/SpaceZCoreTests/CapturePerformanceTests.swift @@ -0,0 +1,90 @@ +import XCTest +@testable import SpaceZCore + +/// Guards the design's main-thread budget (D1): p50 < 2 ms, p95 < 5 ms, +/// hard 8 ms for a 5,000-node capture on device-class hardware. +/// +/// CI runners and simulators are slower and noisier than devices, so the +/// asserted ceiling is configurable via `SPACEZ_CAPTURE_BUDGET_MS` +/// (default 50 ms — an order of magnitude above budget; crossing *that* +/// means a real regression, not runner noise). Actual numbers are printed +/// for the PR's performance-impact section. +@MainActor +final class CapturePerformanceTests: XCTestCase { + private static let nodeCount = 5_000 + + private func makeSyntheticTree() -> TestElement { + // Shape mirrors a real screen: moderate depth, wide fan-out. + // 5 windows-worth of stacks: 5 × 10 × 10 × 10 = 5,000 leaves + spine. + let root = TestElement("root") + var remaining = Self.nodeCount + outer: for section in 0..<50 { + let sectionElement = TestElement("section\(section)") + root.children.append(sectionElement) + for row in 0..<10 { + let rowElement = TestElement("row\(row)") + sectionElement.children.append(rowElement) + for cell in 0..<10 { + rowElement.children.append( + TestElement( + "cell\(cell)", + frame: CGRect(x: 0, y: Double(cell) * 44, width: 390, height: 44) + ) + ) + remaining -= 1 + if remaining <= 0 { break outer } + } + } + } + return root + } + + func testFiveThousandNodeCaptureStaysWithinBudget() { + let engine = makeEngine(roots: [makeSyntheticTree()]) + + // Warm up allocator and registry (first capture mints all IDs). + _ = engine.captureSnapshot() + + var durations: [TimeInterval] = [] + var nodeCount = 0 + for _ in 0..<20 { + let snapshot = engine.captureSnapshot() + durations.append(snapshot.captureDuration) + nodeCount = snapshot.nodeCount + } + durations.sort() + let p50 = durations[durations.count / 2] + let p95 = durations[Int(Double(durations.count) * 0.95) - 1] + + print("[SpaceZ perf] \(nodeCount) nodes — " + + "p50 \(String(format: "%.2f", p50 * 1000))ms, " + + "p95 \(String(format: "%.2f", p95 * 1000))ms") + + let budgetMS = Double(ProcessInfo.processInfo.environment["SPACEZ_CAPTURE_BUDGET_MS"] ?? "") + ?? 50 + XCTAssertLessThan( + p95 * 1000, budgetMS, + "5,000-node capture p95 exceeded \(budgetMS)ms — main-thread budget regression" + ) + } + + func testDiffOfLargeSnapshotsIsFast() { + let root = makeSyntheticTree() + let engine = makeEngine(roots: [root]) + let before = engine.captureSnapshot() + root.children.first?.children.first?.children.first?.alpha = 0.5 + let after = engine.captureSnapshot() + + let start = ContinuousClock.now + let changes = SnapshotDiff.changes(from: before, to: after) + let elapsed = start.duration(to: .now) + + XCTAssertEqual(changes.changed.count, 1) + // Diff runs off-main and never blocks the UI, so this is an + // order-of-magnitude regression gate, not a frame budget: 5,500-node + // diffs measure ~90 ms in a debug build; alert only if that class of + // cost changes. (A tight 100 ms bound flaked on shared CI runners at + // 102.9 ms.) + XCTAssertLessThan(elapsed, .milliseconds(500)) + } +} diff --git a/Tests/SpaceZCoreTests/NodeIDRegistryTests.swift b/Tests/SpaceZCoreTests/NodeIDRegistryTests.swift new file mode 100644 index 0000000..08f4389 --- /dev/null +++ b/Tests/SpaceZCoreTests/NodeIDRegistryTests.swift @@ -0,0 +1,61 @@ +import XCTest +@testable import SpaceZCore + +@MainActor +final class NodeIDRegistryTests: XCTestCase { + func testSameObjectKeepsSameID() { + let registry = NodeIDRegistry() + let object = NSObject() + let first = registry.id(for: object) + let second = registry.id(for: object) + XCTAssertEqual(first, second) + } + + func testDistinctObjectsGetDistinctIDs() { + let registry = NodeIDRegistry() + let a = NSObject() + let b = NSObject() + XCTAssertNotEqual(registry.id(for: a), registry.id(for: b)) + } + + func testReverseLookupReturnsLiveObject() { + let registry = NodeIDRegistry() + let object = NSObject() + let id = registry.id(for: object) + XCTAssertTrue(registry.object(for: id) === object) + } + + func testReverseLookupIsNilAfterDeallocation() { + let registry = NodeIDRegistry() + var id: NodeID? + autoreleasepool { + let object = NSObject() + id = registry.id(for: object) + } + // Weak value: once the object is gone the mapping must not resurrect it. + XCTAssertNil(registry.object(for: id!)) + } + + func testIDsSurviveSiblingReordering() { + // Identity-based IDs must not depend on traversal position — this is + // the property positional IDs ("0.1.3") lack. + let registry = NodeIDRegistry() + let a = NSObject() + let b = NSObject() + let idA = registry.id(for: a) + let idB = registry.id(for: b) + // "Reorder": query in the opposite order. + XCTAssertEqual(registry.id(for: b), idB) + XCTAssertEqual(registry.id(for: a), idA) + } + + func testResetForgetsEverything() { + let registry = NodeIDRegistry() + let object = NSObject() + let before = registry.id(for: object) + registry.reset() + let after = registry.id(for: object) + XCTAssertEqual(after.rawValue, 1) + XCTAssertEqual(before.rawValue, 1) + } +} diff --git a/Tests/SpaceZCoreTests/RedactorAndSearchTests.swift b/Tests/SpaceZCoreTests/RedactorAndSearchTests.swift new file mode 100644 index 0000000..0996c1b --- /dev/null +++ b/Tests/SpaceZCoreTests/RedactorAndSearchTests.swift @@ -0,0 +1,112 @@ +import XCTest +@testable import SpaceZCore + +final class RedactorTests: XCTestCase { + private func node(text: String?) -> InspectorNode { + InspectorNode( + id: NodeID(rawValue: 1), + type: "UILabel", + identifier: "CardNumberLabel", + label: text, + accessibilityLabel: text, + properties: text.map { ["text": .string($0), "tag": .number(3)] } ?? [:] + ) + } + + func testStrictPolicyRedactsSensitiveKeysAndTextualFields() { + let redacted = Redactor.redact(node(text: "4242 4242 4242 4242"), policy: .strict) + + XCTAssertEqual(redacted.properties["text"], .redacted) + XCTAssertEqual(redacted.properties["tag"], .number(3), "non-sensitive keys untouched") + XCTAssertEqual(redacted.label, "***") + XCTAssertEqual(redacted.accessibilityLabel, "***") + XCTAssertEqual( + redacted.identifier, "CardNumberLabel", + "developer-authored identifiers are not user content — search must keep working" + ) + } + + func testNilTextualFieldsStayNil() { + // "has no accessibility label" is diagnostic data; redaction must not + // fabricate a value where none existed. + let redacted = Redactor.redact(node(text: nil), policy: .strict) + XCTAssertNil(redacted.label) + XCTAssertNil(redacted.accessibilityLabel) + } + + func testNonePolicyPassesThrough() { + let original = node(text: "hello") + let redacted = Redactor.redact(original, policy: .none) + XCTAssertEqual(redacted, original) + } + + func testStructureSurvivesRedaction() { + let id = NodeID(rawValue: 7) + let child = NodeID(rawValue: 8) + let snapshot = Snapshot( + version: 1, + rootIDs: [id], + nodes: [ + id: InspectorNode(id: id, type: "root", children: [child]), + child: InspectorNode(id: child, type: "leaf"), + ] + ) + let redacted = Redactor.redact(snapshot, policy: .strict) + XCTAssertEqual(redacted.nodes[id]?.children, [child]) + XCTAssertEqual(redacted.version, snapshot.version) + XCTAssertEqual(redacted.nodeCount, 2) + } +} + +final class SnapshotSearchTests: XCTestCase { + private var snapshot: Snapshot { + let button = InspectorNode( + id: NodeID(rawValue: 1), + type: "UIButton", + identifier: "PayButton", + alpha: 1, + isUserInteractionEnabled: true + ) + let overlay = InspectorNode( + id: NodeID(rawValue: 2), + type: "LoadingOverlay", + alpha: 0, + isUserInteractionEnabled: true + ) + let label = InspectorNode( + id: NodeID(rawValue: 3), + type: "UILabel", + accessibilityLabel: "Price", + properties: ["text": .string("₩10,000")] + ) + return Snapshot( + version: 1, + rootIDs: [button.id], + nodes: [button.id: button, overlay.id: overlay, label.id: label] + ) + } + + func testPlainTextMatchesTypeAndIdentifier() { + // "PayButton" lives in `identifier` — the redaction-surviving field — + // so this exact search works over the remote protocol too. + XCTAssertEqual(snapshot.search("paybutton"), [NodeID(rawValue: 1)]) + XCTAssertEqual(Set(snapshot.search("UI")), [NodeID(rawValue: 1), NodeID(rawValue: 3)]) + } + + func testAttributeQueries() { + XCTAssertEqual(snapshot.search("alpha=0"), [NodeID(rawValue: 2)]) + XCTAssertEqual( + Set(snapshot.search("accessibilityLabel=nil")), + [NodeID(rawValue: 1), NodeID(rawValue: 2)] + ) + XCTAssertEqual( + Set(snapshot.search("interaction=true")), + [NodeID(rawValue: 1), NodeID(rawValue: 2)] + ) + XCTAssertEqual(snapshot.search("text=10,000"), [NodeID(rawValue: 3)]) + } + + func testEmptyQueryReturnsNothing() { + XCTAssertEqual(snapshot.search(" "), []) + } +} diff --git a/Tests/SpaceZCoreTests/SnapshotDiffTests.swift b/Tests/SpaceZCoreTests/SnapshotDiffTests.swift new file mode 100644 index 0000000..f09ae4a --- /dev/null +++ b/Tests/SpaceZCoreTests/SnapshotDiffTests.swift @@ -0,0 +1,69 @@ +import XCTest +@testable import SpaceZCore + +@MainActor +final class SnapshotDiffTests: XCTestCase { + func testNoChangesProducesEmptyChangeSet() { + let engine = makeEngine(roots: [TestElement("root", children: [TestElement("a")])]) + let first = engine.captureSnapshot() + let second = engine.captureSnapshot() + + let changes = SnapshotDiff.changes(from: first, to: second) + + XCTAssertTrue(changes.isEmpty) + XCTAssertEqual(changes.fromVersion, first.version) + XCTAssertEqual(changes.toVersion, second.version) + } + + func testPropertyChangeIsDetectedAsChanged() { + let child = TestElement("child") + let root = TestElement("root", children: [child]) + let engine = makeEngine(roots: [root]) + let before = engine.captureSnapshot() + let childID = before[before.rootIDs[0]]!.children[0] + + child.alpha = 0.3 + let after = engine.captureSnapshot() + let changes = SnapshotDiff.changes(from: before, to: after) + + XCTAssertEqual(changes.changed, [childID]) + XCTAssertTrue(changes.added.isEmpty) + XCTAssertTrue(changes.removed.isEmpty) + } + + func testAddedAndRemovedNodes() { + let removable = TestElement("removable") + let root = TestElement("root", children: [removable]) + let engine = makeEngine(roots: [root]) + let before = engine.captureSnapshot() + let removedID = before[before.rootIDs[0]]!.children[0] + + root.children = [TestElement("fresh")] + let after = engine.captureSnapshot() + let changes = SnapshotDiff.changes(from: before, to: after) + + XCTAssertEqual(changes.removed, [removedID]) + XCTAssertEqual(changes.added.count, 1) + // Parent's children list changed, so the parent is "changed". + XCTAssertEqual(changes.changed, [before.rootIDs[0]]) + XCTAssertEqual(changes.invalidatedIDs.count, 3) + } + + func testChangeIsLocalizedToSubtree() { + // Only the mutated node should be invalidated — this is what makes + // invalidation cheaper than full-tree transfer. + let deep = TestElement("deep") + let mid = TestElement("mid", children: [deep]) + let sibling = TestElement("sibling") + let root = TestElement("root", children: [mid, sibling]) + let engine = makeEngine(roots: [root]) + let before = engine.captureSnapshot() + + deep.hidden = true + let after = engine.captureSnapshot() + let changes = SnapshotDiff.changes(from: before, to: after) + + XCTAssertEqual(changes.changed.count, 1) + XCTAssertEqual(after[changes.changed.first!]?.type, "deep") + } +} diff --git a/Tests/SpaceZCoreTests/SnapshotPipelineTests.swift b/Tests/SpaceZCoreTests/SnapshotPipelineTests.swift new file mode 100644 index 0000000..aa4517d --- /dev/null +++ b/Tests/SpaceZCoreTests/SnapshotPipelineTests.swift @@ -0,0 +1,54 @@ +import XCTest +@testable import SpaceZCore + +final class SnapshotPipelineTests: XCTestCase { + private func snapshot(version: UInt64, alpha: Double = 1) -> Snapshot { + let id = NodeID(rawValue: 1) + return Snapshot( + version: version, + rootIDs: [id], + nodes: [id: InspectorNode(id: id, type: "root", alpha: alpha)] + ) + } + + func testSubscriberReceivesLatestOnSubscribe() async { + let pipeline = SnapshotPipeline() + await pipeline.publish(snapshot(version: 1)) + + let stream = await pipeline.subscribe() + var iterator = stream.makeAsyncIterator() + let received = await iterator.next() + + XCTAssertEqual(received?.version, 1) + } + + func testIdenticalSnapshotIsSuppressed() async { + let pipeline = SnapshotPipeline() + let forwarded1 = await pipeline.publish(snapshot(version: 1)) + // Same content, new version — renders identically, so no forward. + let forwarded2 = await pipeline.publish(snapshot(version: 2)) + let forwarded3 = await pipeline.publish(snapshot(version: 3, alpha: 0.5)) + + XCTAssertTrue(forwarded1) + XCTAssertFalse(forwarded2) + XCTAssertTrue(forwarded3) + let suppressed = await pipeline.suppressedNoOpCount + XCTAssertEqual(suppressed, 1) + } + + func testLatestStateWinsUnderBackpressure() async { + // A slow consumer must see the newest state, not a backlog: publish a + // burst before the consumer reads anything. + let pipeline = SnapshotPipeline() + let stream = await pipeline.subscribe() + + for version in 1...50 { + await pipeline.publish(snapshot(version: UInt64(version), alpha: Double(version) / 100)) + } + + var iterator = stream.makeAsyncIterator() + let first = await iterator.next() + // bufferingNewest(1): the single buffered element is the newest one. + XCTAssertEqual(first?.version, 50) + } +} diff --git a/Tests/SpaceZCoreTests/TestSupport.swift b/Tests/SpaceZCoreTests/TestSupport.swift new file mode 100644 index 0000000..13d74de --- /dev/null +++ b/Tests/SpaceZCoreTests/TestSupport.swift @@ -0,0 +1,73 @@ +import CoreGraphics +import Foundation +@testable import SpaceZCore + +/// Framework-free stand-in for a UI object tree — lets core tests (capture, +/// diff, IDs) run without UIKit. +@MainActor +final class TestElement { + var name: String + var frame: CGRect + var alpha: Double + var hidden: Bool + var interactive: Bool + var children: [TestElement] + + init( + _ name: String, + frame: CGRect = CGRect(x: 0, y: 0, width: 100, height: 100), + alpha: Double = 1, + hidden: Bool = false, + interactive: Bool = false, + children: [TestElement] = [] + ) { + self.name = name + self.frame = frame + self.alpha = alpha + self.hidden = hidden + self.interactive = interactive + self.children = children + } +} + +@MainActor +final class TestDescriptor: NodeDescriptor { + func supports(_ object: AnyObject) -> Bool { object is TestElement } + + func capture(_ object: AnyObject) -> CapturedNodeContent { + guard let element = object as? TestElement else { + return CapturedNodeContent(type: "?") + } + return CapturedNodeContent( + type: element.name, + frame: element.frame, + alpha: element.alpha, + isHidden: element.hidden, + isUserInteractionEnabled: element.interactive, + properties: ["name": .string(element.name)] + ) + } + + func children(of object: AnyObject) -> [AnyObject] { + (object as? TestElement)?.children ?? [] + } +} + +@MainActor +final class TestRootProvider: RootProvider { + var roots: [TestElement] + + init(roots: [TestElement]) { + self.roots = roots + } + + func rootObjects() -> [AnyObject] { roots } +} + +@MainActor +func makeEngine(roots: [TestElement]) -> CaptureEngine { + let engine = CaptureEngine() + engine.descriptors.register(TestDescriptor()) + engine.addRootProvider(TestRootProvider(roots: roots)) + return engine +} diff --git a/Tests/SpaceZRemoteTests/InspectorProtocolTests.swift b/Tests/SpaceZRemoteTests/InspectorProtocolTests.swift new file mode 100644 index 0000000..dca51f0 --- /dev/null +++ b/Tests/SpaceZRemoteTests/InspectorProtocolTests.swift @@ -0,0 +1,206 @@ +import XCTest +@testable import SpaceZCore +@testable import SpaceZRemote +@testable import SpaceZRules + +final class InspectorProtocolTests: XCTestCase { + private func roundTripRequest(_ request: InspectorRequest) throws -> InspectorRequest { + let data = try JSONEncoder().encode(request) + return try InspectorCodec.decodeRequest(data) + } + + func testRequestRoundTrips() throws { + let requests: [InspectorRequest] = [ + .hello(token: "abc123"), + .getRoot, + .getNodes(ids: [NodeID(rawValue: 1), NodeID(rawValue: 27)]), + .getTree, + .search(query: "alpha=0"), + .highlight(id: NodeID(rawValue: 42)), + .highlight(id: nil), + .setProperty(id: NodeID(rawValue: 5), key: "alpha", value: .number(0.5)), + .getIssues, + ] + for request in requests { + XCTAssertEqual(try roundTripRequest(request), request) + } + } + + func testResponseEncodesJSTypedFields() throws { + let node = InspectorNode( + id: NodeID(rawValue: 9), + type: "UIButton", + frame: CGRect(x: 1, y: 2, width: 3, height: 4), + children: [NodeID(rawValue: 10)] + ) + let data = try InspectorCodec.encode(.nodes(version: 7, nodes: [node])) + let json = try JSONSerialization.jsonObject(with: data) as! [String: Any] + + XCTAssertEqual(json["type"] as? String, "nodes") + XCTAssertEqual(json["version"] as? UInt64, 7) + let first = (json["nodes"] as! [[String: Any]])[0] + // NodeID must serialize as a bare number (the web client uses it as a + // Map key), and CGRect as [[x, y], [w, h]]. + XCTAssertEqual(first["id"] as? UInt64, 9) + XCTAssertEqual(first["children"] as? [UInt64], [10]) + let frame = first["frame"] as! [[Double]] + XCTAssertEqual(frame, [[1, 2], [3, 4]]) + } + + func testUnknownRequestTypeThrows() { + let data = Data(#"{"type":"formatDisk"}"#.utf8) + XCTAssertThrowsError(try InspectorCodec.decodeRequest(data)) + } + + func testMalformedJSONThrows() { + XCTAssertThrowsError(try InspectorCodec.decodeRequest(Data("not json".utf8))) + } + + func testIssueSerializes() throws { + let issue = Issue( + ruleID: "invisible-interaction", + nodeID: NodeID(rawValue: 3), + severity: .error, + message: "covered", + suggestion: "fix it" + ) + let data = try InspectorCodec.encode(.issues(version: 1, issues: [issue])) + let json = try JSONSerialization.jsonObject(with: data) as! [String: Any] + let first = (json["issues"] as! [[String: Any]])[0] + XCTAssertEqual(first["severity"] as? String, "error") + XCTAssertEqual(first["nodeID"] as? UInt64, 3) + } +} + +final class HTTPRequestTests: XCTestCase { + func testParsesRequestLineAndQuery() { + let raw = Data("GET /?token=abc%20d&x=1 HTTP/1.1\r\nHost: x\r\n\r\n".utf8) + let request = HTTPRequest.parse(raw) + XCTAssertEqual(request?.method, "GET") + XCTAssertEqual(request?.path, "/") + XCTAssertEqual(request?.query["token"], "abc d") + XCTAssertEqual(request?.query["x"], "1") + } + + func testIncompleteHeadReturnsNil() { + XCTAssertNil(HTTPRequest.parse(Data("GET / HTTP/1.1\r\nHost:".utf8))) + } + + func testResponseFormatting() { + let response = HTTPResponse.ok(contentType: "text/html", body: Data("hi".utf8)) + let text = String(data: response, encoding: .utf8)! + XCTAssertTrue(text.hasPrefix("HTTP/1.1 200 OK\r\n")) + XCTAssertTrue(text.contains("Content-Length: 2")) + XCTAssertTrue(text.hasSuffix("\r\n\r\nhi")) + } +} + +final class RemoteBridgeTests: XCTestCase { + private func makeBridge( + writable: Set = ["alpha"], + onSet: (@Sendable (NodeID, String, InspectorValue) -> Bool)? = nil + ) -> RemoteBridge { + RemoteBridge( + redaction: .strict, + writableKeys: writable, + handlers: RemoteUIHandlers( + highlight: { _ in }, + setProperty: { id, key, value in onSet?(id, key, value) ?? true } + ) + ) + } + + private func snapshot( + version: UInt64 = 1, text: String = "secret", alpha: Double = 1 + ) -> Snapshot { + let id = NodeID(rawValue: 1) + return Snapshot( + version: version, + rootIDs: [id], + nodes: [id: InspectorNode( + id: id, type: "UILabel", alpha: alpha, + properties: ["text": .string(text)] + )] + ) + } + + func testIngestRedactsBeforeServing() async { + let bridge = makeBridge() + await bridge.ingest(snapshot()) + + let response = await bridge.handle(.getNodes(ids: [NodeID(rawValue: 1)])) + guard case .nodes(_, let nodes) = response else { + return XCTFail("expected nodes response") + } + XCTAssertEqual( + nodes[0].properties["text"], .redacted, + "raw text must never be servable from the bridge" + ) + } + + func testSetPropertyRejectsNonAllowlistedKey() async { + let bridge = makeBridge(writable: ["alpha"]) + await bridge.ingest(snapshot()) + + let response = await bridge.handle( + .setProperty(id: NodeID(rawValue: 1), key: "text", value: .string("pwned")) + ) + guard case .ack(let ok, _) = response else { return XCTFail("expected ack") } + XCTAssertFalse(ok) + } + + func testSetPropertyAllowsAllowlistedKey() async { + let bridge = makeBridge(writable: ["alpha"]) + await bridge.ingest(snapshot()) + + let response = await bridge.handle( + .setProperty(id: NodeID(rawValue: 1), key: "alpha", value: .number(0.5)) + ) + guard case .ack(let ok, _) = response else { return XCTFail("expected ack") } + XCTAssertTrue(ok) + } + + func testChangePushesInvalidationNotContent() async { + let bridge = makeBridge() + let received = ReceivedBox() + // Append synchronously so push order is preserved deterministically. + await bridge.setBroadcast { response in + received.append(response) + } + + await bridge.ingest(snapshot(version: 1, text: "a")) + // Redaction maps both texts to [REDACTED]; redacted content is + // identical, so this change is invisible to clients → no push. + await bridge.ingest(snapshot(version: 2, text: "b")) + // A visible change (alpha) must push an invalidation carrying IDs, + // never node content. + await bridge.ingest(snapshot(version: 3, text: "b", alpha: 0.4)) + + let messages = received.snapshotMessages() + XCTAssertEqual(messages.count, 2) + guard case .root = messages[0] else { return XCTFail("expected root push first") } + guard case .invalidate(let version, let invalidated, let removed, _) = messages[1] else { + return XCTFail("expected invalidate push") + } + XCTAssertEqual(version, 3) + XCTAssertEqual(invalidated, [NodeID(rawValue: 1)]) + XCTAssertTrue(removed.isEmpty) + } +} + +private final class ReceivedBox: @unchecked Sendable { + private let lock = NSLock() + private var messages: [InspectorResponse] = [] + + func append(_ response: InspectorResponse) { + lock.lock() + defer { lock.unlock() } + messages.append(response) + } + + func snapshotMessages() -> [InspectorResponse] { + lock.lock() + defer { lock.unlock() } + return messages + } +} diff --git a/Tests/SpaceZRulesTests/BuiltInRulesTests.swift b/Tests/SpaceZRulesTests/BuiltInRulesTests.swift new file mode 100644 index 0000000..8ad22be --- /dev/null +++ b/Tests/SpaceZRulesTests/BuiltInRulesTests.swift @@ -0,0 +1,183 @@ +import XCTest +@testable import SpaceZCore +@testable import SpaceZRules + +final class BuiltInRulesTests: XCTestCase { + private var nextID: UInt64 = 0 + + private func makeNode( + type: String = "UIView", + frame: CGRect = CGRect(x: 0, y: 0, width: 200, height: 200), + alpha: Double = 1, + hidden: Bool = false, + clips: Bool = false, + interactive: Bool = false, + accessibilityLabel: String? = nil, + children: [NodeID] = [], + properties: [String: InspectorValue] = [:] + ) -> InspectorNode { + nextID += 1 + return InspectorNode( + id: NodeID(rawValue: nextID), + type: type, + frame: frame, + alpha: alpha, + isHidden: hidden, + clipsToBounds: clips, + isUserInteractionEnabled: interactive, + accessibilityLabel: accessibilityLabel, + children: children, + properties: properties + ) + } + + private func makeSnapshot(_ nodes: [InspectorNode], roots: [NodeID]? = nil) -> Snapshot { + Snapshot( + version: 1, + rootIDs: roots ?? [nodes[0].id], + nodes: Dictionary(uniqueKeysWithValues: nodes.map { ($0.id, $0) }) + ) + } + + // MARK: - InvisibleInteraction + + func testInvisibleInteractiveOverlayIsFlagged() { + // The lecture's canonical bug: LoadingOverlay alpha 0.01 over PayButton. + let overlay = makeNode( + type: "LoadingOverlay", + frame: CGRect(x: 0, y: 0, width: 393, height: 852), + alpha: 0.01, + interactive: true + ) + let snapshot = makeSnapshot([overlay]) + let issues = RuleEngine(rules: [InvisibleInteractionRule()]).evaluate(snapshot) + + XCTAssertEqual(issues.count, 1) + XCTAssertEqual(issues[0].severity, .error) + XCTAssertEqual(issues[0].nodeID, overlay.id) + } + + func testVisibleOrInertViewsAreNotFlagged() { + let visible = makeNode(alpha: 1, interactive: true) + let inert = makeNode(alpha: 0.01, interactive: false) + let tiny = makeNode( + frame: CGRect(x: 0, y: 0, width: 10, height: 10), alpha: 0.01, interactive: true + ) + let snapshot = makeSnapshot([visible, inert, tiny]) + XCTAssertTrue(RuleEngine(rules: [InvisibleInteractionRule()]).evaluate(snapshot).isEmpty) + } + + // MARK: - MissingAccessibilityLabel + + func testUnlabeledButtonIsFlagged() { + let button = makeNode(type: "UIButton", properties: ["title": .null]) + let snapshot = makeSnapshot([button]) + let issues = RuleEngine(rules: [MissingAccessibilityLabelRule()]).evaluate(snapshot) + XCTAssertEqual(issues.map(\.nodeID), [button.id]) + } + + func testTitledOrLabeledButtonsPass() { + let titled = makeNode(type: "UIButton", properties: ["title": .string("Pay")]) + let labeled = makeNode(type: "UIButton", accessibilityLabel: "Pay") + let plainView = makeNode(type: "UIView") + let snapshot = makeSnapshot([titled, labeled, plainView]) + XCTAssertTrue( + RuleEngine(rules: [MissingAccessibilityLabelRule()]).evaluate(snapshot).isEmpty + ) + } + + // MARK: - DeepHierarchy + + func testDepthThresholdFiresExactlyOncePerChain() { + var nodes: [InspectorNode] = [] + var childID: NodeID? + for _ in 0..<30 { + let node = makeNode(children: childID.map { [$0] } ?? []) + nodes.append(node) + childID = node.id + } + let snapshot = makeSnapshot(nodes.reversed().map { $0 }, roots: [nodes.last!.id]) + let issues = RuleEngine(rules: [DeepHierarchyRule(maxDepth: 25)]).evaluate(snapshot) + XCTAssertEqual(issues.count, 1, "one report at the crossing node, not per descendant") + } + + // MARK: - MassiveSiblings + + func testTooManyChildrenIsFlagged() { + let children = (0..<60).map { _ in makeNode() } + let parent = makeNode(type: "UIStackView", children: children.map(\.id)) + let snapshot = makeSnapshot([parent] + children) + let issues = RuleEngine(rules: [MassiveSiblingsRule(maxChildren: 50)]).evaluate(snapshot) + XCTAssertEqual(issues.map(\.nodeID), [parent.id]) + } + + // MARK: - ClippedChild + + func testChildOutsideClippingParentIsFlagged() { + let child = makeNode(frame: CGRect(x: 380, y: 0, width: 100, height: 40)) + let parent = makeNode( + frame: CGRect(x: 0, y: 0, width: 393, height: 100), + clips: true, + children: [child.id] + ) + let snapshot = makeSnapshot([parent, child]) + let issues = RuleEngine(rules: [ClippedChildRule()]).evaluate(snapshot) + XCTAssertEqual(issues.map(\.nodeID), [child.id]) + } + + func testScrollViewChildrenAreExempt() { + let child = makeNode(frame: CGRect(x: 0, y: 900, width: 393, height: 44)) + let scroll = makeNode( + type: "UIScrollView", + frame: CGRect(x: 0, y: 0, width: 393, height: 852), + clips: true, + children: [child.id] + ) + let snapshot = makeSnapshot([scroll, child]) + XCTAssertTrue(RuleEngine(rules: [ClippedChildRule()]).evaluate(snapshot).isEmpty) + } + + // MARK: - HiddenSubtree + + func testHiddenViewWithManyDescendantsIsFlaggedOnceAtTop() { + let leaves = (0..<25).map { _ in makeNode() } + let hiddenParent = makeNode(hidden: true, children: leaves.map(\.id)) + let root = makeNode(children: [hiddenParent.id]) + let snapshot = makeSnapshot([root, hiddenParent] + leaves, roots: [root.id]) + let issues = RuleEngine( + rules: [HiddenSubtreeRule(minimumDescendants: 20)] + ).evaluate(snapshot) + XCTAssertEqual(issues.map(\.nodeID), [hiddenParent.id]) + } + + // MARK: - Engine ordering & extensibility + + func testIssuesSortedBySeverity() { + let overlay = makeNode(alpha: 0.01, interactive: true) + let children = (0..<60).map { _ in makeNode() } + let crowded = makeNode(children: children.map(\.id)) + let snapshot = makeSnapshot([overlay, crowded] + children) + let issues = RuleEngine( + rules: [MassiveSiblingsRule(), InvisibleInteractionRule()] + ).evaluate(snapshot) + XCTAssertEqual(issues.first?.severity, .error) + } + + func testCustomRuleCanBeRegistered() { + struct DeprecatedComponentRule: UIRule { + let id = "deprecated-component" + func inspect(node: InspectorNode, context: InspectionContext) -> [Issue] { + guard node.type == "DeprecatedPrimaryButton" else { return [] } + return [Issue( + ruleID: id, nodeID: node.id, severity: .warning, + message: "Deprecated component", suggestion: "Use DesignSystem.PrimaryButton" + )] + } + } + let deprecated = makeNode(type: "DeprecatedPrimaryButton") + var engine = RuleEngine(rules: []) + engine.register(DeprecatedComponentRule()) + let issues = engine.evaluate(makeSnapshot([deprecated])) + XCTAssertEqual(issues.map(\.ruleID), ["deprecated-component"]) + } +}