Squashed commit

This commit is contained in:
Shadow
2026-01-10 11:59:49 -06:00
commit 3ece0937a8
38 changed files with 4148 additions and 0 deletions
+35
View File
@@ -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
+136
View File
@@ -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 }}
+9
View File
@@ -0,0 +1,9 @@
.DS_Store
DerivedData/
.build/
CasaCLI/.build/
Packages/
*.xcuserstate
*.xcuserdata/
*.xccheckout
*.xcscmblueprint
+10
View File
@@ -0,0 +1,10 @@
included:
- Casa
opt_in_rules:
- empty_count
- empty_string
- implicitly_unwrapped_optional
- sorted_imports
line_length:
warning: 120
error: 160
+43
View File
@@ -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.
+485
View File
@@ -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 = "<group>"; };
A10000000000000000000012 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
A10000000000000000000013 /* HomeKitManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeKitManager.swift; sourceTree = "<group>"; };
A10000000000000000000014 /* HomeKitServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeKitServer.swift; sourceTree = "<group>"; };
A10000000000000000000016 /* CLIInstaller.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLIInstaller.swift; sourceTree = "<group>"; };
A10000000000000000000017 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
A10000000000000000000018 /* Casa.base.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Casa.base.entitlements; sourceTree = "<group>"; };
A10000000000000000000019 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
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 = "<group>"; };
A10000000000000000000035 /* CasaLogger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CasaLogger.swift; sourceTree = "<group>"; };
A10000000000000000000038 /* CasaModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CasaModels.swift; sourceTree = "<group>"; };
A1000000000000000000003A /* ApiDocsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApiDocsView.swift; sourceTree = "<group>"; };
A10000000000000000000054 /* SparkleUpdater.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SparkleUpdater.swift; sourceTree = "<group>"; };
A10000000000000000000056 /* CasaPasteboard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CasaPasteboard.swift; sourceTree = "<group>"; };
A10000000000000000000058 /* LogsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogsView.swift; sourceTree = "<group>"; };
A10000000000000000000061 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
A10000000000000000000066 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = "<group>"; };
A10000000000000000000068 /* ModuleUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModuleUI.swift; sourceTree = "<group>"; };
A10000000000000000000041 /* CasaTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CasaTests.swift; sourceTree = "<group>"; };
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 = "<group>";
};
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 = "<group>";
};
A10000000000000000000004 /* Casa */ = {
isa = PBXGroup;
children = (
A10000000000000000000017 /* Info.plist */,
A10000000000000000000018 /* Casa.base.entitlements */,
A10000000000000000000019 /* Assets.xcassets */,
);
path = Casa;
sourceTree = "<group>";
};
A10000000000000000000005 /* Frameworks */ = {
isa = PBXGroup;
children = (
A1000000000000000000001C /* HomeKit.framework */,
A1000000000000000000001D /* Network.framework */,
A1000000000000000000004B /* XCTest.framework */,
);
name = Frameworks;
sourceTree = "<group>";
};
A10000000000000000000050 /* CasaTests */ = {
isa = PBXGroup;
children = (
A10000000000000000000041 /* CasaTests.swift */,
);
path = CasaTests;
sourceTree = "<group>";
};
A10000000000000000000006 /* Products */ = {
isa = PBXGroup;
children = (
A1000000000000000000001B /* Casa.app */,
A10000000000000000000043 /* CasaTests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
/* 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 */;
}
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>
@@ -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
}
@@ -0,0 +1,111 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion="1500"
version="1.7">
<BuildAction
parallelizeBuildables="YES"
buildImplicitDependencies="YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting="YES"
buildForRunning="YES"
buildForProfiling="YES"
buildForArchiving="YES"
buildForAnalyzing="YES">
<BuildableReference
BuildableIdentifier="primary"
BlueprintIdentifier="A10000000000000000000007"
BuildableName="Casa.app"
BlueprintName="Casa"
ReferencedContainer="container:Casa.xcodeproj">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting="YES"
buildForRunning="NO"
buildForProfiling="NO"
buildForArchiving="NO"
buildForAnalyzing="NO">
<BuildableReference
BuildableIdentifier="primary"
BlueprintIdentifier="A10000000000000000000047"
BuildableName="CasaTests.xctest"
BlueprintName="CasaTests"
ReferencedContainer="container:Casa.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration="Debug"
selectedDebuggerIdentifier="Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier="Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv="YES">
<Testables>
<TestableReference
skipped="NO">
<BuildableReference
BuildableIdentifier="primary"
BlueprintIdentifier="A10000000000000000000047"
BuildableName="CasaTests.xctest"
BlueprintName="CasaTests"
ReferencedContainer="container:Casa.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
<MacroExpansion>
<BuildableReference
BuildableIdentifier="primary"
BlueprintIdentifier="A10000000000000000000007"
BuildableName="Casa.app"
BlueprintName="Casa"
ReferencedContainer="container:Casa.xcodeproj">
</BuildableReference>
</MacroExpansion>
</TestAction>
<LaunchAction
buildConfiguration="Debug"
selectedDebuggerIdentifier="Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier="Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle="0"
useCustomWorkingDirectory="NO"
ignoresPersistentStateOnLaunch="NO"
debugDocumentVersioning="YES"
debugServiceExtension="internal"
allowLocationSimulation="YES">
<BuildableProductRunnable
runnableDebuggingMode="0">
<BuildableReference
BuildableIdentifier="primary"
BlueprintIdentifier="A10000000000000000000007"
BuildableName="Casa.app"
BlueprintName="Casa"
ReferencedContainer="container:Casa.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration="Release"
shouldUseLaunchSchemeArgsEnv="YES"
savedToolIdentifier=""
useCustomWorkingDirectory="NO"
debugDocumentVersioning="YES">
<BuildableProductRunnable
runnableDebuggingMode="0">
<BuildableReference
BuildableIdentifier="primary"
BlueprintIdentifier="A10000000000000000000007"
BuildableName="Casa.app"
BlueprintName="Casa"
ReferencedContainer="container:Casa.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration="Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration="Release"
revealArchiveInOrganizer="YES">
</ArchiveAction>
</Scheme>
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>SchemeUserState</key>
<dict>
<key>Casa.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>0</integer>
</dict>
</dict>
</dict>
</plist>
+281
View File
@@ -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<UUID>.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 ?? "<id>"
let characteristicId = selectedCharacteristicId ?? "<uuid>"
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/<id>\(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)
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 969 KiB

@@ -0,0 +1,14 @@
{
"images" : [
{
"idiom" : "ios-marketing",
"size" : "1024x1024",
"filename" : "AppIcon1024.png",
"scale" : "1x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
+6
View File
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
+63
View File
@@ -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?
}
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.homekit</key>
<true/>
</dict>
</plist>
+250
View File
@@ -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<AnyCancellable>()
private var serverChange: AnyCancellable?
private var lastPort: UInt16
private var lastToken: String
private var lastHomeKitEnabled: Bool
private var toastTask: Task<Void, Never>?
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
}
+92
View File
@@ -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)
}
}
+52
View File
@@ -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
}
+11
View File
@@ -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
}
}
+103
View File
@@ -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"
}
}
+189
View File
@@ -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<UUID?> {
Binding<UUID?>(
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)
}
}
+70
View File
@@ -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()
}
}
File diff suppressed because it is too large Load Diff
+50
View File
@@ -0,0 +1,50 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDisplayName</key>
<string>Casa</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleName</key>
<string>Casa</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>NSHomeKitUsageDescription</key>
<string>Casa uses HomeKit to discover and control your accessories.</string>
<key>SUEnableAutomaticChecks</key>
<true/>
<key>SUFeedURL</key>
<string>$(SPARKLE_FEED_URL)</string>
<key>SUPublicEDKey</key>
<string>$(SPARKLE_PUBLIC_KEY)</string>
<key>UIRequiresFullScreen</key>
<false/>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<true/>
<key>UISceneConfigurations</key>
<dict>
<key>UIWindowSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneConfigurationName</key>
<string>Default Configuration</string>
<key>UISceneDelegateClassName</key>
<string></string>
</dict>
</array>
</dict>
</dict>
</dict>
</plist>
+87
View File
@@ -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")
}
}
+85
View File
@@ -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)
}
}
}
}
}
+128
View File
@@ -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)
}
}
+88
View File
@@ -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<String> {
Binding<String>(
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)
}
}
+19
View File
@@ -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
+14
View File
@@ -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
}
+23
View File
@@ -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")
]
)
]
)
+320
View File
@@ -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)
}
+148
View File
@@ -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
}
}
+102
View File
@@ -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: <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 <uuid>
casa characteristics set <uuid> 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`
+6
View File
@@ -0,0 +1,6 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
swiftlint --config "$ROOT/.swiftlint.yml"
+26
View File
@@ -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"
+41
View File
@@ -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 <id>`
- `casa characteristics get <id>`
- `casa schema`
Write
- `casa characteristics set <id> <value>`
- `casa characteristics write <id> <value>` (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 <id>`
- `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.