commit 3ece0937a8964045c6af89cea5e1d2582822a6a4 Author: Shadow Date: Sat Jan 10 11:59:49 2026 -0600 Squashed commit diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5d735e9 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,35 @@ +name: CI + +on: + push: + branches: [ main ] + pull_request: + +jobs: + build: + runs-on: macos-14 + steps: + - uses: actions/checkout@v4 + - name: Build + run: | + xcodebuild \ + -project Casa.xcodeproj \ + -scheme Casa \ + -configuration Debug \ + -destination "platform=macOS,variant=Mac Catalyst" \ + -derivedDataPath DerivedData \ + build + + test: + runs-on: macos-14 + steps: + - uses: actions/checkout@v4 + - name: Test + run: | + xcodebuild \ + -project Casa.xcodeproj \ + -scheme Casa \ + -configuration Debug \ + -destination "platform=macOS,variant=Mac Catalyst" \ + -derivedDataPath DerivedData \ + test diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..bc56b19 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,136 @@ +name: Release + +on: + push: + tags: + - "v*" + workflow_dispatch: + inputs: + tag: + description: "Release tag (e.g., v1.2.3)" + required: true + +jobs: + build: + runs-on: macos-14 + env: + APP_NAME: Casa + APP_SCHEME: Casa + APP_PROJECT: Casa.xcodeproj + DESTINATION: platform=macOS,variant=Mac Catalyst + SPARKLE_FEED_URL: https://github.com/${{ github.repository }}/releases/latest/download/appcast.xml + + steps: + - uses: actions/checkout@v4 + + - name: Determine release tag + env: + INPUT_TAG: ${{ github.event.inputs.tag }} + run: | + if [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ]; then + if [ -z "$INPUT_TAG" ]; then + echo "Missing tag input for workflow_dispatch" >&2 + exit 1 + fi + RELEASE_TAG=$INPUT_TAG + else + RELEASE_TAG=$GITHUB_REF_NAME + fi + + if [[ "$RELEASE_TAG" != v* ]]; then + echo "Tag should start with v, got $RELEASE_TAG" >&2 + exit 1 + fi + + VERSION=${RELEASE_TAG#v} + + echo "RELEASE_TAG=$RELEASE_TAG" >> $GITHUB_ENV + echo "VERSION=$VERSION" >> $GITHUB_ENV + + - name: Import signing certificate + env: + CERT_BASE64: ${{ secrets.APPLE_CERT_BASE64 }} + CERT_PASSWORD: ${{ secrets.APPLE_CERT_PASSWORD }} + KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} + run: | + if [ -z "$CERT_BASE64" ]; then + echo "Missing APPLE_CERT_BASE64" >&2 + exit 1 + fi + KEYCHAIN=build.keychain + CERT_PATH=$RUNNER_TEMP/signing.p12 + echo "$CERT_BASE64" | base64 --decode > "$CERT_PATH" + security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN" + security set-keychain-settings -lut 21600 "$KEYCHAIN" + security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN" + security import "$CERT_PATH" -k "$KEYCHAIN" -P "$CERT_PASSWORD" -T /usr/bin/codesign + security list-keychains -s "$KEYCHAIN" login.keychain + + - name: Install provisioning profile + env: + PROFILE_BASE64: ${{ secrets.APPLE_PROVISION_PROFILE_BASE64 }} + run: | + if [ -z "$PROFILE_BASE64" ]; then + echo "Missing APPLE_PROVISION_PROFILE_BASE64" >&2 + exit 1 + fi + PROFILE_DIR="$HOME/Library/MobileDevice/Provisioning Profiles" + mkdir -p "$PROFILE_DIR" + PROFILE_PATH="$PROFILE_DIR/casa.provisionprofile" + echo "$PROFILE_BASE64" | base64 --decode > "$PROFILE_PATH" + + - name: Build + env: + SPARKLE_PUBLIC_KEY: ${{ secrets.SPARKLE_PUBLIC_KEY }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + VERSION: ${{ env.VERSION }} + run: | + xcodebuild \ + -project "$APP_PROJECT" \ + -scheme "$APP_SCHEME" \ + -configuration Release \ + -destination "$DESTINATION" \ + -derivedDataPath DerivedData \ + SPARKLE_PUBLIC_KEY="$SPARKLE_PUBLIC_KEY" \ + SPARKLE_FEED_URL="$SPARKLE_FEED_URL" \ + DEVELOPMENT_TEAM="$APPLE_TEAM_ID" \ + CODE_SIGN_IDENTITY="$APPLE_SIGNING_IDENTITY" \ + MARKETING_VERSION="$VERSION" \ + CURRENT_PROJECT_VERSION="$GITHUB_RUN_NUMBER" \ + build + + - name: Package app + run: | + APP_PATH="DerivedData/Build/Products/Release-maccatalyst/$APP_NAME.app" + mkdir -p dist + ditto -c -k --sequesterRsrc --keepParent "$APP_PATH" "dist/$APP_NAME.zip" + + - name: Install Sparkle tools + run: | + brew update + brew install sparkle + + - name: Generate appcast + env: + SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }} + run: | + if [ -z "$SPARKLE_PRIVATE_KEY" ]; then + echo "Missing SPARKLE_PRIVATE_KEY" >&2 + exit 1 + fi + KEY_PATH="$RUNNER_TEMP/sparkle_private_key" + echo "$SPARKLE_PRIVATE_KEY" > "$KEY_PATH" + generate_appcast \ + --download-url-prefix "https://github.com/${{ github.repository }}/releases/download/${{ env.RELEASE_TAG }}" \ + --signing-key "$KEY_PATH" \ + -o dist/appcast.xml \ + dist + + - name: Upload release assets + uses: softprops/action-gh-release@v2 + with: + files: | + dist/Casa.zip + dist/appcast.xml + tag_name: ${{ env.RELEASE_TAG }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0cee3d1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +.DS_Store +DerivedData/ +.build/ +CasaCLI/.build/ +Packages/ +*.xcuserstate +*.xcuserdata/ +*.xccheckout +*.xcscmblueprint diff --git a/.swiftlint.yml b/.swiftlint.yml new file mode 100644 index 0000000..705d12d --- /dev/null +++ b/.swiftlint.yml @@ -0,0 +1,10 @@ +included: + - Casa +opt_in_rules: + - empty_count + - empty_string + - implicitly_unwrapped_optional + - sorted_imports +line_length: + warning: 120 + error: 160 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..00280cf --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,43 @@ +# Repository Guidelines + +## Project Structure & Module Organization + +- `Casa/` holds the Mac Catalyst app source, SwiftUI views, HomeKit server, and app settings. +- `CasaTests/` contains XCTest unit tests. +- `CasaCLI/` is a Swift Package for the `casa` CLI (embedded into the app build). +- `Casa.xcodeproj/` is the primary Xcode project and scheme. +- `scripts/` contains developer utilities (linting, local restart). +- `Casa/Assets.xcassets/` stores icons and asset catalogs. + +## Build, Test, and Development Commands + +- `./scripts/restart-mac.sh` builds and relaunches the app locally (terminal flow). +- `./scripts/lint.sh` runs SwiftLint with `.swiftlint.yml`. +- `cd CasaCLI && swift run casa devices` runs the CLI directly from the package. +- Xcode: open `Casa.xcodeproj`, set your Team, enable HomeKit capability, then Build/Run. +- Tests: run Product > Test in Xcode (or `xcodebuild -scheme Casa -destination 'platform=macOS' test`, adjust destination as needed). + +## Coding Style & Naming Conventions + +- Swift style follows Xcode defaults (4-space indentation, Swift API naming). +- Keep method names descriptive and use `test...` for XCTest methods. +- SwiftLint is enabled with a 120 warning / 160 error line length; keep lines within limits. + +## Testing Guidelines + +- Framework: XCTest in `CasaTests/`. +- Prefer small, fast unit tests that exercise request/response parsing and payload encoding. +- Name tests with the `test` prefix (e.g., `testHTTPResponseEnvelope`). + +## Commit & Pull Request Guidelines + +- Commit messages use short, imperative summaries (e.g., "Add settings and logging"). +- PRs should include: a clear summary, testing notes, and screenshots for UI changes. +- Link related issues or describe motivation when no issue exists. + +## Security & Configuration Notes + +- HomeKit entitlement is required; run once from Xcode to trigger permissions. +- Sparkle update settings use `SPARKLE_FEED_URL` and `SPARKLE_PUBLIC_KEY` for builds. +- The local API is loopback-only; keep any auth tokens out of source control. +- When running `./scripts/restart-mac.sh` locally you may see a missing “Mac Development” certificate error because the sandboxed agent cannot access your keychain; give the CLI full filesystem access (or run outside the sandbox) so Xcode can read your certs and provisioning profiles. diff --git a/Casa.xcodeproj/project.pbxproj b/Casa.xcodeproj/project.pbxproj new file mode 100644 index 0000000..b293a68 --- /dev/null +++ b/Casa.xcodeproj/project.pbxproj @@ -0,0 +1,485 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 56; + objects = { + +/* Begin PBXBuildFile section */ + A10000000000000000000021 /* CasaApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000011 /* CasaApp.swift */; }; + A10000000000000000000022 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000012 /* ContentView.swift */; }; + A10000000000000000000023 /* HomeKitManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000013 /* HomeKitManager.swift */; }; + A10000000000000000000024 /* HomeKitServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000014 /* HomeKitServer.swift */; }; + A10000000000000000000026 /* CLIInstaller.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000016 /* CLIInstaller.swift */; }; + A10000000000000000000027 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000019 /* Assets.xcassets */; }; + A10000000000000000000031 /* HomeKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A1000000000000000000001C /* HomeKit.framework */; }; + A10000000000000000000032 /* Network.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A1000000000000000000001D /* Network.framework */; }; + A10000000000000000000036 /* CasaSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000034 /* CasaSettings.swift */; }; + A10000000000000000000037 /* CasaLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000035 /* CasaLogger.swift */; }; + A10000000000000000000039 /* CasaModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000038 /* CasaModels.swift */; }; + A1000000000000000000003B /* ApiDocsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1000000000000000000003A /* ApiDocsView.swift */; }; + A10000000000000000000055 /* SparkleUpdater.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000054 /* SparkleUpdater.swift */; }; + A10000000000000000000057 /* CasaPasteboard.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000056 /* CasaPasteboard.swift */; }; + A10000000000000000000059 /* LogsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000058 /* LogsView.swift */; }; + A10000000000000000000060 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000061 /* SettingsView.swift */; }; + A10000000000000000000065 /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000066 /* OnboardingView.swift */; }; + A10000000000000000000067 /* ModuleUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000068 /* ModuleUI.swift */; }; + A10000000000000000000042 /* CasaTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000041 /* CasaTests.swift */; }; + A1000000000000000000004C /* XCTest.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A1000000000000000000004B /* XCTest.framework */; }; + A1000000000000000000004F /* HomeKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A1000000000000000000001C /* HomeKit.framework */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + A10000000000000000000011 /* CasaApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CasaApp.swift; sourceTree = ""; }; + A10000000000000000000012 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + A10000000000000000000013 /* HomeKitManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeKitManager.swift; sourceTree = ""; }; + A10000000000000000000014 /* HomeKitServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeKitServer.swift; sourceTree = ""; }; + A10000000000000000000016 /* CLIInstaller.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLIInstaller.swift; sourceTree = ""; }; + A10000000000000000000017 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + A10000000000000000000018 /* Casa.base.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Casa.base.entitlements; sourceTree = ""; }; + A10000000000000000000019 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + A1000000000000000000001B /* Casa.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; path = Casa.app; sourceTree = BUILT_PRODUCTS_DIR; }; + A1000000000000000000001C /* HomeKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = HomeKit.framework; path = System/Library/Frameworks/HomeKit.framework; sourceTree = SDKROOT; }; + A1000000000000000000001D /* Network.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Network.framework; path = System/Library/Frameworks/Network.framework; sourceTree = SDKROOT; }; + A10000000000000000000034 /* CasaSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CasaSettings.swift; sourceTree = ""; }; + A10000000000000000000035 /* CasaLogger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CasaLogger.swift; sourceTree = ""; }; + A10000000000000000000038 /* CasaModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CasaModels.swift; sourceTree = ""; }; + A1000000000000000000003A /* ApiDocsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApiDocsView.swift; sourceTree = ""; }; + A10000000000000000000054 /* SparkleUpdater.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SparkleUpdater.swift; sourceTree = ""; }; + A10000000000000000000056 /* CasaPasteboard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CasaPasteboard.swift; sourceTree = ""; }; + A10000000000000000000058 /* LogsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogsView.swift; sourceTree = ""; }; + A10000000000000000000061 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; + A10000000000000000000066 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = ""; }; + A10000000000000000000068 /* ModuleUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModuleUI.swift; sourceTree = ""; }; + A10000000000000000000041 /* CasaTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CasaTests.swift; sourceTree = ""; }; + A10000000000000000000043 /* CasaTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; path = CasaTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + A1000000000000000000004B /* XCTest.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = XCTest.framework; path = System/Library/Frameworks/XCTest.framework; sourceTree = SDKROOT; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + A10000000000000000000010 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + A10000000000000000000031 /* HomeKit.framework in Frameworks */, + A10000000000000000000032 /* Network.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + A10000000000000000000045 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + A1000000000000000000004C /* XCTest.framework in Frameworks */, + A1000000000000000000004F /* HomeKit.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + A10000000000000000000002 = { + isa = PBXGroup; + children = ( + A10000000000000000000003 /* Casa */, + A10000000000000000000004 /* Casa */, + A10000000000000000000050 /* CasaTests */, + A10000000000000000000005 /* Frameworks */, + A10000000000000000000006 /* Products */, + ); + sourceTree = ""; + }; + A10000000000000000000003 /* Casa */ = { + isa = PBXGroup; + children = ( + A10000000000000000000011 /* CasaApp.swift */, + A10000000000000000000012 /* ContentView.swift */, + A1000000000000000000003A /* ApiDocsView.swift */, + A10000000000000000000058 /* LogsView.swift */, + A10000000000000000000061 /* SettingsView.swift */, + A10000000000000000000066 /* OnboardingView.swift */, + A10000000000000000000068 /* ModuleUI.swift */, + A10000000000000000000035 /* CasaLogger.swift */, + A10000000000000000000038 /* CasaModels.swift */, + A10000000000000000000056 /* CasaPasteboard.swift */, + A10000000000000000000054 /* SparkleUpdater.swift */, + A10000000000000000000034 /* CasaSettings.swift */, + A10000000000000000000013 /* HomeKitManager.swift */, + A10000000000000000000014 /* HomeKitServer.swift */, + A10000000000000000000016 /* CLIInstaller.swift */, + ); + path = Casa; + sourceTree = ""; + }; + A10000000000000000000004 /* Casa */ = { + isa = PBXGroup; + children = ( + A10000000000000000000017 /* Info.plist */, + A10000000000000000000018 /* Casa.base.entitlements */, + A10000000000000000000019 /* Assets.xcassets */, + ); + path = Casa; + sourceTree = ""; + }; + A10000000000000000000005 /* Frameworks */ = { + isa = PBXGroup; + children = ( + A1000000000000000000001C /* HomeKit.framework */, + A1000000000000000000001D /* Network.framework */, + A1000000000000000000004B /* XCTest.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; + A10000000000000000000050 /* CasaTests */ = { + isa = PBXGroup; + children = ( + A10000000000000000000041 /* CasaTests.swift */, + ); + path = CasaTests; + sourceTree = ""; + }; + A10000000000000000000006 /* Products */ = { + isa = PBXGroup; + children = ( + A1000000000000000000001B /* Casa.app */, + A10000000000000000000043 /* CasaTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + A10000000000000000000007 /* Casa */ = { + isa = PBXNativeTarget; + buildConfigurationList = A10000000000000000000008 /* Build configuration list for PBXNativeTarget "Casa" */; + buildPhases = ( + A1000000000000000000000E /* Sources */, + A1000000000000000000000F /* Resources */, + A10000000000000000000040 /* Build CasaCLI */, + A10000000000000000000010 /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Casa; + productName = Casa; + productReference = A1000000000000000000001B /* Casa.app */; + productType = "com.apple.product-type.application"; + }; + A10000000000000000000047 /* CasaTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = A10000000000000000000048 /* Build configuration list for PBXNativeTarget "CasaTests" */; + buildPhases = ( + A10000000000000000000044 /* Sources */, + A10000000000000000000046 /* Resources */, + A10000000000000000000045 /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + A1000000000000000000004D /* PBXTargetDependency */, + ); + name = CasaTests; + productName = CasaTests; + productReference = A10000000000000000000043 /* CasaTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + A10000000000000000000001 /* Project object */ = { + isa = PBXProject; + attributes = { + LastUpgradeCheck = 1500; + TargetAttributes = { + A10000000000000000000007 = { + CreatedOnToolsVersion = 15.0; + }; + A10000000000000000000047 = { + CreatedOnToolsVersion = 15.0; + }; + }; + }; + buildConfigurationList = A10000000000000000000009 /* Build configuration list for PBXProject "Casa" */; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + ); + mainGroup = A10000000000000000000002; + productRefGroup = A10000000000000000000006 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + A10000000000000000000007 /* Casa */, + A10000000000000000000047 /* CasaTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + A1000000000000000000000F /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + A10000000000000000000027 /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + A10000000000000000000046 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + A10000000000000000000040 /* Build CasaCLI */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + basedOnDependencyAnalysis = 0; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + outputFileListPaths = ( + ); + outputPaths = ( + "$(BUILT_PRODUCTS_DIR)/$(PRODUCT_NAME).app/Contents/Resources/casa", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "set -euo pipefail\nCLI_DIR=\"${SRCROOT}/CasaCLI\"\nCONFIG=\"${CONFIGURATION}\"\nif [ \"$CONFIG\" = \"Release\" ]; then\n BUILD_CONFIG=release\nelse\n BUILD_CONFIG=debug\nfi\nswift build -c \"$BUILD_CONFIG\" --package-path \"$CLI_DIR\"\nCLI_BIN=\"$CLI_DIR/.build/$BUILD_CONFIG/casa\"\nif [ ! -f \"$CLI_BIN\" ]; then\n echo \"Casa CLI binary not found at $CLI_BIN\" >&2\n exit 1\nfi\nTARGET=\"$BUILT_PRODUCTS_DIR/$PRODUCT_NAME.app/Contents/Resources/casa\"\nTARGET_DIR=`dirname \"$TARGET\"`\nmkdir -p \"$TARGET_DIR\"\ncp \"$CLI_BIN\" \"$TARGET\"\nchmod +x \"$TARGET\""; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + A1000000000000000000000E /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + A10000000000000000000021 /* CasaApp.swift in Sources */, + A10000000000000000000022 /* ContentView.swift in Sources */, + A1000000000000000000003B /* ApiDocsView.swift in Sources */, + A10000000000000000000037 /* CasaLogger.swift in Sources */, + A10000000000000000000039 /* CasaModels.swift in Sources */, + A10000000000000000000059 /* LogsView.swift in Sources */, + A10000000000000000000060 /* SettingsView.swift in Sources */, + A10000000000000000000057 /* CasaPasteboard.swift in Sources */, + A10000000000000000000055 /* SparkleUpdater.swift in Sources */, + A10000000000000000000036 /* CasaSettings.swift in Sources */, + A10000000000000000000023 /* HomeKitManager.swift in Sources */, + A10000000000000000000024 /* HomeKitServer.swift in Sources */, + A10000000000000000000026 /* CLIInstaller.swift in Sources */, + A10000000000000000000065 /* OnboardingView.swift in Sources */, + A10000000000000000000067 /* ModuleUI.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + A10000000000000000000044 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + A10000000000000000000042 /* CasaTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + A1000000000000000000004D /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = A10000000000000000000007 /* Casa */; + targetProxy = A1000000000000000000004E /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXContainerItemProxy section */ + A1000000000000000000004E /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = A10000000000000000000001 /* Project object */; + proxyType = 1; + remoteGlobalIDString = A10000000000000000000007; + remoteInfo = Casa; + }; +/* End PBXContainerItemProxy section */ + +/* Begin XCBuildConfiguration section */ + A1000000000000000000000A /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + DEVELOPMENT_TEAM = ""; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + SDKROOT = iphoneos; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + A1000000000000000000000B /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + DEVELOPMENT_TEAM = ""; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + SDKROOT = iphoneos; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + A1000000000000000000000C /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Casa/Casa.base.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = XY32MW6N78; + INFOPLIST_FILE = Casa/Info.plist; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 0.1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.clawdbot.casa; + PRODUCT_NAME = Casa; + SPARKLE_FEED_URL = "https://github.com/yourname/casa/releases/latest/download/appcast.xml"; + SPARKLE_PUBLIC_KEY = "CHANGE_ME"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 2; + }; + name = Debug; + }; + A1000000000000000000000D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Casa/Casa.base.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = XY32MW6N78; + INFOPLIST_FILE = Casa/Info.plist; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 0.1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.clawdbot.casa; + PRODUCT_NAME = Casa; + SPARKLE_FEED_URL = "https://github.com/yourname/casa/releases/latest/download/appcast.xml"; + SPARKLE_PUBLIC_KEY = "CHANGE_ME"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 2; + }; + name = Release; + }; + A10000000000000000000049 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_IDENTITY = "Apple Development"; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = XY32MW6N78; + GENERATE_INFOPLIST_FILE = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.clawdbot.casa.tests; + PRODUCT_NAME = CasaTests; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 2; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Casa.app/Casa"; + }; + name = Debug; + }; + A1000000000000000000004A /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_IDENTITY = "Apple Development"; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = XY32MW6N78; + GENERATE_INFOPLIST_FILE = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.clawdbot.casa.tests; + PRODUCT_NAME = CasaTests; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 2; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Casa.app/Casa"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + A10000000000000000000008 /* Build configuration list for PBXNativeTarget "Casa" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A1000000000000000000000C /* Debug */, + A1000000000000000000000D /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + A10000000000000000000048 /* Build configuration list for PBXNativeTarget "CasaTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A10000000000000000000049 /* Debug */, + A1000000000000000000004A /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + A10000000000000000000009 /* Build configuration list for PBXProject "Casa" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A1000000000000000000000A /* Debug */, + A1000000000000000000000B /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; +/* End XCConfigurationList section */ + }; + rootObject = A10000000000000000000001 /* Project object */; +} diff --git a/Casa.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Casa.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/Casa.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/Casa.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Casa.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..f55129a --- /dev/null +++ b/Casa.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,15 @@ +{ + "originHash" : "e721da7f9826abdffcb6185e886155efa2514bd6234475f1afa893e29eb258d6", + "pins" : [ + { + "identity" : "sparkle", + "kind" : "remoteSourceControl", + "location" : "https://github.com/sparkle-project/Sparkle", + "state" : { + "revision" : "5581748cef2bae787496fe6d61139aebe0a451f6", + "version" : "2.8.1" + } + } + ], + "version" : 3 +} diff --git a/Casa.xcodeproj/xcshareddata/xcschemes/Casa.xcscheme b/Casa.xcodeproj/xcshareddata/xcschemes/Casa.xcscheme new file mode 100644 index 0000000..5bc2eea --- /dev/null +++ b/Casa.xcodeproj/xcshareddata/xcschemes/Casa.xcscheme @@ -0,0 +1,111 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Casa.xcodeproj/xcuserdata/shadow.xcuserdatad/xcschemes/xcschememanagement.plist b/Casa.xcodeproj/xcuserdata/shadow.xcuserdatad/xcschemes/xcschememanagement.plist new file mode 100644 index 0000000..06496bf --- /dev/null +++ b/Casa.xcodeproj/xcuserdata/shadow.xcuserdatad/xcschemes/xcschememanagement.plist @@ -0,0 +1,14 @@ + + + + + SchemeUserState + + Casa.xcscheme_^#shared#^_ + + orderHint + 0 + + + + diff --git a/Casa/ApiDocsView.swift b/Casa/ApiDocsView.swift new file mode 100644 index 0000000..9907271 --- /dev/null +++ b/Casa/ApiDocsView.swift @@ -0,0 +1,281 @@ +import SwiftUI +import UIKit +import HomeKit + +struct ApiDocsView: View { + @EnvironmentObject private var model: CasaAppModel + @ObservedObject private var settings = CasaSettings.shared + let accessories: [HMAccessory] + @Binding var selectedAccessoryId: UUID? + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + header + if settings.homeKitEnabled { + accessoryFilter + } else { + Text("HomeKit module is disabled. Enable it in Settings to view HomeKit endpoints.") + .font(.caption) + .foregroundColor(.secondary) + } + ForEach(endpoints) { endpoint in + EndpointCard(endpoint: endpoint, onCopy: copyToPasteboard) + } + } + .padding(.horizontal, 20) + .padding(.bottom, 20) + .padding(.top, 10) + } + } + + private var header: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Local API") + .font(.title2) + Text("Base URL: \(baseURL)") + .font(.caption) + .foregroundColor(.secondary) + Text(authNote) + .font(.caption) + .foregroundColor(.secondary) + } + } + + private var accessoryFilter: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Accessory Filter") + .font(.headline) + Picker("Accessory", selection: $selectedAccessoryId) { + Text("All accessories").tag(Optional.none) + ForEach(accessories, id: \.uniqueIdentifier) { accessory in + Text(accessory.name).tag(Optional(accessory.uniqueIdentifier)) + } + } + .pickerStyle(.menu) + + if let accessory = selectedAccessory { + Text("Filtering examples for \(accessory.name)") + .font(.caption) + .foregroundColor(.secondary) + } + } + } + + private var baseURL: String { + "http://127.0.0.1:\(settings.port)" + } + + private var authHeader: String { + guard !settings.authToken.isEmpty else { return "" } + return " -H 'X-Casa-Token: \(settings.authToken)'" + } + + private var authNote: String { + if settings.authToken.isEmpty { + return "Auth: disabled" + } + return "Auth: X-Casa-Token header required" + } + + private var endpoints: [ApiEndpoint] { + let boolBody = "{\"value\": true}" + let accessoryId = selectedAccessory?.uniqueIdentifier.uuidString ?? "" + let characteristicId = selectedCharacteristicId ?? "" + var result: [ApiEndpoint] = [ + ApiEndpoint( + method: "GET", + path: "/health", + description: "Server status", + curl: "curl \(baseURL)/health\(authHeader)", + response: "{\"status\": \"running\"}" + ) + ] + + guard settings.homeKitEnabled else { return result } + + result.append(contentsOf: [ + ApiEndpoint( + method: "GET", + path: "/homekit/accessories", + description: "List all accessories", + curl: "curl \(baseURL)/homekit/accessories\(authHeader)", + response: "[{\"id\": \"...\", \"name\": \"...\"}]" + ), + ApiEndpoint( + method: "GET", + path: "/homekit/rooms", + description: "List all rooms", + curl: "curl \(baseURL)/homekit/rooms\(authHeader)", + response: "[{\"id\": \"...\", \"name\": \"...\"}]" + ), + ApiEndpoint( + method: "GET", + path: "/homekit/services", + description: "List all services", + curl: "curl \(baseURL)/homekit/services\(authHeader)", + response: "[{\"id\": \"...\", \"name\": \"...\"}]" + ), + ApiEndpoint( + method: "GET", + path: "/homekit/accessories/:id", + description: "Fetch one accessory with services", + curl: "curl \(baseURL)/homekit/accessories/\(accessoryId)\(authHeader)", + response: "{\"id\": \"...\", \"services\": []}" + ), + ApiEndpoint( + method: "GET", + path: "/homekit/characteristics/:id", + description: "Read a characteristic", + curl: "curl \(baseURL)/homekit/characteristics/\(characteristicId)\(authHeader)", + response: "{\"id\": \"...\", \"value\": true}" + ), + ApiEndpoint( + method: "PUT", + path: "/homekit/characteristics/:id", + description: "Write a characteristic (writable only; read-only returns 405)", + curl: "curl -X PUT \(baseURL)/homekit/characteristics/\(characteristicId)\(authHeader) -H 'Content-Type: application/json' -d '\(boolBody)'", + request: boolBody, + response: "{\"status\": \"queued\"}" + ), + ApiEndpoint( + method: "GET", + path: "/homekit/schema", + description: "Discover writable characteristics and metadata", + curl: "curl \(baseURL)/homekit/schema\(authHeader)", + response: "[{\"id\": \"...\", \"writable\": true, \"valueType\": \"bool\"}]" + ), + ApiEndpoint( + method: "GET", + path: "/homekit/cameras", + description: "List cameras", + curl: "curl \(baseURL)/homekit/cameras\(authHeader)", + response: "[{\"id\": \"...\", \"name\": \"...\"}]" + ), + ApiEndpoint( + method: "GET", + path: "/homekit/cameras/:id", + description: "Fetch one camera", + curl: "curl \(baseURL)/homekit/cameras/\(authHeader)", + response: "{\"id\": \"...\", \"name\": \"...\"}" + ) + ]) + + return result + } + + private func copyToPasteboard(_ value: String) { + Task { @MainActor in + CasaPasteboard.copy(value) + model.showToast("Copied to clipboard") + } + } + + private var selectedAccessory: HMAccessory? { + guard let selectedAccessoryId else { return nil } + return accessories.first { $0.uniqueIdentifier == selectedAccessoryId } + } + + private var selectedCharacteristicId: String? { + guard let accessory = selectedAccessory else { return nil } + for service in accessory.services { + if let characteristic = service.characteristics.first { + return characteristic.uniqueIdentifier.uuidString + } + } + return nil + } + +} + +private struct ApiEndpoint: Identifiable { + let id = UUID() + let method: String + let path: String + let description: String + let curl: String + let request: String? + let response: String? + + init(method: String, path: String, description: String, curl: String, request: String? = nil, response: String? = nil) { + self.method = method + self.path = path + self.description = description + self.curl = curl + self.request = request + self.response = response + } +} + +private struct EndpointCard: View { + let endpoint: ApiEndpoint + let onCopy: (String) -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 8) { + Text(endpoint.method) + .font(.caption) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color.blue.opacity(0.15)) + .foregroundColor(.blue) + .cornerRadius(6) + Text(endpoint.path) + .font(.headline) + Spacer() + Button("Copy curl") { + onCopy(endpoint.curl) + } + .font(.caption) + .buttonStyle(.borderless) + } + + Text(endpoint.description) + .font(.caption) + .foregroundColor(.secondary) + + CodeBlock(title: "curl", text: endpoint.curl, onCopy: onCopy) + + if let request = endpoint.request { + CodeBlock(title: "request", text: request, onCopy: onCopy) + } + + if let response = endpoint.response { + CodeBlock(title: "response", text: response, onCopy: onCopy) + } + } + .padding(12) + .background(Color(UIColor.secondarySystemBackground)) + .cornerRadius(12) + } +} + +private struct CodeBlock: View { + let title: String + let text: String + let onCopy: (String) -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack { + Text(title.uppercased()) + .font(.caption2) + .foregroundColor(.secondary) + Spacer() + Button("Copy") { + onCopy(text) + } + .font(.caption) + .buttonStyle(.borderless) + } + Text(text) + .font(.system(size: 12, weight: .regular, design: .monospaced)) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(8) + .background(Color(UIColor.systemBackground)) + .cornerRadius(8) + } + } +} diff --git a/Casa/Assets.xcassets/AppIcon.appiconset/AppIcon1024.png b/Casa/Assets.xcassets/AppIcon.appiconset/AppIcon1024.png new file mode 100644 index 0000000..6715b23 Binary files /dev/null and b/Casa/Assets.xcassets/AppIcon.appiconset/AppIcon1024.png differ diff --git a/Casa/Assets.xcassets/AppIcon.appiconset/Contents.json b/Casa/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..f1f0467 --- /dev/null +++ b/Casa/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "idiom" : "ios-marketing", + "size" : "1024x1024", + "filename" : "AppIcon1024.png", + "scale" : "1x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Casa/Assets.xcassets/Contents.json b/Casa/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Casa/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Casa/CLIInstaller.swift b/Casa/CLIInstaller.swift new file mode 100644 index 0000000..2b77568 --- /dev/null +++ b/Casa/CLIInstaller.swift @@ -0,0 +1,63 @@ +import Foundation + +enum CLIInstallerResult { + case success(String) + case failure(String) + + var message: String { + switch self { + case .success(let message), .failure(let message): + return message + } + } +} + +enum CLIInstaller { + static func status() -> CLIStatus { + #if targetEnvironment(macCatalyst) + let targetPath = "/usr/local/bin/casa" + let fm = FileManager.default + let exists = fm.fileExists(atPath: targetPath) + let hasCLI = Bundle.main.url(forResource: "casa", withExtension: nil) != nil + if exists { + return CLIStatus(isInstalled: true, canInstall: hasCLI, reason: nil) + } + if !hasCLI { + return CLIStatus(isInstalled: false, canInstall: false, reason: "Embedded CLI not found in the app bundle.") + } + return CLIStatus(isInstalled: false, canInstall: true, reason: nil) + #else + return CLIStatus(isInstalled: false, canInstall: false, reason: "CLI installer is only available on Mac Catalyst.") + #endif + } + + static func installSymlink() -> CLIInstallerResult { + #if targetEnvironment(macCatalyst) + guard let scriptURL = Bundle.main.url(forResource: "casa", withExtension: nil) else { + return .failure("Embedded CLI not found") + } + + let targetPath = "/usr/local/bin/casa" + let targetURL = URL(fileURLWithPath: targetPath) + let fm = FileManager.default + + do { + if fm.fileExists(atPath: targetPath) { + try fm.removeItem(at: targetURL) + } + try fm.createSymbolicLink(at: targetURL, withDestinationURL: scriptURL) + return .success("Symlink installed at /usr/local/bin/casa") + } catch { + return .failure("Failed to install symlink: \(error.localizedDescription)") + } + #else + return .failure("CLI installer is only available on Mac Catalyst") + #endif + } +} + +struct CLIStatus { + let isInstalled: Bool + let canInstall: Bool + let reason: String? +} diff --git a/Casa/Casa.base.entitlements b/Casa/Casa.base.entitlements new file mode 100644 index 0000000..eebd922 --- /dev/null +++ b/Casa/Casa.base.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.developer.homekit + + + diff --git a/Casa/CasaApp.swift b/Casa/CasaApp.swift new file mode 100644 index 0000000..65cae49 --- /dev/null +++ b/Casa/CasaApp.swift @@ -0,0 +1,250 @@ +import Combine +import SwiftUI +import UIKit + +@main +struct CasaApp: App { + @UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate + @StateObject private var model = CasaAppModel.shared + + var body: some Scene { + WindowGroup { + ContentView() + .environmentObject(model) + } + } +} + +@MainActor +final class CasaAppModel: ObservableObject { + static let shared = CasaAppModel() + + let settings = CasaSettings.shared + let logger = CasaLogger() + let homeKit: HomeKitManager + let server: HomeKitServer +#if canImport(Sparkle) + let updater = SparkleUpdater() +#endif + @Published var statusMessage: String = "" + @Published var toastMessage: String? = nil + @Published var cliStatus: CLIStatus = CLIInstaller.status() + private var cancellables = Set() + private var serverChange: AnyCancellable? + private var lastPort: UInt16 + private var lastToken: String + private var lastHomeKitEnabled: Bool + private var toastTask: Task? + + private init() { + self.homeKit = HomeKitManager(logger: logger) + self.server = HomeKitServer(homeKit: homeKit, settings: settings, logger: logger) + self.lastPort = settings.port + self.lastToken = settings.authToken + self.lastHomeKitEnabled = settings.homeKitEnabled + serverChange = server.objectWillChange.sink { [weak self] _ in + self?.objectWillChange.send() + } + observeSettings() + refreshCLIStatus() + } + + private func observeSettings() { + settings.$port + .sink { [weak self] newPort in + self?.handlePortChange(newPort) + } + .store(in: &cancellables) + + settings.$authToken + .sink { [weak self] newToken in + self?.handleAuthTokenChange(newToken) + } + .store(in: &cancellables) + + settings.$autoStart + .sink { [weak self] enabled in + self?.handleAutoStartChange(enabled) + } + .store(in: &cancellables) + + settings.$homeKitEnabled + .sink { [weak self] enabled in + self?.handleHomeKitToggle(enabled) + } + .store(in: &cancellables) + + settings.$onboardingComplete + .sink { [weak self] _ in + self?.objectWillChange.send() + } + .store(in: &cancellables) + + server.$isRunning + .sink { [weak self] isRunning in + self?.statusMessage = isRunning ? "API started" : "API stopped" + } + .store(in: &cancellables) + + server.$lastError + .sink { [weak self] error in + guard let error else { return } + self?.statusMessage = error + } + .store(in: &cancellables) + } + + private func handlePortChange(_ newPort: UInt16) { + guard newPort != lastPort else { return } + lastPort = newPort + logger.log(level: "info", message: "settings_port_changed", metadata: [ + "port": String(newPort) + ]) + restartIfNeeded() + } + + private func handleAuthTokenChange(_ newToken: String) { + guard newToken != lastToken else { return } + lastToken = newToken + logger.log(level: "info", message: "settings_token_changed", metadata: [ + "present": newToken.isEmpty ? "false" : "true" + ]) + restartIfNeeded() + } + + private func handleAutoStartChange(_ enabled: Bool) { + logger.log(level: "info", message: "settings_autostart_changed", metadata: [ + "enabled": enabled ? "true" : "false" + ]) + if enabled, !server.isRunning { + statusMessage = "Starting API..." + server.start() + } else if !enabled, server.isRunning { + server.stop() + } + } + + private func handleHomeKitToggle(_ enabled: Bool) { + guard enabled != lastHomeKitEnabled else { return } + lastHomeKitEnabled = enabled + logger.log(level: "info", message: "settings_homekit_toggle", metadata: [ + "enabled": enabled ? "true" : "false" + ]) + if enabled { + homeKit.start() + } else { + homeKit.stop() + } + } + + private func restartIfNeeded() { + guard server.isRunning else { return } + statusMessage = "Restarting API..." + server.stop() + server.start() + } + + func toggleServer() { + if server.isRunning { + logger.log(level: "info", message: "server_toggle_stop") + server.stop() + } else { + logger.log(level: "info", message: "server_toggle_start") + statusMessage = "Starting API..." + server.start() + } + } + + func installCLI() { + logger.log(level: "info", message: "cli_install_requested") + let result = CLIInstaller.installSymlink() + switch result { + case .success(let message): + statusMessage = message + logger.log(level: "info", message: "cli_install_success", metadata: [ + "message": message + ]) + case .failure(let message): + statusMessage = message + logger.log(level: "error", message: "cli_install_failed", metadata: [ + "message": message + ]) + } + refreshCLIStatus() + } + + func copyDiagnostics() { + var lines: [String] = [] + let bundle = Bundle.main + let version = bundle.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "unknown" + let build = bundle.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "unknown" + lines.append("Casa diagnostics") + lines.append("Version: \(version) (\(build))") + lines.append("Settings: \(settings.diagnostics())") + lines.append("") + lines.append("Recent log:") + lines.append(logger.readLog()) + CasaPasteboard.copy(lines.joined(separator: "\n")) + showToast("Diagnostics copied") + logger.log(level: "info", message: "diagnostics_copied") + } + + func copyLogs() { + CasaPasteboard.copy(logger.readLog()) + showToast("Logs copied") + logger.log(level: "info", message: "logs_copied") + } + + func checkForUpdates() { +#if canImport(Sparkle) + updater.checkForUpdates() + statusMessage = "Checking for updates..." + logger.log(level: "info", message: "updates_check_requested") +#else + statusMessage = "Updates unavailable on this build" + logger.log(level: "info", message: "updates_unavailable") +#endif + } + + func initializeModules() { + if settings.homeKitEnabled { + homeKit.start() + } else { + homeKit.stop() + } + } + + func refreshCLIStatus() { + cliStatus = CLIInstaller.status() + } + + func showToast(_ message: String) { + toastTask?.cancel() + toastMessage = message + toastTask = Task { @MainActor in + try? await Task.sleep(nanoseconds: 2_000_000_000) + toastMessage = nil + } + } +} + +final class AppDelegate: NSObject, UIApplicationDelegate { + func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil + ) -> Bool { + let model = CasaAppModel.shared + model.initializeModules() + if model.settings.autoStart { + model.server.start() + } + + return true + } + +#if targetEnvironment(macCatalyst) + func applicationShouldTerminateAfterLastWindowClosed(_ application: UIApplication) -> Bool { + false + } +#endif +} diff --git a/Casa/CasaLogger.swift b/Casa/CasaLogger.swift new file mode 100644 index 0000000..77efc85 --- /dev/null +++ b/Casa/CasaLogger.swift @@ -0,0 +1,92 @@ +import Foundation + +struct CasaLogEntry: Encodable { + let timestamp: String + let level: String + let message: String + let metadata: [String: String] +} + +final class CasaLogger: ObservableObject { + private let queue = DispatchQueue(label: "casa.logger.queue") + private let logURL: URL + private let maxFileSize: Int + private let maxFiles: Int + @Published private(set) var revision: Int = 0 + + init(maxFileSize: Int = 1_000_000, maxFiles: Int = 5) { + self.maxFileSize = maxFileSize + self.maxFiles = maxFiles + + let baseURL = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).first + let logsURL = baseURL?.appendingPathComponent("Logs/Casa", isDirectory: true) + if let logsURL = logsURL { + try? FileManager.default.createDirectory(at: logsURL, withIntermediateDirectories: true) + self.logURL = logsURL.appendingPathComponent("casa.log") + } else { + self.logURL = URL(fileURLWithPath: "/tmp/casa.log") + } + } + + func log(level: String, message: String, metadata: [String: String] = [:]) { + let entry = CasaLogEntry( + timestamp: ISO8601DateFormatter().string(from: Date()), + level: level, + message: message, + metadata: metadata + ) + write(entry) + } + + func logRequest(method: String, path: String, status: Int, requestId: String, latencyMs: Int) { + log(level: "info", message: "request", metadata: [ + "method": method, + "path": path, + "status": String(status), + "requestId": requestId, + "latencyMs": String(latencyMs) + ]) + } + + func readLog() -> String { + (try? String(contentsOf: logURL, encoding: .utf8)) ?? "" + } + + private func write(_ entry: CasaLogEntry) { + queue.async { + self.rotateIfNeeded() + guard let data = try? JSONEncoder().encode(entry) else { return } + var line = data + line.append(0x0A) + if let handle = try? FileHandle(forWritingTo: self.logURL) { + defer { try? handle.close() } + _ = try? handle.seekToEnd() + try? handle.write(contentsOf: line) + } else { + try? line.write(to: self.logURL, options: .atomic) + } + DispatchQueue.main.async { + self.revision &+= 1 + } + } + } + + private func rotateIfNeeded() { + let size = (try? FileManager.default.attributesOfItem(atPath: logURL.path)[.size] as? NSNumber)?.intValue ?? 0 + guard size >= maxFileSize else { return } + + let fm = FileManager.default + for index in stride(from: maxFiles - 1, through: 1, by: -1) { + let src = logURL.deletingLastPathComponent().appendingPathComponent("casa.log.\(index)") + let dst = logURL.deletingLastPathComponent().appendingPathComponent("casa.log.\(index + 1)") + if fm.fileExists(atPath: src.path) { + try? fm.removeItem(at: dst) + try? fm.moveItem(at: src, to: dst) + } + } + + let first = logURL.deletingLastPathComponent().appendingPathComponent("casa.log.1") + try? fm.removeItem(at: first) + try? fm.moveItem(at: logURL, to: first) + } +} diff --git a/Casa/CasaModels.swift b/Casa/CasaModels.swift new file mode 100644 index 0000000..35d876b --- /dev/null +++ b/Casa/CasaModels.swift @@ -0,0 +1,52 @@ +import Foundation + +struct CasaHome { + let id: String + let name: String +} + +struct CasaRoom { + let id: String + let name: String + let homeId: String +} + +struct CasaCharacteristicMetadata { + let format: String + let minValue: Any? + let maxValue: Any? + let stepValue: Any? + let validValues: [Any] + let units: String +} + +struct CasaCharacteristic { + let id: String + let type: String + let properties: [String] + let metadata: CasaCharacteristicMetadata + let value: Any? +} + +struct CasaService { + let id: String + let name: String + let type: String + let accessoryId: String + let characteristics: [CasaCharacteristic] +} + +struct CasaAccessory { + let id: String + let name: String + let category: String + let room: String + let hasCameraProfile: Bool + let services: [CasaService] +} + +struct CasaCamera { + let id: String + let accessoryId: String + let name: String +} diff --git a/Casa/CasaPasteboard.swift b/Casa/CasaPasteboard.swift new file mode 100644 index 0000000..88f4c3c --- /dev/null +++ b/Casa/CasaPasteboard.swift @@ -0,0 +1,11 @@ +import UIKit +import UniformTypeIdentifiers + +enum CasaPasteboard { + @MainActor + static func copy(_ value: String) { + let item = [UTType.plainText.identifier: value] + UIPasteboard.general.setItems([item], options: [:]) + UIPasteboard.general.string = value + } +} diff --git a/Casa/CasaSettings.swift b/Casa/CasaSettings.swift new file mode 100644 index 0000000..a92a8d0 --- /dev/null +++ b/Casa/CasaSettings.swift @@ -0,0 +1,103 @@ +import Foundation + +final class CasaSettings: ObservableObject { + static let shared = CasaSettings() + + @Published var port: UInt16 { + didSet { persist() } + } + @Published var authToken: String { + didSet { persist() } + } + @Published var autoStart: Bool { + didSet { persist() } + } + @Published var homeKitEnabled: Bool { + didSet { persist() } + } + @Published var onboardingComplete: Bool { + didSet { persist() } + } + + private let defaults: UserDefaults + private var isLoading = true + + private init() { + self.defaults = UserDefaults.standard + let storedPort = defaults.integer(forKey: Keys.port) + self.port = storedPort == 0 ? 14663 : UInt16(storedPort) + self.authToken = defaults.string(forKey: Keys.authToken) ?? "" + if defaults.object(forKey: Keys.autoStart) == nil { + self.autoStart = false + } else { + self.autoStart = defaults.bool(forKey: Keys.autoStart) + } + if defaults.object(forKey: Keys.homeKitEnabled) == nil { + self.homeKitEnabled = false + } else { + self.homeKitEnabled = defaults.bool(forKey: Keys.homeKitEnabled) + } + if defaults.object(forKey: Keys.onboardingComplete) == nil { + self.onboardingComplete = false + } else { + self.onboardingComplete = defaults.bool(forKey: Keys.onboardingComplete) + } + isLoading = false + } + + private init(defaults: UserDefaults) { + self.defaults = defaults + let storedPort = defaults.integer(forKey: Keys.port) + self.port = storedPort == 0 ? 14663 : UInt16(storedPort) + self.authToken = defaults.string(forKey: Keys.authToken) ?? "" + if defaults.object(forKey: Keys.autoStart) == nil { + self.autoStart = false + } else { + self.autoStart = defaults.bool(forKey: Keys.autoStart) + } + if defaults.object(forKey: Keys.homeKitEnabled) == nil { + self.homeKitEnabled = false + } else { + self.homeKitEnabled = defaults.bool(forKey: Keys.homeKitEnabled) + } + if defaults.object(forKey: Keys.onboardingComplete) == nil { + self.onboardingComplete = false + } else { + self.onboardingComplete = defaults.bool(forKey: Keys.onboardingComplete) + } + isLoading = false + } + + #if DEBUG + static func makeForTests(defaults: UserDefaults) -> CasaSettings { + CasaSettings(defaults: defaults) + } + #endif + + private func persist() { + guard !isLoading else { return } + defaults.set(Int(port), forKey: Keys.port) + defaults.set(authToken, forKey: Keys.authToken) + defaults.set(autoStart, forKey: Keys.autoStart) + defaults.set(homeKitEnabled, forKey: Keys.homeKitEnabled) + defaults.set(onboardingComplete, forKey: Keys.onboardingComplete) + } + + func diagnostics() -> [String: Any] { + [ + "port": Int(port), + "authTokenSet": !authToken.isEmpty, + "autoStart": autoStart, + "homeKitEnabled": homeKitEnabled, + "onboardingComplete": onboardingComplete + ] + } + + private enum Keys { + static let port = "casa.settings.port" + static let authToken = "casa.settings.authToken" + static let autoStart = "casa.settings.autoStart" + static let homeKitEnabled = "casa.settings.homeKitEnabled" + static let onboardingComplete = "casa.settings.onboardingComplete" + } +} diff --git a/Casa/ContentView.swift b/Casa/ContentView.swift new file mode 100644 index 0000000..daf6d1f --- /dev/null +++ b/Casa/ContentView.swift @@ -0,0 +1,189 @@ +import SwiftUI +import HomeKit + +struct ContentView: View { + @EnvironmentObject private var model: CasaAppModel + @State private var mainSelection: MainSelection = .apiDocs + @State private var accessorySelection: UUID? = nil + + var body: some View { + ZStack(alignment: .topTrailing) { + if model.settings.onboardingComplete { + tabView + } else { + OnboardingView() + .environmentObject(model) + } + + if let toast = model.toastMessage { + ToastView(message: toast) + .padding(.top, 8) + .padding(.trailing, 8) + .transition(.move(edge: .top).combined(with: .opacity)) + } + } + .animation(.easeOut(duration: 0.2), value: model.toastMessage) + } + + private var selectionBinding: Binding { + Binding( + get: { accessorySelection }, + set: { newValue in + accessorySelection = newValue + if newValue != nil { + mainSelection = .apiDocs + } + } + ) + } + + private var tabView: some View { + TabView(selection: $mainSelection) { + apiDocsView + .tabItem { + Label("HomeKit API", systemImage: "doc.text.magnifyingglass") + } + .tag(MainSelection.apiDocs) + + LogsView() + .tabItem { + Label("Diagnostics & Logs", systemImage: "waveform.path.ecg") + } + .tag(MainSelection.logs) + + SettingsView() + .tabItem { + Label("Settings", systemImage: "gearshape") + } + .tag(MainSelection.settings) + } + } + + private var apiDocsView: some View { + HStack(spacing: 0) { + if model.settings.homeKitEnabled { + AccessorySidebarView( + accessories: model.homeKit.accessories, + selection: $accessorySelection + ) + .frame(minWidth: 220, idealWidth: 240, maxWidth: 300) + .background(Color(UIColor.systemGroupedBackground)) + + Divider() + } + + ApiDocsView( + accessories: model.homeKit.accessories, + selectedAccessoryId: selectionBinding + ) + } + } + +} + +private struct ToastView: View { + let message: String + + var body: some View { + Text(message) + .font(.callout) + .foregroundColor(.primary) + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background(.ultraThinMaterial) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(Color.primary.opacity(0.08), lineWidth: 1) + ) + .cornerRadius(14) + .shadow(color: Color.black.opacity(0.1), radius: 8, x: 0, y: 4) + .accessibilityLabel(message) + } +} + +private enum MainSelection: Hashable { + case apiDocs + case logs + case settings +} + + +private struct AccessorySidebarView: View { + @EnvironmentObject private var model: CasaAppModel + @ObservedObject private var settings = CasaSettings.shared + let accessories: [HMAccessory] + @Binding var selection: UUID? + + var body: some View { + List { + Section("Status") { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 8) { + StatusBadge(status: serverStatus) + Text("Port \(settings.port, format: .number.grouping(.never))") + .font(.caption) + .foregroundColor(.secondary) + } + + Text(settings.authToken.isEmpty ? "Auth: off" : "Auth: on") + .font(.caption) + .foregroundColor(.secondary) + + HStack(spacing: 8) { + Text("HomeKit") + .font(.caption) + .foregroundColor(.secondary) + StatusBadge(status: moduleStatusHomeKit()) + } + } + .padding(.vertical, 4) + } + + Section("Accessories") { + Button { + selection = nil + } label: { + accessoryRow(title: "All accessories", isSelected: selection == nil) + } + .buttonStyle(.plain) + + ForEach(accessories, id: \.uniqueIdentifier) { accessory in + Button { + selection = accessory.uniqueIdentifier + } label: { + accessoryRow(title: accessory.name, isSelected: selection == accessory.uniqueIdentifier) + } + .buttonStyle(.plain) + } + } + } + .listStyle(.insetGrouped) + } + + private func accessoryRow(title: String, isSelected: Bool) -> some View { + HStack { + Text(title) + .foregroundColor(.primary) + Spacer() + if isSelected { + Image(systemName: "checkmark") + .foregroundColor(.accentColor) + } + } + .contentShape(Rectangle()) + } + + private var serverStatus: ModuleStatus { + if model.server.isRunning { + return ModuleStatus(text: "Running", tone: .ok) + } + return ModuleStatus(text: "Stopped", tone: .muted) + } + + private func moduleStatusHomeKit() -> ModuleStatus { + if settings.homeKitEnabled { + return ModuleStatus(text: "Enabled", tone: .ok) + } + return ModuleStatus(text: "Disabled", tone: .muted) + } +} diff --git a/Casa/HomeKitManager.swift b/Casa/HomeKitManager.swift new file mode 100644 index 0000000..ef4cafa --- /dev/null +++ b/Casa/HomeKitManager.swift @@ -0,0 +1,70 @@ +import Foundation +import HomeKit + +@MainActor +final class HomeKitManager: NSObject, ObservableObject { + private let manager = HMHomeManager() + private let logger: CasaLogger + + @Published private(set) var homes: [HMHome] = [] + @Published private(set) var accessories: [HMAccessory] = [] + + init(logger: CasaLogger) { + self.logger = logger + } + + func start() { + manager.delegate = self + logger.log(level: "info", message: "homekit_start") + refreshData() + } + + func stop() { + manager.delegate = nil + homes = [] + accessories = [] + logger.log(level: "info", message: "homekit_stop") + } + + func refreshData() { + homes = manager.homes + accessories = manager.homes.flatMap { $0.accessories } + logger.log(level: "info", message: "homekit_refresh", metadata: [ + "homes": String(homes.count), + "accessories": String(accessories.count) + ]) + } + + func characteristic(with id: UUID) -> HMCharacteristic? { + for home in manager.homes { + for accessory in home.accessories { + for service in accessory.services { + if let match = service.characteristics.first(where: { $0.uniqueIdentifier == id }) { + return match + } + } + } + } + return nil + } + + func characteristic(with idString: String) -> HMCharacteristic? { + guard let id = UUID(uuidString: idString) else { return nil } + return characteristic(with: id) + } +} + +@MainActor +extension HomeKitManager: @preconcurrency HMHomeManagerDelegate { + func homeManagerDidUpdateHomes(_ manager: HMHomeManager) { + logger.log(level: "info", message: "homekit_homes_updated") + refreshData() + } + + func homeManager(_ manager: HMHomeManager, didUpdate status: HMHomeManagerAuthorizationStatus) { + logger.log(level: "info", message: "homekit_auth_status", metadata: [ + "status": String(status.rawValue) + ]) + refreshData() + } +} diff --git a/Casa/HomeKitServer.swift b/Casa/HomeKitServer.swift new file mode 100644 index 0000000..3b23a6a --- /dev/null +++ b/Casa/HomeKitServer.swift @@ -0,0 +1,1007 @@ +import Foundation +import HomeKit +import Network +import UIKit + +final class HomeKitServer: ObservableObject { + private let homeKit: HomeKitManager + private let settings: CasaSettings + private let logger: CasaLogger + private var listener: NWListener? + private let queue = DispatchQueue(label: "casa.server.queue") + private var handlers: [UUID: HTTPConnectionHandler] = [:] + + var port: UInt16 { settings.port } + @Published private(set) var isRunning = false + @Published private(set) var lastError: String? + + init(homeKit: HomeKitManager, settings: CasaSettings, logger: CasaLogger) { + self.homeKit = homeKit + self.settings = settings + self.logger = logger + } + + func start() { + guard !isRunning else { return } + + let params = NWParameters.tcp + params.allowLocalEndpointReuse = true + let port = NWEndpoint.Port(rawValue: self.port) ?? 9123 + + do { + let listener = try NWListener(using: params, on: port) + self.listener = listener + + listener.newConnectionHandler = { [weak self] connection in + self?.handle(connection: connection) + } + + listener.start(queue: queue) + DispatchQueue.main.async { + self.isRunning = true + self.lastError = nil + } + logger.log(level: "info", message: "server_started", metadata: [ + "port": String(self.port) + ]) + } catch { + DispatchQueue.main.async { + self.lastError = "Failed to start server: \(error.localizedDescription)" + } + logger.log(level: "error", message: "server_failed", metadata: [ + "error": String(describing: error) + ]) + } + } + + func stop() { + listener?.cancel() + listener = nil + DispatchQueue.main.async { + self.isRunning = false + self.lastError = nil + } + logger.log(level: "info", message: "server_stopped") + } + + private func handle(connection: NWConnection) { + guard isLoopback(connection.endpoint) else { + logger.log(level: "warn", message: "connection_rejected", metadata: [ + "endpoint": "\(connection.endpoint)" + ]) + connection.cancel() + return + } + + let handlerId = UUID() + let handler = HTTPConnectionHandler( + connection: connection, + queue: queue, + logger: logger, + route: { [weak self] request in + guard let self = self else { + return HTTPResponse.serverError(message: "Server unavailable") + } + return await self.route(request) + }, + onClose: { [weak self] in + self?.queue.async { + self?.handlers[handlerId] = nil + } + } + ) + queue.async { + self.handlers[handlerId] = handler + } + handler.start() + } + + private func isLoopback(_ endpoint: NWEndpoint) -> Bool { + switch endpoint { + case .hostPort(let host, _): + let v4 = IPv4Address("127.0.0.1") + let v6 = IPv6Address("::1") + return host == .ipv4(v4!) || host == .ipv6(v6!) + default: + return false + } + } + + private func route(_ request: HTTPRequest) async -> HTTPResponse { + let started = Date() + let requestId = UUID().uuidString + + func ok(_ payload: JSONValue) -> HTTPResponse { + HTTPResponse.ok(payload, requestId: requestId, started: started) + } + + func error(_ status: Int, _ code: String, _ message: String, details: JSONValue? = nil) -> HTTPResponse { + HTTPResponse.error(status: status, code: code, message: message, details: details, requestId: requestId, started: started) + } + + guard isAuthorized(request) else { + logger.log(level: "warn", message: "request_unauthorized", metadata: [ + "method": request.method, + "path": request.path + ]) + return error(401, "unauthorized", "Missing or invalid auth token") + } + + let homeKitEnabled = await MainActor.run { self.settings.homeKitEnabled } + + let response: HTTPResponse + + switch (request.method, request.path) { + case ("GET", "/health"): + let status = await MainActor.run { self.isRunning } + response = ok(.object([ + "status": .string(status ? "running" : "stopped") + ])) + + case ("GET", "/homekit/homes"): + guard homeKitEnabled else { + response = error(403, "module_disabled", "HomeKit module disabled") + break + } + let homes = await MainActor.run { self.homeKit.homes } + response = ok(HomeKitPayload.homes(HomeKitMapper.homes(from: homes))) + + case ("GET", "/homekit/rooms"): + guard homeKitEnabled else { + response = error(403, "module_disabled", "HomeKit module disabled") + break + } + let homes = await MainActor.run { self.homeKit.homes } + response = ok(HomeKitPayload.rooms(HomeKitMapper.rooms(from: homes))) + + case ("GET", "/homekit/services"): + guard homeKitEnabled else { + response = error(403, "module_disabled", "HomeKit module disabled") + break + } + let accessories = await MainActor.run { self.homeKit.accessories } + response = ok(HomeKitPayload.services(HomeKitMapper.services(from: accessories))) + + case ("GET", "/homekit/accessories"): + guard homeKitEnabled else { + response = error(403, "module_disabled", "HomeKit module disabled") + break + } + let accessories = await MainActor.run { self.homeKit.accessories } + response = ok(HomeKitPayload.accessories(HomeKitMapper.accessories(from: accessories))) + + case ("GET", _): + if let accessoryId = request.pathParameter(prefix: "/homekit/accessories/") { + guard homeKitEnabled else { + response = error(403, "module_disabled", "HomeKit module disabled") + break + } + let accessory = await MainActor.run { self.homeKit.accessory(with: accessoryId) } + guard let accessory else { + response = error(404, "not_found", "Accessory not found") + break + } + response = ok(HomeKitPayload.accessoryPayload(HomeKitMapper.accessory(from: accessory))) + break + } + if let characteristicId = request.pathParameter(prefix: "/homekit/characteristics/") { + guard homeKitEnabled else { + response = error(403, "module_disabled", "HomeKit module disabled") + break + } + let characteristic = await MainActor.run { self.homeKit.characteristic(with: characteristicId) } + guard let characteristic else { + response = error(404, "not_found", "Characteristic not found") + break + } + let value = await readValue(characteristic) + response = ok(HomeKitPayload.characteristicPayload(HomeKitMapper.characteristic(from: characteristic, valueOverride: value))) + break + } + if request.path.hasPrefix("/homekit/cameras/") { + guard homeKitEnabled else { + response = error(403, "module_disabled", "HomeKit module disabled") + break + } + let remainder = String(request.path.dropFirst("/homekit/cameras/".count)) + let cameraId = remainder + let camera = await MainActor.run { self.homeKit.camera(with: cameraId) } + guard let camera else { + response = error(404, "not_found", "Camera not found") + break + } + response = ok(HomeKitPayload.cameraPayload(HomeKitMapper.camera(from: camera))) + break + } + if request.path == "/homekit/cameras" { + guard homeKitEnabled else { + response = error(403, "module_disabled", "HomeKit module disabled") + break + } + let cameras = await MainActor.run { self.homeKit.cameras } + response = ok(HomeKitPayload.cameras(HomeKitMapper.cameras(from: cameras))) + break + } + if request.path == "/homekit/schema" { + guard homeKitEnabled else { + response = error(403, "module_disabled", "HomeKit module disabled") + break + } + let accessories = await MainActor.run { self.homeKit.accessories } + response = ok(HomeKitPayload.schema(HomeKitMapper.accessories(from: accessories))) + break + } + response = error(404, "not_found", "Route not found") + + case ("PUT", _): + if let characteristicId = request.pathParameter(prefix: "/homekit/characteristics/") { + guard homeKitEnabled else { + response = error(403, "module_disabled", "HomeKit module disabled") + break + } + response = await writeCharacteristic(id: characteristicId, body: request.body, requestId: requestId, started: started) + break + } + response = error(404, "not_found", "Route not found") + + case ("POST", "/homekit/characteristic"): + guard homeKitEnabled else { + response = error(403, "module_disabled", "HomeKit module disabled") + break + } + response = await writeCharacteristic(body: request.body, requestId: requestId, started: started) + + default: + response = error(405, "method_not_allowed", "Method not allowed") + } + + logger.logRequest( + method: request.method, + path: request.path, + status: response.status, + requestId: requestId, + latencyMs: response.latencyMs + ) + return response + } + + private func isAuthorized(_ request: HTTPRequest) -> Bool { + let token = settings.authToken.trimmingCharacters(in: .whitespacesAndNewlines) + guard !token.isEmpty else { return true } + + if let header = request.headers["authorization"]?.lowercased(), header.hasPrefix("bearer ") { + let value = header.replacingOccurrences(of: "bearer ", with: "") + return value == token + } + if let header = request.headers["x-casa-token"] { + return header == token + } + return false + } + + private func writeCharacteristic( + id: String? = nil, + body: Data?, + requestId: String, + started: Date + ) async -> HTTPResponse { + func error(_ status: Int, _ code: String, _ message: String) -> HTTPResponse { + HTTPResponse.error(status: status, code: code, message: message, requestId: requestId, started: started) + } + + guard let body = body, + let payload = try? JSONSerialization.jsonObject(with: body) as? [String: Any] else { + return error(400, "invalid_payload", "Body must be JSON") + } + + let idString = id ?? (payload["id"] as? String) + guard let idString = idString, + let characteristicId = UUID(uuidString: idString) else { + return error(400, "invalid_id", "Characteristic id is required") + } + + let characteristic = await MainActor.run { self.homeKit.characteristic(with: characteristicId) } + guard let characteristic else { + logger.log(level: "warn", message: "characteristic_not_found", metadata: [ + "id": idString + ]) + return error(404, "not_found", "Characteristic not found") + } + guard characteristic.properties.contains(HMCharacteristicPropertyWritable) else { + logger.log(level: "warn", message: "characteristic_read_only", metadata: [ + "id": idString + ]) + return error(405, "read_only", "Characteristic is read-only") + } + + let value = payload["value"] + await writeValue(characteristic, value: value) + logger.log(level: "info", message: "characteristic_write", metadata: [ + "id": idString + ]) + + return HTTPResponse.ok(.object([ + "status": .string("queued") + ]), requestId: requestId, started: started) + } + + private func readValue(_ characteristic: HMCharacteristic) async -> Any? { + await withCheckedContinuation { continuation in + characteristic.readValue { _ in + self.logger.log(level: "info", message: "characteristic_read", metadata: [ + "id": characteristic.uniqueIdentifier.uuidString + ]) + continuation.resume(returning: characteristic.value) + } + } + } + + private func writeValue(_ characteristic: HMCharacteristic, value: Any?) async { + await withCheckedContinuation { continuation in + characteristic.writeValue(value) { _ in + self.logger.log(level: "info", message: "characteristic_write_complete", metadata: [ + "id": characteristic.uniqueIdentifier.uuidString + ]) + continuation.resume() + } + } + } + +} + +extension HomeKitServer: @unchecked Sendable {} + +private final class HTTPConnectionHandler { + private let connection: NWConnection + private let queue: DispatchQueue + private let logger: CasaLogger + private let route: (HTTPRequest) async -> HTTPResponse + private let onClose: () -> Void + private var buffer = Data() + private var isProcessing = false + private var shouldClose = false + private var didClose = false + private let maxHeaderBytes = 16 * 1024 + private let maxBodyBytes = 1 * 1024 * 1024 + + init( + connection: NWConnection, + queue: DispatchQueue, + logger: CasaLogger, + route: @escaping (HTTPRequest) async -> HTTPResponse, + onClose: @escaping () -> Void + ) { + self.connection = connection + self.queue = queue + self.logger = logger + self.route = route + self.onClose = onClose + } + + func start() { + connection.stateUpdateHandler = { [weak self] state in + guard let self = self else { return } + switch state { + case .ready: + self.receive() + case .failed, .cancelled: + self.shouldClose = true + default: + break + } + } + connection.start(queue: queue) + } + + private func receive() { + connection.receive(minimumIncompleteLength: 1, maximumLength: 64 * 1024) { [weak self] data, _, _, _ in + guard let self = self else { return } + if let data = data { + self.buffer.append(data) + self.processBuffer() + if !self.shouldClose { + self.receive() + } + } else { + self.close() + } + } + } + + private func processBuffer() { + guard !isProcessing else { return } + while true { + switch HTTPRequest.parse(from: buffer, maxHeaderBytes: maxHeaderBytes, maxBodyBytes: maxBodyBytes) { + case .incomplete: + return + case .invalid(let status, let message): + logger.log(level: "warn", message: "request_invalid", metadata: [ + "status": String(status), + "message": message + ]) + let response = HTTPResponse.error( + status: status, + code: "bad_request", + message: message, + requestId: UUID().uuidString, + started: Date() + ) + send(response, keepAlive: false) + return + case .complete(let request, let consumed): + buffer.removeSubrange(0.. String? { + guard path.hasPrefix(prefix) else { return nil } + let suffix = String(path.dropFirst(prefix.count)) + return suffix.isEmpty ? nil : suffix + } + + static func parse(from data: Data, maxHeaderBytes: Int, maxBodyBytes: Int) -> HTTPParseResult { + guard let headerRange = data.range(of: Data("\r\n\r\n".utf8)) else { + if data.count > maxHeaderBytes { + return .invalid(status: 431, message: "Header too large") + } + return .incomplete + } + let headerData = data.subdata(in: 0.. maxHeaderBytes { + return .invalid(status: 431, message: "Header too large") + } + guard let headerText = String(data: headerData, encoding: .utf8) else { + return .invalid(status: 400, message: "Header is not valid UTF-8") + } + let lines = headerText.components(separatedBy: "\r\n") + guard let requestLine = lines.first else { + return .invalid(status: 400, message: "Missing request line") + } + let requestParts = requestLine.split(separator: " ") + guard requestParts.count >= 3 else { + return .invalid(status: 400, message: "Invalid request line") + } + let method = String(requestParts[0]) + let target = String(requestParts[1]) + let version = String(requestParts[2]) + guard version == "HTTP/1.1" || version == "HTTP/1.0" else { + return .invalid(status: 400, message: "Unsupported HTTP version") + } + + var headers: [String: String] = [:] + for line in lines.dropFirst() { + let parts = line.split(separator: ":", maxSplits: 1).map { $0.trimmingCharacters(in: .whitespaces) } + if parts.count == 2 { + headers[parts[0].lowercased()] = parts[1] + } else if !line.isEmpty { + return .invalid(status: 400, message: "Invalid header line") + } + } + + if version == "HTTP/1.1", headers["host"] == nil { + return .invalid(status: 400, message: "Missing Host header") + } + + if let transferEncoding = headers["transfer-encoding"]?.lowercased(), + transferEncoding.contains("chunked") { + return .invalid(status: 501, message: "Chunked transfer encoding is not supported") + } + + let contentLength = Int(headers["content-length"] ?? "0") ?? 0 + if contentLength > maxBodyBytes { + return .invalid(status: 413, message: "Body too large") + } + let bodyStart = headerRange.upperBound + let totalLength = bodyStart + contentLength + guard data.count >= totalLength else { + return .incomplete + } + let body = contentLength > 0 ? data.subdata(in: bodyStart.. HTTPResponse { + HTTPResponse( + status: 200, + body: body, + requestId: requestId, + latencyMs: HTTPResponse.latencyMs(since: started), + rawBody: nil, + contentType: "application/json" + ) + } + + static func error( + status: Int, + code: String, + message: String, + details: JSONValue? = nil, + requestId: String, + started: Date + ) -> HTTPResponse { + var errorPayload: [String: JSONValue] = [ + "code": .string(code), + "message": .string(message) + ] + if let details = details { + errorPayload["details"] = details + } + + let body: JSONValue = .object([ + "requestId": .string(requestId), + "ok": .bool(false), + "error": .object(errorPayload), + "latencyMs": .number(Double(HTTPResponse.latencyMs(since: started))) + ]) + return HTTPResponse( + status: status, + body: body, + requestId: requestId, + latencyMs: HTTPResponse.latencyMs(since: started), + rawBody: nil, + contentType: "application/json" + ) + } + + static func serverError(message: String) -> HTTPResponse { + HTTPResponse( + status: 500, + body: .object([ + "requestId": .string(UUID().uuidString), + "ok": .bool(false), + "error": .object([ + "code": .string("server_error"), + "message": .string(message) + ]), + "latencyMs": .number(0) + ]), + requestId: UUID().uuidString, + latencyMs: 0, + rawBody: nil, + contentType: "application/json" + ) + } + + static func raw(status: Int, contentType: String, body: Data, requestId: String, started: Date) -> HTTPResponse { + HTTPResponse( + status: status, + body: .null, + requestId: requestId, + latencyMs: HTTPResponse.latencyMs(since: started), + rawBody: body, + contentType: contentType + ) + } + + func encoded(keepAlive: Bool) -> Data { + let bodyData: Data + let contentTypeHeader: String + if let rawBody = rawBody { + bodyData = rawBody + contentTypeHeader = contentType + } else { + if status >= 400 { + bodyData = body.encodedData() + } else { + let envelope: JSONValue = .object([ + "requestId": .string(requestId), + "ok": .bool(true), + "data": body, + "latencyMs": .number(Double(latencyMs)) + ]) + bodyData = envelope.encodedData() + } + contentTypeHeader = contentType + } + + let connection = keepAlive ? "keep-alive" : "close" + let response = "HTTP/1.1 \(status) \(HTTPResponse.reasonPhrase(for: status))\r\n" + + "Content-Type: \(contentTypeHeader)\r\n" + + "Content-Length: \(bodyData.count)\r\n" + + "Connection: \(connection)\r\n" + + "X-Request-Id: \(requestId)\r\n" + + "Cache-Control: no-store\r\n\r\n" + var data = Data(response.utf8) + data.append(bodyData) + return data + } + + private static func latencyMs(since date: Date) -> Int { + Int(Date().timeIntervalSince(date) * 1000.0) + } + + private static func reasonPhrase(for status: Int) -> String { + switch status { + case 200: return "OK" + case 201: return "Created" + case 202: return "Accepted" + case 204: return "No Content" + case 400: return "Bad Request" + case 401: return "Unauthorized" + case 403: return "Forbidden" + case 404: return "Not Found" + case 405: return "Method Not Allowed" + case 413: return "Payload Too Large" + case 431: return "Request Header Fields Too Large" + case 501: return "Not Implemented" + case 500: return "Internal Server Error" + default: return "OK" + } + } +} + +enum JSONValue: Encodable { + case string(String) + case number(Double) + case bool(Bool) + case object([String: JSONValue]) + case array([JSONValue]) + case null + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .string(let value): + try container.encode(value) + case .number(let value): + try container.encode(value) + case .bool(let value): + try container.encode(value) + case .object(let value): + try container.encode(value) + case .array(let value): + try container.encode(value) + case .null: + try container.encodeNil() + } + } + + func encodedData() -> Data { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + return (try? encoder.encode(self)) ?? Data("null".utf8) + } +} + +enum HomeKitPayload { + static func homes(_ homes: [CasaHome]) -> JSONValue { + .array(homes.map { home in + .object([ + "id": .string(home.id), + "name": .string(home.name) + ]) + }) + } + + static func rooms(_ rooms: [CasaRoom]) -> JSONValue { + .array(rooms.map { room in + .object([ + "id": .string(room.id), + "name": .string(room.name), + "homeId": .string(room.homeId) + ]) + }) + } + + static func services(_ services: [CasaService]) -> JSONValue { + .array(services.map { service in + .object([ + "id": .string(service.id), + "name": .string(service.name), + "type": .string(service.type), + "accessoryId": .string(service.accessoryId) + ]) + }) + } + + static func accessories(_ accessories: [CasaAccessory]) -> JSONValue { + .array(accessories.map { accessoryPayload($0) }) + } + + static func accessoryPayload(_ accessory: CasaAccessory) -> JSONValue { + .object([ + "id": .string(accessory.id), + "name": .string(accessory.name), + "category": .string(accessory.category), + "room": .string(accessory.room), + "hasCameraProfile": .bool(accessory.hasCameraProfile), + "services": .array(accessory.services.map { service in + JSONValue.object([ + "id": .string(service.id), + "name": .string(service.name), + "type": .string(service.type), + "characteristics": characteristics(service.characteristics) + ]) + }) + ]) + } + + static func characteristicPayload(_ characteristic: CasaCharacteristic) -> JSONValue { + .object([ + "id": .string(characteristic.id), + "type": .string(characteristic.type), + "metadata": .object([ + "format": .string(characteristic.metadata.format), + "minValue": jsonValue(characteristic.metadata.minValue), + "maxValue": jsonValue(characteristic.metadata.maxValue), + "stepValue": jsonValue(characteristic.metadata.stepValue), + "validValues": jsonArray(characteristic.metadata.validValues), + "units": .string(characteristic.metadata.units) + ]), + "properties": .array(characteristic.properties.map { .string($0) }), + "value": jsonValue(characteristic.value) + ]) + } + + static func cameras(_ cameras: [CasaCamera]) -> JSONValue { + .array(cameras.map { cameraPayload($0) }) + } + + static func cameraPayload(_ camera: CasaCamera) -> JSONValue { + .object([ + "id": .string(camera.id), + "accessoryId": .string(camera.accessoryId), + "name": .string(camera.name) + ]) + } + + static func schema(_ accessories: [CasaAccessory]) -> JSONValue { + let entries = accessories.flatMap { accessory in + accessory.services.flatMap { service in + service.characteristics.map { characteristic in + let format = characteristic.metadata.format + let writable = characteristic.properties.contains("write") + return JSONValue.object([ + "id": .string(characteristic.id), + "accessoryId": .string(accessory.id), + "serviceId": .string(service.id), + "type": .string(characteristic.type), + "format": .string(format), + "writable": .bool(writable), + "minValue": jsonValue(characteristic.metadata.minValue), + "maxValue": jsonValue(characteristic.metadata.maxValue), + "stepValue": jsonValue(characteristic.metadata.stepValue), + "validValues": jsonArray(characteristic.metadata.validValues), + "units": .string(characteristic.metadata.units), + "valueType": .string(valueType(from: format)) + ]) + } + } + } + return .array(entries) + } + + private static func jsonArray(_ values: [Any]) -> JSONValue { + .array(values.map { jsonValue($0) }) + } + + + private static func valueType(from format: String) -> String { + switch format { + case HMCharacteristicMetadataFormatBool: + return "bool" + case HMCharacteristicMetadataFormatInt, + HMCharacteristicMetadataFormatFloat: + return "number" + case HMCharacteristicMetadataFormatString: + return "string" + case HMCharacteristicMetadataFormatData, + HMCharacteristicMetadataFormatTLV8: + return "data" + case HMCharacteristicMetadataFormatUInt8, + HMCharacteristicMetadataFormatUInt16, + HMCharacteristicMetadataFormatUInt32, + HMCharacteristicMetadataFormatUInt64: + return "number" + default: + return "unknown" + } + } + + private static func characteristics(_ characteristics: [CasaCharacteristic]) -> JSONValue { + .array(characteristics.map { characteristicPayload($0) }) + } + + static func jsonValue(_ value: Any?) -> JSONValue { + switch value { + case let number as NSNumber: + if CFGetTypeID(number) == CFBooleanGetTypeID() { + return .bool(number.boolValue) + } + return .number(number.doubleValue) + case let string as String: + return .string(string) + case let date as Date: + return .string(ISO8601DateFormatter().string(from: date)) + case let data as Data: + return .string(data.base64EncodedString()) + case nil: + return .null + default: + return .string("\(String(describing: value))") + } + } +} + +private enum HomeKitMapper { + static func homes(from homes: [HMHome]) -> [CasaHome] { + homes.map { home in + CasaHome(id: home.uniqueIdentifier.uuidString, name: home.name) + } + } + + static func rooms(from homes: [HMHome]) -> [CasaRoom] { + homes.flatMap { home in + home.rooms.map { room in + CasaRoom(id: room.uniqueIdentifier.uuidString, name: room.name, homeId: home.uniqueIdentifier.uuidString) + } + } + } + + static func services(from accessories: [HMAccessory]) -> [CasaService] { + accessories.flatMap { accessory in + accessory.services.map { service in + CasaService( + id: service.uniqueIdentifier.uuidString, + name: service.name, + type: service.serviceType, + accessoryId: accessory.uniqueIdentifier.uuidString, + characteristics: service.characteristics.map { characteristic(from: $0, valueOverride: nil) } + ) + } + } + } + + static func accessories(from accessories: [HMAccessory]) -> [CasaAccessory] { + accessories.map { accessory(from: $0) } + } + + static func accessory(from accessory: HMAccessory) -> CasaAccessory { + CasaAccessory( + id: accessory.uniqueIdentifier.uuidString, + name: accessory.name, + category: accessory.category.localizedDescription, + room: accessory.room?.name ?? "", + hasCameraProfile: accessory.cameraProfiles?.isEmpty == false, + services: accessory.services.map { service in + CasaService( + id: service.uniqueIdentifier.uuidString, + name: service.name, + type: service.serviceType, + accessoryId: accessory.uniqueIdentifier.uuidString, + characteristics: service.characteristics.map { characteristic(from: $0, valueOverride: nil) } + ) + } + ) + } + + static func characteristic(from characteristic: HMCharacteristic, valueOverride: Any?) -> CasaCharacteristic { + let metadata = CasaCharacteristicMetadata( + format: characteristic.metadata?.format ?? "", + minValue: characteristic.metadata?.minimumValue, + maxValue: characteristic.metadata?.maximumValue, + stepValue: characteristic.metadata?.stepValue, + validValues: characteristic.metadata?.validValues ?? [], + units: characteristic.metadata?.units ?? "" + ) + return CasaCharacteristic( + id: characteristic.uniqueIdentifier.uuidString, + type: characteristic.characteristicType, + properties: characteristic.properties, + metadata: metadata, + value: valueOverride ?? characteristic.value + ) + } + + static func cameras(from cameras: [HMCameraProfile]) -> [CasaCamera] { + cameras.map { camera(from: $0) } + } + + static func camera(from camera: HMCameraProfile) -> CasaCamera { + CasaCamera( + id: camera.uniqueIdentifier.uuidString, + accessoryId: camera.accessory?.uniqueIdentifier.uuidString ?? "", + name: camera.accessory?.name ?? "Camera" + ) + } +} + +private extension HomeKitManager { + var cameras: [HMCameraProfile] { + accessories.flatMap { $0.cameraProfiles ?? [] } + } + + func accessory(with id: String) -> HMAccessory? { + accessories.first { $0.uniqueIdentifier.uuidString == id } + } + + func camera(with id: String) -> HMCameraProfile? { + cameras.first { $0.uniqueIdentifier.uuidString == id } + } +} diff --git a/Casa/Info.plist b/Casa/Info.plist new file mode 100644 index 0000000..21eda0e --- /dev/null +++ b/Casa/Info.plist @@ -0,0 +1,50 @@ + + + + + CFBundleDisplayName + Casa + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleName + Casa + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + NSHomeKitUsageDescription + Casa uses HomeKit to discover and control your accessories. + SUEnableAutomaticChecks + + SUFeedURL + $(SPARKLE_FEED_URL) + SUPublicEDKey + $(SPARKLE_PUBLIC_KEY) + UIRequiresFullScreen + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneConfigurationName + Default Configuration + UISceneDelegateClassName + + + + + + + diff --git a/Casa/LogsView.swift b/Casa/LogsView.swift new file mode 100644 index 0000000..7b27642 --- /dev/null +++ b/Casa/LogsView.swift @@ -0,0 +1,87 @@ +import SwiftUI + +struct LogsView: View { + @EnvironmentObject private var model: CasaAppModel + @ObservedObject private var settings = CasaSettings.shared + @State private var diagnosticsText = "" + @State private var logsText = "" + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + header + section(title: "Diagnostics", text: diagnosticsText, onCopy: copyDiagnostics) + section(title: "Logs", text: logsText, onCopy: copyLogs) + } + .padding(.horizontal, 20) + .padding(.bottom, 20) + .padding(.top, 10) + } + .onAppear(perform: refresh) + .onReceive(model.logger.$revision) { _ in + logsText = model.logger.readLog() + } + .onReceive(settings.objectWillChange) { _ in + diagnosticsText = diagnostics() + } + } + + private var header: some View { + HStack { + Text("Diagnostics & Logs") + .font(.title2) + } + } + + private func section(title: String, text: String, onCopy: @escaping () -> Void) -> some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text(title) + .font(.headline) + Spacer() + Button("Copy") { + onCopy() + } + .buttonStyle(.borderless) + } + Text(text.isEmpty ? "No data yet." : text) + .font(.system(size: 12, weight: .regular, design: .monospaced)) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(10) + .background(Color(UIColor.systemBackground)) + .cornerRadius(10) + } + .padding(12) + .background(Color(UIColor.secondarySystemBackground)) + .cornerRadius(12) + } + + private func refresh() { + diagnosticsText = diagnostics() + logsText = model.logger.readLog() + } + + private func diagnostics() -> String { + let bundle = Bundle.main + let version = bundle.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "unknown" + let build = bundle.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "unknown" + var lines: [String] = [] + lines.append("Casa diagnostics") + lines.append("Version: \(version) (\(build))") + lines.append("Settings: \(settings.diagnostics())") + return lines.joined(separator: "\n") + } + + private func copyDiagnostics() { + CasaPasteboard.copy(diagnosticsText) + model.showToast("Diagnostics copied") + model.logger.log(level: "info", message: "diagnostics_copied") + } + + private func copyLogs() { + CasaPasteboard.copy(logsText) + model.showToast("Logs copied") + model.logger.log(level: "info", message: "logs_copied") + } +} diff --git a/Casa/ModuleUI.swift b/Casa/ModuleUI.swift new file mode 100644 index 0000000..e11f163 --- /dev/null +++ b/Casa/ModuleUI.swift @@ -0,0 +1,85 @@ +import SwiftUI + +enum ModuleTone { + case ok + case muted + case warning + case error +} + +struct ModuleStatus { + let text: String + let tone: ModuleTone +} + +struct StatusBadge: View { + let status: ModuleStatus + + var body: some View { + Text(status.text.uppercased()) + .font(.caption2) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .foregroundColor(foregroundColor) + .background(backgroundColor) + .cornerRadius(10) + } + + private var backgroundColor: Color { + switch status.tone { + case .ok: + return Color.green.opacity(0.15) + case .muted: + return Color.gray.opacity(0.2) + case .warning: + return Color.orange.opacity(0.2) + case .error: + return Color.red.opacity(0.2) + } + } + + private var foregroundColor: Color { + switch status.tone { + case .ok: + return .green + case .muted: + return .secondary + case .warning: + return .orange + case .error: + return .red + } + } +} + +struct ModuleToggleRow: View { + let title: String + @Binding var isOn: Bool + let status: ModuleStatus + let isSupported: Bool + let unavailableReason: String? + @State private var showInfo = false + + var body: some View { + HStack(spacing: 12) { + Toggle(title, isOn: $isOn) + .disabled(!isSupported) + StatusBadge(status: status) + + if let unavailableReason, !isSupported { + Button { + showInfo = true + } label: { + Image(systemName: "info.circle") + .foregroundColor(.secondary) + } + .buttonStyle(.borderless) + .alert("Unavailable", isPresented: $showInfo) { + Button("OK") {} + } message: { + Text(unavailableReason) + } + } + } + } +} diff --git a/Casa/OnboardingView.swift b/Casa/OnboardingView.swift new file mode 100644 index 0000000..9b738f2 --- /dev/null +++ b/Casa/OnboardingView.swift @@ -0,0 +1,128 @@ +import SwiftUI + +struct OnboardingView: View { + @EnvironmentObject private var model: CasaAppModel + @ObservedObject private var settings = CasaSettings.shared + @State private var stepIndex = 0 + @State private var cliInstallAttempted = false + + var body: some View { + HStack { + Spacer(minLength: 0) + VStack(spacing: 16) { + Text("Welcome to Casa") + .font(.title) + Text("Enable the Apple modules you want to expose and grant permissions when prompted.") + .font(.callout) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + + Group { + if stepIndex == 0 { + modulesStep + } else { + cliStep + } + } + + HStack(spacing: 8) { + Circle() + .fill(stepIndex == 0 ? Color.primary : Color.secondary.opacity(0.3)) + .frame(width: 8, height: 8) + Circle() + .fill(stepIndex == 1 ? Color.primary : Color.secondary.opacity(0.3)) + .frame(width: 8, height: 8) + } + + HStack { + Button("Back") { + stepIndex = max(0, stepIndex - 1) + } + .disabled(stepIndex == 0) + + Spacer() + + Button(stepIndex == 1 ? "Finish" : "Next") { + if stepIndex == 1 { + settings.onboardingComplete = true + } else { + stepIndex = min(1, stepIndex + 1) + } + } + .buttonStyle(.borderedProminent) + } + } + .frame(maxWidth: 420) + .padding(.horizontal, 48) + .padding(.vertical, 24) + Spacer(minLength: 0) + } + } + + private var modulesStep: some View { + VStack(spacing: 16) { + Text("Modules & Permissions") + .font(.headline) + .frame(maxWidth: .infinity, alignment: .center) + Toggle("Enable HomeKit module", isOn: $settings.homeKitEnabled) + Toggle("Auto-start API server", isOn: $settings.autoStart) + Text("All modules are off by default. You can change these later in Settings.") + .font(.caption) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity, alignment: .center) + + Button("Request HomeKit Access") { + model.homeKit.start() + model.showToast("HomeKit permission prompt requested") + } + .disabled(!settings.homeKitEnabled) + .buttonStyle(.borderedProminent) + .controlSize(.large) + .frame(maxWidth: .infinity) + } + .padding(.horizontal, 24) + } + + private var cliStep: some View { + VStack(spacing: 16) { + Text("Install CLI") + .font(.headline) + .frame(maxWidth: .infinity, alignment: .center) + Text("Install the bundled CLI so you can call the API from the terminal.") + .font(.caption) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity, alignment: .center) + + Button("Install CLI") { + model.installCLI() + cliInstallAttempted = true + } + .disabled(!model.cliStatus.canInstall || model.cliStatus.isInstalled) + .buttonStyle(.borderedProminent) + .controlSize(.large) + .frame(maxWidth: .infinity) + if !model.cliStatus.canInstall, let reason = model.cliStatus.reason { + Text(reason) + .font(.caption) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity, alignment: .center) + } else if model.cliStatus.isInstalled { + Text("CLI already installed at /usr/local/bin/casa.") + .font(.caption) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity, alignment: .center) + } else if cliInstallAttempted && !model.cliStatus.isInstalled { + Text("CLI install failed. Check permissions for /usr/local/bin.") + .font(.caption) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity, alignment: .center) + } + } + .padding(.horizontal, 24) + } +} diff --git a/Casa/SettingsView.swift b/Casa/SettingsView.swift new file mode 100644 index 0000000..f42a0fe --- /dev/null +++ b/Casa/SettingsView.swift @@ -0,0 +1,88 @@ +import SwiftUI + +struct SettingsView: View { + @EnvironmentObject private var model: CasaAppModel + @ObservedObject private var settings = CasaSettings.shared + + var body: some View { + Form { + Section("API Server") { + HStack { + Text(model.server.isRunning ? "Running" : "Stopped") + Spacer() + Button(model.server.isRunning ? "Stop" : "Start") { + model.toggleServer() + } + } + Text(verbatim: "Local API: http://127.0.0.1:\(String(model.server.port))") + .font(.footnote) + if let error = model.server.lastError { + Text(error) + .font(.footnote) + .foregroundColor(.red) + } + if !model.statusMessage.isEmpty { + Text(model.statusMessage) + .font(.footnote) + } + } + + Section("Settings") { + ModuleToggleRow( + title: "Enable HomeKit module", + isOn: $settings.homeKitEnabled, + status: moduleStatusHomeKit(), + isSupported: true, + unavailableReason: nil + ) + + Toggle("Auto-start API", isOn: $settings.autoStart) + + HStack { + Text("Port") + Spacer() + TextField("14663", text: portBinding) + .multilineTextAlignment(.trailing) + .frame(width: 80) + .keyboardType(.numberPad) + } + + SecureField("Auth Token (optional)", text: $settings.authToken) + + HStack(spacing: 8) { + Text("CLI") + StatusBadge(status: model.cliStatus.isInstalled + ? ModuleStatus(text: "Installed", tone: .ok) + : ModuleStatus(text: "Not Installed", tone: .muted) + ) + } + + Button("Check for Updates") { + model.checkForUpdates() + } + + Button("Install CLI Symlink") { + model.installCLI() + } + } + } + } + + private var portBinding: Binding { + Binding( + get: { String(settings.port) }, + set: { value in + if let port = UInt16(value) { + settings.port = port + } + } + ) + } + + private func moduleStatusHomeKit() -> ModuleStatus { + if settings.homeKitEnabled { + return ModuleStatus(text: "Enabled", tone: .ok) + } + return ModuleStatus(text: "Disabled", tone: .muted) + } +} diff --git a/Casa/SparkleUpdater.swift b/Casa/SparkleUpdater.swift new file mode 100644 index 0000000..80c9887 --- /dev/null +++ b/Casa/SparkleUpdater.swift @@ -0,0 +1,19 @@ +import Foundation + +#if canImport(Sparkle) +import Sparkle + +@MainActor +final class SparkleUpdater: NSObject, ObservableObject { + private let controller: SPUStandardUpdaterController + + override init() { + controller = SPUStandardUpdaterController(startingUpdater: true, updaterDelegate: nil, userDriverDelegate: nil) + super.init() + } + + func checkForUpdates() { + controller.checkForUpdates(nil) + } +} +#endif diff --git a/CasaCLI/Package.resolved b/CasaCLI/Package.resolved new file mode 100644 index 0000000..6c245b1 --- /dev/null +++ b/CasaCLI/Package.resolved @@ -0,0 +1,14 @@ +{ + "pins" : [ + { + "identity" : "swift-argument-parser", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-argument-parser", + "state" : { + "revision" : "c5d11a805e765f52ba34ec7284bd4fcd6ba68615", + "version" : "1.7.0" + } + } + ], + "version" : 2 +} diff --git a/CasaCLI/Package.swift b/CasaCLI/Package.swift new file mode 100644 index 0000000..c9ee4d7 --- /dev/null +++ b/CasaCLI/Package.swift @@ -0,0 +1,23 @@ +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "CasaCLI", + platforms: [ + .macOS(.v13) + ], + products: [ + .executable(name: "casa", targets: ["CasaCLI"]) + ], + dependencies: [ + .package(url: "https://github.com/apple/swift-argument-parser", from: "1.3.0") + ], + targets: [ + .executableTarget( + name: "CasaCLI", + dependencies: [ + .product(name: "ArgumentParser", package: "swift-argument-parser") + ] + ) + ] +) diff --git a/CasaCLI/Sources/CasaCLI/main.swift b/CasaCLI/Sources/CasaCLI/main.swift new file mode 100644 index 0000000..1679981 --- /dev/null +++ b/CasaCLI/Sources/CasaCLI/main.swift @@ -0,0 +1,320 @@ +import ArgumentParser +import Foundation + +@main +struct CasaCLI: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "casa", + abstract: "Casa CLI for HomeKit automation", + subcommands: [ + Health.self, + Homes.self, + Devices.self, + Accessory.self, + Rooms.self, + Services.self, + Schema.self, + Characteristics.self, + Cameras.self, + Watch.self + ] + ) + + struct Options: ParsableArguments { + @Option(name: .shortAndLong, help: "Base URL for the Casa API.") + var url: String = ProcessInfo.processInfo.environment["CASA_URL"] ?? "http://127.0.0.1:14663" + + @Option(name: .shortAndLong, help: "Auth token (or set CASA_TOKEN).") + var token: String = ProcessInfo.processInfo.environment["CASA_TOKEN"] ?? "" + + @Flag(name: .shortAndLong, help: "Print the full response envelope.") + var raw: Bool = false + } + + struct Devices: AsyncParsableCommand { + static let configuration = CommandConfiguration(abstract: "List accessories") + @OptionGroup var options: Options + + func run() async throws { + let client = APIClient(baseURL: options.url, token: options.token) + let data = try await client.get(path: "/homekit/accessories") + try output(data: data, raw: options.raw) + } + } + + struct Accessory: AsyncParsableCommand { + static let configuration = CommandConfiguration(abstract: "Fetch one accessory") + @OptionGroup var options: Options + + @Argument(help: "Accessory ID") + var id: String + + func run() async throws { + let client = APIClient(baseURL: options.url, token: options.token) + let data = try await client.get(path: "/homekit/accessories/\(id)") + try output(data: data, raw: options.raw) + } + } + + struct Rooms: AsyncParsableCommand { + static let configuration = CommandConfiguration(abstract: "List rooms") + @OptionGroup var options: Options + + func run() async throws { + let client = APIClient(baseURL: options.url, token: options.token) + let data = try await client.get(path: "/homekit/rooms") + try output(data: data, raw: options.raw) + } + } + + struct Services: AsyncParsableCommand { + static let configuration = CommandConfiguration(abstract: "List services") + @OptionGroup var options: Options + + func run() async throws { + let client = APIClient(baseURL: options.url, token: options.token) + let data = try await client.get(path: "/homekit/services") + try output(data: data, raw: options.raw) + } + } + + struct Homes: AsyncParsableCommand { + static let configuration = CommandConfiguration(abstract: "List homes") + @OptionGroup var options: Options + + func run() async throws { + let client = APIClient(baseURL: options.url, token: options.token) + let data = try await client.get(path: "/homekit/homes") + try output(data: data, raw: options.raw) + } + } + + struct Health: AsyncParsableCommand { + static let configuration = CommandConfiguration(abstract: "Check server health") + @OptionGroup var options: Options + + func run() async throws { + let client = APIClient(baseURL: options.url, token: options.token) + let data = try await client.get(path: "/health") + try output(data: data, raw: options.raw) + } + } + + struct Schema: AsyncParsableCommand { + static let configuration = CommandConfiguration(abstract: "List writable characteristics schema") + @OptionGroup var options: Options + + func run() async throws { + let client = APIClient(baseURL: options.url, token: options.token) + let data = try await client.get(path: "/homekit/schema") + try output(data: data, raw: options.raw) + } + } + + struct Characteristics: AsyncParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Read or write a characteristic", + subcommands: [Get.self, Set.self, Write.self] + ) + + struct Get: AsyncParsableCommand { + static let configuration = CommandConfiguration(abstract: "Read a characteristic") + @OptionGroup var options: Options + + @Argument(help: "Characteristic ID") + var id: String + + func run() async throws { + let client = APIClient(baseURL: options.url, token: options.token) + let data = try await client.get(path: "/homekit/characteristics/\(id)") + try output(data: data, raw: options.raw) + } + } + + struct Set: AsyncParsableCommand { + static let configuration = CommandConfiguration(abstract: "Write a characteristic (PUT)") + @OptionGroup var options: Options + + @Argument(help: "Characteristic ID") + var id: String + + @Argument(help: "Value (JSON literal or plain string)") + var value: String + + func run() async throws { + let client = APIClient(baseURL: options.url, token: options.token) + let payload: [String: Any] = ["value": try parseJSONValue(value)] + let data = try await client.request(method: "PUT", path: "/homekit/characteristics/\(id)", jsonBody: payload) + try output(data: data, raw: options.raw) + } + } + + struct Write: AsyncParsableCommand { + static let configuration = CommandConfiguration(abstract: "Write a characteristic (legacy POST)") + @OptionGroup var options: Options + + @Argument(help: "Characteristic ID") + var id: String + + @Argument(help: "Value (JSON literal or plain string)") + var value: String + + func run() async throws { + let client = APIClient(baseURL: options.url, token: options.token) + let payload: [String: Any] = ["id": id, "value": try parseJSONValue(value)] + let data = try await client.request(method: "POST", path: "/homekit/characteristic", jsonBody: payload) + try output(data: data, raw: options.raw) + } + } + } + + struct Cameras: AsyncParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Camera endpoints", + subcommands: [List.self, Get.self] + ) + + struct List: AsyncParsableCommand { + static let configuration = CommandConfiguration(abstract: "List cameras") + @OptionGroup var options: Options + + func run() async throws { + let client = APIClient(baseURL: options.url, token: options.token) + let data = try await client.get(path: "/homekit/cameras") + try output(data: data, raw: options.raw) + } + } + + struct Get: AsyncParsableCommand { + static let configuration = CommandConfiguration(abstract: "Fetch one camera") + @OptionGroup var options: Options + + @Argument(help: "Camera ID") + var id: String + + func run() async throws { + let client = APIClient(baseURL: options.url, token: options.token) + let data = try await client.get(path: "/homekit/cameras/\(id)") + try output(data: data, raw: options.raw) + } + } + + } + + struct Watch: AsyncParsableCommand { + static let configuration = CommandConfiguration(abstract: "Stream accessory changes") + @OptionGroup var options: Options + + @Option(name: .shortAndLong, help: "Polling interval in seconds.") + var interval: Double = 2.0 + + func run() async throws { + let client = APIClient(baseURL: options.url, token: options.token) + var lastPayload: Data? + while true { + let data = try await client.get(path: "/homekit/accessories") + if data != lastPayload { + try output(data: data, raw: options.raw) + lastPayload = data + } + try await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000)) + } + } + } + +} + +private struct APIClient { + let baseURL: URL + let token: String + + init(baseURL: String, token: String) { + if let url = URL(string: baseURL) { + self.baseURL = url + } else { + self.baseURL = URL(string: "http://127.0.0.1:14663")! + } + self.token = token + } + + func get(path: String) async throws -> Data { + try await request(method: "GET", path: path) + } + + func getRaw(path: String) async throws -> Data { + try await request(method: "GET", path: path, expectJSON: false) + } + + func request(method: String, path: String, jsonBody: [String: Any]? = nil, expectJSON: Bool = true) async throws -> Data { + guard let url = URL(string: path, relativeTo: baseURL) else { + throw RuntimeError("Invalid URL: \(path)") + } + var request = URLRequest(url: url) + request.httpMethod = method + if !token.isEmpty { + request.setValue(token, forHTTPHeaderField: "X-Casa-Token") + } + if let jsonBody { + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSONSerialization.data(withJSONObject: jsonBody, options: []) + } + + let (data, response) = try await URLSession.shared.data(for: request) + guard let http = response as? HTTPURLResponse else { + throw RuntimeError("No HTTP response") + } + guard (200...299).contains(http.statusCode) else { + let message = String(data: data, encoding: .utf8) ?? "HTTP \(http.statusCode)" + throw RuntimeError(message) + } + if expectJSON { + _ = try JSONSerialization.jsonObject(with: data, options: []) + } + return data + } +} + +private func output(data: Data, raw: Bool) throws { + let object = try JSONSerialization.jsonObject(with: data, options: []) + if raw { + let pretty = try JSONSerialization.data(withJSONObject: object, options: [.prettyPrinted, .sortedKeys]) + FileHandle.standardOutput.write(pretty) + FileHandle.standardOutput.write(Data("\n".utf8)) + return + } + + if let dict = object as? [String: Any], let dataField = dict["data"] { + let pretty = try JSONSerialization.data(withJSONObject: dataField, options: [.prettyPrinted, .sortedKeys]) + FileHandle.standardOutput.write(pretty) + FileHandle.standardOutput.write(Data("\n".utf8)) + return + } + + let pretty = try JSONSerialization.data(withJSONObject: object, options: [.prettyPrinted, .sortedKeys]) + FileHandle.standardOutput.write(pretty) + FileHandle.standardOutput.write(Data("\n".utf8)) +} + +struct RuntimeError: Error, CustomStringConvertible { + let description: String + init(_ description: String) { self.description = description } +} + +private func parseJSONValue(_ value: String) throws -> Any { + let data = Data(value.utf8) + if let parsed = try? JSONSerialization.jsonObject(with: data, options: []) { + return parsed + } + if let number = Double(value) { + return number + } + if value.lowercased() == "true" { return true } + if value.lowercased() == "false" { return false } + if value.lowercased() == "null" { return NSNull() } + return value +} + +private func writeFile(path: String, data: Data) throws { + let url = URL(fileURLWithPath: path) + try data.write(to: url) +} diff --git a/CasaTests/CasaTests.swift b/CasaTests/CasaTests.swift new file mode 100644 index 0000000..9bf2bda --- /dev/null +++ b/CasaTests/CasaTests.swift @@ -0,0 +1,148 @@ +import XCTest +import HomeKit +@testable import Casa + +final class CasaTests: XCTestCase { + func testHTTPRequestParse() { + let raw = "GET /health HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n" + let data = Data(raw.utf8) + let parsed = HTTPRequest.parse(from: data, maxHeaderBytes: 4096, maxBodyBytes: 1024 * 1024) + if case let .complete(request, _) = parsed { + XCTAssertEqual(request.method, "GET") + XCTAssertEqual(request.path, "/health") + XCTAssertEqual(request.keepAlive, false) + } else { + XCTFail("Expected complete request") + } + } + + func testHTTPResponseEnvelope() throws { + let response = HTTPResponse.ok(.object(["status": .string("ok")]), requestId: "req-1", started: Date(timeIntervalSince1970: 0)) + let data = response.encoded(keepAlive: true) + let text = String(data: data, encoding: .utf8) ?? "" + XCTAssertTrue(text.contains("HTTP/1.1 200")) + XCTAssertTrue(text.contains("\"requestId\"")) + XCTAssertTrue(text.contains("\"ok\"")) + } + + func testSchemaPayloadIncludesWritableCharacteristics() throws { + let metadata = CasaCharacteristicMetadata( + format: HMCharacteristicMetadataFormatBool, + minValue: nil, + maxValue: nil, + stepValue: nil, + validValues: [], + units: "" + ) + let characteristic = CasaCharacteristic( + id: "char-1", + type: "type-1", + properties: ["write"], + metadata: metadata, + value: true + ) + let service = CasaService( + id: "svc-1", + name: "Service", + type: "type", + accessoryId: "acc-1", + characteristics: [characteristic] + ) + let accessory = CasaAccessory( + id: "acc-1", + name: "Accessory", + category: "Category", + room: "Room", + hasCameraProfile: false, + services: [service] + ) + + let payload = HomeKitPayload.schema([accessory]) + let data = payload.encodedData() + let json = try JSONSerialization.jsonObject(with: data) as? [[String: Any]] + XCTAssertEqual(json?.first?["writable"] as? Bool, true) + XCTAssertEqual(json?.first?["id"] as? String, "char-1") + } + + func testSchemaIncludesValidValuesAndRange() throws { + let metadata = CasaCharacteristicMetadata( + format: HMCharacteristicMetadataFormatInt, + minValue: 0, + maxValue: 100, + stepValue: 5, + validValues: [0, 50, 100], + units: "%" + ) + let characteristic = CasaCharacteristic( + id: "char-2", + type: "type-2", + properties: ["read", "write"], + metadata: metadata, + value: 50 + ) + let service = CasaService( + id: "svc-2", + name: "Service", + type: "type", + accessoryId: "acc-2", + characteristics: [characteristic] + ) + let accessory = CasaAccessory( + id: "acc-2", + name: "Accessory", + category: "Category", + room: "Room", + hasCameraProfile: false, + services: [service] + ) + + let payload = HomeKitPayload.schema([accessory]) + let data = payload.encodedData() + let json = try JSONSerialization.jsonObject(with: data) as? [[String: Any]] + let entry = json?.first + XCTAssertEqual(entry?["stepValue"] as? Double, 5) + XCTAssertEqual(entry?["validValues"] as? [Double], [0, 50, 100]) + XCTAssertEqual(entry?["minValue"] as? Double, 0) + XCTAssertEqual(entry?["maxValue"] as? Double, 100) + } + + func testSettingsDefaultsAreOff() throws { + let suiteName = "casa.tests.defaults.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defaults.removePersistentDomain(forName: suiteName) + let settings = CasaSettings.makeForTests(defaults: defaults) + + XCTAssertEqual(settings.port, 14663) + XCTAssertEqual(settings.authToken, "") + XCTAssertEqual(settings.autoStart, false) + XCTAssertEqual(settings.homeKitEnabled, false) + XCTAssertEqual(settings.onboardingComplete, false) + } + + func testSettingsPersistence() throws { + let suiteName = "casa.tests.persist.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defaults.removePersistentDomain(forName: suiteName) + let settings = CasaSettings.makeForTests(defaults: defaults) + + settings.homeKitEnabled = true + settings.onboardingComplete = true + settings.autoStart = true + + let reloaded = CasaSettings.makeForTests(defaults: defaults) + XCTAssertEqual(reloaded.homeKitEnabled, true) + XCTAssertEqual(reloaded.onboardingComplete, true) + XCTAssertEqual(reloaded.autoStart, true) + } + + func testCLIStatusUnsupportedOnNonCatalyst() { + #if targetEnvironment(macCatalyst) + XCTAssertTrue(CLIInstaller.status().canInstall) + #else + let status = CLIInstaller.status() + XCTAssertEqual(status.isInstalled, false) + XCTAssertEqual(status.canInstall, false) + XCTAssertNotNil(status.reason) + #endif + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..1372c01 --- /dev/null +++ b/README.md @@ -0,0 +1,102 @@ +# Casa + +![Casa](Casa/Assets.xcassets/AppIcon.appiconset/AppIcon1024.png) + +Casa is a Mac Catalyst app that exposes your local HomeKit data as a localhost-only REST API, plus a CLI for scripting. It is designed for automation, agents, and power users who want a fast, reliable bridge into HomeKit on their own machine. + +## Highlights + +- Local-only API (loopback, no remote access) +- HomeKit module is opt-in and off by default +- Built-in CLI for fast scripting and automation +- Onboarding flow for permissions and setup +- Works with existing Home and accessories + +## Quick start + +1) Open `Casa.xcodeproj` in Xcode. +2) Select your Team and enable the HomeKit capability. +3) Build and run the app. +4) Complete onboarding (enable HomeKit and install CLI). + +Build from the terminal: + +``` +./scripts/restart-mac.sh +``` + +## Using the API + +Default base URL: + +``` +http://127.0.0.1:14663 +``` + +If you set an auth token in the app, pass `X-Casa-Token: `. + +Common endpoints: + +- `GET /health` +- `GET /homekit/accessories` +- `GET /homekit/accessories/:id` +- `GET /homekit/rooms` +- `GET /homekit/services` +- `GET /homekit/characteristics/:id` +- `PUT /homekit/characteristics/:id` +- `GET /homekit/schema` +- `GET /homekit/cameras` +- `GET /homekit/cameras/:id` + +## CLI + +The CLI is embedded into the app at build time and can be installed from Settings or onboarding. + +Examples: + +``` +# Health check +casa health + +# Browse HomeKit +casa devices +casa rooms +casa services + +# Read/write a characteristic +casa characteristics get +casa characteristics set true + +# Custom URL/token +CASA_URL=http://127.0.0.1:14663 CASA_TOKEN=token casa devices +``` + +## Development notes + +- The server only binds to loopback (`127.0.0.1`). +- HomeKit access requires the entitlement; the module stays off until enabled in the app. +- If you change the port or auth token, update your CLI environment variables. + +## Troubleshooting + +- No accessories: confirm you have a Home in the Home app and grant HomeKit access in System Settings. +- Server not running: verify the port is free and check the app status badge. +- Missing CLI: use the Install CLI action in onboarding or Settings. + +## Sparkle updates + +Casa uses Sparkle for app updates. Set these build settings or environment values: + +- `SPARKLE_FEED_URL` (appcast URL) +- `SPARKLE_PUBLIC_KEY` (Sparkle EdDSA public key) + +The release workflow expects these secrets: + +- `APPLE_CERT_BASE64` +- `APPLE_CERT_PASSWORD` +- `APPLE_TEAM_ID` +- `APPLE_SIGNING_IDENTITY` +- `APPLE_PROVISION_PROFILE_BASE64` +- `KEYCHAIN_PASSWORD` +- `SPARKLE_PRIVATE_KEY` +- `SPARKLE_PUBLIC_KEY` diff --git a/scripts/lint.sh b/scripts/lint.sh new file mode 100755 index 0000000..842d63f --- /dev/null +++ b/scripts/lint.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +swiftlint --config "$ROOT/.swiftlint.yml" diff --git a/scripts/restart-mac.sh b/scripts/restart-mac.sh new file mode 100755 index 0000000..13ee85f --- /dev/null +++ b/scripts/restart-mac.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +PROJECT="$ROOT/Casa.xcodeproj" +SCHEME="Casa" +DERIVED_DATA="$ROOT/DerivedData" +DESTINATION="platform=macOS,variant=Mac Catalyst" +FORCE_LEGACY_TABS="${FORCE_LEGACY_TABS:-}" + +pkill -x Casa >/dev/null 2>&1 || true + +if [[ -n "$FORCE_LEGACY_TABS" ]]; then + defaults write dev.shadowing.casa forceLegacyTabs -bool "$FORCE_LEGACY_TABS" +fi + +xcodebuild \ + -project "$PROJECT" \ + -scheme "$SCHEME" \ + -configuration Debug \ + -destination "$DESTINATION" \ + -derivedDataPath "$DERIVED_DATA" \ + build + +APP_PATH="$DERIVED_DATA/Build/Products/Debug-maccatalyst/Casa.app" +open -n "$APP_PATH" diff --git a/skills/casa/SKILL.md b/skills/casa/SKILL.md new file mode 100644 index 0000000..cb3f4c6 --- /dev/null +++ b/skills/casa/SKILL.md @@ -0,0 +1,41 @@ +--- +name: casa +description: Control HomeKit devices via the local Casa app and API (localhost-only). +metadata: {"clawdbot":{"emoji":"🏠","requires":{"bins":["casa"]},"install":[{"id":"brew","kind":"brew","formula":"clawdbot/tap/casa","bins":["casa"],"label":"Install Casa (brew)"}]}} +--- + +# Casa + +Use the Casa app + CLI to read and write HomeKit characteristics on this machine. +The CLI talks to the local Casa API (loopback only). + +Quick start +- Ensure the Casa app is running (it should sit in the Dock even if you close the window). +- Health check: `casa health` +- If auth is enabled, set `CASA_TOKEN=...` or pass `--token`. + +Read +- `casa devices` — JSON output can be large; pipe through `jq` (e.g., `casa devices | jq '.[] | {name, room}'`). +- `casa accessory ` +- `casa characteristics get ` +- `casa schema` + +Write +- `casa characteristics set ` +- `casa characteristics write ` (legacy) + +Caching for speed +- Cache `characteristicId` values per device once discovered. +- Treat ids as stable unless the accessory is removed/re-added. +- If a write returns 404, refresh via `casa schema` or `casa devices` and rebuild the mapping. + +CLI +- `casa rooms` +- `casa services` +- `casa cameras list` +- `casa cameras get ` +- `CASA_URL=http://127.0.0.1:14663 CASA_TOKEN=token casa devices` + +Camera note +- Viewing camera feeds or snapshots is not available through HomeKit in Casa. +- Use direct camera streams (RTSP/HTTP) if you need camera media.