mirror of
https://github.com/openclaw/casa.git
synced 2026-06-30 20:58:00 -04:00
Squashed commit
This commit is contained in:
@@ -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
|
||||
@@ -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 }}
|
||||
@@ -0,0 +1,9 @@
|
||||
.DS_Store
|
||||
DerivedData/
|
||||
.build/
|
||||
CasaCLI/.build/
|
||||
Packages/
|
||||
*.xcuserstate
|
||||
*.xcuserdata/
|
||||
*.xccheckout
|
||||
*.xcscmblueprint
|
||||
@@ -0,0 +1,10 @@
|
||||
included:
|
||||
- Casa
|
||||
opt_in_rules:
|
||||
- empty_count
|
||||
- empty_string
|
||||
- implicitly_unwrapped_optional
|
||||
- sorted_imports
|
||||
line_length:
|
||||
warning: 120
|
||||
error: 160
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -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?
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
]
|
||||
)
|
||||
]
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
# Casa
|
||||
|
||||

|
||||
|
||||
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`
|
||||
Executable
+6
@@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
|
||||
swiftlint --config "$ROOT/.swiftlint.yml"
|
||||
Executable
+26
@@ -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"
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user