diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..13edbd0 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,167 @@ +name: KeyLight CI + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +concurrency: + group: keylight-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build-test-analyze: + runs-on: macos-26 + timeout-minutes: 40 + env: + DEVELOPER_DIR: /Applications/Xcode.app/Contents/Developer + KEYLIGHT_SOURCE_PACKAGES: /tmp/KeyLightSourcePackages + + steps: + - name: Check out the reviewed source + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Verify toolchain and source policy + shell: bash + run: | + if ! command -v rg >/dev/null 2>&1; then + brew untap aws/tap >/dev/null 2>&1 || true + HOMEBREW_NO_AUTO_UPDATE=1 brew install ripgrep + fi + xcodebuild -version + test "$(xcodebuild -version | awk 'NR == 1 {split($2, value, "."); print value[1]}')" -ge 26 + if ! xcrun --find metal >/dev/null 2>&1; then + xcodebuild -downloadComponent MetalToolchain + fi + ./scripts/verify-project-policy.sh + + - name: Check shell syntax + shell: bash + run: | + shell_roots=(scripts) + if test -d script; then + shell_roots+=(script) + fi + while IFS= read -r -d '' script_path; do + bash -n "$script_path" + done < <(find "${shell_roots[@]}" -type f -name '*.sh' -print0) + + - name: Test signed-update verification failure cases + shell: bash + run: | + CLANG_MODULE_CACHE_PATH="$RUNNER_TEMP/KeyLightUpdateVerifierModuleCache" \ + SWIFT_MODULECACHE_PATH="$RUNNER_TEMP/KeyLightUpdateVerifierModuleCache" \ + xcrun swift scripts/test-update-signature-verifier.swift + + - name: Resolve only locked dependencies + shell: bash + run: | + xcodebuild \ + -resolvePackageDependencies \ + -project KeyLight.xcodeproj \ + -scheme KeyLight \ + -clonedSourcePackagesDirPath "$KEYLIGHT_SOURCE_PACKAGES" \ + -disableAutomaticPackageResolution \ + -onlyUsePackageVersionsFromResolvedFile + + - name: Strict debug build + shell: bash + run: | + xcodebuild \ + -project KeyLight.xcodeproj \ + -scheme KeyLight \ + -configuration Debug \ + -destination 'generic/platform=macOS' \ + -derivedDataPath "$RUNNER_TEMP/KeyLightDebug" \ + -clonedSourcePackagesDirPath "$KEYLIGHT_SOURCE_PACKAGES" \ + -disableAutomaticPackageResolution \ + -onlyUsePackageVersionsFromResolvedFile \ + SWIFT_VERSION=6 \ + SWIFT_STRICT_CONCURRENCY=complete \ + CODE_SIGNING_ALLOWED=NO \ + build + + - name: Isolated compatibility and unit tests + shell: bash + run: | + xcodebuild \ + -project KeyLight.xcodeproj \ + -scheme KeyLight \ + -configuration Debug \ + -destination 'platform=macOS' \ + -derivedDataPath "$RUNNER_TEMP/KeyLightTests" \ + -clonedSourcePackagesDirPath "$KEYLIGHT_SOURCE_PACKAGES" \ + -disableAutomaticPackageResolution \ + -onlyUsePackageVersionsFromResolvedFile \ + SWIFT_VERSION=6 \ + SWIFT_STRICT_CONCURRENCY=complete \ + CODE_SIGNING_ALLOWED=NO \ + test + + - name: Xcode static analyzer + shell: bash + run: | + xcodebuild \ + -project KeyLight.xcodeproj \ + -scheme KeyLight \ + -configuration Release \ + -destination 'generic/platform=macOS' \ + -derivedDataPath "$RUNNER_TEMP/KeyLightAnalyze" \ + -clonedSourcePackagesDirPath "$KEYLIGHT_SOURCE_PACKAGES" \ + -disableAutomaticPackageResolution \ + -onlyUsePackageVersionsFromResolvedFile \ + SWIFT_VERSION=6 \ + SWIFT_STRICT_CONCURRENCY=complete \ + CODE_SIGNING_ALLOWED=NO \ + analyze + + - name: Universal release build + shell: bash + run: | + xcodebuild \ + -project KeyLight.xcodeproj \ + -scheme KeyLight \ + -configuration Release \ + -destination 'generic/platform=macOS' \ + -derivedDataPath "$RUNNER_TEMP/KeyLightRelease" \ + -clonedSourcePackagesDirPath "$KEYLIGHT_SOURCE_PACKAGES" \ + -disableAutomaticPackageResolution \ + -onlyUsePackageVersionsFromResolvedFile \ + SWIFT_VERSION=6 \ + SWIFT_STRICT_CONCURRENCY=complete \ + ARCHS='arm64 x86_64' \ + ONLY_ACTIVE_ARCH=NO \ + CODE_SIGNING_ALLOWED=NO \ + build + + - name: Validate universal release, privacy, updater, and metallib + shell: bash + run: | + app="$RUNNER_TEMP/KeyLightRelease/Build/Products/Release/KeyLight.app" + info="$app/Contents/Info.plist" + resources="$app/Contents/Resources" + binary="$app/Contents/MacOS/KeyLight" + sparkle="$app/Contents/Frameworks/Sparkle.framework" + test -d "$app" + test "$(lipo -archs "$binary")" = 'x86_64 arm64' || test "$(lipo -archs "$binary")" = 'arm64 x86_64' + test "$(lipo -archs "$sparkle/Sparkle")" = 'x86_64 arm64' || test "$(lipo -archs "$sparkle/Sparkle")" = 'arm64 x86_64' + test "$(plutil -extract CFBundleShortVersionString raw -o - "$sparkle/Resources/Info.plist")" = '2.9.5' + test -f "$resources/PrivacyInfo.xcprivacy" + plutil -lint "$resources/PrivacyInfo.xcprivacy" + test "$(plutil -extract NSPrivacyTracking raw -o - "$resources/PrivacyInfo.xcprivacy")" = 'false' + test -f "$resources/default.metallib" + strings "$resources/default.metallib" | rg -F keyLightRefractionVertex + strings "$resources/default.metallib" | rg -F keyLightRefractionFragment + test -z "$(find "$resources" -type f -name '*.metal' -print -quit)" + test "$(plutil -extract SUEnableAutomaticChecks raw -o - "$info")" = 'false' + test "$(plutil -extract SUAutomaticallyUpdate raw -o - "$info")" = 'false' + test "$(plutil -extract SUSendProfileInfo raw -o - "$info")" = 'false' + test "$(plutil -extract SUVerifyUpdateBeforeExtraction raw -o - "$info")" = 'true' + test "$(plutil -extract SURequireSignedFeed raw -o - "$info")" = 'true' + test -z "$(plutil -extract SUFeedURL raw -o - "$info" 2>/dev/null || true)" + test -z "$(plutil -extract SUPublicEDKey raw -o - "$info" 2>/dev/null || true)" + DEVELOPER_DIR="$DEVELOPER_DIR" xcrun vtool -show-build "$binary" > "$RUNNER_TEMP/KeyLight-vtool.txt" + test "$(rg -c '^ minos 14\.0$' "$RUNNER_TEMP/KeyLight-vtool.txt")" = '2' diff --git a/.github/workflows/update-feed-audit.yml b/.github/workflows/update-feed-audit.yml new file mode 100644 index 0000000..d67af93 --- /dev/null +++ b/.github/workflows/update-feed-audit.yml @@ -0,0 +1,31 @@ +name: Signed Update Feed Audit + +on: + workflow_dispatch: + schedule: + - cron: '23 7 * * 3' + +permissions: + contents: read + +jobs: + audit: + if: >- + ${{ vars.KEYLIGHT_APPCAST_URL != '' && + vars.KEYLIGHT_SPARKLE_PUBLIC_ED_KEY != '' && + vars.KEYLIGHT_EXPECTED_TEAM_ID != '' }} + runs-on: macos-26 + timeout-minutes: 15 + env: + DEVELOPER_DIR: /Applications/Xcode.app/Contents/Developer + KEYLIGHT_APPCAST_URL: ${{ vars.KEYLIGHT_APPCAST_URL }} + KEYLIGHT_SPARKLE_PUBLIC_ED_KEY: ${{ vars.KEYLIGHT_SPARKLE_PUBLIC_ED_KEY }} + KEYLIGHT_EXPECTED_TEAM_ID: ${{ vars.KEYLIGHT_EXPECTED_TEAM_ID }} + + steps: + - name: Check out feed-audit code + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Verify signed feed, release notes, archive, and Developer ID + shell: bash + run: ./scripts/audit-update-feed.sh diff --git a/.gitignore b/.gitignore index b874a2e..c5c804a 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,7 @@ xcuserdata/ .build/ .swiftpm/ Package.resolved +!KeyLight.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved # Fastlane fastlane/report.xml @@ -41,3 +42,6 @@ dist/.DS_Store # Logs *.log + +# LLVM profiling output +*.profraw diff --git a/Configurations/App-Debug.xcconfig b/Configurations/App-Debug.xcconfig new file mode 100644 index 0000000..2e04680 --- /dev/null +++ b/Configurations/App-Debug.xcconfig @@ -0,0 +1,3 @@ +#include "Shared.xcconfig" + +PRODUCT_BUNDLE_IDENTIFIER = com.keylight.app.debug diff --git a/Configurations/App-Release.xcconfig b/Configurations/App-Release.xcconfig new file mode 100644 index 0000000..50d4136 --- /dev/null +++ b/Configurations/App-Release.xcconfig @@ -0,0 +1,4 @@ +#include "Shared.xcconfig" + +PRODUCT_BUNDLE_IDENTIFIER = com.keylight.app +DEAD_CODE_STRIPPING = YES diff --git a/Configurations/Shared.xcconfig b/Configurations/Shared.xcconfig new file mode 100644 index 0000000..169d569 --- /dev/null +++ b/Configurations/Shared.xcconfig @@ -0,0 +1,14 @@ +// KeyLight's single checked-in source of truth for compatibility and toolchain settings. +MACOSX_DEPLOYMENT_TARGET = 14.0 +MARKETING_VERSION = 2.0.0 +CURRENT_PROJECT_VERSION = 26 +KEYLIGHT_BUILD_CHANNEL = Development +SWIFT_VERSION = 6.0 +SWIFT_STRICT_CONCURRENCY = complete +SWIFT_EMIT_LOC_STRINGS = YES +LOCALIZATION_PREFERS_STRING_CATALOGS = YES + +// Production release automation must provide both values. Keeping them empty +// makes local/debug builds network-silent and UpdateService fails closed. +KEYLIGHT_SPARKLE_FEED_URL = +KEYLIGHT_SPARKLE_PUBLIC_ED_KEY = diff --git a/Configurations/Tests.xcconfig b/Configurations/Tests.xcconfig new file mode 100644 index 0000000..9b60fe6 --- /dev/null +++ b/Configurations/Tests.xcconfig @@ -0,0 +1,3 @@ +#include "Shared.xcconfig" + +PRODUCT_BUNDLE_IDENTIFIER = com.keylight.tests diff --git a/KeyLight.xcodeproj/project.pbxproj b/KeyLight.xcodeproj/project.pbxproj index 7c07de2..b13dc0a 100644 --- a/KeyLight.xcodeproj/project.pbxproj +++ b/KeyLight.xcodeproj/project.pbxproj @@ -8,23 +8,66 @@ /* Begin PBXBuildFile section */ 039A35018C6044DEB0471E90 /* VariantPresets in Resources */ = {isa = PBXBuildFile; fileRef = 27E329D7653A4E568EDE7BA0 /* VariantPresets */; }; + A10000000000000000000007 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000007 /* Localizable.xcstrings */; }; + A10000000000000000000023 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000023 /* PrivacyInfo.xcprivacy */; }; + A10000000000000000000024 /* UpdateService.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000024 /* UpdateService.swift */; }; + A10000000000000000000025 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = D10000000000000000000002 /* Sparkle */; }; + A10000000000000000000026 /* SurfaceMotionEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000026 /* SurfaceMotionEngine.swift */; }; + A10000000000000000000027 /* GuidedCalibrationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000027 /* GuidedCalibrationView.swift */; }; + A10000000000000000000028 /* ConfigurationSnapshotFilePanelHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000028 /* ConfigurationSnapshotFilePanelHelper.swift */; }; + A10000000000000000000029 /* SettingsSnapshotsTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000029 /* SettingsSnapshotsTab.swift */; }; 07FA3D6B610C5E5FAC7C85D0 /* KeyPositionEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B781FCBD49CEEA98C03DFE6 /* KeyPositionEditorView.swift */; }; 27696294CEEDE6A794C4145D /* GlowOverlayWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42C7F70959DB4F1BA7CCCFFA /* GlowOverlayWindow.swift */; }; + A10000000000000000000010 /* LiquidGlassTransitionMath.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000010 /* LiquidGlassTransitionMath.swift */; }; + A10000000000000000000011 /* LiquidGlassGlowRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000011 /* LiquidGlassGlowRenderer.swift */; }; + A10000000000000000000012 /* SavedDomainValues.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000012 /* SavedDomainValues.swift */; }; + A10000000000000000000013 /* KeyboardEventDecoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000013 /* KeyboardEventDecoder.swift */; }; + A10000000000000000000014 /* SettingsAppearanceTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000014 /* SettingsAppearanceTab.swift */; }; + A10000000000000000000015 /* SettingsKeyboardTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000015 /* SettingsKeyboardTab.swift */; }; + A10000000000000000000016 /* SettingsGeneralTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000016 /* SettingsGeneralTab.swift */; }; + A10000000000000000000017 /* GlowRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000017 /* GlowRenderer.swift */; }; + A10000000000000000000018 /* ThemeStringCodec.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000018 /* ThemeStringCodec.swift */; }; + A10000000000000000000019 /* LayoutProfileCodec.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000019 /* LayoutProfileCodec.swift */; }; + A10000000000000000000020 /* SettingsGlowPreviewSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000020 /* SettingsGlowPreviewSession.swift */; }; + A10000000000000000000021 /* PhysicalRefractionRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000021 /* PhysicalRefractionRenderer.swift */; }; + A10000000000000000000022 /* PhysicalRefractionShaders.metal in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000022 /* PhysicalRefractionShaders.metal */; }; 46143D0364269D8C823EA4B2 /* KeyLightApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = C09A21A722EA6548D1A666BA /* KeyLightApp.swift */; }; 4F43E5FBF3B77CBFF2C9F788 /* KeyLightLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = B57913A75F4E826856365A90 /* KeyLightLog.swift */; }; 528521486593AF92A20564C2 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C615F1CC81CCBC89F42A6C7 /* AppDelegate.swift */; }; + A1000000000000000000000F /* AppCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1000000000000000000000F /* AppCoordinator.swift */; }; 5A446C00CDDD2A0D023651E4 /* SettingsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 860BB44450D83CBBBD94011F /* SettingsManager.swift */; }; 5FD43430E908B2FC9D6C0E6F /* MenuBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A412D4CD99F5DA22CFB459FF /* MenuBarView.swift */; }; 63A8DA821C1F6C2367C6BEBA /* KeyLightTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D59BCB596E5BD5A0EF67140E /* KeyLightTests.swift */; }; 747D4CB8286737C324B93967 /* SettingsContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 671302256D22F0E5BC78DFF7 /* SettingsContentView.swift */; }; - 776DB7153EE21994DB5F6EB0 /* KeyPositionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF16039069552DFBD2B407B8 /* KeyPositionManager.swift */; }; + 776DB7153EE21994DB5F6EB0 /* KeyboardLayoutInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF16039069552DFBD2B407B8 /* KeyboardLayoutInfo.swift */; }; 7D4E5760CEC04C8FEE13B7C6 /* GlowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4445A35B38579943EA41E74A /* GlowView.swift */; }; 9F64426EDDD79A7EF0CC7765 /* KeyboardMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98EA0CAEE1416A86E026E1A2 /* KeyboardMonitor.swift */; }; A7B2A3B092627FA47B9D65A9 /* PermissionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D63107884532D8A27F6025 /* PermissionManager.swift */; }; A9010A8A1A2B1D091025D801 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 4F8224F3A76E1840324735CA /* Assets.xcassets */; }; - CD5CA122BCD87C886457A4E3 /* SettingsWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B3D1F9FAF222631B3CCA5F /* SettingsWindow.swift */; }; + A10000000000000000000001 /* AppPreferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000001 /* AppPreferences.swift */; }; + A10000000000000000000002 /* KeyLayoutStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000002 /* KeyLayoutStore.swift */; }; + A10000000000000000000003 /* RuntimeInteraction.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000003 /* RuntimeInteraction.swift */; }; + A10000000000000000000008 /* RendererConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000008 /* RendererConfiguration.swift */; }; + A1000000000000000000000D /* KeyLightModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1000000000000000000000D /* KeyLightModel.swift */; }; + A1000000000000000000000E /* SettingsComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1000000000000000000000E /* SettingsComponents.swift */; }; + A10000000000000000000004 /* PersistenceValidation.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000004 /* PersistenceValidation.swift */; }; + A10000000000000000000005 /* PreferencesStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000005 /* PreferencesStore.swift */; }; + A10000000000000000000009 /* InputController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000009 /* InputController.swift */; }; + A1000000000000000000000A /* OverlayController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1000000000000000000000A /* OverlayController.swift */; }; + A1000000000000000000000B /* LaunchAtLoginService.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1000000000000000000000B /* LaunchAtLoginService.swift */; }; + A1000000000000000000000C /* HotKeyService.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1000000000000000000000C /* HotKeyService.swift */; }; + A10000000000000000000006 /* PermissionSetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000006 /* PermissionSetupView.swift */; }; + A10000000000000000000101 /* KeyLayoutStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000101 /* KeyLayoutStoreTests.swift */; }; + A10000000000000000000102 /* RuntimeInteractionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000102 /* RuntimeInteractionTests.swift */; }; + A10000000000000000000103 /* PreferencesCompatibilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000103 /* PreferencesCompatibilityTests.swift */; }; + A10000000000000000000104 /* PerformanceContractTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000104 /* PerformanceContractTests.swift */; }; + A10000000000000000000105 /* InputControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000105 /* InputControllerTests.swift */; }; + A10000000000000000000106 /* OverlayControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000106 /* OverlayControllerTests.swift */; }; + A10000000000000000000107 /* SystemServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000107 /* SystemServiceTests.swift */; }; + A10000000000000000000108 /* AppCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000108 /* AppCoordinatorTests.swift */; }; + A10000000000000000000109 /* KeyboardMonitorContractTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000109 /* KeyboardMonitorContractTests.swift */; }; + A1000000000000000000010A /* RendererContractTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1000000000000000000010A /* RendererContractTests.swift */; }; D2991C84D5077D4DB29A0667 /* KeyMapping.swift in Sources */ = {isa = PBXBuildFile; fileRef = 90DB2F39E07F2484FB958455 /* KeyMapping.swift */; }; - F98158A4EBE361E6FF4FA032 /* KeyWidthManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF7BCAFB57AC882326F2F065 /* KeyWidthManager.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -40,12 +83,34 @@ /* Begin PBXFileReference section */ 02F652729ECDF87B6FB00C8E /* KeyLight.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = KeyLight.app; sourceTree = BUILT_PRODUCTS_DIR; }; 27E329D7653A4E568EDE7BA0 /* VariantPresets */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Resources/VariantPresets; sourceTree = ""; }; + B10000000000000000000007 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Resources/Localizable.xcstrings; sourceTree = ""; }; + B10000000000000000000023 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = Resources/PrivacyInfo.xcprivacy; sourceTree = ""; }; + B10000000000000000000024 /* UpdateService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateService.swift; sourceTree = ""; }; + B10000000000000000000026 /* SurfaceMotionEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SurfaceMotionEngine.swift; sourceTree = ""; }; + B10000000000000000000027 /* GuidedCalibrationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GuidedCalibrationView.swift; sourceTree = ""; }; + B10000000000000000000028 /* ConfigurationSnapshotFilePanelHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigurationSnapshotFilePanelHelper.swift; sourceTree = ""; }; + B10000000000000000000029 /* SettingsSnapshotsTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsSnapshotsTab.swift; sourceTree = ""; }; + B1000000000000000000002A /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 2B781FCBD49CEEA98C03DFE6 /* KeyPositionEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyPositionEditorView.swift; sourceTree = ""; }; 42C7F70959DB4F1BA7CCCFFA /* GlowOverlayWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlowOverlayWindow.swift; sourceTree = ""; }; + B10000000000000000000010 /* LiquidGlassTransitionMath.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiquidGlassTransitionMath.swift; sourceTree = ""; }; + B10000000000000000000011 /* LiquidGlassGlowRenderer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiquidGlassGlowRenderer.swift; sourceTree = ""; }; + B10000000000000000000012 /* SavedDomainValues.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavedDomainValues.swift; sourceTree = ""; }; + B10000000000000000000013 /* KeyboardEventDecoder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardEventDecoder.swift; sourceTree = ""; }; + B10000000000000000000014 /* SettingsAppearanceTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsAppearanceTab.swift; sourceTree = ""; }; + B10000000000000000000015 /* SettingsKeyboardTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsKeyboardTab.swift; sourceTree = ""; }; + B10000000000000000000016 /* SettingsGeneralTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsGeneralTab.swift; sourceTree = ""; }; + B10000000000000000000017 /* GlowRenderer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlowRenderer.swift; sourceTree = ""; }; + B10000000000000000000018 /* ThemeStringCodec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeStringCodec.swift; sourceTree = ""; }; + B10000000000000000000019 /* LayoutProfileCodec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LayoutProfileCodec.swift; sourceTree = ""; }; + B10000000000000000000020 /* SettingsGlowPreviewSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsGlowPreviewSession.swift; sourceTree = ""; }; + B10000000000000000000021 /* PhysicalRefractionRenderer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhysicalRefractionRenderer.swift; sourceTree = ""; }; + B10000000000000000000022 /* PhysicalRefractionShaders.metal */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.metal; path = PhysicalRefractionShaders.metal; sourceTree = ""; }; 4445A35B38579943EA41E74A /* GlowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlowView.swift; sourceTree = ""; }; 4E8696A9A26491AFE761AD84 /* KeyLightTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = KeyLightTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 4F8224F3A76E1840324735CA /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 5C615F1CC81CCBC89F42A6C7 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + B1000000000000000000000F /* AppCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppCoordinator.swift; sourceTree = ""; }; 671302256D22F0E5BC78DFF7 /* SettingsContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsContentView.swift; sourceTree = ""; }; 80EA56167A91AC0F9FCDF342 /* KeyLight.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = KeyLight.entitlements; sourceTree = ""; }; 860BB44450D83CBBBD94011F /* SettingsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsManager.swift; sourceTree = ""; }; @@ -53,21 +118,61 @@ 98EA0CAEE1416A86E026E1A2 /* KeyboardMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardMonitor.swift; sourceTree = ""; }; A412D4CD99F5DA22CFB459FF /* MenuBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarView.swift; sourceTree = ""; }; B57913A75F4E826856365A90 /* KeyLightLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyLightLog.swift; sourceTree = ""; }; - BF7BCAFB57AC882326F2F065 /* KeyWidthManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyWidthManager.swift; sourceTree = ""; }; + B10000000000000000000001 /* AppPreferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppPreferences.swift; sourceTree = ""; }; + B10000000000000000000002 /* KeyLayoutStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyLayoutStore.swift; sourceTree = ""; }; + B10000000000000000000003 /* RuntimeInteraction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RuntimeInteraction.swift; sourceTree = ""; }; + B10000000000000000000008 /* RendererConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RendererConfiguration.swift; sourceTree = ""; }; + B1000000000000000000000D /* KeyLightModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyLightModel.swift; sourceTree = ""; }; + B1000000000000000000000E /* SettingsComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsComponents.swift; sourceTree = ""; }; + B10000000000000000000004 /* PersistenceValidation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceValidation.swift; sourceTree = ""; }; + B10000000000000000000005 /* PreferencesStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesStore.swift; sourceTree = ""; }; + B10000000000000000000009 /* InputController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputController.swift; sourceTree = ""; }; + B1000000000000000000000A /* OverlayController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverlayController.swift; sourceTree = ""; }; + B1000000000000000000000B /* LaunchAtLoginService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LaunchAtLoginService.swift; sourceTree = ""; }; + B1000000000000000000000C /* HotKeyService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotKeyService.swift; sourceTree = ""; }; + B10000000000000000000006 /* PermissionSetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionSetupView.swift; sourceTree = ""; }; + B10000000000000000000101 /* KeyLayoutStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyLayoutStoreTests.swift; sourceTree = ""; }; + B10000000000000000000102 /* RuntimeInteractionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RuntimeInteractionTests.swift; sourceTree = ""; }; + B10000000000000000000103 /* PreferencesCompatibilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesCompatibilityTests.swift; sourceTree = ""; }; + B10000000000000000000104 /* PerformanceContractTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerformanceContractTests.swift; sourceTree = ""; }; + B10000000000000000000105 /* InputControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputControllerTests.swift; sourceTree = ""; }; + B10000000000000000000106 /* OverlayControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverlayControllerTests.swift; sourceTree = ""; }; + B10000000000000000000107 /* SystemServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemServiceTests.swift; sourceTree = ""; }; + B10000000000000000000108 /* AppCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppCoordinatorTests.swift; sourceTree = ""; }; + B10000000000000000000109 /* KeyboardMonitorContractTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardMonitorContractTests.swift; sourceTree = ""; }; + B1000000000000000000010A /* RendererContractTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RendererContractTests.swift; sourceTree = ""; }; C09A21A722EA6548D1A666BA /* KeyLightApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyLightApp.swift; sourceTree = ""; }; D59BCB596E5BD5A0EF67140E /* KeyLightTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyLightTests.swift; sourceTree = ""; }; - D6B3D1F9FAF222631B3CCA5F /* SettingsWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsWindow.swift; sourceTree = ""; }; E9D63107884532D8A27F6025 /* PermissionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionManager.swift; sourceTree = ""; }; - EF16039069552DFBD2B407B8 /* KeyPositionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyPositionManager.swift; sourceTree = ""; }; + EF16039069552DFBD2B407B8 /* KeyboardLayoutInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardLayoutInfo.swift; sourceTree = ""; }; + C10000000000000000000001 /* Shared.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Shared.xcconfig; sourceTree = ""; }; + C10000000000000000000002 /* App-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "App-Debug.xcconfig"; sourceTree = ""; }; + C10000000000000000000003 /* App-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "App-Release.xcconfig"; sourceTree = ""; }; + C10000000000000000000004 /* Tests.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Tests.xcconfig; sourceTree = ""; }; /* End PBXFileReference section */ +/* Begin PBXFrameworksBuildPhase section */ + D10000000000000000000003 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + A10000000000000000000025 /* Sparkle in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + /* Begin PBXGroup section */ 470FCC04D668642A7587B435 /* KeyLight */ = { isa = PBXGroup; children = ( 5C615F1CC81CCBC89F42A6C7 /* AppDelegate.swift */, + B1000000000000000000000F /* AppCoordinator.swift */, 4F8224F3A76E1840324735CA /* Assets.xcassets */, 27E329D7653A4E568EDE7BA0 /* VariantPresets */, + B10000000000000000000007 /* Localizable.xcstrings */, + B10000000000000000000023 /* PrivacyInfo.xcprivacy */, + B1000000000000000000002A /* Info.plist */, 80EA56167A91AC0F9FCDF342 /* KeyLight.entitlements */, C09A21A722EA6548D1A666BA /* KeyLightApp.swift */, FD032F8FD5BC34CC1AFA5B65 /* Models */, @@ -90,11 +195,24 @@ isa = PBXGroup; children = ( 42C7F70959DB4F1BA7CCCFFA /* GlowOverlayWindow.swift */, + B10000000000000000000017 /* GlowRenderer.swift */, + B10000000000000000000010 /* LiquidGlassTransitionMath.swift */, + B10000000000000000000011 /* LiquidGlassGlowRenderer.swift */, + B10000000000000000000021 /* PhysicalRefractionRenderer.swift */, + B10000000000000000000022 /* PhysicalRefractionShaders.metal */, + B10000000000000000000026 /* SurfaceMotionEngine.swift */, 4445A35B38579943EA41E74A /* GlowView.swift */, 2B781FCBD49CEEA98C03DFE6 /* KeyPositionEditorView.swift */, + B10000000000000000000027 /* GuidedCalibrationView.swift */, A412D4CD99F5DA22CFB459FF /* MenuBarView.swift */, + B10000000000000000000006 /* PermissionSetupView.swift */, + B1000000000000000000000E /* SettingsComponents.swift */, + B10000000000000000000014 /* SettingsAppearanceTab.swift */, + B10000000000000000000015 /* SettingsKeyboardTab.swift */, + B10000000000000000000016 /* SettingsGeneralTab.swift */, + B10000000000000000000029 /* SettingsSnapshotsTab.swift */, + B10000000000000000000020 /* SettingsGlowPreviewSession.swift */, 671302256D22F0E5BC78DFF7 /* SettingsContentView.swift */, - D6B3D1F9FAF222631B3CCA5F /* SettingsWindow.swift */, ); path = Views; sourceTree = ""; @@ -102,16 +220,38 @@ CB030EA6A981855BD1BB0F8C = { isa = PBXGroup; children = ( + C10000000000000000000010 /* Configurations */, 470FCC04D668642A7587B435 /* KeyLight */, CB60C03DD5926444D0A7EED5 /* KeyLightTests */, 82FD832C9DD48433F01A0A0E /* Products */, ); sourceTree = ""; }; + C10000000000000000000010 /* Configurations */ = { + isa = PBXGroup; + children = ( + C10000000000000000000002 /* App-Debug.xcconfig */, + C10000000000000000000003 /* App-Release.xcconfig */, + C10000000000000000000001 /* Shared.xcconfig */, + C10000000000000000000004 /* Tests.xcconfig */, + ); + path = Configurations; + sourceTree = ""; + }; CB60C03DD5926444D0A7EED5 /* KeyLightTests */ = { isa = PBXGroup; children = ( D59BCB596E5BD5A0EF67140E /* KeyLightTests.swift */, + B10000000000000000000101 /* KeyLayoutStoreTests.swift */, + B10000000000000000000102 /* RuntimeInteractionTests.swift */, + B10000000000000000000103 /* PreferencesCompatibilityTests.swift */, + B10000000000000000000104 /* PerformanceContractTests.swift */, + B10000000000000000000105 /* InputControllerTests.swift */, + B10000000000000000000106 /* OverlayControllerTests.swift */, + B10000000000000000000107 /* SystemServiceTests.swift */, + B10000000000000000000108 /* AppCoordinatorTests.swift */, + B10000000000000000000109 /* KeyboardMonitorContractTests.swift */, + B1000000000000000000010A /* RendererContractTests.swift */, ); path = KeyLightTests; sourceTree = ""; @@ -119,9 +259,20 @@ EF1C3EAFA1858CC351E9121B /* Services */ = { isa = PBXGroup; children = ( + B10000000000000000000009 /* InputController.swift */, + B1000000000000000000000A /* OverlayController.swift */, + B1000000000000000000000B /* LaunchAtLoginService.swift */, + B1000000000000000000000C /* HotKeyService.swift */, + B10000000000000000000013 /* KeyboardEventDecoder.swift */, + B10000000000000000000019 /* LayoutProfileCodec.swift */, 98EA0CAEE1416A86E026E1A2 /* KeyboardMonitor.swift */, B57913A75F4E826856365A90 /* KeyLightLog.swift */, E9D63107884532D8A27F6025 /* PermissionManager.swift */, + B10000000000000000000004 /* PersistenceValidation.swift */, + B10000000000000000000005 /* PreferencesStore.swift */, + B10000000000000000000018 /* ThemeStringCodec.swift */, + B10000000000000000000024 /* UpdateService.swift */, + B10000000000000000000028 /* ConfigurationSnapshotFilePanelHelper.swift */, 860BB44450D83CBBBD94011F /* SettingsManager.swift */, ); path = Services; @@ -131,8 +282,13 @@ isa = PBXGroup; children = ( 90DB2F39E07F2484FB958455 /* KeyMapping.swift */, - EF16039069552DFBD2B407B8 /* KeyPositionManager.swift */, - BF7BCAFB57AC882326F2F065 /* KeyWidthManager.swift */, + B10000000000000000000001 /* AppPreferences.swift */, + B10000000000000000000002 /* KeyLayoutStore.swift */, + B10000000000000000000003 /* RuntimeInteraction.swift */, + B10000000000000000000008 /* RendererConfiguration.swift */, + B1000000000000000000000D /* KeyLightModel.swift */, + B10000000000000000000012 /* SavedDomainValues.swift */, + EF16039069552DFBD2B407B8 /* KeyboardLayoutInfo.swift */, ); path = Models; sourceTree = ""; @@ -145,6 +301,7 @@ buildConfigurationList = F7CC2366B8A76C3FB90DA53E /* Build configuration list for PBXNativeTarget "KeyLight" */; buildPhases = ( 56861CD666B2671CA0011CAD /* Sources */, + D10000000000000000000003 /* Frameworks */, EBC510420F5F75FD94CBABAA /* Resources */, ); buildRules = ( @@ -153,6 +310,7 @@ ); name = KeyLight; packageProductDependencies = ( + D10000000000000000000002 /* Sparkle */, ); productName = KeyLight; productReference = 02F652729ECDF87B6FB00C8E /* KeyLight.app */; @@ -203,6 +361,9 @@ preferredProjectObjectVersion = 77; projectDirPath = ""; projectRoot = ""; + packageReferences = ( + D10000000000000000000001 /* XCRemoteSwiftPackageReference "Sparkle" */, + ); targets = ( 6E6B9FE66AEC515454A37875 /* KeyLight */, E98B075415FCE1A3D4A8B82A /* KeyLightTests */, @@ -216,6 +377,8 @@ buildActionMask = 2147483647; files = ( A9010A8A1A2B1D091025D801 /* Assets.xcassets in Resources */, + A10000000000000000000007 /* Localizable.xcstrings in Resources */, + A10000000000000000000023 /* PrivacyInfo.xcprivacy in Resources */, 039A35018C6044DEB0471E90 /* VariantPresets in Resources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -228,20 +391,50 @@ buildActionMask = 2147483647; files = ( 528521486593AF92A20564C2 /* AppDelegate.swift in Sources */, + A1000000000000000000000F /* AppCoordinator.swift in Sources */, + A10000000000000000000001 /* AppPreferences.swift in Sources */, 27696294CEEDE6A794C4145D /* GlowOverlayWindow.swift in Sources */, + A10000000000000000000017 /* GlowRenderer.swift in Sources */, + A10000000000000000000010 /* LiquidGlassTransitionMath.swift in Sources */, + A10000000000000000000026 /* SurfaceMotionEngine.swift in Sources */, + A10000000000000000000011 /* LiquidGlassGlowRenderer.swift in Sources */, + A10000000000000000000021 /* PhysicalRefractionRenderer.swift in Sources */, + A10000000000000000000022 /* PhysicalRefractionShaders.metal in Sources */, 7D4E5760CEC04C8FEE13B7C6 /* GlowView.swift in Sources */, 46143D0364269D8C823EA4B2 /* KeyLightApp.swift in Sources */, 4F43E5FBF3B77CBFF2C9F788 /* KeyLightLog.swift in Sources */, D2991C84D5077D4DB29A0667 /* KeyMapping.swift in Sources */, + A10000000000000000000002 /* KeyLayoutStore.swift in Sources */, + A10000000000000000000003 /* RuntimeInteraction.swift in Sources */, + A10000000000000000000008 /* RendererConfiguration.swift in Sources */, + A1000000000000000000000D /* KeyLightModel.swift in Sources */, + A10000000000000000000012 /* SavedDomainValues.swift in Sources */, 07FA3D6B610C5E5FAC7C85D0 /* KeyPositionEditorView.swift in Sources */, - 776DB7153EE21994DB5F6EB0 /* KeyPositionManager.swift in Sources */, - F98158A4EBE361E6FF4FA032 /* KeyWidthManager.swift in Sources */, + A10000000000000000000027 /* GuidedCalibrationView.swift in Sources */, + 776DB7153EE21994DB5F6EB0 /* KeyboardLayoutInfo.swift in Sources */, 9F64426EDDD79A7EF0CC7765 /* KeyboardMonitor.swift in Sources */, + A10000000000000000000009 /* InputController.swift in Sources */, + A1000000000000000000000A /* OverlayController.swift in Sources */, + A1000000000000000000000B /* LaunchAtLoginService.swift in Sources */, + A1000000000000000000000C /* HotKeyService.swift in Sources */, + A10000000000000000000013 /* KeyboardEventDecoder.swift in Sources */, + A10000000000000000000019 /* LayoutProfileCodec.swift in Sources */, 5FD43430E908B2FC9D6C0E6F /* MenuBarView.swift in Sources */, A7B2A3B092627FA47B9D65A9 /* PermissionManager.swift in Sources */, + A10000000000000000000004 /* PersistenceValidation.swift in Sources */, + A10000000000000000000005 /* PreferencesStore.swift in Sources */, + A10000000000000000000018 /* ThemeStringCodec.swift in Sources */, + A10000000000000000000024 /* UpdateService.swift in Sources */, + A10000000000000000000006 /* PermissionSetupView.swift in Sources */, + A1000000000000000000000E /* SettingsComponents.swift in Sources */, + A10000000000000000000014 /* SettingsAppearanceTab.swift in Sources */, + A10000000000000000000015 /* SettingsKeyboardTab.swift in Sources */, + A10000000000000000000016 /* SettingsGeneralTab.swift in Sources */, + A10000000000000000000029 /* SettingsSnapshotsTab.swift in Sources */, + A10000000000000000000020 /* SettingsGlowPreviewSession.swift in Sources */, 747D4CB8286737C324B93967 /* SettingsContentView.swift in Sources */, + A10000000000000000000028 /* ConfigurationSnapshotFilePanelHelper.swift in Sources */, 5A446C00CDDD2A0D023651E4 /* SettingsManager.swift in Sources */, - CD5CA122BCD87C886457A4E3 /* SettingsWindow.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -250,6 +443,16 @@ buildActionMask = 2147483647; files = ( 63A8DA821C1F6C2367C6BEBA /* KeyLightTests.swift in Sources */, + A10000000000000000000101 /* KeyLayoutStoreTests.swift in Sources */, + A10000000000000000000102 /* RuntimeInteractionTests.swift in Sources */, + A10000000000000000000103 /* PreferencesCompatibilityTests.swift in Sources */, + A10000000000000000000104 /* PerformanceContractTests.swift in Sources */, + A10000000000000000000105 /* InputControllerTests.swift in Sources */, + A10000000000000000000106 /* OverlayControllerTests.swift in Sources */, + A10000000000000000000107 /* SystemServiceTests.swift in Sources */, + A10000000000000000000108 /* AppCoordinatorTests.swift in Sources */, + A10000000000000000000109 /* KeyboardMonitorContractTests.swift in Sources */, + A1000000000000000000010A /* RendererContractTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -266,6 +469,7 @@ /* Begin XCBuildConfiguration section */ 113AEA07AD722150E4C94547 /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReference = C10000000000000000000004 /* Tests.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; COMBINE_HIDPI_IMAGES = YES; @@ -275,17 +479,15 @@ "@executable_path/../Frameworks", "@loader_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 14.0; - PRODUCT_BUNDLE_IDENTIFIER = com.keylight.tests; PRODUCT_NAME = KeyLightTests; SDKROOT = macosx; - SWIFT_VERSION = 6; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/KeyLight.app/Contents/MacOS/KeyLight"; }; name = Debug; }; 506319F50821A4446B96C3B6 /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReference = C10000000000000000000001 /* Shared.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; CLANG_ANALYZER_NONNULL = YES; @@ -329,19 +531,18 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 14.0; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-O"; - SWIFT_VERSION = 5.0; }; name = Release; }; 68A80B2D57FBCC5BADC82422 /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReference = C10000000000000000000003 /* App-Release.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGNING_REQUIRED = NO; @@ -350,26 +551,22 @@ CODE_SIGN_INJECT_BASE_ENTITLEMENTS = NO; CODE_SIGN_STYLE = Manual; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 1; ENABLE_HARDENED_RUNTIME = YES; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_GENERATION_MODE = GeneratedFile; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = KeyLight/Info.plist; INFOPLIST_KEY_LSUIElement = YES; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.keylight.app; PRODUCT_NAME = KeyLight; SDKROOT = macosx; - SWIFT_VERSION = 6; }; name = Release; }; 69CFA93E8C0872B505014AAB /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReference = C10000000000000000000002 /* App-Debug.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGNING_REQUIRED = NO; @@ -377,25 +574,21 @@ CODE_SIGN_IDENTITY = "-"; CODE_SIGN_STYLE = Manual; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 1; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_GENERATION_MODE = GeneratedFile; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = KeyLight/Info.plist; INFOPLIST_KEY_LSUIElement = YES; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.keylight.app; PRODUCT_NAME = KeyLight; SDKROOT = macosx; - SWIFT_VERSION = 6; }; name = Debug; }; 8B61209CDBB695CD83B29EC4 /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReference = C10000000000000000000004 /* Tests.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; COMBINE_HIDPI_IMAGES = YES; @@ -405,17 +598,15 @@ "@executable_path/../Frameworks", "@loader_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 14.0; - PRODUCT_BUNDLE_IDENTIFIER = com.keylight.tests; PRODUCT_NAME = KeyLightTests; SDKROOT = macosx; - SWIFT_VERSION = 6; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/KeyLight.app/Contents/MacOS/KeyLight"; }; name = Release; }; DD01F7444BB7237CA813B995 /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReference = C10000000000000000000001 /* Shared.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; CLANG_ANALYZER_NONNULL = YES; @@ -465,7 +656,6 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 14.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; @@ -473,7 +663,6 @@ SDKROOT = macosx; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; }; name = Debug; }; @@ -508,6 +697,25 @@ defaultConfigurationName = Debug; }; /* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + D10000000000000000000001 /* XCRemoteSwiftPackageReference "Sparkle" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/sparkle-project/Sparkle"; + requirement = { + kind = exactVersion; + version = 2.9.5; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + D10000000000000000000002 /* Sparkle */ = { + isa = XCSwiftPackageProductDependency; + package = D10000000000000000000001 /* XCRemoteSwiftPackageReference "Sparkle" */; + productName = Sparkle; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = 0AAFDC1F005C92A40D7E4633 /* Project object */; } diff --git a/KeyLight.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/KeyLight.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..e91495a --- /dev/null +++ b/KeyLight.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,15 @@ +{ + "originHash" : "e721da7f9826abdffcb6185e886155efa2514bd6234475f1afa893e29eb258d6", + "pins" : [ + { + "identity" : "sparkle", + "kind" : "remoteSourceControl", + "location" : "https://github.com/sparkle-project/Sparkle", + "state" : { + "revision" : "79bc9e872948e47877e76f194cb0c8e0412b0b90", + "version" : "2.9.5" + } + } + ], + "version" : 3 +} diff --git a/KeyLight/AppCoordinator.swift b/KeyLight/AppCoordinator.swift new file mode 100644 index 0000000..b1fc63f --- /dev/null +++ b/KeyLight/AppCoordinator.swift @@ -0,0 +1,625 @@ +import AppKit + +struct AccessibilityDisplayOptions: Equatable, Sendable { + let reduceMotion: Bool + let reduceTransparency: Bool + let increaseContrast: Bool +} + +@MainActor +protocol AppCoordinatorInputControlling: AnyObject { + func start(isEnabled: Bool) + func stop() + func setEnabled(_ enabled: Bool) + func applicationDidBecomeActive() + func handleSleep() + func handleWake() + func requestPermission() + func retry() + func openInputMonitoringSettings() +} + +extension InputController: AppCoordinatorInputControlling { + func start(isEnabled: Bool) { + start(isEnabled: isEnabled, allowPermissionRequest: false) + } + + func setEnabled(_ enabled: Bool) { + setEnabled(enabled, allowPermissionRequest: false) + } +} + +@MainActor +protocol AppCoordinatorOverlayControlling: AnyObject { + var availableDisplays: [OverlayDisplayDescriptor] { get } + var activeDisplayPersistentID: String? { get } + var activeDisplayPersistentIDs: [String] { get } + + func start() + func shutdown() + func setEnabled(_ enabled: Bool) + func apply(effectStyle: EffectStyle, configuration: RendererConfiguration) + func handle(_ event: KeyboardEvent, target: GlowTarget?) + func updateDisplayTopology() + func setDisplaySelection(_ selection: OverlayDisplaySelection) + func setMirroredDisplayIDs(_ persistentIDs: Set) + func setPreview(_ target: GlowTarget, source: PreviewSource) + func clearPreview(_ source: PreviewSource) + func setChordPreview(_ targets: [GlowTarget]) + func clearChordPreview() + func setRuntimeStatusHandler( + _ handler: (@MainActor (EffectRuntimeStatus) -> Void)? + ) +} + +extension AppCoordinatorOverlayControlling { + var availableDisplays: [OverlayDisplayDescriptor] { [] } + var activeDisplayPersistentID: String? { nil } + var activeDisplayPersistentIDs: [String] { [] } + + func setDisplaySelection(_ selection: OverlayDisplaySelection) {} + func setMirroredDisplayIDs(_ persistentIDs: Set) {} + func setChordPreview(_ targets: [GlowTarget]) {} + func clearChordPreview() {} + + func setRuntimeStatusHandler( + _ handler: (@MainActor (EffectRuntimeStatus) -> Void)? + ) {} +} + +extension OverlayController: AppCoordinatorOverlayControlling { + func updateDisplayTopology() { + updateDisplayTopology(forceRecreation: false) + } +} + +@MainActor +protocol AppCoordinatorHotKeyServicing: AnyObject { + func start() + func stop() + func setShortcut(_ shortcut: GlobalShortcut) +} + +extension HotKeyService: AppCoordinatorHotKeyServicing {} + +extension AppCoordinatorHotKeyServicing { + func setShortcut(_ shortcut: GlobalShortcut) {} +} + +/// Owns KeyLight's runtime services and translates user-facing model changes +/// into atomic controller operations. AppKit lifecycle remains at the narrow +/// AppDelegate boundary; platform notifications are reconciled here. +@MainActor +final class AppCoordinator { + typealias InputControllerFactory = @MainActor ( + _ onKeyboardEvent: @escaping @MainActor (KeyboardEvent) -> Void, + _ onStatusChange: @escaping @MainActor (InputControllerStatus) -> Void + ) -> any AppCoordinatorInputControlling + + typealias OverlayControllerFactory = @MainActor ( + _ onPhysicalEvent: @escaping @MainActor (KeyboardEvent) -> Void + ) -> any AppCoordinatorOverlayControlling + + typealias HotKeyServiceFactory = @MainActor ( + _ onPress: @escaping @MainActor @Sendable () -> Void, + _ onStatusChange: @escaping @MainActor @Sendable (HotKeyServiceStatus) -> Void + ) -> any AppCoordinatorHotKeyServicing + + typealias AccessibilityOptionsProvider = @MainActor () -> AccessibilityDisplayOptions + typealias PowerEnvironmentProvider = @MainActor () -> PowerEnvironmentState + + let model: KeyLightModel + + private let defaultGlowBaseWidth: CGFloat = 60 + private let notificationCenter: NotificationCenter + private let workspaceNotificationCenter: NotificationCenter + private let keyLayoutStore: KeyLayoutStore + private let inputControllerFactory: InputControllerFactory + private let overlayControllerFactory: OverlayControllerFactory + private let hotKeyServiceFactory: HotKeyServiceFactory + private let accessibilityOptionsProvider: AccessibilityOptionsProvider + private let powerEnvironmentProvider: PowerEnvironmentProvider + + private lazy var overlayController = overlayControllerFactory( + { [weak self] event in + self?.receivePhysicalEventFromOverlay(event) + } + ) + + private lazy var inputController = inputControllerFactory( + { [weak self] event in + self?.handleKeyboardEvent(event) + }, + { [weak self] status in + self?.applyInputStatus(status) + } + ) + + private lazy var hotKeyService = hotKeyServiceFactory( + { [weak self] in + guard let self, self.isStarted else { return } + self.model.isEnabled.toggle() + }, + { [weak self] status in + self?.applyHotKeyStatus(status) + } + ) + + private struct ObserverToken { + let center: NotificationCenter + let token: NSObjectProtocol + } + + private var observerTokens: [ObserverToken] = [] + private var isStarted = false + private var reduceMotionEnabled = false + private var reduceTransparencyEnabled = false + private var increaseContrastEnabled = false + private var deferredDisplayLayoutBinding: (displayID: String, profileID: UUID)? + + init( + model: KeyLightModel, + notificationCenter: NotificationCenter = .default, + workspaceNotificationCenter: NotificationCenter = NSWorkspace.shared.notificationCenter, + keyLayoutStore: KeyLayoutStore, + inputControllerFactory: @escaping InputControllerFactory = { onEvent, onStatus in + InputController( + onKeyboardEvent: onEvent, + onStatusChange: onStatus + ) + }, + overlayControllerFactory: @escaping OverlayControllerFactory = { onPhysicalEvent in + OverlayController(onPhysicalEvent: onPhysicalEvent) + }, + hotKeyServiceFactory: @escaping HotKeyServiceFactory = { onPress, onStatus in + HotKeyService( + onPress: onPress, + onStatusChange: onStatus + ) + }, + accessibilityOptionsProvider: @escaping AccessibilityOptionsProvider = { + AccessibilityDisplayOptions( + reduceMotion: NSWorkspace.shared.accessibilityDisplayShouldReduceMotion, + reduceTransparency: NSWorkspace.shared.accessibilityDisplayShouldReduceTransparency, + increaseContrast: NSWorkspace.shared.accessibilityDisplayShouldIncreaseContrast + ) + }, + powerEnvironmentProvider: @escaping PowerEnvironmentProvider = { + PowerEnvironmentState.current() + } + ) { + self.model = model + self.notificationCenter = notificationCenter + self.workspaceNotificationCenter = workspaceNotificationCenter + self.keyLayoutStore = keyLayoutStore + self.inputControllerFactory = inputControllerFactory + self.overlayControllerFactory = overlayControllerFactory + self.hotKeyServiceFactory = hotKeyServiceFactory + self.accessibilityOptionsProvider = accessibilityOptionsProvider + self.powerEnvironmentProvider = powerEnvironmentProvider + } + + func start() { + guard !isStarted else { return } + isStarted = true + + KeyLightLogger.app.notice("Application startup began") + + #if DEBUG + KeyMapping.assertParityContracts() + #endif + + // Seed the transient state before connecting the runtime so startup + // applies one complete renderer configuration rather than briefly + // starting capture and immediately falling back. + model.updatePowerEnvironmentState(powerEnvironmentProvider()) + + model.connectRuntime( + onEnabledChange: { [weak self] enabled in + self?.setEnabled(enabled) + }, + onConfigurationChange: { [weak self] in + self?.applyRendererConfiguration() + }, + onPermissionRequest: { [weak self] in + self?.inputController.requestPermission() + }, + onPermissionRetry: { [weak self] in + self?.inputController.retry() + }, + onOpenInputMonitoringSettings: { [weak self] in + self?.inputController.openInputMonitoringSettings() + }, + onPreviewSet: { [weak self] target, source in + guard let self, self.isStarted else { return } + self.overlayController.setPreview(target, source: source) + }, + onPreviewClear: { [weak self] source in + guard let self, self.isStarted else { return } + self.overlayController.clearPreview(source) + }, + onChordPreviewSet: { [weak self] targets in + guard let self, self.isStarted else { return } + self.overlayController.setChordPreview(targets) + }, + onChordPreviewClear: { [weak self] in + guard let self, self.isStarted else { return } + self.overlayController.clearChordPreview() + }, + onDisplaySelectionChange: { [weak self] selection in + guard let self, self.isStarted else { return } + self.overlayController.setDisplaySelection(selection) + self.synchronizeDisplayStateAndLayout() + }, + onMirroredDisplaysChange: { [weak self] persistentIDs in + guard let self, self.isStarted else { return } + self.overlayController.setMirroredDisplayIDs(persistentIDs) + self.synchronizeDisplayStateAndLayout() + }, + onDisplayLayoutBindingChange: { [weak self] displayID, profileID in + guard let self, self.isStarted, + displayID == self.overlayController.activeDisplayPersistentID else { + return + } + self.applyBoundLayout(profileID, forDisplay: displayID) + }, + onShortcutChange: { [weak self] shortcut in + guard let self, self.isStarted else { return } + self.hotKeyService.setShortcut(shortcut) + } + ) + + installPlatformObservers() + overlayController.setRuntimeStatusHandler { [weak self] status in + self?.model.updateEffectRuntimeStatus(status) + } + overlayController.setDisplaySelection(model.overlayDisplaySelection) + overlayController.setMirroredDisplayIDs(model.mirroredDisplayIDs) + overlayController.start() + synchronizeDisplayStateAndLayout() + overlayController.setEnabled(model.isEnabled) + updateAccessibilityDisplayOptions() + hotKeyService.setShortcut(model.globalShortcut) + hotKeyService.start() + + // Permission prompts are reserved for the model's explicit user action. + inputController.start(isEnabled: model.isEnabled) + model.requestPermissionSetupIfNeeded() + + KeyLightLogger.app.notice("Application startup completed") + } + + func shutdown() { + guard isStarted else { return } + isStarted = false + + model.disconnectRuntime() + removePlatformObservers() + inputController.stop() + hotKeyService.stop() + overlayController.shutdown() + keyLayoutStore.flush() + model.flushPendingPersist() + } + + // MARK: - Model actions + + private func setEnabled(_ enabled: Bool) { + KeyLightLogger.app.notice("Effect enabled state changed: \(enabled, privacy: .public)") + overlayController.setEnabled(enabled) + // Enabling the effect is never treated as permission-request consent. + inputController.setEnabled(enabled) + } + + private func applyRendererConfiguration() { + let colorMode: RendererConfiguration.ColorMode + switch model.colorMode { + case .solid: + colorMode = .solid(model.glowNSColor) + case .positionGradient: + colorMode = .positionGradient( + start: model.gradientStartNSColor, + end: model.gradientEndNSColor + ) + case .rainbow: + colorMode = .rainbow + case .randomPerKey: + colorMode = .randomPerKey + } + + let configuration = RendererConfiguration( + colorMode: colorMode, + shapeProfile: model.surfaceShapeProfile, + baseKeyWidth: defaultGlowBaseWidth, + glowHeight: CGFloat(model.glowSize), + widthMultiplier: CGFloat(model.glowWidth), + maximumOpacity: Float(model.glowOpacity), + refractionStrength: CGFloat(model.physicalRefractionStrength), + fadeDuration: model.fadeDuration, + roundness: CGFloat(model.glowRoundness), + fullness: CGFloat(model.glowFullness), + reduceMotion: reduceMotionEnabled, + reduceTransparency: reduceTransparencyEnabled, + increaseContrast: increaseContrastEnabled, + chordAppearance: model.chordAppearance, + powerSavingMode: model.powerSavingMode, + powerEnvironmentState: model.powerEnvironmentState + ) + + overlayController.apply( + effectStyle: model.effectStyle, + configuration: configuration + ) + } + + // MARK: - Input + + // SECURITY: This boundary receives only normalized metadata. Never log key codes. + private func handleKeyboardEvent(_ event: KeyboardEvent) { + guard isStarted else { return } + + let target: GlowTarget? + if event.action == .down, let keyCode = event.canonicalKeyCode { + let keyInfo = KeyMapping.keyInfo(for: keyCode) + target = .physicalKey( + keyCode, + horizontalPosition: Double(keyLayoutStore.adjustedPosition( + for: keyCode, + originalPosition: keyInfo.position + )), + keyWidth: Double(keyLayoutStore.effectiveWidth( + for: keyCode, + defaultWidth: keyInfo.width + )) + ) + } else { + target = nil + } + + KeyLightSignposts.overlayStateUpdated(sequence: event.sequence) + overlayController.handle(event, target: target) + } + + private func applyInputStatus(_ status: InputControllerStatus) { + guard isStarted else { return } + + let previousState = model.inputMonitoringState + model.updateInputMonitoring( + state: status.state, + appPath: status.runningApplicationPath, + installationIssue: status.installationIssue + ) + + if previousState != status.state { + let stateDescription = String(describing: status.state) + KeyLightLogger.permissions.notice( + "Input Monitoring state changed to \(stateDescription, privacy: .public)" + ) + } + } + + private func applyHotKeyStatus(_ status: HotKeyServiceStatus) { + guard isStarted else { return } + + switch status { + case .stopped, .registering: + model.updateGlobalHotKeyStatus(.checking) + case .registered: + model.updateGlobalHotKeyStatus(.registered) + case .unavailable: + model.updateGlobalHotKeyStatus(.unavailable) + } + } + + // MARK: - Platform lifecycle + + private func installPlatformObservers() { + guard observerTokens.isEmpty else { return } + + let screenToken = notificationCenter.addObserver( + forName: NSApplication.didChangeScreenParametersNotification, + object: nil, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + guard let self, self.isStarted else { return } + self.overlayController.updateDisplayTopology() + self.synchronizeDisplayStateAndLayout() + } + } + observerTokens.append(ObserverToken(center: notificationCenter, token: screenToken)) + + let activationToken = notificationCenter.addObserver( + forName: NSApplication.didBecomeActiveNotification, + object: nil, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + guard let self, self.isStarted else { return } + self.inputController.applicationDidBecomeActive() + self.model.refreshLaunchAtLoginStatus() + } + } + observerTokens.append(ObserverToken(center: notificationCenter, token: activationToken)) + + let sleepToken = workspaceNotificationCenter.addObserver( + forName: NSWorkspace.willSleepNotification, + object: nil, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + guard let self, self.isStarted else { return } + self.overlayController.setEnabled(false) + self.inputController.handleSleep() + } + } + observerTokens.append(ObserverToken( + center: workspaceNotificationCenter, + token: sleepToken + )) + + let wakeToken = workspaceNotificationCenter.addObserver( + forName: NSWorkspace.didWakeNotification, + object: nil, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + guard let self, self.isStarted else { return } + self.overlayController.updateDisplayTopology() + self.synchronizeDisplayStateAndLayout() + self.overlayController.setEnabled(self.model.isEnabled) + self.inputController.handleWake() + } + } + observerTokens.append(ObserverToken( + center: workspaceNotificationCenter, + token: wakeToken + )) + + let screensSleepToken = workspaceNotificationCenter.addObserver( + forName: NSWorkspace.screensDidSleepNotification, + object: nil, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + guard let self, self.isStarted else { return } + self.overlayController.setEnabled(false) + self.inputController.handleSleep() + } + } + observerTokens.append(ObserverToken( + center: workspaceNotificationCenter, + token: screensSleepToken + )) + + let screensWakeToken = workspaceNotificationCenter.addObserver( + forName: NSWorkspace.screensDidWakeNotification, + object: nil, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + guard let self, self.isStarted else { return } + self.overlayController.updateDisplayTopology() + self.synchronizeDisplayStateAndLayout() + self.overlayController.setEnabled(self.model.isEnabled) + self.inputController.handleWake() + } + } + observerTokens.append(ObserverToken( + center: workspaceNotificationCenter, + token: screensWakeToken + )) + + let accessibilityToken = workspaceNotificationCenter.addObserver( + forName: NSWorkspace.accessibilityDisplayOptionsDidChangeNotification, + object: nil, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + self?.updateAccessibilityDisplayOptions() + } + } + observerTokens.append(ObserverToken( + center: workspaceNotificationCenter, + token: accessibilityToken + )) + + let lowPowerToken = notificationCenter.addObserver( + forName: Notification.Name.NSProcessInfoPowerStateDidChange, + object: nil, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + self?.updatePowerEnvironmentState() + } + } + observerTokens.append(ObserverToken( + center: notificationCenter, + token: lowPowerToken + )) + + let thermalToken = notificationCenter.addObserver( + forName: ProcessInfo.thermalStateDidChangeNotification, + object: nil, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + self?.updatePowerEnvironmentState() + } + } + observerTokens.append(ObserverToken( + center: notificationCenter, + token: thermalToken + )) + } + + private func removePlatformObservers() { + for observation in observerTokens { + observation.center.removeObserver(observation.token) + } + observerTokens.removeAll() + } + + private func updateAccessibilityDisplayOptions() { + guard isStarted else { return } + let options = accessibilityOptionsProvider() + reduceMotionEnabled = options.reduceMotion + reduceTransparencyEnabled = options.reduceTransparency + increaseContrastEnabled = options.increaseContrast + applyRendererConfiguration() + + KeyLightLogger.app.debug( + "Accessibility options changed: reduceMotion=\(self.reduceMotionEnabled, privacy: .public), reduceTransparency=\(self.reduceTransparencyEnabled, privacy: .public), increaseContrast=\(self.increaseContrastEnabled, privacy: .public)" + ) + } + + private func updatePowerEnvironmentState() { + guard isStarted else { return } + model.updatePowerEnvironmentState(powerEnvironmentProvider()) + } + + private func receivePhysicalEventFromOverlay(_ event: KeyboardEvent) { + guard isStarted else { return } + model.receivePhysicalKeyboardEvent(event) + } + + private func synchronizeDisplayStateAndLayout() { + let activeDisplayID = overlayController.activeDisplayPersistentID + model.updateDisplayState( + availableDisplays: overlayController.availableDisplays, + activeDisplayPersistentID: activeDisplayID, + activeDisplayPersistentIDs: overlayController.activeDisplayPersistentIDs + ) + guard let activeDisplayID else { return } + applyBoundLayout( + model.boundLayoutProfileID(forDisplay: activeDisplayID), + forDisplay: activeDisplayID + ) + } + + private func applyBoundLayout(_ profileID: UUID?, forDisplay displayID: String) { + guard let profileID, + keyLayoutStore.selectedProfileID != profileID else { + deferredDisplayLayoutBinding = nil + return + } + + if keyLayoutStore.selectedProfileIsEdited { + let pending = (displayID: displayID, profileID: profileID) + if deferredDisplayLayoutBinding?.displayID != pending.displayID || + deferredDisplayLayoutBinding?.profileID != pending.profileID { + model.feedback = UserFeedback( + severity: .warning, + title: String(localized: "Display Layout Not Applied"), + detail: String(localized: "Save or revert the current keyboard calibration before switching to this display's bound layout.") + ) + } + deferredDisplayLayoutBinding = pending + return + } + + if keyLayoutStore.selectSavedProfile(id: profileID) { + deferredDisplayLayoutBinding = nil + } + } +} diff --git a/KeyLight/AppDelegate.swift b/KeyLight/AppDelegate.swift index d9ece85..e0b993d 100644 --- a/KeyLight/AppDelegate.swift +++ b/KeyLight/AppDelegate.swift @@ -1,670 +1,176 @@ import AppKit -import Carbon.HIToolbox +/// The smallest AppKit bridge in KeyLight: NSApplication lifecycle enters the +/// runtime coordinator here, while SwiftUI scenes read the coordinator's model. @MainActor final class AppDelegate: NSObject, NSApplicationDelegate { - private let overlayWindowHeight: CGFloat = 120 - private let defaultGlowBaseWidth: CGFloat = 60 - - var overlayWindows: [CGDirectDisplayID: GlowOverlayWindow] = [:] - var keyboardMonitor: KeyboardMonitor? - var keyPositionEditorWindow: KeyPositionEditorWindow? - let appState = AppState() - - private var cachedKeyboardDisplayID: CGDirectDisplayID? - - private var hotkeyEventHandler: EventHandlerRef? - private var hotkeyRef: EventHotKeyRef? - private var retainedSelfForHotkey: Unmanaged? - - private var notificationObservers: [Any] = [] - private var workspaceObservers: [Any] = [] - - private var permissionCheckTimer: Timer? - private var permissionPollInterval: TimeInterval? - private var lastKnownPermissionState: Bool? - - private let permissionPollFast: TimeInterval = 5 - private let permissionPollSlow: TimeInterval = 300 - - private var settingsDebounceWorkItem: DispatchWorkItem? - private var reduceMotionEnabled: Bool = false - - private struct GlowPreviewPayload: Sendable { - let keyCode: UInt16? - let position: CGFloat - let keyWidth: CGFloat - - init?(userInfo: [AnyHashable: Any]?) { - guard let userInfo, - let position = userInfo["position"] as? CGFloat, - let keyWidth = userInfo["keyWidth"] as? CGFloat else { - return nil - } - - if let keyCode = userInfo["keyCode"] as? UInt16 { - self.keyCode = keyCode - } else if let keyCodeNumber = userInfo["keyCode"] as? NSNumber { - self.keyCode = keyCodeNumber.uint16Value - } else if let intCode = userInfo["keyCode"] as? Int, - intCode >= 0, - intCode <= Int(UInt16.max) { - self.keyCode = UInt16(intCode) - } else { - self.keyCode = nil - } - - self.position = position - self.keyWidth = keyWidth - } - } - - func applicationDidFinishLaunching(_ notification: Notification) { - KeyLightLog("Starting up...") + private let settings: SettingsManager + private let layoutStore: KeyLayoutStore + private let updater: UpdateService + private let coordinator: AppCoordinator + override init() { + let preferences: PreferencesStore #if DEBUG - KeyMapping.assertParityContracts() - #endif - - let hasPermission = PermissionManager.shared.hasInputMonitoringPermission() - KeyLightLog("Input Monitoring permission = \(hasPermission)") - - if !hasPermission { - PermissionManager.shared.requestInputMonitoringPermission() - } - - setupOverlayWindows() - - if hasPermission && appState.isEnabled { - setupKeyboardMonitor() - } - - setupGlobalHotkey() - setupNotificationObservers() - setupWorkspaceObservers() - - updateReduceMotion() - - let interval: TimeInterval = (hasPermission && keyboardMonitor != nil) ? permissionPollSlow : permissionPollFast - reschedulePermissionTimer(interval: interval) - - applySettings() - KeyLightLog("Ready!") - } - - private func setupNotificationObservers() { - notificationObservers.append( - NotificationCenter.default.addObserver( - forName: .glowSettingsChanged, - object: nil, - queue: .main - ) { [weak self] _ in - self?.settingsChanged() - } - ) - - notificationObservers.append( - NotificationCenter.default.addObserver( - forName: NSApplication.didChangeScreenParametersNotification, - object: nil, - queue: .main - ) { [weak self] _ in - self?.screenChanged() - } - ) - - notificationObservers.append( - NotificationCenter.default.addObserver( - forName: .openKeyPositionEditor, - object: nil, - queue: .main - ) { [weak self] _ in - self?.openKeyPositionEditor() - } - ) - - notificationObservers.append( - NotificationCenter.default.addObserver( - forName: .keyPositionsChanged, - object: nil, - queue: .main - ) { [weak self] _ in - self?.keyPositionsChanged() - } - ) - - notificationObservers.append( - NotificationCenter.default.addObserver( - forName: .showGlowPreview, - object: nil, - queue: .main - ) { [weak self] notification in - let payload = GlowPreviewPayload(userInfo: notification.userInfo) - guard let payload else { return } - self?.showGlowPreview(payload) - } - ) - - notificationObservers.append( - NotificationCenter.default.addObserver( - forName: .hideGlowPreview, - object: nil, - queue: .main - ) { [weak self] _ in - self?.hideGlowPreview() - } - ) - - notificationObservers.append( - NotificationCenter.default.addObserver( - forName: .openSettingsWindow, - object: nil, - queue: .main - ) { [weak self] _ in - self?.openSettingsWindow() - } - ) - - notificationObservers.append( - NotificationCenter.default.addObserver( - forName: NSApplication.didBecomeActiveNotification, - object: nil, - queue: .main - ) { [weak self] _ in - self?.checkPermissionStatus() - } - ) - } - - private func setupWorkspaceObservers() { - workspaceObservers.append( - NSWorkspace.shared.notificationCenter.addObserver( - forName: NSWorkspace.willSleepNotification, - object: nil, - queue: .main - ) { [weak self] _ in - self?.handleSleep() - } - ) - - workspaceObservers.append( - NSWorkspace.shared.notificationCenter.addObserver( - forName: NSWorkspace.didWakeNotification, - object: nil, - queue: .main - ) { [weak self] _ in - self?.handleWake() - } - ) - - workspaceObservers.append( - NSWorkspace.shared.notificationCenter.addObserver( - forName: NSWorkspace.screensDidSleepNotification, - object: nil, - queue: .main - ) { [weak self] _ in - self?.handleSleep() - } - ) - - workspaceObservers.append( - NSWorkspace.shared.notificationCenter.addObserver( - forName: NSWorkspace.screensDidWakeNotification, - object: nil, - queue: .main - ) { [weak self] _ in - self?.handleWake() - } - ) - - workspaceObservers.append( - NSWorkspace.shared.notificationCenter.addObserver( - forName: NSWorkspace.accessibilityDisplayOptionsDidChangeNotification, - object: nil, - queue: .main - ) { [weak self] _ in - self?.updateReduceMotion() - } - ) - } - - func applicationWillTerminate(_ notification: Notification) { - appState.flushPendingPersist() - - settingsDebounceWorkItem?.cancel() - settingsDebounceWorkItem = nil - KeyPositionManager.shared.cancelPendingWork() - - SettingsWindowController.shared.closeWindow() - keyPositionEditorWindow?.close() - keyPositionEditorWindow = nil - - keyboardMonitor?.stop() - keyboardMonitor = nil - - for window in overlayWindows.values { - window.glowView?.clearHeldKeys() - window.close() - } - overlayWindows.removeAll() - - for observer in notificationObservers { - NotificationCenter.default.removeObserver(observer) - } - notificationObservers.removeAll() - - for observer in workspaceObservers { - NSWorkspace.shared.notificationCenter.removeObserver(observer) - } - workspaceObservers.removeAll() - - if let handler = hotkeyEventHandler { - RemoveEventHandler(handler) - hotkeyEventHandler = nil - } - if let hotkey = hotkeyRef { - UnregisterEventHotKey(hotkey) - hotkeyRef = nil - } - - retainedSelfForHotkey?.release() - retainedSelfForHotkey = nil - - permissionCheckTimer?.invalidate() - permissionCheckTimer = nil - permissionPollInterval = nil - } - - // MARK: - Multi-Monitor - - private func displayID(for screen: NSScreen) -> CGDirectDisplayID? { - screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? CGDirectDisplayID - } - - private func setupOverlayWindows() { - for window in overlayWindows.values { - window.glowView?.clearHeldKeys() - window.close() - } - overlayWindows.removeAll() - - for screen in NSScreen.screens { - guard let id = displayID(for: screen) else { continue } - - let frame = NSRect( - x: screen.frame.origin.x, - y: screen.frame.origin.y, - width: screen.frame.width, - height: overlayWindowHeight + if KeyLightDebugLaunchConfiguration.isEnabled, + let defaults = UserDefaults( + suiteName: KeyLightDebugLaunchConfiguration.defaultsSuiteName + ) { + defaults.removePersistentDomain( + forName: KeyLightDebugLaunchConfiguration.defaultsSuiteName ) - - let window = GlowOverlayWindow(contentRect: frame) - window.orderFrontRegardless() - overlayWindows[id] = window - KeyLightLog("Overlay window created for screen \(id) at \(frame)") - } - - updateKeyboardDisplayID() - } - - private var keyboardDisplayID: CGDirectDisplayID? { - if let cached = cachedKeyboardDisplayID { - return cached - } - updateKeyboardDisplayID() - return cachedKeyboardDisplayID - } - - private func updateKeyboardDisplayID() { - for screen in NSScreen.screens { - if let id = displayID(for: screen), CGDisplayIsBuiltin(id) != 0 { - cachedKeyboardDisplayID = id - return - } - } - - let fallbackScreen = NSScreen.main ?? NSScreen.screens.first - cachedKeyboardDisplayID = fallbackScreen.flatMap { displayID(for: $0) } - } - - private var primaryOverlayWindow: GlowOverlayWindow? { - guard let id = keyboardDisplayID else { return nil } - return overlayWindows[id] - } - - private func setupKeyboardMonitor() { - keyboardMonitor?.stop() - keyboardMonitor = nil - - for window in overlayWindows.values { - window.glowView?.clearHeldKeys() + preferences = PreferencesStore(userDefaults: defaults) + } else { + preferences = .standard } + #else + preferences = .standard + #endif + let settings = SettingsManager(preferencesStore: preferences) + let layoutStore = KeyLayoutStore( + preferencesStore: preferences, + settingsManager: settings + ) + let model = KeyLightModel(settings: settings) + let updater = UpdateService() - let monitor = KeyboardMonitor { [weak self] event in - self?.handleKeyEvent(event) + #if DEBUG + if let requestedEffect = KeyLightDebugLaunchConfiguration.effectStyle { + model.effectStyle = requestedEffect } - monitor.start() - keyboardMonitor = monitor - } - - // MARK: - Global Hotkey - - private func setupGlobalHotkey() { - var hotKeyRefLocal: EventHotKeyRef? - let hotKeyID = EventHotKeyID(signature: OSType(0x4B4C4754), id: 1) // "KLGT" - - let modifiers: UInt32 = UInt32(cmdKey | shiftKey) - let keyCode: UInt32 = 0x28 // K - - let status = RegisterEventHotKey(keyCode, modifiers, hotKeyID, GetApplicationEventTarget(), 0, &hotKeyRefLocal) - - if status == noErr { - hotkeyRef = hotKeyRefLocal - KeyLightLog("Global hotkey Cmd+Shift+K registered") - - let retained = Unmanaged.passRetained(self) - retainedSelfForHotkey = retained - - var eventType = EventTypeSpec( - eventClass: OSType(kEventClassKeyboard), - eventKind: UInt32(kEventHotKeyPressed) - ) + #endif - InstallEventHandler( - GetApplicationEventTarget(), - { _, _, userData in - guard let userData = userData else { return noErr } - let appDelegate = Unmanaged.fromOpaque(userData).takeUnretainedValue() - DispatchQueue.main.async { - appDelegate.toggleEffect() - } - return noErr - }, - 1, - &eventType, - retained.toOpaque(), - &hotkeyEventHandler + let coordinator: AppCoordinator + #if DEBUG + if KeyLightDebugLaunchConfiguration.isEnabled { + coordinator = AppCoordinator( + model: model, + keyLayoutStore: layoutStore, + inputControllerFactory: { _, onStatus in + DebugInputController(onStatusChange: onStatus) + } ) } else { - KeyLightLog("Failed to register global hotkey (status: \(status))") - } - } - - @objc func toggleEffect() { - appState.isEnabled.toggle() - KeyLightLog("Effect \(appState.isEnabled ? "enabled" : "disabled")") - - if !appState.isEnabled { - for window in overlayWindows.values { - window.glowView?.clearHeldKeys() - } - keyboardMonitor?.stop() - keyboardMonitor = nil - } else if PermissionManager.shared.hasInputMonitoringPermission() { - setupKeyboardMonitor() - } else { - PermissionManager.shared.requestInputMonitoringPermission() + coordinator = AppCoordinator( + model: model, + keyLayoutStore: layoutStore + ) } + #else + coordinator = AppCoordinator( + model: model, + keyLayoutStore: layoutStore + ) + #endif - applySettings() + self.settings = settings + self.layoutStore = layoutStore + self.updater = updater + self.coordinator = coordinator + super.init() } - // MARK: - Notifications - - private func settingsChanged() { - settingsDebounceWorkItem?.cancel() - let workItem = DispatchWorkItem { [weak self] in - self?.applySettings() - } - settingsDebounceWorkItem = workItem - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05, execute: workItem) + var appState: KeyLightModel { + coordinator.model } - private func screenChanged() { - updateOverlayWindows() - applySettings() + var settingsManager: SettingsManager { + settings } - private func updateOverlayWindows() { - var currentDisplayIDs: Set = [] - - for screen in NSScreen.screens { - guard let id = displayID(for: screen) else { continue } - currentDisplayIDs.insert(id) - - if let existingWindow = overlayWindows[id] { - let frame = NSRect( - x: screen.frame.origin.x, - y: screen.frame.origin.y, - width: screen.frame.width, - height: overlayWindowHeight - ) - existingWindow.setFrame(frame, display: true) - } else { - let frame = NSRect( - x: screen.frame.origin.x, - y: screen.frame.origin.y, - width: screen.frame.width, - height: overlayWindowHeight - ) - let window = GlowOverlayWindow(contentRect: frame) - window.orderFrontRegardless() - overlayWindows[id] = window - KeyLightLog("Overlay window created for screen \(id) at \(frame)") - } - } - - for (id, window) in overlayWindows where !currentDisplayIDs.contains(id) { - window.glowView?.clearHeldKeys() - window.close() - overlayWindows.removeValue(forKey: id) - KeyLightLog("Overlay window removed for disconnected screen \(id)") - } - - updateKeyboardDisplayID() - } - - private func openSettingsWindow() { - SettingsWindowController.shared.showWindow(appState: appState) - } - - private func openKeyPositionEditor() { - if keyPositionEditorWindow == nil { - keyPositionEditorWindow = KeyPositionEditorWindow() - } - keyPositionEditorWindow?.makeKeyAndOrderFront(nil) - NSApp.activate(ignoringOtherApps: true) + var keyLayoutStore: KeyLayoutStore { + layoutStore } - private func keyPositionsChanged() { - KeyPositionManager.shared.reloadOffsets() + var updateService: UpdateService { + updater } - // MARK: - Sleep / Wake - - private func handleSleep() { - KeyLightLog("System sleeping, pausing keyboard monitor") - keyboardMonitor?.stop() - keyboardMonitor = nil - - for window in overlayWindows.values { - window.glowView?.clearHeldKeys() + func applicationDidFinishLaunching(_ notification: Notification) { + // Packaging launches the staged executable once to prove that dyld, + // embedded frameworks, the hardened-runtime signature, and the app + // entry point are mutually compatible. Exit before monitoring, + // capture, updater startup, or user-interface presentation so this + // verification cannot touch permissions or persisted state. + if ProcessInfo.processInfo.environment[ + "KEYLIGHT_PACKAGE_LAUNCH_SMOKE_TEST" + ] == "1" { + NSApp.terminate(nil) + return } - permissionCheckTimer?.invalidate() - permissionCheckTimer = nil + coordinator.start() + updater.start() } - private func handleWake() { - KeyLightLog("System waking, resuming keyboard monitor") - - for window in overlayWindows.values { - window.glowView?.clearHeldKeys() - } - - let hasPermission = PermissionManager.shared.hasInputMonitoringPermission() - if hasPermission && appState.isEnabled { - setupKeyboardMonitor() - } - - let interval = (hasPermission && (!appState.isEnabled || keyboardMonitor != nil)) ? permissionPollSlow : permissionPollFast - reschedulePermissionTimer(interval: interval) + func applicationWillTerminate(_ notification: Notification) { + coordinator.shutdown() } +} - // MARK: - Preview Glow - - private var previewKeyCode: UInt16 = 9999 - - private func showGlowPreview(_ payload: GlowPreviewPayload) { - guard let glowView = primaryOverlayWindow?.glowView else { return } - - if appState.colorMode == .randomPerKey { - let requestedKeyCode = payload.keyCode ?? previewKeyCode - glowView.glowColor = appState.randomPerKeyNSColor(for: requestedKeyCode) - glowView.colorResolver = nil - } +#if DEBUG +enum KeyLightDebugLaunchConfiguration { + static let defaultsSuiteName = "com.keylight.ui-baseline" + private static let environment = ProcessInfo.processInfo.environment - glowView.updateGlowPosition(at: payload.position, keyCode: previewKeyCode, keyWidth: payload.keyWidth) + static var isEnabled: Bool { + environment["KEYLIGHT_UI_TEST_MODE"] == "1" } - private func hideGlowPreview() { - guard let glowView = primaryOverlayWindow?.glowView else { return } - glowView.hideGlow(keyCode: previewKeyCode) + static var requestedScene: String? { + guard isEnabled else { return nil } + return environment["KEYLIGHT_UI_TEST_SCENE"]?.lowercased() + ?? "settings" } - // MARK: - Accessibility - - private func updateReduceMotion() { - let shouldReduceMotion = NSWorkspace.shared.accessibilityDisplayShouldReduceMotion - let changed = shouldReduceMotion != reduceMotionEnabled - reduceMotionEnabled = shouldReduceMotion - - if changed { - applySettings() + static var effectStyle: EffectStyle? { + guard isEnabled, + let raw = environment["KEYLIGHT_UI_TEST_EFFECT"] else { + return nil } - - KeyLightLog("Reduce motion: \(reduceMotionEnabled)") + return EffectStyle(rawValue: raw) } +} - // MARK: - Permission Monitoring - - private func checkPermissionStatus() { - let hasPermission = PermissionManager.shared.hasInputMonitoringPermission() - let permissionChanged = (lastKnownPermissionState == nil) || (lastKnownPermissionState != hasPermission) - lastKnownPermissionState = hasPermission - - if !hasPermission { - if keyboardMonitor != nil { - KeyLightLog("Input Monitoring permission was revoked") - keyboardMonitor?.stop() - keyboardMonitor = nil - } - reschedulePermissionTimer(interval: permissionPollFast) - if permissionChanged { - NotificationCenter.default.post(name: .permissionStatusChanged, object: nil) - } - return - } - - if appState.isEnabled && keyboardMonitor == nil { - setupKeyboardMonitor() - } - - let monitorHealthy = !appState.isEnabled || keyboardMonitor != nil - reschedulePermissionTimer(interval: monitorHealthy ? permissionPollSlow : permissionPollFast) +@MainActor +private final class DebugInputController: AppCoordinatorInputControlling { + private let onStatusChange: @MainActor (InputControllerStatus) -> Void + private var enabled = false - if permissionChanged { - NotificationCenter.default.post(name: .permissionStatusChanged, object: nil) - } + init( + onStatusChange: @escaping @MainActor (InputControllerStatus) -> Void + ) { + self.onStatusChange = onStatusChange } - private func reschedulePermissionTimer(interval: TimeInterval) { - if let current = permissionPollInterval, - abs(current - interval) < 0.001, - permissionCheckTimer != nil { - return - } - - permissionCheckTimer?.invalidate() - let timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in - Task { @MainActor [weak self] in - self?.checkPermissionStatus() - } - } - timer.tolerance = min(10.0, interval * 0.5) - permissionCheckTimer = timer - permissionPollInterval = interval + func start(isEnabled: Bool) { + enabled = isEnabled + publish() } - // MARK: - Apply Settings / Events - - private func applySettings() { - for window in overlayWindows.values { - guard let glowView = window.glowView else { continue } - - glowView.maxOpacity = Float(appState.glowOpacity) - glowView.baseKeyWidth = defaultGlowBaseWidth - glowView.glowHeight = CGFloat(appState.glowSize) - glowView.widthMultiplier = CGFloat(appState.glowWidth) - glowView.fadeOutDuration = appState.fadeDuration - glowView.glowRoundness = CGFloat(appState.glowRoundness) - glowView.glowFullness = CGFloat(appState.glowFullness) + func stop() {} - switch appState.colorMode { - case .solid: - glowView.glowColor = appState.glowNSColor - glowView.colorResolver = nil - case .positionGradient: - glowView.glowColor = appState.glowNSColor - glowView.colorResolver = { [weak appState] position in - guard let appState = appState else { return .blue } - return interpolateColor( - from: appState.gradientStartNSColor, - to: appState.gradientEndNSColor, - fraction: position - ) - } - case .rainbow: - glowView.glowColor = appState.glowNSColor - glowView.colorResolver = { position in - let clamped = max(0.0, min(1.0, position)) - return NSColor(hue: clamped, saturation: 0.9, brightness: 1.0, alpha: 1.0) - } - case .randomPerKey: - glowView.glowColor = appState.randomPerKeyNSColor(for: 0) - glowView.colorResolver = nil - } - } + func setEnabled(_ enabled: Bool) { + self.enabled = enabled + publish() } - // SECURITY: This handler is for visual positioning only. Never log keystroke data. - private func handleKeyEvent(_ event: KeyEvent) { - NotificationCenter.default.post( - name: event.isKeyDown ? .physicalKeyDown : .physicalKeyUp, - object: nil, - userInfo: ["keyCode": event.keyCode] - ) - - guard let glowView = primaryOverlayWindow?.glowView else { - return - } - - if event.isKeyDown { - guard appState.isEnabled else { return } - - if appState.colorMode == .randomPerKey { - glowView.glowColor = appState.randomPerKeyNSColor(for: event.keyCode) - glowView.colorResolver = nil - } + func applicationDidBecomeActive() { publish() } + func handleSleep() {} + func handleWake() { publish() } + func requestPermission() { publish() } + func retry() { publish() } + func openInputMonitoringSettings() {} - glowView.showGlow( - at: event.horizontalPosition, - keyCode: event.keyCode, - keyWidth: event.keyWidth - ) - } else { - // Always process keyUp to avoid stuck glows when disabling mid-press - glowView.hideGlow(keyCode: event.keyCode) - } + private func publish() { + onStatusChange(InputControllerStatus( + state: enabled ? .active : .authorized, + runningApplicationPath: "/Applications/KeyLight Motion Preview.app", + installationIssue: nil, + lastKnownAuthorization: true, + monitorRunning: enabled, + recheckInterval: nil + )) } } +#endif diff --git a/KeyLight/Info.plist b/KeyLight/Info.plist new file mode 100644 index 0000000..62e101a --- /dev/null +++ b/KeyLight/Info.plist @@ -0,0 +1,50 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + $(PRODUCT_NAME) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconFile + AppIcon + CFBundleIconName + AppIcon + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + LSUIElement + + KeyLightBuildChannel + $(KEYLIGHT_BUILD_CHANNEL) + SUAutomaticallyUpdate + + SUEnableAutomaticChecks + + SUFeedURL + $(KEYLIGHT_SPARKLE_FEED_URL) + SUPublicEDKey + $(KEYLIGHT_SPARKLE_PUBLIC_ED_KEY) + SURequireSignedFeed + + SUSendProfileInfo + + SUSignedFeedFailureExpirationInterval + 0 + SUVerifyUpdateBeforeExtraction + + + diff --git a/KeyLight/KeyLightApp.swift b/KeyLight/KeyLightApp.swift index 5e538fe..df17bf5 100644 --- a/KeyLight/KeyLightApp.swift +++ b/KeyLight/KeyLightApp.swift @@ -4,18 +4,381 @@ // import SwiftUI +import AppKit @main struct KeyLightApp: App { @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate + private let applicationName = KeyLightApplicationIdentity.displayName var body: some Scene { MenuBarExtra { - MenuBarMenuView() - .environmentObject(appDelegate.appState) + MenuBarMenuView(model: appDelegate.appState) } label: { - MenuBarLabel() - .environmentObject(appDelegate.appState) + MenuBarLabel(model: appDelegate.appState) + #if DEBUG + .background { + DebugLaunchSceneRouter() + } + #endif + } + + Settings { + SettingsView( + model: appDelegate.appState, + settings: appDelegate.settingsManager, + keyLayoutStore: appDelegate.keyLayoutStore, + updateService: appDelegate.updateService + ) + .background( + KeyLightWindowBridge(identifier: .settings) + ) + } + + Window("Keyboard Calibration", id: KeyLightSceneID.keyEditor) { + KeyPositionEditorSceneRoot( + model: appDelegate.appState, + layoutStore: appDelegate.keyLayoutStore + ) + .background( + KeyLightWindowBridge( + identifier: .keyEditor, + consumesUnmodifiedSpace: true + ) + ) + } + .defaultSize(width: 1_050, height: 460) + .windowResizability(.contentMinSize) + + Window("Guided Keyboard Calibration", id: KeyLightSceneID.guidedCalibration) { + GuidedCalibrationSceneRoot( + model: appDelegate.appState, + settings: appDelegate.settingsManager, + layoutStore: appDelegate.keyLayoutStore + ) + .background( + KeyLightWindowBridge( + identifier: .guidedCalibration, + consumesUnmodifiedSpace: true + ) + ) + } + .defaultSize(width: 720, height: 560) + .windowResizability(.contentMinSize) + + Window("\(applicationName) Setup", id: KeyLightSceneID.setup) { + PermissionSetupView( + model: appDelegate.appState, + updateService: appDelegate.updateService + ) + .background( + KeyLightWindowBridge(identifier: .setup) + ) + } + .defaultSize(width: 560, height: 460) + .windowResizability(.contentMinSize) + } +} + +#if DEBUG +private struct DebugLaunchSceneRouter: View { + @Environment(\.openSettings) private var openSettings + @Environment(\.openWindow) private var openWindow + @State private var didRoute = false + + var body: some View { + Color.clear + .frame(width: 0, height: 0) + .task { + guard !didRoute, + let requestedScene = + KeyLightDebugLaunchConfiguration.requestedScene else { + return + } + didRoute = true + await Task.yield() + switch requestedScene { + case "setup": + KeyLightWindowActivation.present(.setup) { + openWindow(id: KeyLightSceneID.setup) + } + case "keyboard", "calibration": + KeyLightWindowActivation.present(.keyEditor) { + openWindow(id: KeyLightSceneID.keyEditor) + } + case "guided-calibration": + KeyLightWindowActivation.present(.guidedCalibration) { + openWindow(id: KeyLightSceneID.guidedCalibration) + } + default: + KeyLightWindowActivation.present(.settings) { + openSettings() + } + } + } + } +} +#endif + +struct KeyLightBuildIdentity: Equatable, Sendable { + let displayName: String + let bundleIdentifier: String + let version: String + let build: String + let channel: String + let bundlePath: String + + init( + info: [String: Any], + bundleIdentifier: String?, + bundlePath: String + ) { + let resolvedName = ( + info["CFBundleDisplayName"] as? String + ?? info["CFBundleName"] as? String + ?? "KeyLight" + ).trimmingCharacters(in: .whitespacesAndNewlines) + let resolvedBundleIdentifier = ( + bundleIdentifier + ?? info["CFBundleIdentifier"] as? String + ?? "com.keylight.app" + ).trimmingCharacters(in: .whitespacesAndNewlines) + let explicitChannel = (info["KeyLightBuildChannel"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) + + displayName = resolvedName.isEmpty ? "KeyLight" : resolvedName + self.bundleIdentifier = resolvedBundleIdentifier.isEmpty + ? "com.keylight.app" + : resolvedBundleIdentifier + version = Self.nonempty(info["CFBundleShortVersionString"] as? String) + build = Self.nonempty(info["CFBundleVersion"] as? String) + channel = Self.resolvedChannel( + explicitChannel: explicitChannel, + bundleIdentifier: self.bundleIdentifier + ) + self.bundlePath = bundlePath + } + + init(bundle: Bundle = .main) { + self.init( + info: bundle.infoDictionary ?? [:], + bundleIdentifier: bundle.bundleIdentifier, + bundlePath: bundle.bundlePath + ) + } + + var versionDescription: String { + guard build != version else { return version } + return "\(version) (\(build))" + } + + var supportSummary: String { + [ + "\(displayName) \(versionDescription)", + "Channel: \(channel)", + "Bundle ID: \(bundleIdentifier)", + "Bundle path: \(bundlePath)" + ].joined(separator: "\n") + } + + private static func nonempty(_ value: String?) -> String { + guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), + !value.isEmpty else { + return "—" + } + return value + } + + private static func resolvedChannel( + explicitChannel: String?, + bundleIdentifier: String + ) -> String { + if let explicitChannel, !explicitChannel.isEmpty { + return explicitChannel + } + switch bundleIdentifier { + case "com.keylight.app.motionpreview": return "Motion Preview" + case "com.keylight.app.debug": return "Local Debug" + case "com.keylight.app.v2": return "Side-by-Side" + case "com.keylight.app": return "Production" + default: return "Development" + } + } +} + +enum KeyLightApplicationIdentity { + static var current: KeyLightBuildIdentity { + KeyLightBuildIdentity() + } + + static var displayName: String { + current.displayName + } + + static var bundleName: String { + let name = Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String ?? displayName + return name.hasSuffix(".app") ? name : "\(name).app" + } +} + +enum KeyLightSceneID { + static let keyEditor = "key-editor" + static let guidedCalibration = "guided-calibration" + static let setup = "setup" +} + +enum KeyLightWindowIdentifier: String, Sendable { + case settings = "com.keylight.window.settings" + case keyEditor = "com.keylight.window.key-editor" + case guidedCalibration = "com.keylight.window.guided-calibration" + case setup = "com.keylight.window.setup" + + var appKitIdentifier: NSUserInterfaceItemIdentifier { + NSUserInterfaceItemIdentifier(rawValue) + } +} + +enum KeyLightCalibrationKeyPolicy { + static func consumesLocalControlActivation( + keyCode: UInt16, + modifierFlags: NSEvent.ModifierFlags + ) -> Bool { + keyCode == 49 && modifierFlags.intersection([ + .command, .control, .option + ]).isEmpty + } +} + +/// SwiftUI creates the scenes; this tiny AppKit edge makes an explicit menu +/// action behave like a foreground command in an LSUIElement application. +@MainActor +enum KeyLightWindowActivation { + static func present( + _ identifier: KeyLightWindowIdentifier, + opening action: () -> Void + ) { + NSApp.activate(ignoringOtherApps: true) + action() + activate(identifier) + DispatchQueue.main.async { + activate(identifier) + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.08) { + activate(identifier) + } + } + + static func activate(_ identifier: KeyLightWindowIdentifier) { + NSApp.activate(ignoringOtherApps: true) + guard let window = NSApp.windows.first(where: { + $0.identifier == identifier.appKitIdentifier + }) else { return } + window.makeKeyAndOrderFront(nil) + } +} + +struct KeyLightWindowBridge: NSViewRepresentable { + let identifier: KeyLightWindowIdentifier + var consumesUnmodifiedSpace = false + + func makeNSView(context: Context) -> KeyLightWindowBridgeView { + KeyLightWindowBridgeView( + identifier: identifier, + consumesUnmodifiedSpace: consumesUnmodifiedSpace + ) + } + + func updateNSView( + _ nsView: KeyLightWindowBridgeView, + context: Context + ) { + nsView.configure( + identifier: identifier, + consumesUnmodifiedSpace: consumesUnmodifiedSpace + ) + } +} + +@MainActor +final class KeyLightWindowBridgeView: NSView { + private var windowIdentifier: KeyLightWindowIdentifier + private var consumesUnmodifiedSpace: Bool + // NSEvent's opaque monitor token is created, used, and cleared only on the + // AppKit main actor. Marking the storage unsafe-nonisolated prevents Swift + // 6's synthesized nonisolated deinitializer from treating `Any` as a + // cross-actor transfer. + nonisolated(unsafe) private var keyMonitor: Any? + + init( + identifier: KeyLightWindowIdentifier, + consumesUnmodifiedSpace: Bool + ) { + windowIdentifier = identifier + self.consumesUnmodifiedSpace = consumesUnmodifiedSpace + super.init(frame: .zero) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configure( + identifier: KeyLightWindowIdentifier, + consumesUnmodifiedSpace: Bool + ) { + windowIdentifier = identifier + self.consumesUnmodifiedSpace = consumesUnmodifiedSpace + configureWindow() + } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + configureWindow() + } + + override func viewWillMove(toWindow newWindow: NSWindow?) { + if newWindow == nil { + removeKeyMonitor() + } + super.viewWillMove(toWindow: newWindow) + } + + private func configureWindow() { + removeKeyMonitor() + guard let window else { return } + window.identifier = windowIdentifier.appKitIdentifier + if consumesUnmodifiedSpace { + keyMonitor = NSEvent.addLocalMonitorForEvents( + matching: .keyDown + ) { [weak self, weak window] event in + guard let self, + let window, + self.window === window, + event.window === window, + KeyLightCalibrationKeyPolicy + .consumesLocalControlActivation( + keyCode: event.keyCode, + modifierFlags: event.modifierFlags + ) else { + return event + } + // The listen-only global tap has already observed this Space + // event. Suppress only its local control activation so Reset + // All cannot become an accidental default button. + return nil + } + } + DispatchQueue.main.async { + KeyLightWindowActivation.activate(self.windowIdentifier) } } + + private func removeKeyMonitor() { + if let keyMonitor { + NSEvent.removeMonitor(keyMonitor) + self.keyMonitor = nil + } + } + } diff --git a/KeyLight/Models/AppPreferences.swift b/KeyLight/Models/AppPreferences.swift new file mode 100644 index 0000000..896d01c --- /dev/null +++ b/KeyLight/Models/AppPreferences.swift @@ -0,0 +1,436 @@ +import Foundation + +/// The established color-selection modes used by preferences, saved themes, +/// and renderer configuration. Raw values are part of KeyLight's compatibility +/// contract and must not change. +enum ColorMode: String, CaseIterable, Codable, Sendable { + case solid = "solid" + case positionGradient = "positionGradient" + case randomPerKey = "randomPerKey" + case rainbow = "rainbow" +} + +/// The renderer styles supported by KeyLight. +/// +/// Availability resolution belongs to the domain value rather than the +/// persistence store so views and renderers do not depend on SettingsManager. +enum EffectStyle: String, CaseIterable, Codable, Sendable { + case classicGlow = "classicGlow" + // Retained only as wire-format migration tokens for preview-era themes + // and preferences. They are deliberately absent from `allCases` and never + // reach a renderer. + case classicPlus = "classicPlus" + case liquidGlass = "liquidGlass" + case systemGlass = "systemGlass" + case physicalRefraction = "physicalRefraction" + case solidBlack = "solidBlack" + + static var allCases: [EffectStyle] { + [.classicGlow, .systemGlass, .physicalRefraction, .solidBlack] + } + + var supportedStyle: EffectStyle { + switch self { + case .classicPlus: + return .classicGlow + case .liquidGlass: + return .systemGlass + case .classicGlow, .systemGlass, .physicalRefraction, .solidBlack: + return self + } + } + + var isRetired: Bool { + self != supportedStyle + } + + var displayName: String { + switch self { + case .classicGlow: + return String(localized: "Classic Glow") + case .classicPlus: + return String(localized: "Classic Glow") + case .liquidGlass: + return String(localized: "System Glass") + case .systemGlass: + return String(localized: "System Glass") + case .physicalRefraction: + return String(localized: "Physical Refraction") + case .solidBlack: + return String(localized: "Solid Black") + } + } + + var isAvailableOnCurrentSystem: Bool { + switch supportedStyle { + case .classicGlow: + return true + case .systemGlass, .physicalRefraction, .solidBlack: + return Self.liquidGlassAvailableOnCurrentSystem + case .classicPlus, .liquidGlass: + return false + } + } + + var resolvedForCurrentSystem: EffectStyle { + resolved(liquidGlassAvailable: Self.liquidGlassAvailableOnCurrentSystem) + } + + func resolved(liquidGlassAvailable: Bool) -> EffectStyle { + let supported = supportedStyle + return supported.requiresMacOS26 && !liquidGlassAvailable + ? .classicGlow + : supported + } + + var requiresMacOS26: Bool { + switch supportedStyle { + case .classicGlow: + false + case .systemGlass, .physicalRefraction, .solidBlack: + true + case .classicPlus, .liquidGlass: + false + } + } + + var usesClassicColorConfiguration: Bool { + supportedStyle == .classicGlow + } + + var usesScreenCapture: Bool { + supportedStyle == .physicalRefraction + } + + private static var liquidGlassAvailableOnCurrentSystem: Bool { + #if compiler(>=6.2) + if #available(macOS 26.0, *) { + return true + } + #endif + return false + } +} + +/// Controls whether simultaneous keys form one cohesive surface or retain +/// visibly independent material boundaries. +enum ChordSurfaceStyle: String, CaseIterable, Codable, Sendable { + case naturalMerge = "naturalMerge" + case independent = "independent" + + var displayName: String { + switch self { + case .naturalMerge: + return String(localized: "Natural Merge") + case .independent: + return String(localized: "Independent") + } + } +} + +/// Appearance applied only while at least two physical or chord-test keys are +/// active. A multiplier of 1 preserves the established single-key rendering. +struct ChordAppearance: Codable, Equatable, Sendable { + static let intensityRange: ClosedRange = 0.5 ... 1.5 + + var style: ChordSurfaceStyle + var intensityMultiplier: Double + + init( + style: ChordSurfaceStyle = .naturalMerge, + intensityMultiplier: Double = 1 + ) { + self.style = style + self.intensityMultiplier = Self.normalizedIntensity( + intensityMultiplier + ) + } + + static let `default` = ChordAppearance() + + private enum CodingKeys: String, CodingKey { + case style + case intensityMultiplier + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.init( + style: try container.decodeIfPresent( + ChordSurfaceStyle.self, + forKey: .style + ) ?? .naturalMerge, + intensityMultiplier: try container.decodeIfPresent( + Double.self, + forKey: .intensityMultiplier + ) ?? 1 + ) + } + + var normalized: ChordAppearance { + ChordAppearance( + style: style, + intensityMultiplier: intensityMultiplier + ) + } + + func opacity(_ baseOpacity: Float, activeMemberCount: Int) -> Float { + guard activeMemberCount >= 2 else { return baseOpacity } + let scaled = Double(baseOpacity) * intensityMultiplier + guard scaled.isFinite else { return baseOpacity } + return Float(min(max(scaled, 0), 1)) + } + + private static func normalizedIntensity(_ value: Double) -> Double { + guard value.isFinite else { return 1 } + return min(max(value, intensityRange.lowerBound), intensityRange.upperBound) + } +} + +enum PowerSavingMode: String, CaseIterable, Codable, Sendable { + case off = "off" + case automatic = "automatic" + + var displayName: String { + switch self { + case .off: + return String(localized: "Off") + case .automatic: + return String(localized: "Automatic") + } + } +} + +enum PowerThermalState: String, CaseIterable, Codable, Sendable { + case nominal + case fair + case serious + case critical + + init(_ state: ProcessInfo.ThermalState) { + switch state { + case .nominal: + self = .nominal + case .fair: + self = .fair + case .serious: + self = .serious + case .critical: + self = .critical + @unknown default: + self = .serious + } + } + + var displayName: String { + rawValue.capitalized + } + + var requiresFallback: Bool { + self == .serious || self == .critical + } +} + +/// A privacy-safe, serializable snapshot of the two macOS signals used by +/// Automatic Power Saving. It contains no battery percentage or device data. +struct PowerEnvironmentState: Codable, Equatable, Sendable { + var isLowPowerModeEnabled: Bool + var thermalState: PowerThermalState + + static let normal = PowerEnvironmentState( + isLowPowerModeEnabled: false, + thermalState: .nominal + ) + + var requiresFallback: Bool { + isLowPowerModeEnabled || thermalState.requiresFallback + } + + var fallbackReason: String? { + switch (isLowPowerModeEnabled, thermalState) { + case (true, .serious): + return String(localized: "Low Power Mode and serious thermal pressure") + case (true, .critical): + return String(localized: "Low Power Mode and critical thermal pressure") + case (true, _): + return String(localized: "Low Power Mode") + case (false, .serious): + return String(localized: "Serious thermal pressure") + case (false, .critical): + return String(localized: "Critical thermal pressure") + case (false, .nominal), (false, .fair): + return nil + } + } + + static func current(_ processInfo: ProcessInfo = .processInfo) -> Self { + PowerEnvironmentState( + isLowPowerModeEnabled: processInfo.isLowPowerModeEnabled, + thermalState: PowerThermalState(processInfo.thermalState) + ) + } +} + +/// The established silhouette for all surface-based effects. +/// +/// Shape selection was briefly available in preview builds. Persisted themes +/// from those builds still decode, but every retired profile intentionally +/// normalizes to `currentWave` so removing the control cannot invalidate a +/// user's saved settings. +enum SurfaceShapeProfile: String, CaseIterable, Codable, Sendable { + case currentWave = "currentWave" + + var displayName: String { + String(localized: "Current Wave") + } + + static func persistedValue(rawValue: String) -> SurfaceShapeProfile? { + switch rawValue { + case "currentWave", "keyCap", "opticalDome", "softPillow": + return .currentWave + default: + return nil + } + } + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let rawValue = try container.decode(String.self) + guard let value = Self.persistedValue(rawValue: rawValue) else { + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "Unknown KeyLight surface shape." + ) + } + self = value + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(rawValue) + } +} + +/// The color-related portion of KeyLight's appearance configuration. +/// +/// This is intentionally a value type and does not define a new persistence +/// format. `SettingsManager` continues to read and write the established +/// UserDefaults keys and theme strings. +struct ColorConfiguration: Codable, Equatable, Sendable { + var mode: ColorMode + var solidHex: String + var gradientStartHex: String + var gradientEndHex: String + + static let `default` = ColorConfiguration( + mode: .positionGradient, + solidHex: "68B8FF", + gradientStartHex: "68B8FF", + gradientEndHex: "00E69A" + ) +} + +/// A complete, renderer-independent snapshot of the current visual effect. +struct EffectConfiguration: Codable, Equatable, Sendable { + var style: EffectStyle + var shapeProfile: SurfaceShapeProfile + var color: ColorConfiguration + var opacity: Double + /// Multiplies the physical lens path length without changing its glass + /// refractive index. A value of 1 preserves the original tuned appearance. + var refractionStrength: Double = 1.0 + var height: Double + var width: Double + var roundness: Double + var hardness: Double + var fadeDuration: Double + + static let `default` = EffectConfiguration( + style: .classicGlow, + shapeProfile: .currentWave, + color: .default, + opacity: 0.8013, + refractionStrength: 1.0, + height: 80.5536, + width: 1.0, + roundness: 0.7069, + hardness: 0.6046, + fadeDuration: 1.0004 + ) + + static func defaultConfiguration(for requestedStyle: EffectStyle) -> EffectConfiguration { + let style = requestedStyle.supportedStyle + var configuration = Self.default + configuration.style = style + switch style { + case .classicGlow: + break + case .systemGlass: + configuration.opacity = 0.80 + case .physicalRefraction: + configuration.opacity = 0.80 + configuration.refractionStrength = 1.0 + case .solidBlack: + // The renderer remains geometrically animated but fully opaque. + configuration.opacity = 1.0 + case .classicPlus, .liquidGlass: + break + } + return configuration + } +} + +/// User-facing preferences that are independent from saved themes and layouts. +struct AppPreferences: Equatable, Sendable { + var isEnabled: Bool + var launchAtLogin: Bool + var effect: EffectConfiguration + var chordAppearance: ChordAppearance + var powerSavingMode: PowerSavingMode + + static let `default` = AppPreferences( + isEnabled: true, + launchAtLogin: false, + effect: .default, + chordAppearance: .default, + powerSavingMode: .automatic + ) +} + +/// Structured feedback for UI surfaces. Recovery actions are identifiers rather +/// than closures so feedback remains equatable and can be routed by a coordinator. +struct UserFeedback: Equatable, Identifiable, Sendable { + enum Severity: String, Equatable, Sendable { + case information + case success + case warning + case error + } + + enum RecoveryAction: String, Equatable, Sendable { + case checkAgain + case retry + case undo + case openInputMonitoringSettings + } + + var id: UUID + var severity: Severity + var title: String + var detail: String? + var recoveryAction: RecoveryAction? + + init( + id: UUID = UUID(), + severity: Severity, + title: String, + detail: String? = nil, + recoveryAction: RecoveryAction? = nil + ) { + self.id = id + self.severity = severity + self.title = title + self.detail = detail + self.recoveryAction = recoveryAction + } +} diff --git a/KeyLight/Models/KeyLayoutStore.swift b/KeyLight/Models/KeyLayoutStore.swift new file mode 100644 index 0000000..98e01be --- /dev/null +++ b/KeyLight/Models/KeyLayoutStore.swift @@ -0,0 +1,534 @@ +import Combine +import CoreGraphics +import Foundation + +/// A complete, value-semantic snapshot of the user's keyboard calibration. +/// +/// The persisted representation intentionally remains split across the legacy +/// `KeyPositionOffsets` and `KeyWidthOverrides` UserDefaults keys. This value is +/// only the in-memory boundary that lets position and width edits participate in +/// the same transaction and undo history. +struct KeyLayout: Equatable { + var offsets: [UInt16: CGFloat] + var widthMultipliers: [UInt16: CGFloat] + + init( + offsets: [UInt16: CGFloat] = [:], + widthMultipliers: [UInt16: CGFloat] = [:] + ) { + self.offsets = offsets + self.widthMultipliers = widthMultipliers + } + + static let empty = KeyLayout() +} + +/// Owns the live keyboard calibration, its saved baseline, history, and legacy +/// persistence. It reads and writes the established position and width keys as +/// one atomic value without an internal notification bus. +@MainActor +final class KeyLayoutStore: ObservableObject { + static let offsetsKey = "KeyPositionOffsets" + static let widthMultipliersKey = "KeyWidthOverrides" + + static let maximumEntryCount = 512 + static let minimumOffset: CGFloat = -0.5 + static let maximumOffset: CGFloat = 0.5 + static let minimumWidthMultiplier: CGFloat = 0.1 + static let maximumWidthMultiplier: CGFloat = 5.0 + + @Published private(set) var layout: KeyLayout + @Published private(set) var baseline: KeyLayout + @Published private(set) var savedProfiles: [KeyMappingProfile] + @Published private(set) var selectedProfileID: UUID? + @Published private(set) var canUndo = false + @Published private(set) var canRedo = false + + var keyOffsets: [UInt16: CGFloat] { layout.offsets } + var widthMultipliers: [UInt16: CGFloat] { layout.widthMultipliers } + var isEdited: Bool { layout != baseline } + var selectedProfile: KeyMappingProfile? { + selectedProfileID.flatMap { id in savedProfiles.first(where: { $0.id == id }) } + } + var selectedProfileIsEdited: Bool { + guard let selectedProfile else { return false } + let savedLayout = Self.normalized(KeyLayout( + offsets: selectedProfile.keyOffsets, + widthMultipliers: selectedProfile.keyWidthOverrides + )) + return savedLayout != layout + } + + private let preferencesStore: PreferencesStore + private let settingsManager: SettingsManager? + private let debounceNanoseconds: UInt64 + private let maximumUndoLevels: Int + + private var undoStack: [KeyLayout] = [] + private var redoStack: [KeyLayout] = [] + private var gestureStartLayout: KeyLayout? + private var pendingCommitTask: Task? + + private static let allowedKeyCodes = Set(KeyboardLayoutInfo.allKeys.map(\.id)) + + init( + preferencesStore: PreferencesStore = .standard, + settingsManager: SettingsManager? = nil, + debounceInterval: TimeInterval = 0.1, + maximumUndoLevels: Int = 50 + ) { + self.preferencesStore = preferencesStore + self.settingsManager = settingsManager + self.debounceNanoseconds = Self.nanoseconds(for: debounceInterval) + self.maximumUndoLevels = max(1, maximumUndoLevels) + + let loaded = Self.loadLayout(from: preferencesStore) + self.layout = loaded + self.baseline = loaded + let profiles = settingsManager?.savedKeyMappingProfiles ?? [] + self.savedProfiles = profiles + self.selectedProfileID = settingsManager?.activeLayoutID + ?? settingsManager.flatMap { manager in + profiles.first(where: { $0.name == manager.currentKeyMappingProfileName })?.id + } + } + + /// Test/source-compatibility initializer. All reads and writes still cross + /// the PreferencesStore adapter; KeyLayoutStore never talks to defaults + /// directly. + convenience init( + defaults: UserDefaults, + debounceInterval: TimeInterval = 0.1, + maximumUndoLevels: Int = 50 + ) { + self.init( + preferencesStore: PreferencesStore(userDefaults: defaults), + settingsManager: nil, + debounceInterval: debounceInterval, + maximumUndoLevels: maximumUndoLevels + ) + } + + // MARK: - Read access + + /// Refreshes saved-profile identity after a persistence transaction. The + /// layout draft itself remains untouched so imports and renames cannot + /// silently discard unsaved calibration edits. + func reloadSavedProfiles(from manager: SettingsManager? = nil) { + guard let manager = manager ?? settingsManager else { return } + let profiles = manager.savedKeyMappingProfiles + savedProfiles = profiles + selectedProfileID = manager.activeLayoutID + ?? profiles.first(where: { $0.name == manager.currentKeyMappingProfileName })?.id + } + + /// Applies a saved layout by stable identity. Display routing uses this + /// same transaction as the Settings profile picker so geometry and legacy + /// selection persistence cannot drift apart. + @discardableResult + func selectSavedProfile(id: UUID) -> Bool { + guard let profile = savedProfiles.first(where: { $0.id == id }) else { + return false + } + apply( + KeyLayout( + offsets: profile.keyOffsets, + widthMultipliers: profile.keyWidthOverrides + ), + asBaseline: true + ) + settingsManager?.activeLayoutID = profile.id + selectedProfileID = profile.id + return true + } + + func effectiveOffset(for keyCode: UInt16) -> CGFloat { + layout.offsets[KeyboardLayoutInfo.canonicalKeyCode(for: keyCode)] ?? 0 + } + + func adjustedPosition(for keyCode: UInt16, originalPosition: CGFloat) -> CGFloat { + let adjusted = originalPosition + effectiveOffset(for: keyCode) + return min(max(adjusted, 0), 1) + } + + func effectiveWidthMultiplier(for keyCode: UInt16) -> CGFloat { + layout.widthMultipliers[KeyboardLayoutInfo.canonicalKeyCode(for: keyCode)] ?? 1 + } + + func effectiveWidth(for keyCode: UInt16, defaultWidth: CGFloat) -> CGFloat { + defaultWidth * effectiveWidthMultiplier(for: keyCode) + } + + func hasWidthMultiplierOverride(for keyCode: UInt16) -> Bool { + layout.widthMultipliers[KeyboardLayoutInfo.canonicalKeyCode(for: keyCode)] != nil + } + + // MARK: - Editing + + func setOffset(_ offset: CGFloat, for keyCode: UInt16) { + guard let canonicalKeyCode = Self.allowedCanonicalKeyCode(for: keyCode) else { return } + + var next = layout + let finiteOffset = offset.isFinite ? offset : 0 + next.offsets[canonicalKeyCode] = Self.clampOffset(finiteOffset) + mutate(to: next) + } + + func setWidthMultiplier(_ multiplier: CGFloat, for keyCode: UInt16) { + guard let canonicalKeyCode = Self.allowedCanonicalKeyCode(for: keyCode) else { return } + + var next = layout + let finiteMultiplier = multiplier.isFinite ? multiplier : 1 + next.widthMultipliers[canonicalKeyCode] = Self.clampWidthMultiplier(finiteMultiplier) + mutate(to: next) + } + + /// Begins a coalesced editor gesture. All position and width changes until + /// `endGestureTransaction()` become one undo step. + func beginGestureTransaction() { + guard gestureStartLayout == nil else { return } + gestureStartLayout = layout + } + + func endGestureTransaction() { + finishGestureTransactionIfNeeded() + } + + /// Replaces offsets and widths together and records one undo step. + /// Passing `asBaseline` is appropriate when selecting a saved profile. + func apply(_ newLayout: KeyLayout, asBaseline: Bool = false) { + finishGestureTransactionIfNeeded() + let normalized = Self.normalized(newLayout) + mutate(to: normalized) + if asBaseline { + baseline = normalized + } + } + + /// Removes both calibration dimensions for one key as one undo step. + func resetKey(_ keyCode: UInt16) { + guard let canonicalKeyCode = Self.allowedCanonicalKeyCode(for: keyCode) else { return } + finishGestureTransactionIfNeeded() + + var next = layout + next.offsets.removeValue(forKey: canonicalKeyCode) + next.widthMultipliers.removeValue(forKey: canonicalKeyCode) + mutate(to: next) + } + + /// Compatibility operation for callers that still edit position and width + /// independently. New calibration UI should prefer `resetKey(_:)`. + func resetOffset(for keyCode: UInt16) { + guard let canonicalKeyCode = Self.allowedCanonicalKeyCode(for: keyCode) else { return } + finishGestureTransactionIfNeeded() + + var next = layout + next.offsets.removeValue(forKey: canonicalKeyCode) + mutate(to: next) + } + + func resetWidthMultiplier(for keyCode: UInt16) { + guard let canonicalKeyCode = Self.allowedCanonicalKeyCode(for: keyCode) else { return } + finishGestureTransactionIfNeeded() + + var next = layout + next.widthMultipliers.removeValue(forKey: canonicalKeyCode) + mutate(to: next) + } + + /// Restores the unmodified keyboard as one atomic operation. + func resetAll() { + finishGestureTransactionIfNeeded() + mutate(to: .empty) + } + + func resetAllOffsets() { + finishGestureTransactionIfNeeded() + var next = layout + next.offsets.removeAll() + mutate(to: next) + } + + func resetAllWidthMultipliers() { + finishGestureTransactionIfNeeded() + var next = layout + next.widthMultipliers.removeAll() + mutate(to: next) + } + + func replaceAllOffsets(_ offsets: [UInt16: CGFloat]) { + finishGestureTransactionIfNeeded() + var next = layout + next.offsets = offsets + mutate(to: next) + } + + func replaceAllWidthMultipliers(_ multipliers: [UInt16: CGFloat]) { + finishGestureTransactionIfNeeded() + var next = layout + next.widthMultipliers = multipliers + mutate(to: next) + } + + /// Restores the last saved/profile baseline as one atomic operation. + func revert() { + finishGestureTransactionIfNeeded() + mutate(to: baseline) + } + + /// Marks the live value as the saved/profile baseline without changing its + /// persistence format or clearing useful undo history. + func markCurrentAsBaseline() { + baseline = layout + } + + // MARK: - History + + func undo() { + finishGestureTransactionIfNeeded() + guard let previous = undoStack.popLast() else { return } + + appendToRedo(layout) + replaceLiveLayout(with: previous) + updateHistoryAvailability() + } + + func redo() { + finishGestureTransactionIfNeeded() + guard let next = redoStack.popLast() else { return } + + appendToUndo(layout) + replaceLiveLayout(with: next) + updateHistoryAvailability() + } + + // MARK: - Persistence + + /// Immediately writes the current value. Call this during orderly termination. + func flush() { + pendingCommitTask?.cancel() + pendingCommitTask = nil + persistCurrentLayout() + } + + /// Cancels trailing persistence without discarding current in-memory edits. + func cancelPendingWork() { + pendingCommitTask?.cancel() + pendingCommitTask = nil + } + + /// Reloads legacy values written by an older build and treats them as the + /// new saved baseline. This is a migration bridge, not a second format. + func reloadFromPersistence() { + cancelPendingWork() + let loaded = Self.loadLayout(from: preferencesStore) + guard loaded != layout else { return } + + gestureStartLayout = nil + undoStack.removeAll() + redoStack.removeAll() + layout = loaded + baseline = loaded + updateHistoryAvailability() + } + + // MARK: - Compatibility helpers + + static func normalized(_ layout: KeyLayout) -> KeyLayout { + KeyLayout( + offsets: normalizedOffsets(layout.offsets), + widthMultipliers: normalizedWidthMultipliers(layout.widthMultipliers) + ) + } + + static func normalizedOffsets(_ offsets: [UInt16: CGFloat]) -> [UInt16: CGFloat] { + normalizedValues(offsets, clamp: clampOffset) + } + + static func normalizedWidthMultipliers(_ multipliers: [UInt16: CGFloat]) -> [UInt16: CGFloat] { + normalizedValues(multipliers, clamp: clampWidthMultiplier) + } + + static func normalizedImportedOffsets(from offsets: [String: CGFloat]) -> [String: CGFloat] { + let decoded = offsets.reduce(into: [UInt16: CGFloat]()) { result, pair in + guard let keyCode = UInt16(pair.key), pair.value.isFinite else { return } + result[keyCode] = pair.value + } + return normalizedOffsets(decoded).reduce(into: [String: CGFloat]()) { result, pair in + result[String(pair.key)] = pair.value + } + } + + func exportOffsets() -> [String: CGFloat] { + Self.stringKeyed(layout.offsets) + } + + func exportWidthMultipliers() -> [String: CGFloat] { + Self.stringKeyed(layout.widthMultipliers) + } + + // MARK: - Internal mutation + + private func mutate(to proposed: KeyLayout) { + let normalized = Self.normalized(proposed) + guard normalized != layout else { return } + + if gestureStartLayout == nil { + appendToUndo(layout) + redoStack.removeAll() + } + replaceLiveLayout(with: normalized) + updateHistoryAvailability() + } + + private func replaceLiveLayout(with replacement: KeyLayout) { + guard replacement != layout else { return } + + layout = replacement + scheduleCommit() + } + + private func finishGestureTransactionIfNeeded() { + guard let start = gestureStartLayout else { return } + gestureStartLayout = nil + + if start != layout { + appendToUndo(start) + redoStack.removeAll() + } + updateHistoryAvailability() + } + + private func appendToUndo(_ snapshot: KeyLayout) { + undoStack.append(snapshot) + trim(&undoStack) + } + + private func appendToRedo(_ snapshot: KeyLayout) { + redoStack.append(snapshot) + trim(&redoStack) + } + + private func trim(_ history: inout [KeyLayout]) { + if history.count > maximumUndoLevels { + history.removeFirst(history.count - maximumUndoLevels) + } + } + + private func updateHistoryAvailability() { + canUndo = !undoStack.isEmpty + canRedo = !redoStack.isEmpty + } + + // MARK: - Deferred compatibility commit + + private func scheduleCommit() { + pendingCommitTask?.cancel() + let delay = debounceNanoseconds + pendingCommitTask = Task { @MainActor [weak self] in + if delay > 0 { + do { + try await Task.sleep(nanoseconds: delay) + } catch { + return + } + } + guard !Task.isCancelled, let self else { return } + self.pendingCommitTask = nil + self.persistCurrentLayout() + } + } + + private func persistCurrentLayout() { + preferencesStore.set(Self.stringKeyed(layout.offsets), forKey: Self.offsetsKey) + preferencesStore.set(Self.stringKeyed(layout.widthMultipliers), forKey: Self.widthMultipliersKey) + } + + // MARK: - Normalization + + private static func loadLayout(from preferencesStore: PreferencesStore) -> KeyLayout { + KeyLayout( + offsets: normalizedOffsets(numericDictionary(in: preferencesStore, forKey: offsetsKey)), + widthMultipliers: normalizedWidthMultipliers( + numericDictionary(in: preferencesStore, forKey: widthMultipliersKey) + ) + ) + } + + private static func numericDictionary( + in preferencesStore: PreferencesStore, + forKey key: String + ) -> [UInt16: CGFloat] { + guard let stored = preferencesStore.dictionary(forKey: key) else { return [:] } + + var result: [UInt16: CGFloat] = [:] + result.reserveCapacity(min(stored.count, maximumEntryCount)) + for stringKey in stored.keys.sorted() { + guard let keyCode = UInt16(stringKey), + let rawValue = stored[stringKey] as? NSNumber else { + continue + } + result[keyCode] = CGFloat(truncating: rawValue) + } + return result + } + + private static func normalizedValues( + _ values: [UInt16: CGFloat], + clamp: (CGFloat) -> CGFloat + ) -> [UInt16: CGFloat] { + var canonicalValues: [UInt16: CGFloat] = [:] + var aliasFallbackValues: [UInt16: CGFloat] = [:] + + for keyCode in values.keys.sorted() { + guard let value = values[keyCode], value.isFinite, + let canonicalKeyCode = allowedCanonicalKeyCode(for: keyCode) else { + continue + } + + let normalizedValue = clamp(value) + if keyCode == canonicalKeyCode { + canonicalValues[canonicalKeyCode] = normalizedValue + } else if aliasFallbackValues[canonicalKeyCode] == nil { + aliasFallbackValues[canonicalKeyCode] = normalizedValue + } + } + + var merged = aliasFallbackValues + for (keyCode, value) in canonicalValues { + merged[keyCode] = value + } + + var result: [UInt16: CGFloat] = [:] + result.reserveCapacity(min(merged.count, maximumEntryCount)) + for keyCode in merged.keys.sorted().prefix(maximumEntryCount) { + result[keyCode] = merged[keyCode] + } + return result + } + + private static func allowedCanonicalKeyCode(for keyCode: UInt16) -> UInt16? { + let canonicalKeyCode = KeyboardLayoutInfo.canonicalKeyCode(for: keyCode) + return allowedKeyCodes.contains(canonicalKeyCode) ? canonicalKeyCode : nil + } + + private static func clampOffset(_ value: CGFloat) -> CGFloat { + min(max(value, minimumOffset), maximumOffset) + } + + private static func clampWidthMultiplier(_ value: CGFloat) -> CGFloat { + min(max(value, minimumWidthMultiplier), maximumWidthMultiplier) + } + + private static func stringKeyed(_ values: [UInt16: CGFloat]) -> [String: CGFloat] { + values.reduce(into: [String: CGFloat]()) { result, pair in + result[String(pair.key)] = pair.value + } + } + + private static func nanoseconds(for interval: TimeInterval) -> UInt64 { + guard interval.isFinite, interval > 0 else { return 0 } + let nanoseconds = interval * 1_000_000_000 + return UInt64(min(nanoseconds, Double(UInt64.max))) + } +} diff --git a/KeyLight/Models/KeyLightModel.swift b/KeyLight/Models/KeyLightModel.swift new file mode 100644 index 0000000..784516b --- /dev/null +++ b/KeyLight/Models/KeyLightModel.swift @@ -0,0 +1,845 @@ +import AppKit +import Observation +import SwiftUI + +/// The single user-facing state boundary shared by KeyLight's scene roots. +/// Platform lifecycle and renderer ownership remain outside this model. +@MainActor +@Observable +final class KeyLightModel { + typealias FeedbackAnnouncer = @MainActor (String) -> Void + + private let settings: SettingsManager + @ObservationIgnored private let feedbackAnnouncer: FeedbackAnnouncer + + @ObservationIgnored private var isLoading = true + @ObservationIgnored private var persistWorkItem: DispatchWorkItem? + @ObservationIgnored private var enabledChangeHandler: (@MainActor (Bool) -> Void)? + @ObservationIgnored private var configurationChangeHandler: (@MainActor () -> Void)? + @ObservationIgnored private var permissionRequestHandler: (@MainActor () -> Void)? + @ObservationIgnored private var permissionRetryHandler: (@MainActor () -> Void)? + @ObservationIgnored private var inputMonitoringSettingsHandler: (@MainActor () -> Void)? + @ObservationIgnored private var previewSetHandler: (@MainActor (GlowTarget, PreviewSource) -> Void)? + @ObservationIgnored private var previewClearHandler: (@MainActor (PreviewSource) -> Void)? + @ObservationIgnored private var chordPreviewSetHandler: (@MainActor ([GlowTarget]) -> Void)? + @ObservationIgnored private var chordPreviewClearHandler: (@MainActor () -> Void)? + @ObservationIgnored private var displaySelectionHandler: (@MainActor (OverlayDisplaySelection) -> Void)? + @ObservationIgnored private var mirroredDisplaysHandler: (@MainActor (Set) -> Void)? + @ObservationIgnored private var displayLayoutBindingHandler: (@MainActor (String, UUID?) -> Void)? + @ObservationIgnored private var shortcutChangeHandler: (@MainActor (GlobalShortcut) -> Void)? + @ObservationIgnored private var physicalKeySequence: UInt = 0 + + private let persistDebounceInterval: TimeInterval = 0.1 + + var isEnabled: Bool = true { + didSet { + if !isLoading { + settings.isEnabled = isEnabled + enabledChangeHandler?(isEnabled) + configurationChangeHandler?() + } + } + } + + var inputMonitoringState: InputMonitoringState = .checking + var inputMonitoringAppPath: String = Bundle.main.bundlePath + var inputMonitoringInstallationIssue: String? + private(set) var hasSeenPermissionExplanation: Bool = false + private(set) var permissionSetupPresentationRequested: Bool = false + private(set) var physicalKeyActivity: PhysicalKeyActivity? + private(set) var savedThemes: [Theme] = [] + private(set) var selectedThemeID: UUID? + var feedback: UserFeedback? { + didSet { + guard feedback != oldValue, let feedback else { return } + announce(feedback) + } + } + private(set) var globalHotKeyStatus: GlobalHotKeyStatus = .checking + private(set) var effectRuntimeStatus: EffectRuntimeStatus = .initial + private(set) var availableDisplays: [OverlayDisplayDescriptor] = [] + private(set) var activeDisplayPersistentID: String? + private(set) var activeDisplayPersistentIDs: [String] = [] + + var overlayDisplaySelection: OverlayDisplaySelection = .automatic { + didSet { + guard !isLoading, overlayDisplaySelection != oldValue else { return } + settings.overlayDisplaySelection = overlayDisplaySelection + displaySelectionHandler?(overlayDisplaySelection) + } + } + + var mirroredDisplayIDs: Set = [] { + didSet { + guard !isLoading, mirroredDisplayIDs != oldValue else { return } + settings.mirroredDisplayIDs = mirroredDisplayIDs + mirroredDisplaysHandler?(mirroredDisplayIDs) + } + } + + var globalShortcut: GlobalShortcut = .default { + didSet { + guard !isLoading, globalShortcut != oldValue else { return } + settings.globalShortcut = globalShortcut + shortcutChangeHandler?(globalShortcut) + } + } + + var glowColor: Color = Color(hex: "68B8FF") ?? Color(red: 0.41, green: 0.72, blue: 1.0) { + didSet { + glowNSColor = NSColor(glowColor) + if !isLoading { + debouncedPersist() + configurationChangeHandler?() + } + } + } + + var glowOpacity: Double = 0.8013 { + didSet { + if !isLoading { + debouncedPersist() + configurationChangeHandler?() + } + } + } + + var physicalRefractionStrength: Double = 1.0 { + didSet { + if !isLoading { + debouncedPersist() + configurationChangeHandler?() + } + } + } + + var glowSize: Double = 80.5536 { + didSet { + if !isLoading { + debouncedPersist() + configurationChangeHandler?() + } + } + } + + var glowWidth: Double = 1.0 { + didSet { + if !isLoading { + debouncedPersist() + configurationChangeHandler?() + } + } + } + + var glowRoundness: Double = 0.7069 { + didSet { + if !isLoading { + debouncedPersist() + configurationChangeHandler?() + } + } + } + + var glowFullness: Double = 0.6046 { + didSet { + if !isLoading { + debouncedPersist() + configurationChangeHandler?() + } + } + } + + var fadeDuration: Double = 1.0004 { + didSet { + if !isLoading { + debouncedPersist() + configurationChangeHandler?() + } + } + } + + var launchAtLogin: Bool = false { + didSet { + if !isLoading { + let requestedValue = launchAtLogin + let result = settings.setLaunchAtLogin(requestedValue) + let actualValue = result.status.isEnabled + if !result.isApplied || actualValue != requestedValue { + isLoading = true + launchAtLogin = actualValue + isLoading = false + feedback = launchAtLoginFeedback(for: result) + } + } + } + } + + var colorMode: ColorMode = .positionGradient { + didSet { + if !isLoading { + debouncedPersist() + configurationChangeHandler?() + } + } + } + + var effectStyle: EffectStyle = .classicGlow { + didSet { + guard !isLoading else { return } + let previousStyle = oldValue.supportedStyle + let requestedStyle = effectStyle.supportedStyle + if effectStyle != requestedStyle { + isLoading = true + effectStyle = requestedStyle + isLoading = false + } + guard previousStyle != requestedStyle else { return } + switchEffectProfile( + from: previousStyle, + to: requestedStyle + ) + } + } + + var chordAppearance: ChordAppearance = .default { + didSet { + guard !isLoading else { return } + let normalized = chordAppearance.normalized + if chordAppearance != normalized { + isLoading = true + chordAppearance = normalized + isLoading = false + } + settings.chordAppearance = normalized + configurationChangeHandler?() + } + } + + var powerSavingMode: PowerSavingMode = .automatic { + didSet { + guard !isLoading, powerSavingMode != oldValue else { return } + settings.powerSavingMode = powerSavingMode + configurationChangeHandler?() + } + } + + private(set) var powerEnvironmentState: PowerEnvironmentState = .normal + + var automaticPowerSavingIsActive: Bool { + powerSavingMode == .automatic + && powerEnvironmentState.requiresFallback + } + + var surfaceShapeProfile: SurfaceShapeProfile = .currentWave { + didSet { + if !isLoading { + debouncedPersist() + configurationChangeHandler?() + } + } + } + + var gradientStartColor: Color = Color(hex: "68B8FF") ?? .blue { + didSet { + gradientStartNSColor = NSColor(gradientStartColor) + if !isLoading { + debouncedPersist() + configurationChangeHandler?() + } + } + } + + var gradientEndColor: Color = Color(hex: "00E69A") ?? .green { + didSet { + gradientEndNSColor = NSColor(gradientEndColor) + if !isLoading { + debouncedPersist() + configurationChangeHandler?() + } + } + } + + @ObservationIgnored + private(set) var glowNSColor = NSColor(red: 0.41, green: 0.72, blue: 1.0, alpha: 1.0) + + @ObservationIgnored + private(set) var gradientStartNSColor = NSColor(red: 0.41, green: 0.72, blue: 1.0, alpha: 1.0) + + @ObservationIgnored + private(set) var gradientEndNSColor = NSColor(red: 0.0, green: 0.90, blue: 0.60, alpha: 1.0) + + init( + settings: SettingsManager, + feedbackAnnouncer: @escaping FeedbackAnnouncer = KeyLightModel.announceFeedback + ) { + self.settings = settings + self.feedbackAnnouncer = feedbackAnnouncer + loadSettings() + reloadSavedThemes() + isLoading = false + } + + var selectedTheme: Theme? { + selectedThemeID.flatMap { id in savedThemes.first(where: { $0.id == id }) } + } + + var currentEffectConfiguration: EffectConfiguration { + effectConfiguration(style: effectStyle) + } + + private func effectConfiguration(style: EffectStyle) -> EffectConfiguration { + EffectConfiguration( + style: style.supportedStyle, + shapeProfile: surfaceShapeProfile, + color: ColorConfiguration( + mode: colorMode, + solidHex: Self.normalizedHex(glowColor.toHex(), fallback: "68B8FF"), + gradientStartHex: Self.normalizedHex(gradientStartColor.toHex(), fallback: "68B8FF"), + gradientEndHex: Self.normalizedHex(gradientEndColor.toHex(), fallback: "00E69A") + ), + opacity: glowOpacity, + refractionStrength: physicalRefractionStrength, + height: glowSize, + width: glowWidth, + roundness: glowRoundness, + hardness: glowFullness, + fadeDuration: fadeDuration + ) + } + + var selectedThemeIsEdited: Bool { + guard let selectedTheme else { return false } + return Self.effectConfiguration(for: selectedTheme) != currentEffectConfiguration + } + + /// Connects the user-facing model to its runtime owner. Renderer updates + /// are deliberately immediate; persistence keeps the single trailing + /// debounce below. Reconnecting replaces the previous owner atomically. + func connectRuntime( + onEnabledChange: @escaping @MainActor (Bool) -> Void, + onConfigurationChange: @escaping @MainActor () -> Void, + onPermissionRequest: @escaping @MainActor () -> Void, + onPermissionRetry: @escaping @MainActor () -> Void, + onOpenInputMonitoringSettings: @escaping @MainActor () -> Void = {}, + onPreviewSet: @escaping @MainActor (GlowTarget, PreviewSource) -> Void = { _, _ in }, + onPreviewClear: @escaping @MainActor (PreviewSource) -> Void = { _ in }, + onChordPreviewSet: @escaping @MainActor ([GlowTarget]) -> Void = { _ in }, + onChordPreviewClear: @escaping @MainActor () -> Void = {}, + onDisplaySelectionChange: @escaping @MainActor (OverlayDisplaySelection) -> Void = { _ in }, + onMirroredDisplaysChange: @escaping @MainActor (Set) -> Void = { _ in }, + onDisplayLayoutBindingChange: @escaping @MainActor (String, UUID?) -> Void = { _, _ in }, + onShortcutChange: @escaping @MainActor (GlobalShortcut) -> Void = { _ in } + ) { + enabledChangeHandler = onEnabledChange + configurationChangeHandler = onConfigurationChange + permissionRequestHandler = onPermissionRequest + permissionRetryHandler = onPermissionRetry + inputMonitoringSettingsHandler = onOpenInputMonitoringSettings + previewSetHandler = onPreviewSet + previewClearHandler = onPreviewClear + chordPreviewSetHandler = onChordPreviewSet + chordPreviewClearHandler = onChordPreviewClear + displaySelectionHandler = onDisplaySelectionChange + mirroredDisplaysHandler = onMirroredDisplaysChange + displayLayoutBindingHandler = onDisplayLayoutBindingChange + shortcutChangeHandler = onShortcutChange + } + + func disconnectRuntime() { + enabledChangeHandler = nil + configurationChangeHandler = nil + permissionRequestHandler = nil + permissionRetryHandler = nil + inputMonitoringSettingsHandler = nil + previewSetHandler = nil + previewClearHandler = nil + chordPreviewSetHandler = nil + chordPreviewClearHandler = nil + displaySelectionHandler = nil + mirroredDisplaysHandler = nil + displayLayoutBindingHandler = nil + shortcutChangeHandler = nil + } + + func requestInputMonitoringPermission() { + permissionRequestHandler?() + guard inputMonitoringState == .permissionRequired else { return } + feedback = UserFeedback( + severity: .warning, + title: String(localized: "Finish Input Monitoring in System Settings"), + detail: String(localized: "macOS did not grant the new app identity. Remove any stale KeyLight row, add this installed app again, turn it on, then return and choose Retry Monitor."), + recoveryAction: .openInputMonitoringSettings + ) + // This follows an explicit Allow action and avoids the previous silent + // no-op when TCC retained a stale entry for an older preview build. + inputMonitoringSettingsHandler?() + } + + func retryInputMonitoring() { + permissionRetryHandler?() + guard inputMonitoringState == .permissionRequired || + inputMonitoringState == .monitorUnavailable else { + return + } + feedback = UserFeedback( + severity: .warning, + title: String(localized: "Input Monitoring Still Needs Attention"), + detail: String(localized: "If KeyLight is already listed, remove the stale row, add the installed app again, turn it on, and retry."), + recoveryAction: .openInputMonitoringSettings + ) + } + + func refreshLaunchAtLoginStatus() { + let authoritativeValue = settings.launchAtLogin + guard authoritativeValue != launchAtLogin else { return } + isLoading = true + launchAtLogin = authoritativeValue + isLoading = false + } + + func openInputMonitoringSettings() { + inputMonitoringSettingsHandler?() + } + + func selectEffect(_ style: EffectStyle) { + effectStyle = style.supportedStyle + } + + /// Re-resolves permission-dependent renderers without manufacturing a + /// settings mutation or rewriting the selected effect profile. + func refreshEffectRenderer() { + configurationChangeHandler?() + } + + func setPreview(_ target: GlowTarget, source: PreviewSource) { + guard target.id == .preview(source) else { return } + previewSetHandler?(target, source) + } + + func clearPreview(_ source: PreviewSource) { + previewClearHandler?(source) + } + + func setChordPreview(_ targets: [GlowTarget]) { + guard !targets.isEmpty, + targets.count <= PreviewSource.chordTestSources.count, + targets.allSatisfy({ target in + guard case .preview(let source) = target.id else { return false } + return source.isChordTest + }) else { + return + } + chordPreviewSetHandler?(targets) + } + + func clearChordPreview() { + chordPreviewClearHandler?() + } + + func updateDisplayState( + availableDisplays: [OverlayDisplayDescriptor], + activeDisplayPersistentID: String?, + activeDisplayPersistentIDs: [String] + ) { + self.availableDisplays = availableDisplays + self.activeDisplayPersistentID = activeDisplayPersistentID + self.activeDisplayPersistentIDs = activeDisplayPersistentIDs + } + + func boundLayoutProfileID(forDisplay persistentDisplayID: String) -> UUID? { + settings.displayLayoutProfileBindings[persistentDisplayID] + } + + func setLayoutProfileBinding(_ profileID: UUID?, forDisplay persistentDisplayID: String) { + settings.setLayoutProfileBinding(profileID, forDisplay: persistentDisplayID) + displayLayoutBindingHandler?(persistentDisplayID, profileID) + } + + func announce(_ feedback: UserFeedback) { + let message = [feedback.title, feedback.detail] + .compactMap { $0 } + .joined(separator: ". ") + feedbackAnnouncer(message) + } + + func receivePhysicalKeyboardEvent(_ event: KeyboardEvent) { + guard let keyCode = event.canonicalKeyCode else { + if event.action == .streamReset { + physicalKeyActivity = nil + } + return + } + let isDown: Bool + switch event.action { + case .down: + isDown = true + case .up: + isDown = false + case .streamReset: + physicalKeyActivity = nil + return + } + physicalKeySequence &+= 1 + physicalKeyActivity = PhysicalKeyActivity( + sequence: physicalKeySequence, + keyCode: keyCode, + isDown: isDown + ) + } + + func flushPendingPersist() { + persistWorkItem?.cancel() + persistWorkItem = nil + persistAllSettings() + } + + func loadSettings() { + isLoading = true + let preferences = settings.appPreferences + let effect = preferences.effect + isEnabled = preferences.isEnabled + hasSeenPermissionExplanation = settings.hasSeenPermissionExplanation + glowColor = Color(hex: effect.color.solidHex) ?? Color(hex: "68B8FF") ?? Color(red: 0.41, green: 0.72, blue: 1.0) + glowOpacity = effect.opacity + physicalRefractionStrength = effect.refractionStrength + glowSize = effect.height + glowWidth = effect.width + glowRoundness = effect.roundness + glowFullness = effect.hardness + fadeDuration = effect.fadeDuration + launchAtLogin = preferences.launchAtLogin + overlayDisplaySelection = settings.overlayDisplaySelection + mirroredDisplayIDs = settings.mirroredDisplayIDs + globalShortcut = settings.globalShortcut + colorMode = effect.color.mode + effectStyle = effect.style + chordAppearance = preferences.chordAppearance + powerSavingMode = preferences.powerSavingMode + surfaceShapeProfile = effect.shapeProfile + gradientStartColor = Color(hex: effect.color.gradientStartHex) ?? Color(hex: "68B8FF") ?? .blue + gradientEndColor = Color(hex: effect.color.gradientEndHex) ?? Color(hex: "00E69A") ?? .green + isLoading = false + } + + /// Reloads only after SettingsManager has completed an atomic snapshot + /// transaction. All observable values are replaced while persistence is + /// muted, then existing runtime boundaries receive the complete state. + func reloadManagedConfiguration() { + persistWorkItem?.cancel() + persistWorkItem = nil + loadSettings() + reloadSavedThemes() + + configurationChangeHandler?() + displaySelectionHandler?(overlayDisplaySelection) + mirroredDisplaysHandler?(mirroredDisplayIDs) + shortcutChangeHandler?(globalShortcut) + } + + func reloadSavedThemes() { + savedThemes = settings.savedThemes + selectedThemeID = settings.activeThemeID + ?? savedThemes.first(where: { $0.name == settings.currentThemeName })?.id + } + + func markPermissionExplanationSeen() { + guard !hasSeenPermissionExplanation else { return } + hasSeenPermissionExplanation = true + settings.hasSeenPermissionExplanation = true + } + + func requestPermissionSetupIfNeeded() { + guard settings.shouldPresentOnboarding else { return } + permissionSetupPresentationRequested = true + } + + func deferOnboarding() { + settings.deferOnboarding() + permissionSetupPresentationRequested = false + } + + func completeOnboarding() { + settings.completeOnboarding() + permissionSetupPresentationRequested = false + } + + func updateGlobalHotKeyStatus(_ status: GlobalHotKeyStatus) { + globalHotKeyStatus = status + guard status == .unavailable else { return } + feedback = UserFeedback( + severity: .warning, + title: String(localized: "Keyboard Shortcut Unavailable"), + detail: String(localized: "\(globalShortcut.displayName) could not be registered. Record a different shortcut or enable KeyLight from the menu.") + ) + } + + func updateEffectRuntimeStatus(_ status: EffectRuntimeStatus) { + effectRuntimeStatus = status + } + + func updatePowerEnvironmentState(_ state: PowerEnvironmentState) { + guard powerEnvironmentState != state else { return } + powerEnvironmentState = state + configurationChangeHandler?() + } + + func updateInputMonitoring( + state: InputMonitoringState, + appPath: String, + installationIssue: String? + ) { + let previousState = inputMonitoringState + inputMonitoringState = state + inputMonitoringAppPath = appPath + inputMonitoringInstallationIssue = installationIssue + guard previousState != state else { return } + + switch state { + case .checking, .starting: + break + case .permissionRequired: + feedback = UserFeedback( + severity: .warning, + title: String(localized: "Input Monitoring Required"), + detail: installationIssue ?? String(localized: "Allow Input Monitoring so KeyLight can detect key presses."), + recoveryAction: installationIssue == nil ? .openInputMonitoringSettings : nil + ) + case .authorized: + feedback = UserFeedback( + severity: .information, + title: String(localized: "Input Monitoring Allowed"), + detail: String(localized: "Enable KeyLight to start key detection.") + ) + case .active: + feedback = UserFeedback( + severity: .success, + title: String(localized: "Input Monitoring Active"), + detail: String(localized: "KeyLight is ready to detect key presses.") + ) + case .monitorUnavailable: + feedback = UserFeedback( + severity: .error, + title: String(localized: "Input Monitoring Unavailable"), + detail: String(localized: "Permission is allowed, but KeyLight could not start key detection."), + recoveryAction: .retry + ) + } + } + + @discardableResult + func consumePermissionSetupPresentationRequest() -> Bool { + guard permissionSetupPresentationRequested else { return false } + permissionSetupPresentationRequested = false + return true + } + + func applyTheme(_ theme: Theme) { + persistWorkItem?.cancel() + persistWorkItem = nil + settings.setEffectConfiguration( + effectConfiguration(style: effectStyle), + for: effectStyle + ) + + isLoading = true + glowColor = Color(hex: theme.colorHex) ?? glowColor + glowOpacity = theme.opacity + physicalRefractionStrength = theme.refractionStrength + glowSize = theme.size + glowWidth = theme.width + glowRoundness = theme.glowRoundness + glowFullness = theme.glowFullness + fadeDuration = theme.fadeDuration + colorMode = theme.colorMode + effectStyle = theme.effectStyle.supportedStyle + surfaceShapeProfile = theme.shapeProfile + gradientStartColor = Color(hex: theme.gradientStartHex ?? "68B8FF") ?? gradientStartColor + gradientEndColor = Color(hex: theme.gradientEndHex ?? "00E69A") ?? gradientEndColor + isLoading = false + + persistWorkItem?.cancel() + persistAllSettings() + settings.activeThemeID = theme.id + reloadSavedThemes() + configurationChangeHandler?() + } + + func currentTheme() -> Theme { + Theme( + name: settings.currentThemeName, + colorHex: glowColor.toHex() ?? "68B8FF", + opacity: glowOpacity, + refractionStrength: physicalRefractionStrength, + size: glowSize, + width: glowWidth, + glowRoundness: glowRoundness, + glowFullness: glowFullness, + fadeDuration: fadeDuration, + colorMode: colorMode, + effectStyle: effectStyle, + shapeProfile: surfaceShapeProfile, + gradientStartHex: gradientStartColor.toHex() ?? "68B8FF", + gradientEndHex: gradientEndColor.toHex() ?? "00E69A" + ) + } + + private func debouncedPersist() { + persistWorkItem?.cancel() + let workItem = DispatchWorkItem { [weak self] in + self?.persistAllSettings() + } + persistWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + persistDebounceInterval, execute: workItem) + } + + private func persistAllSettings() { + settings.effectConfiguration = currentEffectConfiguration + } + + private func switchEffectProfile( + from previousStyle: EffectStyle, + to requestedStyle: EffectStyle + ) { + persistWorkItem?.cancel() + persistWorkItem = nil + + settings.setEffectConfiguration( + effectConfiguration(style: previousStyle), + for: previousStyle + ) + + let destination = settings.effectConfiguration( + for: requestedStyle + ) + isLoading = true + applyEffectConfigurationValues(destination) + effectStyle = destination.style.supportedStyle + isLoading = false + + settings.effectConfiguration = destination + configurationChangeHandler?() + } + + private func applyEffectConfigurationValues( + _ configuration: EffectConfiguration + ) { + glowColor = Color(hex: configuration.color.solidHex) ?? glowColor + glowOpacity = configuration.opacity + physicalRefractionStrength = configuration.refractionStrength + glowSize = configuration.height + glowWidth = configuration.width + glowRoundness = configuration.roundness + glowFullness = configuration.hardness + fadeDuration = configuration.fadeDuration + colorMode = configuration.color.mode + surfaceShapeProfile = configuration.shapeProfile + gradientStartColor = Color( + hex: configuration.color.gradientStartHex + ) ?? gradientStartColor + gradientEndColor = Color( + hex: configuration.color.gradientEndHex + ) ?? gradientEndColor + } + + private func launchAtLoginFeedback(for result: LaunchAtLoginChangeResult) -> UserFeedback { + switch result.outcome { + case .requiresApproval: + return UserFeedback( + severity: .warning, + title: String(localized: "Launch at Login Needs Approval"), + detail: String(localized: "Allow KeyLight in System Settings › General › Login Items, then try again.") + ) + case .rejected: + return UserFeedback( + severity: .warning, + title: String(localized: "Launch at Login Wasn’t Changed"), + detail: String(localized: "macOS kept the previous launch-at-login setting.") + ) + case .failed(let failure): + return UserFeedback( + severity: .error, + title: String(localized: "Launch at Login Failed"), + detail: failure == .registrationFailed + ? String(localized: "KeyLight could not enable launch at login. The previous setting was restored.") + : String(localized: "KeyLight could not disable launch at login. The previous setting was restored.") + ) + case .applied: + return UserFeedback( + severity: .warning, + title: String(localized: "Launch at Login Wasn’t Changed"), + detail: String(localized: "macOS reported a different launch-at-login state, so KeyLight restored the current system value.") + ) + } + } + + private static func effectConfiguration(for theme: Theme) -> EffectConfiguration { + EffectConfiguration( + style: theme.effectStyle, + shapeProfile: theme.shapeProfile, + color: ColorConfiguration( + mode: theme.colorMode, + solidHex: normalizedHex(theme.colorHex, fallback: "68B8FF"), + gradientStartHex: normalizedHex(theme.gradientStartHex, fallback: "68B8FF"), + gradientEndHex: normalizedHex(theme.gradientEndHex, fallback: "00E69A") + ), + opacity: theme.opacity, + refractionStrength: theme.refractionStrength, + height: theme.size, + width: theme.width, + roundness: theme.glowRoundness, + hardness: theme.glowFullness, + fadeDuration: theme.fadeDuration + ) + } + + private static func normalizedHex(_ value: String?, fallback: String) -> String { + (value ?? fallback).uppercased() + } + + private static func announceFeedback(_ message: String) { + guard !message.isEmpty else { return } + NSAccessibility.post( + element: NSApp as Any, + notification: .announcementRequested, + userInfo: [ + .announcement: message, + .priority: NSAccessibilityPriorityLevel.medium.rawValue + ] + ) + } +} + +extension Color { + init?(hex: String) { + var hexSanitized = hex.trimmingCharacters(in: .whitespacesAndNewlines) + hexSanitized = hexSanitized.replacingOccurrences(of: "#", with: "") + + guard hexSanitized.count == 6 else { return nil } + + var rgb: UInt64 = 0 + guard Scanner(string: hexSanitized).scanHexInt64(&rgb) else { return nil } + + self.init( + red: Double((rgb & 0xFF0000) >> 16) / 255.0, + green: Double((rgb & 0x00FF00) >> 8) / 255.0, + blue: Double(rgb & 0x0000FF) / 255.0 + ) + } + + func toHex() -> String? { + guard let components = NSColor(self).usingColorSpace(.sRGB)?.cgColor.components else { + return nil + } + + let r = components.count > 0 ? components[0] : 0 + let g = components.count > 1 ? components[1] : 0 + let b = components.count > 2 ? components[2] : 0 + + func byte(_ component: CGFloat) -> Int { + guard component.isFinite else { return 0 } + return Int((min(max(component, 0), 1) * 255).rounded()) + } + + return String(format: "%02X%02X%02X", byte(r), byte(g), byte(b)) + } +} diff --git a/KeyLight/Models/KeyMapping.swift b/KeyLight/Models/KeyMapping.swift index d55d371..3adbaa6 100644 --- a/KeyLight/Models/KeyMapping.swift +++ b/KeyLight/Models/KeyMapping.swift @@ -4,7 +4,6 @@ import CoreGraphics /// Maps macOS key codes to horizontal screen positions and widths /// Based on MacBook Air keyboard layout - keys projected to bottom screen edge /// Positions are normalized 0.0 (left edge) to 1.0 (right edge) -@MainActor struct KeyMapping: Sendable { /// Key properties including position and width diff --git a/KeyLight/Models/KeyWidthManager.swift b/KeyLight/Models/KeyWidthManager.swift deleted file mode 100644 index 6c5419d..0000000 --- a/KeyLight/Models/KeyWidthManager.swift +++ /dev/null @@ -1,217 +0,0 @@ -import Foundation -import CoreGraphics -import Combine - -/// Manages custom per-key width multipliers that persist across app launches -@MainActor -final class KeyWidthManager: ObservableObject { - static let shared = KeyWidthManager() - - /// Width multiplier for each key (keyCode -> multiplier, 1.0 = default) - @Published private(set) var keyWidthOverrides: [UInt16: CGFloat] = [:] - - private let userDefaultsKey = "KeyWidthOverrides" - - // Undo/Redo support - private var undoStack: [[UInt16: CGFloat]] = [] - private var redoStack: [[UInt16: CGFloat]] = [] - private let maxUndoLevels = 50 - - // Debouncing for saves and notifications - private var saveWorkItem: DispatchWorkItem? - private var notificationWorkItem: DispatchWorkItem? - private let debounceInterval: TimeInterval = 0.1 - - private static let maxOverridesCount = 512 - private static let allowedKeyCodes: Set = Set(KeyboardLayoutInfo.allKeys.map(\.id)) - private static let minWidthMultiplier: CGFloat = 0.1 - private static let maxWidthMultiplier: CGFloat = 5.0 - - var canUndo: Bool { !undoStack.isEmpty } - var canRedo: Bool { !redoStack.isEmpty } - - private init() { - loadOverrides() - } - - /// Get the effective width for a key (default width * override multiplier) - func effectiveWidth(for keyCode: UInt16, defaultWidth: CGFloat) -> CGFloat { - let multiplier = effectiveWidthMultiplier(for: keyCode) - return defaultWidth * multiplier - } - - func effectiveWidthMultiplier(for keyCode: UInt16) -> CGFloat { - let canonicalKeyCode = KeyboardLayoutInfo.canonicalKeyCode(for: keyCode) - if let direct = keyWidthOverrides[canonicalKeyCode] { - return direct - } - if canonicalKeyCode != keyCode, let legacyDirect = keyWidthOverrides[keyCode] { - return legacyDirect - } - return 1.0 - } - - func hasDirectOverride(for keyCode: UInt16) -> Bool { - keyWidthOverrides[keyCode] != nil - } - - /// Set a width multiplier for a key - func setWidthMultiplier(_ multiplier: CGFloat, for keyCode: UInt16) { - pushUndo() - let sanitizedMultiplier: CGFloat - if multiplier.isFinite { - sanitizedMultiplier = min(max(multiplier, Self.minWidthMultiplier), Self.maxWidthMultiplier) - } else { - sanitizedMultiplier = 1.0 - } - let canonicalKeyCode = KeyboardLayoutInfo.canonicalKeyCode(for: keyCode) - keyWidthOverrides[canonicalKeyCode] = sanitizedMultiplier - if canonicalKeyCode != keyCode { - keyWidthOverrides.removeValue(forKey: keyCode) - } - debouncedSave() - debouncedNotify() - } - - /// Reset a single key to its default width - func resetKey(_ keyCode: UInt16) { - pushUndo() - let canonicalKeyCode = KeyboardLayoutInfo.canonicalKeyCode(for: keyCode) - keyWidthOverrides.removeValue(forKey: canonicalKeyCode) - if canonicalKeyCode != keyCode { - keyWidthOverrides.removeValue(forKey: keyCode) - } - saveImmediately() - NotificationCenter.default.post(name: .keyWidthsChanged, object: nil) - } - - /// Reset all keys to their default widths - func resetAllKeys() { - pushUndo() - keyWidthOverrides.removeAll() - saveImmediately() - NotificationCenter.default.post(name: .keyWidthsChanged, object: nil) - } - - /// Replace all overrides at once (for profile loading) - func replaceAllOverrides(_ overrides: [UInt16: CGFloat]) { - pushUndo() - keyWidthOverrides = normalizedOverrides(from: overrides) - saveImmediately() - NotificationCenter.default.post(name: .keyWidthsChanged, object: nil) - } - - /// Export overrides as a string-keyed dictionary. - func exportOverrides() -> [String: CGFloat] { - keyWidthOverrides.reduce(into: [String: CGFloat]()) { result, pair in - result[String(pair.key)] = pair.value - } - } - - // MARK: - Undo/Redo - - private func pushUndo() { - undoStack.append(keyWidthOverrides) - if undoStack.count > maxUndoLevels { - undoStack.removeFirst() - } - redoStack.removeAll() - } - - func undo() { - guard let previousState = undoStack.popLast() else { return } - redoStack.append(keyWidthOverrides) - keyWidthOverrides = previousState - saveImmediately() - NotificationCenter.default.post(name: .keyWidthsChanged, object: nil) - } - - func redo() { - guard let nextState = redoStack.popLast() else { return } - undoStack.append(keyWidthOverrides) - keyWidthOverrides = nextState - saveImmediately() - NotificationCenter.default.post(name: .keyWidthsChanged, object: nil) - } - - // MARK: - Debounced Operations - - private func debouncedSave() { - saveWorkItem?.cancel() - let workItem = DispatchWorkItem { [weak self] in - self?.saveImmediately() - } - saveWorkItem = workItem - DispatchQueue.main.asyncAfter(deadline: .now() + debounceInterval, execute: workItem) - } - - private func debouncedNotify() { - notificationWorkItem?.cancel() - let workItem = DispatchWorkItem { - NotificationCenter.default.post(name: .keyWidthsChanged, object: nil) - } - notificationWorkItem = workItem - DispatchQueue.main.asyncAfter(deadline: .now() + debounceInterval, execute: workItem) - } - - // MARK: - Persistence - - private func saveImmediately() { - saveWorkItem?.cancel() - let stringKeyedDict = keyWidthOverrides.reduce(into: [String: CGFloat]()) { result, pair in - result[String(pair.key)] = pair.value - } - UserDefaults.standard.set(stringKeyedDict, forKey: userDefaultsKey) - } - - private func normalizedOverrides(from overrides: [UInt16: CGFloat]) -> [UInt16: CGFloat] { - var canonicalValues: [UInt16: CGFloat] = [:] - var aliasFallbackValues: [UInt16: CGFloat] = [:] - - for keyCode in overrides.keys.sorted() { - guard let value = overrides[keyCode], value.isFinite else { continue } - let canonicalKeyCode = KeyboardLayoutInfo.canonicalKeyCode(for: keyCode) - guard Self.allowedKeyCodes.contains(canonicalKeyCode) else { continue } - - let clamped = min(max(value, Self.minWidthMultiplier), Self.maxWidthMultiplier) - if keyCode == canonicalKeyCode { - canonicalValues[canonicalKeyCode] = clamped - } else if aliasFallbackValues[canonicalKeyCode] == nil { - aliasFallbackValues[canonicalKeyCode] = clamped - } - } - - var merged: [UInt16: CGFloat] = aliasFallbackValues - for (keyCode, value) in canonicalValues { - merged[keyCode] = value - } - - var normalized: [UInt16: CGFloat] = [:] - normalized.reserveCapacity(min(merged.count, Self.maxOverridesCount)) - for keyCode in merged.keys.sorted() { - if normalized.count >= Self.maxOverridesCount { break } - if let value = merged[keyCode] { - normalized[keyCode] = value - } - } - - return normalized - } - - private func loadOverrides() { - guard let dict = UserDefaults.standard.dictionary(forKey: userDefaultsKey) as? [String: CGFloat] else { - return - } - - var decoded: [UInt16: CGFloat] = [:] - decoded.reserveCapacity(min(dict.count, Self.maxOverridesCount)) - for (key, value) in dict { - if decoded.count >= Self.maxOverridesCount { break } - if let keyCode = UInt16(key) { - decoded[keyCode] = value - } - } - - keyWidthOverrides = normalizedOverrides(from: decoded) - } -} diff --git a/KeyLight/Models/KeyPositionManager.swift b/KeyLight/Models/KeyboardLayoutInfo.swift similarity index 51% rename from KeyLight/Models/KeyPositionManager.swift rename to KeyLight/Models/KeyboardLayoutInfo.swift index 003b09f..2fc3f99 100644 --- a/KeyLight/Models/KeyPositionManager.swift +++ b/KeyLight/Models/KeyboardLayoutInfo.swift @@ -1,245 +1,5 @@ -import Foundation import CoreGraphics -import Combine - -/// Manages custom per-key position offsets that persist across app launches -@MainActor -final class KeyPositionManager: ObservableObject { - static let shared = KeyPositionManager() - - /// UserDefaults key for storing position offsets (shared with SettingsManager for export/import) - static let offsetsKey = "KeyPositionOffsets" - - /// Position offset for each key (keyCode -> horizontal offset as fraction of screen width) - @Published private(set) var keyOffsets: [UInt16: CGFloat] = [:] - - private let userDefaultsKey = offsetsKey - - private var undoStack: [[UInt16: CGFloat]] = [] - private var redoStack: [[UInt16: CGFloat]] = [] - private let maxUndoLevels = 50 - - private var saveWorkItem: DispatchWorkItem? - private var notificationWorkItem: DispatchWorkItem? - private let debounceInterval: TimeInterval = 0.1 - - private static let maxOffsetsCount = 512 - private static let allowedKeyCodes: Set = Set(KeyboardLayoutInfo.allKeys.map(\.id)) - - var canUndo: Bool { !undoStack.isEmpty } - var canRedo: Bool { !redoStack.isEmpty } - - private init() { - loadOffsets() - } - - func adjustedPosition(for keyCode: UInt16, originalPosition: CGFloat) -> CGFloat { - guard !keyOffsets.isEmpty else { return originalPosition } - let offset = effectiveOffset(for: keyCode) - if offset == 0 { - return originalPosition - } - return max(0.0, min(1.0, originalPosition + offset)) - } - - func effectiveOffset(for keyCode: UInt16) -> CGFloat { - let canonicalKeyCode = KeyboardLayoutInfo.canonicalKeyCode(for: keyCode) - if let direct = keyOffsets[canonicalKeyCode] { - return direct - } - if canonicalKeyCode != keyCode, let legacyDirect = keyOffsets[keyCode] { - return legacyDirect - } - return 0.0 - } - - func setOffset(_ offset: CGFloat, for keyCode: UInt16) { - pushUndo() - let clampedOffset = max(-0.5, min(0.5, offset.isFinite ? offset : 0)) - let canonicalKeyCode = KeyboardLayoutInfo.canonicalKeyCode(for: keyCode) - keyOffsets[canonicalKeyCode] = clampedOffset - if canonicalKeyCode != keyCode { - keyOffsets.removeValue(forKey: keyCode) - } - debouncedSave() - debouncedNotify() - } - - func resetKey(_ keyCode: UInt16) { - pushUndo() - let canonicalKeyCode = KeyboardLayoutInfo.canonicalKeyCode(for: keyCode) - keyOffsets.removeValue(forKey: canonicalKeyCode) - if canonicalKeyCode != keyCode { - keyOffsets.removeValue(forKey: keyCode) - } - saveOffsetsImmediately() - NotificationCenter.default.post(name: .keyPositionsChanged, object: nil) - } - - func resetAllKeys() { - pushUndo() - keyOffsets.removeAll() - saveOffsetsImmediately() - NotificationCenter.default.post(name: .keyPositionsChanged, object: nil) - } - - /// Replace all offsets at once. - func replaceAllOffsets(_ offsets: [UInt16: CGFloat]) { - pushUndo() - keyOffsets = normalizedOffsets(from: offsets) - saveOffsetsImmediately() - NotificationCenter.default.post(name: .keyPositionsChanged, object: nil) - } - - /// Export offsets as a string-keyed dictionary. - func exportOffsets() -> [String: CGFloat] { - keyOffsets.reduce(into: [String: CGFloat]()) { result, pair in - result[String(pair.key)] = pair.value - } - } - - /// Normalize imported string-keyed offsets using the same canonicalization/clamping rules as runtime edits. - static func normalizedImportedOffsets(from offsets: [String: CGFloat]) -> [String: CGFloat] { - var decoded: [UInt16: CGFloat] = [:] - decoded.reserveCapacity(offsets.count) - - for (key, value) in offsets { - guard let keyCode = UInt16(key), value.isFinite else { continue } - decoded[keyCode] = value - } - - let normalized = KeyPositionManager.shared.normalizedOffsets(from: decoded) - return normalized.reduce(into: [String: CGFloat]()) { result, pair in - result[String(pair.key)] = pair.value - } - } - - func undo() { - guard let previousState = undoStack.popLast() else { return } - redoStack.append(keyOffsets) - keyOffsets = previousState - saveOffsetsImmediately() - NotificationCenter.default.post(name: .keyPositionsChanged, object: nil) - } - - func redo() { - guard let nextState = redoStack.popLast() else { return } - undoStack.append(keyOffsets) - keyOffsets = nextState - saveOffsetsImmediately() - NotificationCenter.default.post(name: .keyPositionsChanged, object: nil) - } - - func loadProfile(_ profile: SettingsManager.KeyMappingProfile) { - pushUndo() - keyOffsets = normalizedOffsets(from: profile.keyOffsets) - KeyWidthManager.shared.replaceAllOverrides(profile.keyWidthOverrides) - SettingsManager.shared.currentKeyMappingProfileName = profile.name - saveOffsetsImmediately() - NotificationCenter.default.post(name: .keyPositionsChanged, object: nil) - } - - func reloadOffsets() { - loadOffsets() - } - - func cancelPendingWork() { - saveWorkItem?.cancel() - saveWorkItem = nil - notificationWorkItem?.cancel() - notificationWorkItem = nil - } - - private func pushUndo() { - undoStack.append(keyOffsets) - if undoStack.count > maxUndoLevels { - undoStack.removeFirst() - } - redoStack.removeAll() - } - - private func debouncedSave() { - saveWorkItem?.cancel() - let workItem = DispatchWorkItem { [weak self] in - self?.saveOffsetsImmediately() - } - saveWorkItem = workItem - DispatchQueue.main.asyncAfter(deadline: .now() + debounceInterval, execute: workItem) - } - - private func debouncedNotify() { - notificationWorkItem?.cancel() - let workItem = DispatchWorkItem { - NotificationCenter.default.post(name: .keyPositionsChanged, object: nil) - } - notificationWorkItem = workItem - DispatchQueue.main.asyncAfter(deadline: .now() + debounceInterval, execute: workItem) - } - - private func saveOffsetsImmediately() { - saveWorkItem?.cancel() - let stringKeyedDict = keyOffsets.reduce(into: [String: CGFloat]()) { result, pair in - result[String(pair.key)] = pair.value - } - UserDefaults.standard.set(stringKeyedDict, forKey: userDefaultsKey) - } - - private func normalizedOffsets(from offsets: [UInt16: CGFloat]) -> [UInt16: CGFloat] { - var canonicalValues: [UInt16: CGFloat] = [:] - var aliasFallbackValues: [UInt16: CGFloat] = [:] - - for keyCode in offsets.keys.sorted() { - guard let value = offsets[keyCode], value.isFinite else { continue } - let canonicalKeyCode = KeyboardLayoutInfo.canonicalKeyCode(for: keyCode) - guard Self.allowedKeyCodes.contains(canonicalKeyCode) else { continue } - - let clamped = max(-0.5, min(0.5, value)) - if keyCode == canonicalKeyCode { - canonicalValues[canonicalKeyCode] = clamped - } else if aliasFallbackValues[canonicalKeyCode] == nil { - aliasFallbackValues[canonicalKeyCode] = clamped - } - } - - var merged: [UInt16: CGFloat] = aliasFallbackValues - for (keyCode, value) in canonicalValues { - merged[keyCode] = value - } - - var normalized: [UInt16: CGFloat] = [:] - normalized.reserveCapacity(min(merged.count, Self.maxOffsetsCount)) - for keyCode in merged.keys.sorted() { - if normalized.count >= Self.maxOffsetsCount { break } - if let value = merged[keyCode] { - normalized[keyCode] = value - } - } - return normalized - } - - #if DEBUG - func _testNormalizedOffsets(from offsets: [UInt16: CGFloat]) -> [UInt16: CGFloat] { - normalizedOffsets(from: offsets) - } - #endif - - private func loadOffsets() { - guard let dict = UserDefaults.standard.dictionary(forKey: userDefaultsKey) as? [String: CGFloat] else { - return - } - - var decoded: [UInt16: CGFloat] = [:] - decoded.reserveCapacity(min(dict.count, Self.maxOffsetsCount)) - for (key, value) in dict { - if decoded.count >= Self.maxOffsetsCount { break } - if let keyCode = UInt16(key) { - decoded[keyCode] = value - } - } - - keyOffsets = normalizedOffsets(from: decoded) - } -} +import Foundation /// Provides keyboard layout info for the position editor struct KeyboardLayoutInfo { diff --git a/KeyLight/Models/RendererConfiguration.swift b/KeyLight/Models/RendererConfiguration.swift new file mode 100644 index 0000000..c9a82ce --- /dev/null +++ b/KeyLight/Models/RendererConfiguration.swift @@ -0,0 +1,226 @@ +import AppKit + +/// Shared, testable policy used by both renderers to guarantee that Reduce +/// Motion never schedules position or bounds animations. +enum RendererMotionPolicy { + static func geometryDuration( + _ requestedDuration: CFTimeInterval, + reduceMotion: Bool + ) -> CFTimeInterval { + guard !reduceMotion, + requestedDuration.isFinite, + requestedDuration > 0 else { + return 0 + } + return requestedDuration + } + + static func allowsGeometryAnimation(reduceMotion: Bool) -> Bool { + geometryDuration(1, reduceMotion: reduceMotion) > 0 + } +} + +/// Complete renderer input applied as one settings transaction. +/// +/// The renderers never observe partially updated settings. Numeric values are +/// normalized at the boundary so Classic and the surface renderers receive the same +/// finite configuration without duplicating validation policy. +struct RendererConfiguration: Equatable { + enum ColorMode: Equatable { + case solid(NSColor) + case positionGradient(start: NSColor, end: NSColor) + case rainbow + case randomPerKey + } + + let colorMode: ColorMode + let shapeProfile: SurfaceShapeProfile + let baseKeyWidth: CGFloat + let glowHeight: CGFloat + let widthMultiplier: CGFloat + let maximumOpacity: Float + let refractionStrength: CGFloat + let fadeDuration: CFTimeInterval + let roundness: CGFloat + let fullness: CGFloat + let reduceMotion: Bool + let reduceTransparency: Bool + let increaseContrast: Bool + let randomPreviewFallbackKeyCode: UInt16 + let chordAppearance: ChordAppearance + let powerSavingMode: PowerSavingMode + let powerEnvironmentState: PowerEnvironmentState + + init( + colorMode: ColorMode, + shapeProfile: SurfaceShapeProfile = .currentWave, + baseKeyWidth: CGFloat = 60, + glowHeight: CGFloat = 60, + widthMultiplier: CGFloat = 1, + maximumOpacity: Float = 0.7, + refractionStrength: CGFloat = 1, + fadeDuration: CFTimeInterval = 1.5, + roundness: CGFloat = 1, + fullness: CGFloat = 0.5, + reduceMotion: Bool = false, + reduceTransparency: Bool = false, + increaseContrast: Bool = false, + randomPreviewFallbackKeyCode: UInt16 = 9_999, + chordAppearance: ChordAppearance = .default, + powerSavingMode: PowerSavingMode = .automatic, + powerEnvironmentState: PowerEnvironmentState = .normal + ) { + self.colorMode = colorMode + self.shapeProfile = shapeProfile + self.baseKeyWidth = Self.sanitized( + baseKeyWidth, + default: 60, + range: 1 ... 500 + ) + self.glowHeight = Self.sanitized( + glowHeight, + default: 60, + range: 4 ... 200 + ) + self.widthMultiplier = Self.sanitized( + widthMultiplier, + default: 1, + range: 0.05 ... 5 + ) + self.maximumOpacity = Self.sanitized( + maximumOpacity, + default: 0.7, + range: 0 ... 1 + ) + self.refractionStrength = Self.sanitized( + refractionStrength, + default: 1, + range: 0.5 ... 2.5 + ) + self.fadeDuration = Self.sanitized( + fadeDuration, + default: 1, + range: 0.05 ... 5 + ) + self.roundness = Self.sanitized( + roundness, + default: 1, + range: 0 ... 1 + ) + self.fullness = Self.sanitized( + fullness, + default: 0.5, + range: 0 ... 1 + ) + self.reduceMotion = reduceMotion + self.reduceTransparency = reduceTransparency + self.increaseContrast = increaseContrast + self.randomPreviewFallbackKeyCode = randomPreviewFallbackKeyCode + self.chordAppearance = chordAppearance.normalized + self.powerSavingMode = powerSavingMode + self.powerEnvironmentState = powerEnvironmentState + } + + var automaticPowerSavingIsActive: Bool { + powerSavingMode == .automatic + && powerEnvironmentState.requiresFallback + } + + func resolvedEffectStyle(for selectedStyle: EffectStyle) -> EffectStyle { + if automaticPowerSavingIsActive, + selectedStyle.supportedStyle == .physicalRefraction { + return EffectStyle.systemGlass.resolvedForCurrentSystem + } + return selectedStyle.resolvedForCurrentSystem + } + + static let standard = RendererConfiguration( + colorMode: .solid(NSColor(red: 0.2, green: 0.6, blue: 1, alpha: 1)) + ) + + /// Returns nil only for solid color, allowing Classic Glow to retain its + /// existing color-array cache for the common path. + func resolvedColorOverride(for target: GlowTarget) -> NSColor? { + switch colorMode { + case .solid: + return nil + case .positionGradient(let start, let end): + return Self.interpolateColor( + from: start, + to: end, + fraction: CGFloat(target.horizontalPosition) + ) + case .rainbow: + let position = CGFloat(target.horizontalPosition) + return NSColor( + hue: position, + saturation: 0.9, + brightness: 1, + alpha: 1 + ) + case .randomPerKey: + return Self.randomColor( + for: target.colorReferenceKeyCode ?? randomPreviewFallbackKeyCode + ) + } + } + + func resolvedColor(for target: GlowTarget) -> NSColor { + resolvedColorOverride(for: target) ?? solidColor + } + + var solidColor: NSColor { + switch colorMode { + case .solid(let color): + return color + case .positionGradient(let start, _): + return start + case .rainbow, .randomPerKey: + return NSColor(red: 0.2, green: 0.6, blue: 1, alpha: 1) + } + } + + private static func randomColor(for keyCode: UInt16) -> NSColor { + let seed = UInt32(keyCode) &* 1_103_515_245 &+ 12_345 + let hue = CGFloat(seed % 10_000) / 10_000 + return NSColor(hue: hue, saturation: 0.85, brightness: 1, alpha: 1) + } + + private static func interpolateColor( + from: NSColor, + to: NSColor, + fraction: CGFloat + ) -> NSColor { + let t = max(0, min(1, fraction)) + let fromColor = from.usingColorSpace(.sRGB) ?? from + let toColor = to.usingColorSpace(.sRGB) ?? to + + var fr: CGFloat = 0 + var fg: CGFloat = 0 + var fb: CGFloat = 0 + var fa: CGFloat = 0 + var tr: CGFloat = 0 + var tg: CGFloat = 0 + var tb: CGFloat = 0 + var ta: CGFloat = 0 + + fromColor.getRed(&fr, green: &fg, blue: &fb, alpha: &fa) + toColor.getRed(&tr, green: &tg, blue: &tb, alpha: &ta) + + return NSColor( + red: fr + (tr - fr) * t, + green: fg + (tg - fg) * t, + blue: fb + (tb - fb) * t, + alpha: fa + (ta - fa) * t + ) + } + + private static func sanitized( + _ value: T, + default defaultValue: T, + range: ClosedRange + ) -> T { + guard value.isFinite else { return defaultValue } + return min(max(value, range.lowerBound), range.upperBound) + } +} diff --git a/KeyLight/Models/RuntimeInteraction.swift b/KeyLight/Models/RuntimeInteraction.swift new file mode 100644 index 0000000..1368075 --- /dev/null +++ b/KeyLight/Models/RuntimeInteraction.swift @@ -0,0 +1,373 @@ +import Foundation + +enum InputMonitoringState: Equatable, Sendable { + case checking + case permissionRequired + case authorized + case starting + case active + case monitorUnavailable +} + +enum GlobalHotKeyStatus: Equatable, Sendable { + case checking + case registered + case unavailable +} + +/// One ephemeral, privacy-safe physical-key transition for setup verification +/// and calibration highlighting. The model retains no history or characters. +struct PhysicalKeyActivity: Equatable, Sendable { + let sequence: UInt + let keyCode: UInt16 + let isDown: Bool +} + +/// Identifies an internal, non-physical glow preview. +enum PreviewSource: String, CaseIterable, Hashable, Sendable { + case settings + case keyEditor + case guidedCalibration + case chordTest1 + case chordTest2 + case chordTest3 + case chordTest4 + + static let chordTestSources: [PreviewSource] = [ + .chordTest1, .chordTest2, .chordTest3, .chordTest4 + ] + + var isChordTest: Bool { + Self.chordTestSources.contains(self) + } + + fileprivate static let renderPriority: [PreviewSource] = [ + .chordTest1, .chordTest2, .chordTest3, .chordTest4, + .guidedCalibration, .keyEditor, .settings + ] +} + +/// Stable identity for either a physically held key or a transient preview. +enum GlowID: Hashable, Sendable { + case physicalKey(UInt16) + case preview(PreviewSource) +} + +/// Renderer-independent geometry for one glow. +/// +/// Values use `Double` rather than AppKit/CoreGraphics types so the interaction +/// model remains portable and straightforward to unit test. Construction keeps +/// every target finite and within the renderer's existing safe ranges. +struct GlowTarget: Hashable, Sendable { + static let horizontalPositionRange: ClosedRange = 0 ... 1 + static let keyWidthRange: ClosedRange = 0.05 ... 5 + + let id: GlowID + let colorReferenceKeyCode: UInt16? + let horizontalPosition: Double + let keyWidth: Double + + static func physicalKey( + _ canonicalKeyCode: UInt16, + horizontalPosition: Double, + keyWidth: Double + ) -> GlowTarget { + GlowTarget( + id: .physicalKey(canonicalKeyCode), + colorReferenceKeyCode: canonicalKeyCode, + horizontalPosition: horizontalPosition, + keyWidth: keyWidth + ) + } + + static func preview( + _ source: PreviewSource, + colorReferenceKeyCode: UInt16? = nil, + horizontalPosition: Double, + keyWidth: Double + ) -> GlowTarget { + GlowTarget( + id: .preview(source), + colorReferenceKeyCode: colorReferenceKeyCode, + horizontalPosition: horizontalPosition, + keyWidth: keyWidth + ) + } + + private init( + id: GlowID, + colorReferenceKeyCode: UInt16?, + horizontalPosition: Double, + keyWidth: Double + ) { + self.id = id + self.colorReferenceKeyCode = colorReferenceKeyCode + self.horizontalPosition = Self.sanitized( + horizontalPosition, + default: 0.5, + in: Self.horizontalPositionRange + ) + self.keyWidth = Self.sanitized( + keyWidth, + default: 1, + in: Self.keyWidthRange + ) + } + + private static func sanitized( + _ value: Double, + default defaultValue: Double, + in range: ClosedRange + ) -> Double { + guard value.isFinite else { return defaultValue } + return min(max(value, range.lowerBound), range.upperBound) + } +} + +/// Privacy-safe keyboard metadata emitted after platform events are decoded. +/// +/// It intentionally has no character, modifier text, or raw event payload. +/// `canonicalKeyCode` is populated only for key transitions; stream resets use +/// `nil` and clear all physical interaction state. +struct KeyboardEvent: Equatable, Sendable { + enum Action: Equatable, Sendable { + case down + case up + case streamReset + } + + enum Source: Equatable, Sendable { + case eventTap + case consumerHID + case lifecycle + } + + let action: Action + let canonicalKeyCode: UInt16? + let isRepeat: Bool + let source: Source + let timestamp: TimeInterval + let sequence: UInt64 + + static func keyDown( + _ canonicalKeyCode: UInt16, + isRepeat: Bool = false, + source: Source, + timestamp: TimeInterval, + sequence: UInt64 = 0 + ) -> KeyboardEvent { + KeyboardEvent( + action: .down, + canonicalKeyCode: canonicalKeyCode, + isRepeat: isRepeat, + source: source, + timestamp: timestamp, + sequence: sequence + ) + } + + static func keyUp( + _ canonicalKeyCode: UInt16, + source: Source, + timestamp: TimeInterval, + sequence: UInt64 = 0 + ) -> KeyboardEvent { + KeyboardEvent( + action: .up, + canonicalKeyCode: canonicalKeyCode, + isRepeat: false, + source: source, + timestamp: timestamp, + sequence: sequence + ) + } + + static func streamReset( + source: Source = .lifecycle, + timestamp: TimeInterval, + sequence: UInt64 = 0 + ) -> KeyboardEvent { + KeyboardEvent( + action: .streamReset, + canonicalKeyCode: nil, + isRepeat: false, + source: source, + timestamp: timestamp, + sequence: sequence + ) + } + + private init( + action: Action, + canonicalKeyCode: UInt16?, + isRepeat: Bool, + source: Source, + timestamp: TimeInterval, + sequence: UInt64 + ) { + self.action = action + self.canonicalKeyCode = canonicalKeyCode + self.isRepeat = isRepeat + self.source = source + self.timestamp = timestamp.isFinite && timestamp >= 0 ? timestamp : 0 + self.sequence = sequence + } +} + +/// Resolution before and after one interaction-state mutation. +/// +/// Consumers can ignore no-op transitions, refresh when the ID is unchanged but +/// geometry differs, and switch targets when the resolved ID changes. +struct GlowInteractionTransition: Equatable, Sendable { + let previous: GlowTarget? + let current: GlowTarget? + + var isNoOp: Bool { previous == current } +} + +/// Deterministic source of truth for physical-key ordering and preview priority. +/// +/// The value has no actor affinity. `OverlayController` owns it on the main actor +/// while pure tests and event normalization remain actor-free. +struct GlowInteractionState: Equatable, Sendable { + private(set) var heldPhysicalKeyCodes: [UInt16] = [] + private var physicalTargets: [UInt16: GlowTarget] = [:] + private var previewTargets: [PreviewSource: GlowTarget] = [:] + + var resolvedTarget: GlowTarget? { + if let keyCode = heldPhysicalKeyCodes.last { + return physicalTargets[keyCode] + } + + for source in PreviewSource.renderPriority { + if let target = previewTargets[source] { + return target + } + } + + return nil + } + + var activePreviewSourcesInPriorityOrder: [PreviewSource] { + PreviewSource.renderPriority.filter { previewTargets[$0] != nil } + } + + var activeChordTestTargetsInSourceOrder: [GlowTarget] { + PreviewSource.chordTestSources.compactMap { previewTargets[$0] } + } + + /// All physically held targets in deterministic press order. Concurrent + /// renderers use the complete chord, while `resolvedTarget` remains the + /// newest key for priority decisions and legacy renderer fallbacks. + var activePhysicalTargetsInPressOrder: [GlowTarget] { + heldPhysicalKeyCodes.compactMap { physicalTargets[$0] } + } + + func target(for id: GlowID) -> GlowTarget? { + switch id { + case .physicalKey(let keyCode): + return physicalTargets[keyCode] + case .preview(let source): + return previewTargets[source] + } + } + + /// Applies one normalized keyboard event. A key-down requires a matching + /// physical target resolved from the current layout. Key-up and reset events + /// ignore `target`. + @discardableResult + mutating func handle( + _ event: KeyboardEvent, + target: GlowTarget? = nil + ) -> GlowInteractionTransition { + let previous = resolvedTarget + + switch event.action { + case .down: + guard let keyCode = event.canonicalKeyCode, + let target, + target.id == .physicalKey(keyCode) else { + return GlowInteractionTransition(previous: previous, current: previous) + } + + physicalTargets[keyCode] = target + if !heldPhysicalKeyCodes.contains(keyCode) { + heldPhysicalKeyCodes.append(keyCode) + } + + case .up: + guard let keyCode = event.canonicalKeyCode else { + return GlowInteractionTransition(previous: previous, current: previous) + } + physicalTargets.removeValue(forKey: keyCode) + heldPhysicalKeyCodes.removeAll { $0 == keyCode } + + case .streamReset: + physicalTargets.removeAll(keepingCapacity: true) + heldPhysicalKeyCodes.removeAll(keepingCapacity: true) + } + + return GlowInteractionTransition(previous: previous, current: resolvedTarget) + } + + @discardableResult + mutating func setPreview( + _ target: GlowTarget, + for source: PreviewSource + ) -> GlowInteractionTransition { + let previous = resolvedTarget + guard target.id == .preview(source) else { + return GlowInteractionTransition(previous: previous, current: previous) + } + + previewTargets[source] = target + return GlowInteractionTransition(previous: previous, current: resolvedTarget) + } + + @discardableResult + mutating func clearPreview(_ source: PreviewSource) -> GlowInteractionTransition { + let previous = resolvedTarget + previewTargets.removeValue(forKey: source) + return GlowInteractionTransition(previous: previous, current: resolvedTarget) + } + + @discardableResult + mutating func replaceChordTestTargets( + _ targets: [GlowTarget] + ) -> GlowInteractionTransition { + let previous = resolvedTarget + for source in PreviewSource.chordTestSources { + previewTargets.removeValue(forKey: source) + } + for source in PreviewSource.chordTestSources { + if let target = targets.first(where: { $0.id == .preview(source) }) { + previewTargets[source] = target + } + } + return GlowInteractionTransition(previous: previous, current: resolvedTarget) + } + + @discardableResult + mutating func clearChordTestTargets() -> GlowInteractionTransition { + replaceChordTestTargets([]) + } + + /// Clears only physical input, retaining previews so the highest-priority + /// still-active preview resumes immediately. + @discardableResult + mutating func clearPhysicalInput() -> GlowInteractionTransition { + let previous = resolvedTarget + physicalTargets.removeAll(keepingCapacity: true) + heldPhysicalKeyCodes.removeAll(keepingCapacity: true) + return GlowInteractionTransition(previous: previous, current: resolvedTarget) + } + + @discardableResult + mutating func clearAll() -> GlowInteractionTransition { + let previous = resolvedTarget + physicalTargets.removeAll(keepingCapacity: true) + heldPhysicalKeyCodes.removeAll(keepingCapacity: true) + previewTargets.removeAll(keepingCapacity: true) + return GlowInteractionTransition(previous: previous, current: nil) + } +} diff --git a/KeyLight/Models/SavedDomainValues.swift b/KeyLight/Models/SavedDomainValues.swift new file mode 100644 index 0000000..3cb99e4 --- /dev/null +++ b/KeyLight/Models/SavedDomainValues.swift @@ -0,0 +1,513 @@ +import Foundation + +/// A persisted appearance snapshot. Coding keys and defaults are part of the +/// compatibility contract for the existing `savedThemes` UserDefaults value. +struct Theme: Codable, Identifiable, Equatable { + var id = UUID() + var name: String + var colorHex: String + var opacity: Double + var refractionStrength: Double + var size: Double + var width: Double + var glowRoundness: Double + var glowFullness: Double + var fadeDuration: Double + var colorMode: ColorMode + var effectStyle: EffectStyle + var shapeProfile: SurfaceShapeProfile + var gradientStartHex: String? + var gradientEndHex: String? + + enum CodingKeys: String, CodingKey { + case id + case name + case colorHex + case opacity + case refractionStrength + case size + case width + case glowRoundness + case glowFullness + case fadeDuration + case colorMode + case effectStyle + case shapeProfile + case gradientStartHex + case gradientEndHex + } + + init( + id: UUID = UUID(), + name: String, + colorHex: String, + opacity: Double, + refractionStrength: Double = 1.0, + size: Double, + width: Double, + glowRoundness: Double = 1.0, + glowFullness: Double = 0.5, + fadeDuration: Double, + colorMode: ColorMode, + effectStyle: EffectStyle = .classicGlow, + shapeProfile: SurfaceShapeProfile = .currentWave, + gradientStartHex: String?, + gradientEndHex: String? + ) { + self.id = id + self.name = name + self.colorHex = colorHex + self.opacity = opacity + self.refractionStrength = refractionStrength + self.size = size + self.width = width + self.glowRoundness = glowRoundness + self.glowFullness = glowFullness + self.fadeDuration = fadeDuration + self.colorMode = colorMode + self.effectStyle = effectStyle + self.shapeProfile = shapeProfile + self.gradientStartHex = gradientStartHex + self.gradientEndHex = gradientEndHex + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + name = (try? container.decode(String.self, forKey: .name)) ?? "Imported" + id = (try? container.decode(UUID.self, forKey: .id)) + ?? LegacyRecordIdentity.id(kind: "theme", name: name) + colorHex = (try? container.decode(String.self, forKey: .colorHex)) ?? "68B8FF" + opacity = (try? container.decode(Double.self, forKey: .opacity)) ?? 0.8013 + refractionStrength = + (try? container.decode(Double.self, forKey: .refractionStrength)) + ?? 1.0 + size = (try? container.decode(Double.self, forKey: .size)) ?? 80.5536 + width = (try? container.decode(Double.self, forKey: .width)) ?? 1.0 + glowRoundness = (try? container.decode(Double.self, forKey: .glowRoundness)) ?? 0.7069 + glowFullness = (try? container.decode(Double.self, forKey: .glowFullness)) ?? 0.6046 + fadeDuration = (try? container.decode(Double.self, forKey: .fadeDuration)) ?? 1.0004 + + if let mode = try? container.decode(ColorMode.self, forKey: .colorMode) { + colorMode = mode + } else { + let rawMode = (try? container.decode(String.self, forKey: .colorMode)) + ?? ColorMode.positionGradient.rawValue + colorMode = rawMode == "gradient" + ? .positionGradient + : (ColorMode(rawValue: rawMode) ?? .positionGradient) + } + + effectStyle = ( + (try? container.decode(EffectStyle.self, forKey: .effectStyle)) + ?? .classicGlow + ).supportedStyle + shapeProfile = (try? container.decode( + SurfaceShapeProfile.self, + forKey: .shapeProfile + )) ?? .currentWave + gradientStartHex = try? container.decode(String.self, forKey: .gradientStartHex) + gradientEndHex = try? container.decode(String.self, forKey: .gradientEndHex) + } + + static let defaultTheme = Theme( + name: "current", + colorHex: "68B8FF", + opacity: 0.8013, + refractionStrength: 1.0, + size: 80.5536, + width: 1.0, + glowRoundness: 0.7069, + glowFullness: 0.6046, + fadeDuration: 1.0004, + colorMode: .positionGradient, + effectStyle: .classicGlow, + shapeProfile: .currentWave, + gradientStartHex: "68B8FF", + gradientEndHex: "00E69A" + ) +} + +/// A persisted keyboard-calibration snapshot. The custom string-keyed encoding +/// preserves the existing layout JSON representation exactly. +struct KeyMappingProfile: Codable, Identifiable, Equatable { + var id = UUID() + var name: String + var keyOffsets: [UInt16: CGFloat] + var keyWidthOverrides: [UInt16: CGFloat] + + enum CodingKeys: String, CodingKey { + case id, name, keyOffsets, keyWidthOverrides + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + try container.encode(name, forKey: .name) + let stringKeyedOffsets = keyOffsets.reduce(into: [String: CGFloat]()) { result, pair in + result[String(pair.key)] = pair.value + } + try container.encode(stringKeyedOffsets, forKey: .keyOffsets) + + let stringKeyedWidths = keyWidthOverrides.reduce(into: [String: CGFloat]()) { result, pair in + result[String(pair.key)] = pair.value + } + try container.encode(stringKeyedWidths, forKey: .keyWidthOverrides) + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + name = try container.decode(String.self, forKey: .name) + id = (try? container.decode(UUID.self, forKey: .id)) + ?? LegacyRecordIdentity.id(kind: "layout", name: name) + let stringKeyed = try container.decode([String: CGFloat].self, forKey: .keyOffsets) + keyOffsets = stringKeyed.reduce(into: [UInt16: CGFloat]()) { result, pair in + if let keyCode = UInt16(pair.key) { + result[keyCode] = pair.value + } + } + + let widthKeyed = (try? container.decode([String: CGFloat].self, forKey: .keyWidthOverrides)) ?? [:] + keyWidthOverrides = widthKeyed.reduce(into: [UInt16: CGFloat]()) { result, pair in + if let keyCode = UInt16(pair.key) { + result[keyCode] = pair.value + } + } + } + + init(name: String, keyOffsets: [UInt16: CGFloat], keyWidthOverrides: [UInt16: CGFloat] = [:]) { + self.name = name + self.keyOffsets = keyOffsets + self.keyWidthOverrides = keyWidthOverrides + } +} + +/// A persisted reusable gradient pair. +struct GradientPreset: Codable, Identifiable, Equatable { + var id = UUID() + var startHex: String + var endHex: String + var name: String? +} + +/// The live calibration embedded in a complete configuration snapshot. +/// String-keyed coding keeps the JSON readable and matches KeyLight's existing +/// layout transfer format without making UserDefaults the interchange format. +struct ConfigurationSnapshotCalibration: Codable, Equatable { + var offsets: [UInt16: CGFloat] + var widthMultipliers: [UInt16: CGFloat] + + init( + offsets: [UInt16: CGFloat] = [:], + widthMultipliers: [UInt16: CGFloat] = [:] + ) { + self.offsets = offsets + self.widthMultipliers = widthMultipliers + } + + static let empty = ConfigurationSnapshotCalibration() + + private enum CodingKeys: String, CodingKey { + case offsets + case widthMultipliers + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + offsets = Self.decodeValues( + try container.decodeIfPresent( + [String: CGFloat].self, + forKey: .offsets + ) ?? [:] + ) + widthMultipliers = Self.decodeValues( + try container.decodeIfPresent( + [String: CGFloat].self, + forKey: .widthMultipliers + ) ?? [:] + ) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(Self.encodeValues(offsets), forKey: .offsets) + try container.encode( + Self.encodeValues(widthMultipliers), + forKey: .widthMultipliers + ) + } + + private static func decodeValues( + _ values: [String: CGFloat] + ) -> [UInt16: CGFloat] { + values.reduce(into: [:]) { result, pair in + guard let keyCode = UInt16(pair.key) else { return } + result[keyCode] = pair.value + } + } + + private static func encodeValues( + _ values: [UInt16: CGFloat] + ) -> [String: CGFloat] { + values.reduce(into: [:]) { result, pair in + result[String(pair.key)] = pair.value + } + } +} + +/// The strict, typed allowlist of app-managed values that a configuration +/// snapshot may replace. Unknown JSON fields are ignored by Codable and never +/// become arbitrary preference writes. +struct ConfigurationSnapshotPayload: Codable, Equatable { + var currentEffect: EffectConfiguration + var effectConfigurations: [String: EffectConfiguration] + var chordAppearance: ChordAppearance + var powerSavingMode: PowerSavingMode + var themes: [Theme] + var currentThemeName: String + var activeThemeID: UUID? + var layoutProfiles: [KeyMappingProfile] + var currentLayoutName: String + var activeLayoutID: UUID? + var currentCalibration: ConfigurationSnapshotCalibration + var primaryDisplaySelection: String + var mirroredDisplayIDs: [String] + var displayLayoutProfileBindings: [String: UUID] + var globalShortcut: GlobalShortcut + var gradientPresets: [GradientPreset] + + init( + currentEffect: EffectConfiguration = .default, + effectConfigurations: [String: EffectConfiguration] = Self.defaultEffectConfigurations, + chordAppearance: ChordAppearance = .default, + powerSavingMode: PowerSavingMode = .automatic, + themes: [Theme] = [Theme.defaultTheme], + currentThemeName: String = Theme.defaultTheme.name, + activeThemeID: UUID? = nil, + layoutProfiles: [KeyMappingProfile] = [], + currentLayoutName: String = "None", + activeLayoutID: UUID? = nil, + currentCalibration: ConfigurationSnapshotCalibration = .empty, + primaryDisplaySelection: String = "automatic", + mirroredDisplayIDs: [String] = [], + displayLayoutProfileBindings: [String: UUID] = [:], + globalShortcut: GlobalShortcut = .default, + gradientPresets: [GradientPreset] = [] + ) { + self.currentEffect = currentEffect + self.effectConfigurations = effectConfigurations + self.chordAppearance = chordAppearance + self.powerSavingMode = powerSavingMode + self.themes = themes + self.currentThemeName = currentThemeName + self.activeThemeID = activeThemeID + self.layoutProfiles = layoutProfiles + self.currentLayoutName = currentLayoutName + self.activeLayoutID = activeLayoutID + self.currentCalibration = currentCalibration + self.primaryDisplaySelection = primaryDisplaySelection + self.mirroredDisplayIDs = mirroredDisplayIDs + self.displayLayoutProfileBindings = displayLayoutProfileBindings + self.globalShortcut = globalShortcut + self.gradientPresets = gradientPresets + } + + static let `default` = ConfigurationSnapshotPayload() + + static var defaultEffectConfigurations: [String: EffectConfiguration] { + Dictionary(uniqueKeysWithValues: EffectStyle.allCases.map { style in + (style.rawValue, EffectConfiguration.defaultConfiguration(for: style)) + }) + } + + enum CodingKeys: String, CodingKey, CaseIterable { + case currentEffect + case effectConfigurations + case chordAppearance + case powerSavingMode + case themes + case currentThemeName + case activeThemeID + case layoutProfiles + case currentLayoutName + case activeLayoutID + case currentCalibration + case primaryDisplaySelection + case mirroredDisplayIDs + case displayLayoutProfileBindings + case globalShortcut + case gradientPresets + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.init( + currentEffect: try container.decodeIfPresent( + EffectConfiguration.self, + forKey: .currentEffect + ) ?? .default, + effectConfigurations: try container.decodeIfPresent( + [String: EffectConfiguration].self, + forKey: .effectConfigurations + ) ?? Self.defaultEffectConfigurations, + chordAppearance: try container.decodeIfPresent( + ChordAppearance.self, + forKey: .chordAppearance + ) ?? .default, + powerSavingMode: try container.decodeIfPresent( + PowerSavingMode.self, + forKey: .powerSavingMode + ) ?? .automatic, + themes: try container.decodeIfPresent( + [Theme].self, + forKey: .themes + ) ?? [Theme.defaultTheme], + currentThemeName: try container.decodeIfPresent( + String.self, + forKey: .currentThemeName + ) ?? Theme.defaultTheme.name, + activeThemeID: try container.decodeIfPresent( + UUID.self, + forKey: .activeThemeID + ), + layoutProfiles: try container.decodeIfPresent( + [KeyMappingProfile].self, + forKey: .layoutProfiles + ) ?? [], + currentLayoutName: try container.decodeIfPresent( + String.self, + forKey: .currentLayoutName + ) ?? "None", + activeLayoutID: try container.decodeIfPresent( + UUID.self, + forKey: .activeLayoutID + ), + currentCalibration: try container.decodeIfPresent( + ConfigurationSnapshotCalibration.self, + forKey: .currentCalibration + ) ?? .empty, + primaryDisplaySelection: try container.decodeIfPresent( + String.self, + forKey: .primaryDisplaySelection + ) ?? "automatic", + mirroredDisplayIDs: try container.decodeIfPresent( + [String].self, + forKey: .mirroredDisplayIDs + ) ?? [], + displayLayoutProfileBindings: try container.decodeIfPresent( + [String: UUID].self, + forKey: .displayLayoutProfileBindings + ) ?? [:], + globalShortcut: try container.decodeIfPresent( + GlobalShortcut.self, + forKey: .globalShortcut + ) ?? .default, + gradientPresets: try container.decodeIfPresent( + [GradientPreset].self, + forKey: .gradientPresets + ) ?? [] + ) + } +} + +/// Versioned interchange document used by both the in-app library and +/// `.keylight-snapshot.json` files. +struct ConfigurationSnapshotDocument: Codable, Identifiable, Equatable { + static let documentKind = "keylightConfigurationSnapshot" + static let currentVersion = 1 + + var kind: String + var version: Int + var id: UUID + var name: String + var createdAt: Date + var configuration: ConfigurationSnapshotPayload + + init( + kind: String = Self.documentKind, + version: Int = Self.currentVersion, + id: UUID = UUID(), + name: String, + createdAt: Date = Date(), + configuration: ConfigurationSnapshotPayload + ) { + self.kind = kind + self.version = version + self.id = id + self.name = name + self.createdAt = createdAt + self.configuration = configuration + } + + private enum CodingKeys: String, CodingKey { + case kind + case version + case id + case name + case createdAt + case configuration + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + kind = try container.decode(String.self, forKey: .kind) + version = try container.decode(Int.self, forKey: .version) + id = try container.decodeIfPresent(UUID.self, forKey: .id) ?? UUID() + name = try container.decodeIfPresent(String.self, forKey: .name) + ?? "Imported Snapshot" + createdAt = try container.decodeIfPresent(Date.self, forKey: .createdAt) + ?? Date(timeIntervalSince1970: 0) + configuration = try container.decodeIfPresent( + ConfigurationSnapshotPayload.self, + forKey: .configuration + ) ?? .default + } +} + +/// Legacy themes and layouts used their names as identity. Derive a stable, +/// domain-separated UUID so an older build stripping `id` cannot make the same +/// record change identity on every load. +private enum LegacyRecordIdentity { + static func id(kind: String, name: String) -> UUID { + let canonicalName = name.precomposedStringWithCanonicalMapping + let bytes = Array("KeyLight\u{0}\(kind)\u{0}\(canonicalName)".utf8) + + let high = fnv1a64(bytes, seed: 0xcbf29ce484222325) + let low = fnv1a64(bytes, seed: 0x84222325cbf29ce4) + + var uuidBytes: [UInt8] = [ + UInt8(truncatingIfNeeded: high >> 56), + UInt8(truncatingIfNeeded: high >> 48), + UInt8(truncatingIfNeeded: high >> 40), + UInt8(truncatingIfNeeded: high >> 32), + UInt8(truncatingIfNeeded: high >> 24), + UInt8(truncatingIfNeeded: high >> 16), + UInt8(truncatingIfNeeded: high >> 8), + UInt8(truncatingIfNeeded: high), + UInt8(truncatingIfNeeded: low >> 56), + UInt8(truncatingIfNeeded: low >> 48), + UInt8(truncatingIfNeeded: low >> 40), + UInt8(truncatingIfNeeded: low >> 32), + UInt8(truncatingIfNeeded: low >> 24), + UInt8(truncatingIfNeeded: low >> 16), + UInt8(truncatingIfNeeded: low >> 8), + UInt8(truncatingIfNeeded: low) + ] + + // RFC 9562 UUIDv8 leaves payload semantics to the application. + uuidBytes[6] = (uuidBytes[6] & 0x0f) | 0x80 + uuidBytes[8] = (uuidBytes[8] & 0x3f) | 0x80 + + return UUID(uuid: ( + uuidBytes[0], uuidBytes[1], uuidBytes[2], uuidBytes[3], + uuidBytes[4], uuidBytes[5], uuidBytes[6], uuidBytes[7], + uuidBytes[8], uuidBytes[9], uuidBytes[10], uuidBytes[11], + uuidBytes[12], uuidBytes[13], uuidBytes[14], uuidBytes[15] + )) + } + + private static func fnv1a64(_ bytes: [UInt8], seed: UInt64) -> UInt64 { + bytes.reduce(seed) { hash, byte in + (hash ^ UInt64(byte)) &* 0x100000001b3 + } + } +} diff --git a/KeyLight/Resources/Localizable.xcstrings b/KeyLight/Resources/Localizable.xcstrings new file mode 100644 index 0000000..f7d417f --- /dev/null +++ b/KeyLight/Resources/Localizable.xcstrings @@ -0,0 +1,1224 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "" : { + + }, + "#" : { + + }, + "%@ Setup" : { + + }, + "%@ seconds" : { + + }, + "%@ · Edited" : { + + }, + "%@, %@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$@, %2$@" + } + } + } + }, + "%@s" : { + + }, + "%lld" : { + + }, + "%lld percent" : { + + }, + "%lld points" : { + + }, + "%lld%%" : { + + }, + "100% preserves the current tuned glass. Higher values increase the optical path length and color separation only at the top and side edges; the bottom stays transparent." : { + + }, + "A layout named \"%@\" already exists." : { + + }, + "A layout with that name already exists." : { + + }, + "A theme named \"%@\" already exists." : { + + }, + "About %@" : { + + }, + "Accessibility permission is not required." : { + + }, + "Actions for keyboard layout %@" : { + + }, + "Actions for theme %@" : { + + }, + "Active" : { + + }, + "Active display" : { + + }, + "Add Gradient Colors" : { + + }, + "Adjusts backdrop displacement at the top and side edges only" : { + + }, + "Allow Input Monitoring" : { + + }, + "Allow Input Monitoring so KeyLight can detect key presses." : { + + }, + "Allow Input Monitoring…" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Allow Input Monitoring…" + } + } + } + }, + "Allow KeyLight in System Settings › General › Login Items, then try again." : { + + }, + "Allow Screen Recording" : { + + }, + "Allow Screen Recording…" : { + + }, + "An opaque black silhouette that retracts geometrically." : { + + }, + "Appearance" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Appearance" + } + } + } + }, + "Applied \"%@\"." : { + + }, + "Automatically check for updates" : { + + }, + "Available with Classic Glow and Classic+" : { + + }, + "Backdrop refraction with System Glass fallback." : { + + }, + "Bundled preset file not found: %@." : { + + }, + "Bundled presets are unavailable in this build." : { + + }, + "Calibrate Keyboard…" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Calibrate Keyboard…" + } + } + } + }, + "Cancel" : { + + }, + "Capture-free comparison: Apple controls the clear-glass optics. KeyLight supplies only the key shape, grouping, and motion." : { + + }, + "Capture-free optics controlled by macOS." : { + + }, + "Capture-free optics controlled entirely by Apple's compositor." : { + + }, + "Check Again" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Check Again" + } + } + } + }, + "Check Failed" : { + + }, + "Check for Updates…" : { + + }, + "Checking Input Monitoring" : { + + }, + "Checking…" : { + + }, + "Checks Input Monitoring and restarts key detection" : { + + }, + "Choose Undo within ten seconds to restore it." : { + + }, + "Choose Your Effect" : { + + }, + "Choose a unique name up to 100 characters." : { + + }, + "Classic Glow" : { + + }, + "Classic Glow fallback is active on this macOS version." : { + + }, + "Classic color and softness with fluid concurrent-key morphing." : { + + }, + "Classic color with fluid concurrent-key motion." : { + + }, + "Classic+" : { + + }, + "Click a key to select, then drag to adjust its glow position. Use the slider to adjust glow width." : { + + }, + "Color" : { + + }, + "Color Mode" : { + + }, + "Color mode" : { + + }, + "Color settings are unavailable for this effect" : { + + }, + "Colors are distributed left-to-right by key position." : { + + }, + "Command Shift K: %@" : { + + }, + "Command-Shift-K could not be registered. KeyLight can still be enabled from the menu." : { + + }, + "Compact" : { + + }, + "Contacts only the signed KeyLight update feed" : { + + }, + "Continue" : { + + }, + "Continue with Fallback" : { + + }, + "Controls glow boundary feather (0% soft, 100% crisp)." : { + + }, + "Controls the corner profile of Classic Glow and Classic+" : { + + }, + "Controls the visibility of Apple's system glass; the system controls its lensing and refraction." : { + + }, + "Controls the visibility of the optical contour. The body remains clear." : { + + }, + "Copy" : { + + }, + "Copy this KeyLight theme string to share the current appearance." : { + + }, + "Current Wave" : { + + }, + "Custom neutral and prismatic glass optics." : { + + }, + "Default" : { + + }, + "Delete" : { + + }, + "Delete Layout \"%@\"?" : { + + }, + "Delete Selected" : { + + }, + "Delete Theme \"%@\"?" : { + + }, + "Deleted layout \"%@\"." : { + + }, + "Deleted theme \"%@\"." : { + + }, + "Deletion Undone" : { + + }, + "Diagnostics Export Failed" : { + + }, + "Diagnostics Exported" : { + + }, + "Disable %@" : { + + }, + "Dismiss %@" : { + + }, + "Display %u" : { + + }, + "Done" : { + + }, + "Drag keys horizontally to align the glow effect with your physical keyboard" : { + + }, + "Each key uses a deterministic random color derived from its key code." : { + + }, + "Effect" : { + + }, + "Effect Runtime" : { + + }, + "Effect Style" : { + + }, + "Enable %@" : { + + }, + "Enable KeyLight to start key detection." : { + + }, + "End" : { + + }, + "Erase All Local KeyLight Settings?" : { + + }, + "Erase Local Settings…" : { + + }, + "Erase and Return to Setup" : { + + }, + "Erasing settings does not revoke macOS permissions. Input Monitoring and Screen Recording must be revoked separately in System Settings." : { + + }, + "Explains how to install KeyLight before allowing Input Monitoring" : { + + }, + "Explains the exact installation correction before Input Monitoring is requested" : { + + }, + "Explains why KeyLight needs Input Monitoring before requesting access" : { + + }, + "Export Current…" : { + + }, + "Export Redacted Diagnostics…" : { + + }, + "Fade Duration" : { + + }, + "Fade duration" : { + + }, + "Fallback" : { + + }, + "Finish in System Settings" : { + + }, + "General" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "General" + } + } + } + }, + "Geometry" : { + + }, + "Global Shortcut" : { + + }, + "Glow Color" : { + + }, + "Glow Width:" : { + + }, + "Glow color" : { + + }, + "Glow width for %@" : { + + }, + "Gradient Colors" : { + + }, + "Gradient End" : { + + }, + "Gradient Start" : { + + }, + "Gradient end color" : { + + }, + "Gradient start color" : { + + }, + "Grant Access" : { + + }, + "Hardness" : { + + }, + "Height" : { + + }, + "Hex" : { + + }, + "Hex %@" : { + + }, + "ISO <> is between left Shift and Z/Y." : { + + }, + "Idle" : { + + }, + "If this app is already listed but access remains unavailable, remove its stale row, add the installed app again, turn it on, and retry the monitor." : { + + }, + "Import Theme" : { + + }, + "Import and Apply" : { + + }, + "Import…" : { + + }, + "Input Monitoring" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Input Monitoring" + } + } + } + }, + "Input Monitoring Active" : { + + }, + "Input Monitoring Allowed" : { + + }, + "Input Monitoring Authorized" : { + + }, + "Input Monitoring Needs Attention" : { + + }, + "Input Monitoring Required" : { + + }, + "Input Monitoring Starting" : { + + }, + "Input Monitoring Unavailable" : { + + }, + "Input Monitoring is allowed, but the keyboard event loop could not start. Retry it or review the installed KeyLight entry in System Settings." : { + + }, + "Input Monitoring is granted and key presses are being monitored." : { + + }, + "Input Monitoring is granted, but KeyLight could not start the keyboard monitor." : { + + }, + "Input Monitoring is granted. Enable KeyLight to start the keyboard monitor." : { + + }, + "Input Monitoring needs attention" : { + + }, + "Input Monitoring needs attention before KeyLight can detect keys reliably." : { + + }, + "Input Monitoring status: %@" : { + + }, + "Input Monitoring verification succeeded. Choose an effect." : { + + }, + "Input Monitoring: %@" : { + + }, + "Invalid theme string." : { + + }, + "Keeps Classic Glow as the safe default and does not reopen this setup automatically" : { + + }, + "Key Detected" : { + + }, + "Key Layout (Position + Width)" : { + + }, + "Key layout profiles store keyboard geometry only: per-key offsets and per-key glow width overrides." : { + + }, + "KeyLight Is Ready" : { + + }, + "KeyLight could not disable launch at login. The previous setting was restored." : { + + }, + "KeyLight could not enable launch at login. The previous setting was restored." : { + + }, + "KeyLight could not encode the current layout." : { + + }, + "KeyLight could not encode the current theme." : { + + }, + "KeyLight is checking macOS permission and keyboard monitor status." : { + + }, + "KeyLight is ready to detect key presses." : { + + }, + "KeyLight is ready. Choose Done to close setup." : { + + }, + "KeyLight is starting the keyboard monitor." : { + + }, + "KeyLight needs Input Monitoring to detect key presses." : { + + }, + "KeyLight needs Input Monitoring to receive global key press and release events and position the effect under the corresponding physical key." : { + + }, + "KeyLight stores appearance themes, keyboard layouts, onboarding state, and update preferences locally. It stores no typing history or captured imagery." : { + + }, + "KeyLight turns physical key presses into a fluid light surface along the bottom of your display." : { + + }, + "KeyLight's neutral and prismatic custom glass treatment." : { + + }, + "Keyboard" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Keyboard" + } + } + } + }, + "Keyboard Calibration" : { + + }, + "Keyboard Shortcut Unavailable" : { + + }, + "Keyboard layout %@" : { + + }, + "Launch at Login" : { + + }, + "Launch at Login Failed" : { + + }, + "Launch at Login Needs Approval" : { + + }, + "Launch at Login Wasn’t Changed" : { + + }, + "Layout Couldn’t Be Exported" : { + + }, + "Layout Couldn’t Be Saved" : { + + }, + "Layout Export Failed" : { + + }, + "Layout Exported" : { + + }, + "Layout Import Failed" : { + + }, + "Layout Imported" : { + + }, + "Layout Renamed" : { + + }, + "Layout Saved" : { + + }, + "Layout Updated" : { + + }, + "Layout profile actions" : { + + }, + "Layout profile contains too many key entries (max 512)." : { + + }, + "Layout profile file is too large (max 1MB)." : { + + }, + "Layout profile name" : { + + }, + "Layout profile name already exists." : { + + }, + "Layout profile name is missing." : { + + }, + "Liquid Glass" : { + + }, + "Liquid Glass uses the untinted adaptive system material. Color settings remain saved for Classic Glow and Classic+." : { + + }, + "Local Data" : { + + }, + "Lower values clear the body while preserving native lensing and strengthening chromatic top and side refraction." : { + + }, + "Maximum sampled strip" : { + + }, + "Modified" : { + + }, + "Motion" : { + + }, + "Move left for a compact wave or right for a wider, softer wave" : { + + }, + "Name cannot be empty." : { + + }, + "Needs attention" : { + + }, + "No Screen Capture" : { + + }, + "No saved themes yet." : { + + }, + "No typing history, analytics, accounts, or cloud sync" : { + + }, + "Not Now" : { + + }, + "Not selected" : { + + }, + "Off by default. When enabled, KeyLight contacts only its signed HTTPS update feed and sends no system profile or analytics." : { + + }, + "Offset: %@" : { + + }, + "Only a 180–200 point strip at the bottom of the selected display is sampled. Frames stay in GPU-backed memory and are never saved." : { + + }, + "Opacity" : { + + }, + "Opaque silhouette with geometric retraction." : { + + }, + "Open Applications" : { + + }, + "Open Input Monitoring Settings" : { + + }, + "Open Screen Recording Settings" : { + + }, + "Open Settings" : { + + }, + "Open System Settings" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Open System Settings" + } + } + } + }, + "Opens the Input Monitoring privacy settings" : { + + }, + "Optics" : { + + }, + "Optional Screen Recording" : { + + }, + "Ordinary key events are reduced to identity and press/release metadata" : { + + }, + "Original single-target glow with unchanged timing." : { + + }, + "Paste a KeyLight theme string. A valid theme is saved and applied immediately." : { + + }, + "Permission is allowed, but KeyLight could not start key detection." : { + + }, + "Permission required" : { + + }, + "Physical Refraction" : { + + }, + "Physical Refraction samples the screen beneath the lens instead of applying a synthetic color. Color settings remain saved for Classic Glow and Classic+." : { + + }, + "Physical Refraction starts capture only when a visible key surface first appears. It samples one bottom strip at up to 30 fps, retains one latest GPU-backed frame, and stops two seconds after the final retraction." : { + + }, + "Physical capture" : { + + }, + "Physical capture is idle until a key surface appears, retains one latest IOSurface-backed frame, and stops after the two-second post-retraction grace period." : { + + }, + "Physically modeled backdrop refraction with System Glass fallback." : { + + }, + "Position Gradient" : { + + }, + "Presets" : { + + }, + "Press and release any physical key once." : { + + }, + "Press to preview. Use Left and Right Arrow to adjust position; hold Shift for larger steps." : { + + }, + "Privacy" : { + + }, + "Privacy & Permissions" : { + + }, + "Quit %@" : { + + }, + "Quit this copy, install it in Applications using the exact filename shown above, then launch that app. In Input Monitoring, remove any stale row, add the installed app again, turn it on, and retry in the relaunched app." : { + + }, + "Rainbow" : { + + }, + "Random Per Key" : { + + }, + "Ready" : { + + }, + "Redo (Cmd+Shift+Z)" : { + + }, + "Redo keyboard calibration" : { + + }, + "Refraction Strength" : { + + }, + "Refraction strength" : { + + }, + "Releases" : { + + }, + "Renamed \"%@\" to \"%@\"." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Renamed \"%1$@\" to \"%2$@\"." + } + } + } + }, + "Rename…" : { + + }, + "Renderer" : { + + }, + "Replaces the selected layout with the current calibration" : { + + }, + "Replaces the selected theme with the current appearance" : { + + }, + "Requests Input Monitoring access for KeyLight" : { + + }, + "Requests Input Monitoring permission for KeyLight" : { + + }, + "Reset All" : { + + }, + "Reset This Key" : { + + }, + "Reset all key positions to defaults?" : { + + }, + "Resolve Installation…" : { + + }, + "Resolved" : { + + }, + "Restores the selected layout's saved calibration" : { + + }, + "Restores the selected theme's saved appearance" : { + + }, + "Restores this key's position and glow width" : { + + }, + "Retries the KeyLight keyboard monitor" : { + + }, + "Retry" : { + + }, + "Retry Monitor" : { + + }, + "Revert" : { + + }, + "Review" : { + + }, + "Review Access…" : { + + }, + "Review Installation…" : { + + }, + "Round" : { + + }, + "Roundness" : { + + }, + "Running app" : { + + }, + "Save" : { + + }, + "Save As…" : { + + }, + "Saved \"%@\"." : { + + }, + "Saved Classic Settings" : { + + }, + "Saved Item Deleted" : { + + }, + "Saved the current appearance to \"%@\"." : { + + }, + "Saved the current calibration to \"%@\"." : { + + }, + "Saved the current layout as \"%@\"." : { + + }, + "Screen Recording Allowed" : { + + }, + "Screen Recording Not Allowed" : { + + }, + "Screen Recording allowed" : { + + }, + "Screen Recording is allowed" : { + + }, + "Screen Recording is not yet allowed" : { + + }, + "Screen Recording is optional and used only by Physical Refraction" : { + + }, + "Screen Recording permission required" : { + + }, + "Selected" : { + + }, + "Selected key is modified" : { + + }, + "Selected key uses defaults" : { + + }, + "Selected, edited" : { + + }, + "Selected: %@" : { + + }, + "Selecting the effect never requests permission. The button below is the explicit consent action. System Glass is the capture-free alternative." : { + + }, + "Selects this capture-free effect and updates the live preview." : { + + }, + "Sets the tempo for reveal, key-to-key flow, and fade-out." : { + + }, + "Sets the tempo for reveal, key-to-key flow, and geometric retraction." : { + + }, + "Settings…" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Settings…" + } + } + } + }, + "Setup Complete" : { + + }, + "Setup is complete. Your selected effect and update choice are saved." : { + + }, + "Share Theme" : { + + }, + "Shareable theme string" : { + + }, + "Share…" : { + + }, + "Sharp" : { + + }, + "Smoothness" : { + + }, + "Software Updates" : { + + }, + "Solid" : { + + }, + "Solid Black" : { + + }, + "Solid Black is always fully opaque inside the active silhouette." : { + + }, + "Solid Black uses an opaque black fill with no tint, outline, glass, or shadow. Color settings remain saved for Classic Glow and Classic+." : { + + }, + "Start" : { + + }, + "Starting" : { + + }, + "Starts KeyLight automatically after you sign in" : { + + }, + "Stopping" : { + + }, + "Stopping soon" : { + + }, + "System Glass" : { + + }, + "System Glass uses only Apple's untinted compositor material—without KeyLight's backing, painted optical edge, or Metal refraction. Color settings remain saved for Classic Glow and Classic+." : { + + }, + "Technical Details" : { + + }, + "The completion screen stays open until you choose Done; setup never dismisses itself automatically." : { + + }, + "The file is not a valid KeyLight layout profile." : { + + }, + "The file is too large (maximum 1 MB)." : { + + }, + "The left side strongly compresses the wave; the right side spreads and softens it." : { + + }, + "The original single-target KeyLight glow with unchanged pixels and timing." : { + + }, + "The redacted report contains no key codes, characters, or captured pixels." : { + + }, + "The saved item was restored." : { + + }, + "The system pasteboard did not accept the theme string." : { + + }, + "Theme %@" : { + + }, + "Theme Couldn’t Be Copied" : { + + }, + "Theme Couldn’t Be Saved" : { + + }, + "Theme Couldn’t Be Shared" : { + + }, + "Theme Import Failed" : { + + }, + "Theme Imported" : { + + }, + "Theme Renamed" : { + + }, + "Theme Saved" : { + + }, + "Theme String Copied" : { + + }, + "Theme Updated" : { + + }, + "Theme actions" : { + + }, + "Theme name" : { + + }, + "Theme name already exists." : { + + }, + "Theme string is too large." : { + + }, + "Theme string to import" : { + + }, + "Themes" : { + + }, + "Themes store glow style settings only (color, effect, and fade)." : { + + }, + "These values stay unchanged and return when Classic Glow or Classic+ is selected." : { + + }, + "This disables KeyLight and Launch at Login, stops monitoring and capture, clears themes, layouts, preferences, onboarding state, and updater state, then returns to setup. macOS privacy permissions are not revoked." : { + + }, + "This local preview has no production update feed or public signing key, so it cannot make update requests." : { + + }, + "This permission is optional and used only by Physical Refraction. Selecting or previewing that effect never requests access." : { + + }, + "This removes the saved item. You can undo for ten seconds." : { + + }, + "Troubleshooting" : { + + }, + "Turn on KeyLight in Privacy & Security › Input Monitoring, then return here. KeyLight checks permission without showing another prompt." : { + + }, + "Typed characters are not retained, logged, exported, or sent anywhere." : { + + }, + "Unavailable in This Build" : { + + }, + "Unavailable on this macOS version; Classic Glow remains the fallback." : { + + }, + "Undo (%llds)" : { + + }, + "Undo (Cmd+Z)" : { + + }, + "Undo keyboard calibration" : { + + }, + "Unsupported layout profile version (%lld). Please update KeyLight." : { + + }, + "Until access is allowed, the selected effect safely renders with capture-free System Glass. KeyLight never requests this permission automatically." : { + + }, + "Up to Date" : { + + }, + "Update Layout" : { + + }, + "Update Theme" : { + + }, + "Updates" : { + + }, + "Use System Glass Instead" : { + + }, + "Use color %@" : { + + }, + "Use gradient from %@ to %@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Use gradient from %1$@ to %2$@" + } + } + } + }, + "Uses Optional Screen Recording" : { + + }, + "Uses optional Screen Recording only while a physical surface is active; System Glass is the capture-free fallback." : { + + }, + "Verify Installation" : { + + }, + "Verify a Physical Key" : { + + }, + "Version" : { + + }, + "Version %@ Available" : { + + }, + "Welcome to KeyLight" : { + + }, + "Wide + Soft" : { + + }, + "Width" : { + + }, + "macOS 26+" : { + + }, + "macOS kept the previous launch-at-login setting." : { + + }, + "macOS reported a different launch-at-login state, so KeyLight restored the current system value." : { + + }, + "⌘⇧K" : { + + } + }, + "version" : "1.0" +} \ No newline at end of file diff --git a/KeyLight/Resources/PrivacyInfo.xcprivacy b/KeyLight/Resources/PrivacyInfo.xcprivacy new file mode 100644 index 0000000..b465824 --- /dev/null +++ b/KeyLight/Resources/PrivacyInfo.xcprivacy @@ -0,0 +1,39 @@ + + + + + NSPrivacyTracking + + NSPrivacyTrackingDomains + + NSPrivacyCollectedDataTypes + + NSPrivacyAccessedAPITypes + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryFileTimestamp + NSPrivacyAccessedAPITypeReasons + + 3B52.1 + + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategorySystemBootTime + NSPrivacyAccessedAPITypeReasons + + 35F9.1 + + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryUserDefaults + NSPrivacyAccessedAPITypeReasons + + CA92.1 + + + + + diff --git a/KeyLight/Resources/VariantPresets/macbook-ansi-baseline-layout.json b/KeyLight/Resources/VariantPresets/macbook-ansi-baseline-layout.json new file mode 100644 index 0000000..59f0da3 --- /dev/null +++ b/KeyLight/Resources/VariantPresets/macbook-ansi-baseline-layout.json @@ -0,0 +1,7 @@ +{ + "version": 1, + "kind": "layoutProfile", + "name": "MacBook ANSI Baseline", + "keyOffsets": {}, + "keyWidthOverrides": {} +} diff --git a/KeyLight/Resources/VariantPresets/macbook-iso-baseline-layout.json b/KeyLight/Resources/VariantPresets/macbook-iso-baseline-layout.json new file mode 100644 index 0000000..c997173 --- /dev/null +++ b/KeyLight/Resources/VariantPresets/macbook-iso-baseline-layout.json @@ -0,0 +1,7 @@ +{ + "version": 1, + "kind": "layoutProfile", + "name": "MacBook ISO Baseline", + "keyOffsets": {}, + "keyWidthOverrides": {} +} diff --git a/KeyLight/Resources/VariantPresets/magic-keyboard-compact-baseline-layout.json b/KeyLight/Resources/VariantPresets/magic-keyboard-compact-baseline-layout.json new file mode 100644 index 0000000..4c45d9b --- /dev/null +++ b/KeyLight/Resources/VariantPresets/magic-keyboard-compact-baseline-layout.json @@ -0,0 +1,7 @@ +{ + "version": 1, + "kind": "layoutProfile", + "name": "Magic Keyboard Compact Baseline", + "keyOffsets": {}, + "keyWidthOverrides": {} +} diff --git a/KeyLight/Resources/VariantPresets/variant-presets-manifest.json b/KeyLight/Resources/VariantPresets/variant-presets-manifest.json index 631dfdc..39018c1 100644 --- a/KeyLight/Resources/VariantPresets/variant-presets-manifest.json +++ b/KeyLight/Resources/VariantPresets/variant-presets-manifest.json @@ -12,6 +12,24 @@ "variantId": "macbook-pro-14-m4", "displayName": "MacBook Pro 14 M4", "resourcePath": "macbook-pro-14-m4-layout.json" + }, + { + "id": "macbook-ansi-baseline", + "variantId": "macbook-ansi", + "displayName": "MacBook ANSI Baseline", + "resourcePath": "macbook-ansi-baseline-layout.json" + }, + { + "id": "macbook-iso-baseline", + "variantId": "macbook-iso", + "displayName": "MacBook ISO Baseline", + "resourcePath": "macbook-iso-baseline-layout.json" + }, + { + "id": "magic-keyboard-compact-baseline", + "variantId": "magic-keyboard-compact", + "displayName": "Magic Keyboard Compact Baseline", + "resourcePath": "magic-keyboard-compact-baseline-layout.json" } ] } diff --git a/KeyLight/Services/ConfigurationSnapshotFilePanelHelper.swift b/KeyLight/Services/ConfigurationSnapshotFilePanelHelper.swift new file mode 100644 index 0000000..8afeab4 --- /dev/null +++ b/KeyLight/Services/ConfigurationSnapshotFilePanelHelper.swift @@ -0,0 +1,142 @@ +import AppKit +import Darwin +import Foundation +import UniformTypeIdentifiers + +enum ConfigurationSnapshotFileError: LocalizedError { + case invalidFilename + case notRegularFile + case fileSizeUnavailable + case fileTooLarge + + var errorDescription: String? { + switch self { + case .invalidFilename: + return String( + localized: "Choose a .keylight-snapshot.json file." + ) + case .notRegularFile: + return String(localized: "Selected item is not a regular file.") + case .fileSizeUnavailable: + return String(localized: "Could not determine the file size.") + case .fileTooLarge: + return String( + localized: "The snapshot file is too large (maximum 1 MB)." + ) + } + } +} + +/// Keeps AppKit panel creation, lifetime, filename policy, and bounded file IO +/// out of SwiftUI. The returned nil value means the user cancelled. +@MainActor +final class ConfigurationSnapshotFilePanelHelper { + static let filenameSuffix = ".keylight-snapshot.json" + + private var activeOpenPanel: NSOpenPanel? + private var activeSavePanel: NSSavePanel? + + func chooseImportData() throws -> Data? { + guard activeOpenPanel == nil else { return nil } + let panel = NSOpenPanel() + activeOpenPanel = panel + defer { activeOpenPanel = nil } + + panel.allowedContentTypes = [.json] + panel.allowsMultipleSelection = false + panel.canChooseDirectories = false + panel.canChooseFiles = true + panel.prompt = String(localized: "Import") + + guard panel.runModal() == .OK, let url = panel.url else { + return nil + } + guard url.lastPathComponent.lowercased().hasSuffix( + Self.filenameSuffix + ) else { + throw ConfigurationSnapshotFileError.invalidFilename + } + return try Self.readBoundedData( + from: url, + maximumSize: SettingsManager + .maximumConfigurationSnapshotImportSize + ) + } + + func export( + _ data: Data, + suggestedName: String + ) throws -> URL? { + guard activeSavePanel == nil else { return nil } + let panel = NSSavePanel() + activeSavePanel = panel + defer { activeSavePanel = nil } + + panel.allowedContentTypes = [.json] + panel.canCreateDirectories = true + panel.nameFieldStringValue = Self.exportFilename( + for: suggestedName + ) + panel.prompt = String(localized: "Export") + + guard panel.runModal() == .OK, let selectedURL = panel.url else { + return nil + } + let url = Self.normalizedExportURL(selectedURL) + try data.write(to: url, options: .atomic) + return url + } + + static func exportFilename(for name: String) -> String { + let characters = name.unicodeScalars.map { scalar -> Character in + let allowed = CharacterSet.alphanumerics + .union(CharacterSet(charactersIn: "-_")) + return allowed.contains(scalar) ? Character(scalar) : "-" + } + let sanitized = String(characters) + .replacingOccurrences(of: "--", with: "-") + .trimmingCharacters(in: CharacterSet(charactersIn: "-")) + let base = sanitized.isEmpty ? "KeyLight-Snapshot" : sanitized + return base + filenameSuffix + } + + private static func normalizedExportURL(_ selectedURL: URL) -> URL { + guard !selectedURL.lastPathComponent.lowercased().hasSuffix( + filenameSuffix + ) else { + return selectedURL + } + let baseURL = selectedURL.pathExtension.lowercased() == "json" + ? selectedURL.deletingPathExtension() + : selectedURL + return baseURL + .appendingPathExtension("keylight-snapshot") + .appendingPathExtension("json") + } + + private static func readBoundedData( + from url: URL, + maximumSize: Int + ) throws -> Data { + let handle = try FileHandle(forReadingFrom: url) + defer { try? handle.close() } + + var fileStatus = stat() + guard fstat(handle.fileDescriptor, &fileStatus) == 0, + fileStatus.st_size >= 0 else { + throw ConfigurationSnapshotFileError.fileSizeUnavailable + } + guard (fileStatus.st_mode & S_IFMT) == S_IFREG else { + throw ConfigurationSnapshotFileError.notRegularFile + } + guard fileStatus.st_size <= Int64(maximumSize) else { + throw ConfigurationSnapshotFileError.fileTooLarge + } + + let data = try handle.read(upToCount: maximumSize + 1) ?? Data() + guard data.count <= maximumSize else { + throw ConfigurationSnapshotFileError.fileTooLarge + } + return data + } +} diff --git a/KeyLight/Services/HotKeyService.swift b/KeyLight/Services/HotKeyService.swift new file mode 100644 index 0000000..5bd23bc --- /dev/null +++ b/KeyLight/Services/HotKeyService.swift @@ -0,0 +1,344 @@ +import Carbon.HIToolbox +import Foundation + +struct GlobalShortcut: Codable, Equatable, Hashable, Sendable { + let keyCode: UInt32 + let modifiers: UInt32 + + static let `default` = GlobalShortcut( + uncheckedKeyCode: UInt32(kVK_ANSI_K), + modifiers: UInt32(cmdKey | shiftKey) + ) + + private static let allowedModifiers = UInt32(cmdKey | shiftKey | optionKey | controlKey) + + init?(keyCode: UInt32, modifiers: UInt32) { + let normalizedModifiers = modifiers & Self.allowedModifiers + guard keyCode <= 255, normalizedModifiers != 0 else { + return nil + } + self.keyCode = keyCode + self.modifiers = normalizedModifiers + } + + private init(uncheckedKeyCode: UInt32, modifiers: UInt32) { + keyCode = uncheckedKeyCode + self.modifiers = modifiers + } + + var displayName: String { + var result = "" + if modifiers & UInt32(cmdKey) != 0 { result += "⌘" } + if modifiers & UInt32(controlKey) != 0 { result += "⌃" } + if modifiers & UInt32(optionKey) != 0 { result += "⌥" } + if modifiers & UInt32(shiftKey) != 0 { result += "⇧" } + return result + Self.keyDisplayName(keyCode) + } + + private static func keyDisplayName(_ keyCode: UInt32) -> String { + let names: [UInt32: String] = [ + UInt32(kVK_ANSI_A): "A", UInt32(kVK_ANSI_B): "B", + UInt32(kVK_ANSI_C): "C", UInt32(kVK_ANSI_D): "D", + UInt32(kVK_ANSI_E): "E", UInt32(kVK_ANSI_F): "F", + UInt32(kVK_ANSI_G): "G", UInt32(kVK_ANSI_H): "H", + UInt32(kVK_ANSI_I): "I", UInt32(kVK_ANSI_J): "J", + UInt32(kVK_ANSI_K): "K", UInt32(kVK_ANSI_L): "L", + UInt32(kVK_ANSI_M): "M", UInt32(kVK_ANSI_N): "N", + UInt32(kVK_ANSI_O): "O", UInt32(kVK_ANSI_P): "P", + UInt32(kVK_ANSI_Q): "Q", UInt32(kVK_ANSI_R): "R", + UInt32(kVK_ANSI_S): "S", UInt32(kVK_ANSI_T): "T", + UInt32(kVK_ANSI_U): "U", UInt32(kVK_ANSI_V): "V", + UInt32(kVK_ANSI_W): "W", UInt32(kVK_ANSI_X): "X", + UInt32(kVK_ANSI_Y): "Y", UInt32(kVK_ANSI_Z): "Z", + UInt32(kVK_ANSI_0): "0", UInt32(kVK_ANSI_1): "1", + UInt32(kVK_ANSI_2): "2", UInt32(kVK_ANSI_3): "3", + UInt32(kVK_ANSI_4): "4", UInt32(kVK_ANSI_5): "5", + UInt32(kVK_ANSI_6): "6", UInt32(kVK_ANSI_7): "7", + UInt32(kVK_ANSI_8): "8", UInt32(kVK_ANSI_9): "9", + UInt32(kVK_Space): "Space", UInt32(kVK_Tab): "⇥", + UInt32(kVK_Return): "↩", UInt32(kVK_Escape): "⎋", + UInt32(kVK_Delete): "⌫", UInt32(kVK_ForwardDelete): "⌦", + UInt32(kVK_LeftArrow): "←", UInt32(kVK_RightArrow): "→", + UInt32(kVK_UpArrow): "↑", UInt32(kVK_DownArrow): "↓", + UInt32(kVK_Home): "↖", UInt32(kVK_End): "↘", + UInt32(kVK_PageUp): "⇞", UInt32(kVK_PageDown): "⇟", + UInt32(kVK_F1): "F1", UInt32(kVK_F2): "F2", + UInt32(kVK_F3): "F3", UInt32(kVK_F4): "F4", + UInt32(kVK_F5): "F5", UInt32(kVK_F6): "F6", + UInt32(kVK_F7): "F7", UInt32(kVK_F8): "F8", + UInt32(kVK_F9): "F9", UInt32(kVK_F10): "F10", + UInt32(kVK_F11): "F11", UInt32(kVK_F12): "F12", + UInt32(kVK_ANSI_Minus): "-", UInt32(kVK_ANSI_Equal): "=", + UInt32(kVK_ANSI_LeftBracket): "[", UInt32(kVK_ANSI_RightBracket): "]", + UInt32(kVK_ANSI_Semicolon): ";", UInt32(kVK_ANSI_Quote): "'", + UInt32(kVK_ANSI_Comma): ",", UInt32(kVK_ANSI_Period): ".", + UInt32(kVK_ANSI_Slash): "/", UInt32(kVK_ANSI_Backslash): "\\", + UInt32(kVK_ANSI_Grave): "`" + ] + return names[keyCode] ?? "Key \(keyCode)" + } +} + +enum HotKeyRegistrationFailure: Error, Equatable, Sendable { + case eventHandlerInstallationFailed(status: Int32) + case hotKeyRegistrationFailed(status: Int32) +} + +enum HotKeyServiceStatus: Equatable, Sendable { + case stopped + case registering + case registered + case unavailable(HotKeyRegistrationFailure) +} + +@MainActor +protocol HotKeyRegistration: AnyObject { + func unregister() +} + +@MainActor +protocol HotKeyRegistering: AnyObject { + func register( + _ shortcut: GlobalShortcut, + onPress: @escaping @MainActor @Sendable () -> Void + ) -> Result +} + +/// Owns KeyLight's one global shortcut and atomically replaces its Carbon +/// registration when the user records a different combination. +@MainActor +final class HotKeyService { + private let registrar: any HotKeyRegistering + private let onPress: @MainActor @Sendable () -> Void + private let onStatusChange: @MainActor @Sendable (HotKeyServiceStatus) -> Void + + private var registration: (any HotKeyRegistration)? + private var generation: UInt = 0 + private var shortcut: GlobalShortcut + + private(set) var status: HotKeyServiceStatus = .stopped + + init( + registrar: any HotKeyRegistering = CarbonHotKeyRegistrar(), + shortcut: GlobalShortcut = .default, + onPress: @escaping @MainActor @Sendable () -> Void, + onStatusChange: @escaping @MainActor @Sendable (HotKeyServiceStatus) -> Void = { _ in } + ) { + self.registrar = registrar + self.shortcut = shortcut + self.onPress = onPress + self.onStatusChange = onStatusChange + } + + /// Starts registration once. Calling start while registered is a no-op; + /// calling it after a failure performs an explicit retry. + func start() { + guard registration == nil, status != .registering else { return } + + generation &+= 1 + let attemptGeneration = generation + updateStatus(.registering) + + let result = registrar.register(shortcut) { [weak self] in + guard let self, + self.generation == attemptGeneration, + self.registration != nil else { + return + } + self.onPress() + } + + switch result { + case .success(let newRegistration): + guard generation == attemptGeneration, status == .registering else { + newRegistration.unregister() + return + } + registration = newRegistration + updateStatus(.registered) + case .failure(let failure): + guard generation == attemptGeneration, status == .registering else { + return + } + updateStatus(.unavailable(failure)) + } + } + + /// Unregisters all Carbon resources exactly once and invalidates callbacks + /// retained by an old registration. + func stop() { + guard registration != nil || status != .stopped else { return } + + generation &+= 1 + let existingRegistration = registration + registration = nil + existingRegistration?.unregister() + updateStatus(.stopped) + } + + func setShortcut(_ shortcut: GlobalShortcut) { + guard shortcut != self.shortcut else { return } + let shouldRestart = status != .stopped + if shouldRestart { + stop() + } + self.shortcut = shortcut + if shouldRestart { + start() + } + } + + private func updateStatus(_ next: HotKeyServiceStatus) { + guard status != next else { return } + status = next + onStatusChange(next) + } +} + +@MainActor +private final class CarbonHotKeyRegistrar: HotKeyRegistering { + private static let signature = OSType(0x4B4C4754) // "KLGT" + private static let identifier: UInt32 = 1 + + func register( + _ shortcut: GlobalShortcut, + onPress: @escaping @MainActor @Sendable () -> Void + ) -> Result { + let hotKeyID = EventHotKeyID( + signature: Self.signature, + id: Self.identifier + ) + let callbackBox = CarbonHotKeyCallbackBox( + hotKeyID: hotKeyID, + onPress: onPress + ) + let retainedCallbackBox = Unmanaged.passRetained(callbackBox) + + var eventType = EventTypeSpec( + eventClass: OSType(kEventClassKeyboard), + eventKind: UInt32(kEventHotKeyPressed) + ) + var eventHandler: EventHandlerRef? + let handlerStatus = InstallEventHandler( + GetApplicationEventTarget(), + keyLightCarbonHotKeyHandler, + 1, + &eventType, + retainedCallbackBox.toOpaque(), + &eventHandler + ) + + guard handlerStatus == noErr, let eventHandler else { + retainedCallbackBox.release() + return .failure(.eventHandlerInstallationFailed( + status: handlerStatus == noErr ? Int32(paramErr) : Int32(handlerStatus) + )) + } + + var hotKey: EventHotKeyRef? + let registrationStatus = RegisterEventHotKey( + shortcut.keyCode, + shortcut.modifiers, + hotKeyID, + GetApplicationEventTarget(), + 0, + &hotKey + ) + + guard registrationStatus == noErr, let hotKey else { + RemoveEventHandler(eventHandler) + retainedCallbackBox.release() + return .failure(.hotKeyRegistrationFailed( + status: registrationStatus == noErr ? Int32(paramErr) : Int32(registrationStatus) + )) + } + + return .success(CarbonHotKeyRegistration( + hotKey: hotKey, + eventHandler: eventHandler, + retainedCallbackBox: retainedCallbackBox + )) + } +} + +private final class CarbonHotKeyCallbackBox: @unchecked Sendable { + let hotKeyID: EventHotKeyID + let onPress: @MainActor @Sendable () -> Void + + init( + hotKeyID: EventHotKeyID, + onPress: @escaping @MainActor @Sendable () -> Void + ) { + self.hotKeyID = hotKeyID + self.onPress = onPress + } + + func dispatchPress() { + Task { @MainActor [onPress] in + onPress() + } + } +} + +private let keyLightCarbonHotKeyHandler: EventHandlerUPP = { _, event, userData in + guard let event, let userData else { + return OSStatus(eventNotHandledErr) + } + + let callbackBox = Unmanaged + .fromOpaque(userData) + .takeUnretainedValue() + var receivedID = EventHotKeyID() + let parameterStatus = GetEventParameter( + event, + EventParamName(kEventParamDirectObject), + EventParamType(typeEventHotKeyID), + nil, + MemoryLayout.size, + nil, + &receivedID + ) + + guard parameterStatus == noErr, + receivedID.signature == callbackBox.hotKeyID.signature, + receivedID.id == callbackBox.hotKeyID.id else { + return OSStatus(eventNotHandledErr) + } + + callbackBox.dispatchPress() + return noErr +} + +@MainActor +private final class CarbonHotKeyRegistration: HotKeyRegistration { + private var hotKey: EventHotKeyRef? + private var eventHandler: EventHandlerRef? + private var retainedCallbackBox: Unmanaged? + + init( + hotKey: EventHotKeyRef, + eventHandler: EventHandlerRef, + retainedCallbackBox: Unmanaged + ) { + self.hotKey = hotKey + self.eventHandler = eventHandler + self.retainedCallbackBox = retainedCallbackBox + } + + func unregister() { + guard hotKey != nil || eventHandler != nil || retainedCallbackBox != nil else { + return + } + + if let hotKey { + UnregisterEventHotKey(hotKey) + self.hotKey = nil + } + if let eventHandler { + RemoveEventHandler(eventHandler) + self.eventHandler = nil + } + retainedCallbackBox?.release() + retainedCallbackBox = nil + } +} diff --git a/KeyLight/Services/InputController.swift b/KeyLight/Services/InputController.swift new file mode 100644 index 0000000..ac4ca9e --- /dev/null +++ b/KeyLight/Services/InputController.swift @@ -0,0 +1,500 @@ +import Foundation + +@MainActor +protocol InputPermissionProviding: AnyObject { + var runningApplicationPath: String { get } + var installationIssue: String? { get } + + func hasInputMonitoringPermission() -> Bool + func requestInputMonitoringPermission() -> Bool + func openInputMonitoringSettings() +} + +extension PermissionManager: InputPermissionProviding {} + +@MainActor +protocol InputMonitoringSession: AnyObject { + var isRunning: Bool { get } + + @discardableResult + func start() -> Bool + func stop() +} + +extension KeyboardMonitor: InputMonitoringSession {} + +@MainActor +protocol InputControllerRecheckToken: AnyObject { + func cancel() +} + +/// Platform-neutral event accepted from an injected monitor session. +/// Production receives one merged KeyboardMonitor callback while retaining the +/// event-tap versus Consumer-HID source identity. +struct InputMonitorEvent: Equatable, Sendable { + let keyCode: UInt16 + let isKeyDown: Bool + let isRepeat: Bool + let source: KeyboardEvent.Source + let sequence: UInt64 + + init( + keyCode: UInt16, + isKeyDown: Bool, + isRepeat: Bool = false, + source: KeyboardEvent.Source = .eventTap, + sequence: UInt64 = 0 + ) { + self.keyCode = keyCode + self.isKeyDown = isKeyDown + self.isRepeat = isKeyDown && isRepeat + self.source = source + self.sequence = sequence + } +} + +/// Complete user-facing input status for a mechanical AppDelegate migration. +struct InputControllerStatus: Equatable, Sendable { + let state: InputMonitoringState + let runningApplicationPath: String + let installationIssue: String? + let lastKnownAuthorization: Bool? + let monitorRunning: Bool + let recheckInterval: TimeInterval? +} + +/// Owns Input Monitoring permission reconciliation and the global keyboard +/// monitor lifecycle. It deliberately does not own rendering or KeyLightModel. +@MainActor +final class InputController { + typealias MonitorFactory = @MainActor ( + _ onEvent: @escaping @MainActor (InputMonitorEvent) -> Void, + _ onStreamReset: @escaping @MainActor () -> Void, + _ onUnavailable: @escaping @MainActor () -> Void + ) -> any InputMonitoringSession + + typealias RecheckScheduler = @MainActor ( + _ interval: TimeInterval, + _ tolerance: TimeInterval, + _ action: @escaping @MainActor () -> Void + ) -> any InputControllerRecheckToken + + private let permissionProvider: any InputPermissionProviding + private let monitorFactory: MonitorFactory + private let recheckScheduler: RecheckScheduler + private let clock: @MainActor () -> TimeInterval + private let isTestEnvironment: Bool + private let fastRecheckInterval: TimeInterval + private let slowRecheckInterval: TimeInterval + private let onKeyboardEvent: @MainActor (KeyboardEvent) -> Void + private let onStatusChange: @MainActor (InputControllerStatus) -> Void + + private var monitor: (any InputMonitoringSession)? + private var monitorGeneration: UInt = 0 + private var recheckToken: (any InputControllerRecheckToken)? + private var lastEmittedStatus: InputControllerStatus? + + private(set) var state: InputMonitoringState = .checking + private(set) var isEnabled = false + private(set) var isStarted = false + private(set) var isSleeping = false + private(set) var lastKnownAuthorization: Bool? + private(set) var currentRecheckInterval: TimeInterval? + + var status: InputControllerStatus { + InputControllerStatus( + state: state, + runningApplicationPath: permissionProvider.runningApplicationPath, + installationIssue: permissionProvider.installationIssue, + lastKnownAuthorization: lastKnownAuthorization, + monitorRunning: monitor?.isRunning == true, + recheckInterval: currentRecheckInterval + ) + } + + init( + permissionProvider: any InputPermissionProviding = PermissionManager(), + monitorFactory: @escaping MonitorFactory = InputController.makeLiveMonitor, + recheckScheduler: @escaping RecheckScheduler = InputController.scheduleLiveRecheck, + clock: @escaping @MainActor () -> TimeInterval = { + ProcessInfo.processInfo.systemUptime + }, + isTestEnvironment: Bool = ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil, + fastRecheckInterval: TimeInterval = 5, + slowRecheckInterval: TimeInterval = 300, + onKeyboardEvent: @escaping @MainActor (KeyboardEvent) -> Void, + onStatusChange: @escaping @MainActor (InputControllerStatus) -> Void = { _ in } + ) { + self.permissionProvider = permissionProvider + self.monitorFactory = monitorFactory + self.recheckScheduler = recheckScheduler + self.clock = clock + self.isTestEnvironment = isTestEnvironment + self.fastRecheckInterval = Self.sanitizedInterval(fastRecheckInterval, fallback: 5) + self.slowRecheckInterval = Self.sanitizedInterval(slowRecheckInterval, fallback: 300) + self.onKeyboardEvent = onKeyboardEvent + self.onStatusChange = onStatusChange + } + + /// Starts permission reconciliation. Calling this repeatedly updates the + /// enabled intent but never creates a duplicate running monitor. + func start(isEnabled: Bool, allowPermissionRequest: Bool = false) { + isStarted = true + isSleeping = false + self.isEnabled = isEnabled + reconcilePermission(allowRequest: allowPermissionRequest) + } + + /// Stops all controller-owned work. Repeated calls are no-ops. + func stop() { + guard isStarted || monitor != nil || recheckToken != nil else { return } + isStarted = false + isSleeping = false + cancelRecheck() + stopMonitor(emitReset: true, resetSource: .lifecycle) + state = lastKnownAuthorization == true ? .authorized : .permissionRequired + publishStatusIfChanged() + } + + func setEnabled(_ enabled: Bool, allowPermissionRequest: Bool = false) { + let changed = enabled != isEnabled + isEnabled = enabled + + if !enabled, changed { + stopMonitor(emitReset: true, resetSource: .lifecycle) + } + + guard isStarted, !isSleeping else { + publishStatusIfChanged() + return + } + reconcilePermission(allowRequest: allowPermissionRequest) + } + + /// Rechecks TCC when KeyLight becomes active, without prompting. + func applicationDidBecomeActive() { + guard isStarted, !isSleeping else { return } + reconcilePermission(allowRequest: false) + } + + func handleSleep() { + guard isStarted, !isSleeping else { return } + isSleeping = true + cancelRecheck() + stopMonitor(emitReset: true, resetSource: .lifecycle) + state = lastKnownAuthorization == true ? .authorized : .permissionRequired + publishStatusIfChanged() + } + + func handleWake() { + guard isStarted, isSleeping else { return } + isSleeping = false + emitStreamReset(source: .lifecycle) + reconcilePermission(allowRequest: false) + } + + /// Explicit user permission action from setup or recovery UI. + func requestPermission() { + guard isStarted, !isSleeping else { return } + reconcilePermission(allowRequest: true) + } + + /// Explicit non-prompting retry from menus or Settings. + func retry() { + guard isStarted, !isSleeping else { return } + reconcilePermission(allowRequest: false) + } + + func openInputMonitoringSettings() { + permissionProvider.openInputMonitoringSettings() + } + + /// Stops the current stream and immediately reconciles a fresh monitor. + func restart() { + guard isStarted, !isSleeping else { return } + stopMonitor(emitReset: true, resetSource: .eventTap) + reconcilePermission(allowRequest: false) + } + + // MARK: - Reconciliation + + private func reconcilePermission(allowRequest: Bool) { + guard isStarted, !isSleeping else { return } + + if isTestEnvironment { + cancelRecheck() + stopMonitor(emitReset: monitor != nil, resetSource: .lifecycle) + state = .checking + publishStatusIfChanged() + return + } + + var authorized = permissionProvider.hasInputMonitoringPermission() + var action = InputMonitoringReconciliationResolver.resolve( + installationIssue: permissionProvider.installationIssue, + authorized: authorized, + allowRequest: allowRequest, + isEnabled: isEnabled, + monitorExists: monitor != nil, + monitorRunning: monitor?.isRunning == true + ) + + if action == .requestPermission { + authorized = permissionProvider.requestInputMonitoringPermission() + action = InputMonitoringReconciliationResolver.resolve( + installationIssue: permissionProvider.installationIssue, + authorized: authorized, + allowRequest: false, + isEnabled: isEnabled, + monitorExists: monitor != nil, + monitorRunning: monitor?.isRunning == true + ) + } + + lastKnownAuthorization = authorized + + switch action { + case .requestPermission: + assertionFailure("Permission requests must resolve before applying reconciliation actions") + state = .permissionRequired + rescheduleRecheck(interval: fastRecheckInterval) + + case .settle(let settledState, let shouldStopMonitor): + if shouldStopMonitor { + stopMonitor(emitReset: true, resetSource: .lifecycle) + } + state = settledState + let healthy = settledState == .authorized || settledState == .active + rescheduleRecheck(interval: healthy ? slowRecheckInterval : fastRecheckInterval) + + case .startMonitor(let shouldStopExisting): + if shouldStopExisting { + stopMonitor(emitReset: true, resetSource: .eventTap) + } + state = .starting + publishStatusIfChanged() + + let succeeded = startMonitor() + state = InputMonitoringReconciliationResolver.stateAfterMonitorStart(succeeded: succeeded) + rescheduleRecheck(interval: succeeded ? slowRecheckInterval : fastRecheckInterval) + } + + publishStatusIfChanged() + } + + // MARK: - Monitor lifecycle + + @discardableResult + private func startMonitor() -> Bool { + if monitor?.isRunning == true { + return true + } + + if monitor != nil { + stopMonitor(emitReset: true, resetSource: .eventTap) + } + + monitorGeneration &+= 1 + let generation = monitorGeneration + let newMonitor = monitorFactory( + { [weak self] event in + self?.handleMonitorEvent(event, generation: generation) + }, + { [weak self] in + self?.handleMonitorStreamReset(generation: generation) + }, + { [weak self] in + self?.handleMonitorUnavailable(generation: generation) + } + ) + monitor = newMonitor + + guard newMonitor.start() else { + newMonitor.stop() + monitor = nil + return false + } + return true + } + + private func stopMonitor( + emitReset: Bool, + resetSource: KeyboardEvent.Source + ) { + guard let existingMonitor = monitor else { + if emitReset { + emitStreamReset(source: resetSource) + } + return + } + + monitorGeneration &+= 1 + monitor = nil + existingMonitor.stop() + if emitReset { + emitStreamReset(source: resetSource) + } + } + + private func handleMonitorUnavailable(generation: UInt) { + guard generation == monitorGeneration, + monitor != nil, + isStarted, + !isSleeping else { + return + } + stopMonitor(emitReset: true, resetSource: .eventTap) + reconcilePermission(allowRequest: false) + } + + /// A re-enabled event tap may have dropped key-up events while disabled. + /// Clear downstream held-key state without replacing the healthy session. + private func handleMonitorStreamReset(generation: UInt) { + guard generation == monitorGeneration, + monitor != nil, + isStarted, + !isSleeping else { + return + } + emitStreamReset(source: .eventTap) + } + + private func handleMonitorEvent(_ event: InputMonitorEvent, generation: UInt) { + guard generation == monitorGeneration, + monitor != nil, + isStarted, + !isSleeping else { + return + } + + let keyCode = KeyboardLayoutInfo.canonicalKeyCode(for: event.keyCode) + let timestamp = clock() + let normalized: KeyboardEvent + if event.isKeyDown { + normalized = .keyDown( + keyCode, + isRepeat: event.isRepeat, + source: event.source, + timestamp: timestamp, + sequence: event.sequence + ) + } else { + normalized = .keyUp( + keyCode, + source: event.source, + timestamp: timestamp, + sequence: event.sequence + ) + } + KeyLightSignposts.normalizedEventDispatched( + sequence: normalized.sequence + ) + onKeyboardEvent(normalized) + } + + private func emitStreamReset(source: KeyboardEvent.Source) { + onKeyboardEvent(.streamReset(source: source, timestamp: clock())) + } + + // MARK: - Rechecks and status + + private func rescheduleRecheck(interval: TimeInterval) { + if let currentRecheckInterval, + abs(currentRecheckInterval - interval) < 0.001, + recheckToken != nil { + return + } + + cancelRecheck() + currentRecheckInterval = interval + recheckToken = recheckScheduler( + interval, + min(10, interval * 0.5) + ) { [weak self] in + self?.reconcilePermission(allowRequest: false) + } + } + + private func cancelRecheck() { + recheckToken?.cancel() + recheckToken = nil + currentRecheckInterval = nil + } + + private func publishStatusIfChanged() { + let next = status + guard next != lastEmittedStatus else { return } + lastEmittedStatus = next + onStatusChange(next) + } + + private static func sanitizedInterval(_ interval: TimeInterval, fallback: TimeInterval) -> TimeInterval { + interval.isFinite && interval > 0 ? interval : fallback + } + + // MARK: - Live adapters + + private static func makeLiveMonitor( + onEvent: @escaping @MainActor (InputMonitorEvent) -> Void, + onStreamReset: @escaping @MainActor () -> Void, + onUnavailable: @escaping @MainActor () -> Void + ) -> any InputMonitoringSession { + KeyboardMonitor( + onStreamReset: { _ in + Task { @MainActor in + onStreamReset() + } + }, + onBecameUnavailable: { _ in + Task { @MainActor in + onUnavailable() + } + }, + callback: { event in + onEvent(InputMonitorEvent( + keyCode: event.keyCode, + isKeyDown: event.isKeyDown, + isRepeat: event.isRepeat, + source: event.source, + sequence: event.sequence + )) + } + ) + } + + private static func scheduleLiveRecheck( + interval: TimeInterval, + tolerance: TimeInterval, + action: @escaping @MainActor () -> Void + ) -> any InputControllerRecheckToken { + LiveInputControllerRecheckToken( + interval: interval, + tolerance: tolerance, + action: action + ) + } +} + +@MainActor +private final class LiveInputControllerRecheckToken: InputControllerRecheckToken { + private var timer: Timer? + + init( + interval: TimeInterval, + tolerance: TimeInterval, + action: @escaping @MainActor () -> Void + ) { + let timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { _ in + Task { @MainActor in + action() + } + } + timer.tolerance = tolerance + self.timer = timer + } + + func cancel() { + timer?.invalidate() + timer = nil + } +} diff --git a/KeyLight/Services/KeyLightLog.swift b/KeyLight/Services/KeyLightLog.swift index b458549..90c4f21 100644 --- a/KeyLight/Services/KeyLightLog.swift +++ b/KeyLight/Services/KeyLightLog.swift @@ -1,10 +1,75 @@ import Foundation +import OSLog -/// Debug-only logging — completely stripped from Release builds. -/// SECURITY: Never log key codes, key events, or any keystroke-related data. -@inline(__always) -func KeyLightLog(_ message: @autoclosure () -> String) { +/// Privacy-safe unified loggers. Never log key codes, key events, typed text, +/// or any other keystroke-derived data. +enum KeyLightLogger { + private static let subsystem = Bundle.main.bundleIdentifier ?? "com.keylight.app" + + static let app = Logger(subsystem: subsystem, category: "App") + static let permissions = Logger(subsystem: subsystem, category: "Permissions") + static let keyboardMonitor = Logger(subsystem: subsystem, category: "KeyboardMonitor") + static let display = Logger(subsystem: subsystem, category: "Display") + static let renderer = Logger(subsystem: subsystem, category: "Renderer") + static let storage = Logger(subsystem: subsystem, category: "Storage") + static let imports = Logger(subsystem: subsystem, category: "Import") + static let recovery = Logger(subsystem: subsystem, category: "Recovery") +} + +/// Local Instruments markers for the latency pipeline. They carry only an +/// anonymous, process-local sequence number and are absent from Release builds. +/// Never add key codes, characters, geometry, captured content, or file paths. +enum KeyLightSignposts { #if DEBUG - print("KeyLight: \(message())") + private static let log = OSLog( + subsystem: Bundle.main.bundleIdentifier ?? "com.keylight.app", + category: "Latency" + ) + + static func eventReceived(sequence: UInt64) { + os_signpost(.event, log: log, name: "Event Received", "sequence=%{public}llu", sequence) + } + + static func eventNormalized(sequence: UInt64) { + os_signpost(.event, log: log, name: "Event Normalized", "sequence=%{public}llu", sequence) + } + + static func normalizedEventDispatched(sequence: UInt64) { + os_signpost(.event, log: log, name: "Normalized Event Dispatched", "sequence=%{public}llu", sequence) + } + + static func overlayStateUpdated(sequence: UInt64) { + os_signpost(.event, log: log, name: "Overlay State Updated", "sequence=%{public}llu", sequence) + } + + static func rendererSubmitted(sequence: UInt64) { + os_signpost(.event, log: log, name: "Renderer Submitted", "sequence=%{public}llu", sequence) + } + + static func captureStarted() { + os_signpost(.event, log: log, name: "Capture Started") + } + + static func captureStopped() { + os_signpost(.event, log: log, name: "Capture Stopped") + } + + static func framePresented(sequence: UInt64) { + os_signpost(.event, log: log, name: "Capture Frame Presented", "sequence=%{public}llu", sequence) + } + + static func frameDropped(sequence: UInt64) { + os_signpost(.event, log: log, name: "Capture Frame Dropped", "sequence=%{public}llu", sequence) + } + #else + static func eventReceived(sequence: UInt64) {} + static func eventNormalized(sequence: UInt64) {} + static func normalizedEventDispatched(sequence: UInt64) {} + static func overlayStateUpdated(sequence: UInt64) {} + static func rendererSubmitted(sequence: UInt64) {} + static func captureStarted() {} + static func captureStopped() {} + static func framePresented(sequence: UInt64) {} + static func frameDropped(sequence: UInt64) {} #endif } diff --git a/KeyLight/Services/KeyboardEventDecoder.swift b/KeyLight/Services/KeyboardEventDecoder.swift new file mode 100644 index 0000000..036a77d --- /dev/null +++ b/KeyLight/Services/KeyboardEventDecoder.swift @@ -0,0 +1,357 @@ +import Foundation + +/// Privacy-safe platform event metadata emitted after decoding. Geometry is +/// resolved later from the live layout; characters never leave decoding. +struct KeyEvent: Sendable { + let keyCode: UInt16 + let isKeyDown: Bool + let isRepeat: Bool + let source: KeyboardEvent.Source + let sequence: UInt64 + + init( + keyCode: UInt16, + isKeyDown: Bool, + isRepeat: Bool, + source: KeyboardEvent.Source = .eventTap, + sequence: UInt64 = 0 + ) { + self.keyCode = keyCode + self.isKeyDown = isKeyDown + self.isRepeat = isKeyDown && isRepeat + self.source = source + self.sequence = sequence + } +} + +/// Deterministic keyboard decoding and source-deduplication state. This type +/// deliberately has no event-tap, HID, run-loop, permission, or rendering +/// ownership so fixtures can exercise the complete normalization policy. +struct KeyboardEventDecoder: Sendable { + enum MediaEventSource: Sendable { + case systemDefined + case hid + } + + enum ResolutionConfidence: Equatable, Sendable { + case high + case unknown + } + + struct KeyboardResolution: Equatable, Sendable { + let keyCode: UInt16 + let confidence: ResolutionConfidence + } + + struct MediaTransition: Equatable, Sendable { + let keyCode: UInt16 + let isKeyDown: Bool + } + + private var recentMediaEventTimes: [UInt32: TimeInterval] = [:] + private var recentHIDMediaEventTimes: [UInt32: TimeInterval] = [:] + private var recentSystemMediaEventTimes: [UInt32: TimeInterval] = [:] + private var recentTrustedKeyboardTopRowEvents: [Bool: (keyCode: UInt16, timestamp: TimeInterval)] = [:] + private var modifierKeyStates: [UInt16: Bool] = [:] + private var lastCapsLockTransitionTime: TimeInterval = -1 + + private let mediaDedupWindow: TimeInterval = 0.03 + private let keyboardTopRowSourceWindow: TimeInterval = 0.04 + private let capsLockSystemEventGuardWindow: TimeInterval = 0.08 + + // Legacy compatibility mapping for system-defined media key events. + private static let legacyNXMap: [Int: UInt16] = [ + 0: 500, // Brightness Down + 1: 501, // Brightness Up + 2: 502, // Mission Control + 3: 503, // Spotlight/Launchpad + 7: 507, // Legacy F8 media position + 16: 516, // Play/Pause + 17: 517, // Next + 18: 518, // Mute + ] + + // Canonical NX_* mapping from ev_keymap.h. + private static let canonicalNXMap: [Int: UInt16] = [ + 3: 500, // NX_KEYTYPE_BRIGHTNESS_DOWN + 2: 501, // NX_KEYTYPE_BRIGHTNESS_UP + 18: 506, // NX_KEYTYPE_PREVIOUS + 16: 516, // NX_KEYTYPE_PLAY + 17: 517, // NX_KEYTYPE_NEXT + 19: 517, // NX_KEYTYPE_FAST + 20: 506, // NX_KEYTYPE_REWIND + 7: 518, // NX_KEYTYPE_MUTE + 1: 519, // NX_KEYTYPE_SOUND_DOWN + 0: 520, // NX_KEYTYPE_SOUND_UP + ] + private static let canonicalPreferredNXCodes: Set = [0, 1, 2, 3, 7, 16, 17, 18, 19, 20] + + private static let modifierKeyCodes: Set = [54, 55, 56, 57, 58, 59, 60, 61, 62, 63] + private static let modifierCounterpartKeyCode: [UInt16: UInt16] = [ + 55: 54, + 54: 55, + 58: 61, + 61: 58, + 59: 62, + 62: 59, + 56: 60, + 60: 56, + ] + + private static let functionCharacterToFunctionKeyCode: [UInt32: UInt16] = [ + 0xF704: 122, // F1 + 0xF705: 120, // F2 + 0xF706: 99, // F3 + 0xF707: 118, // F4 + 0xF708: 96, // F5 + 0xF709: 97, // F6 + 0xF70A: 98, // F7 + 0xF70B: 100, // F8 + 0xF70C: 101, // F9 + 0xF70D: 109, // F10 + 0xF70E: 103, // F11 + 0xF70F: 111, // F12 + ] + + // NSEvent.SpecialKey F1...F12 use the same stable function-key scalar + // values. Keeping the raw fixture boundary here avoids importing AppKit. + private static let specialKeyRawValueToFunctionKeyCode: [Int: UInt16] = [ + 0xF704: 122, + 0xF705: 120, + 0xF706: 99, + 0xF707: 118, + 0xF708: 96, + 0xF709: 97, + 0xF70A: 98, + 0xF70B: 100, + 0xF70C: 101, + 0xF70D: 109, + 0xF70E: 103, + 0xF70F: 111, + ] + + // Trusted raw keyboard codes observed on media-mode top-row keys. These + // are used only when special-key/scalar metadata is absent. + private static let trustedTopRowRawFunctionKeyCodeMap: [UInt16: UInt16] = [ + 145: 122, // F1 + 144: 120, // F2 + 160: 99, // F3 + 131: 118, // F4 + 177: 96, // F5 + 176: 97, // F6 + 178: 97, // F6 Do Not Disturb on newer Apple keyboards + 173: 98, // F7 + 174: 100, // F8 + 175: 101, // F9 + 74: 109, // F10 + 73: 103, // F11 + 72: 111, // F12 + ] + + private static let topRowFunctionKeyCodes: Set = [ + 122, 120, 99, 118, 96, 97, 98, 100, 101, 109, 103, 111, + ] + + mutating func reset() { + recentMediaEventTimes.removeAll() + recentHIDMediaEventTimes.removeAll() + recentSystemMediaEventTimes.removeAll() + recentTrustedKeyboardTopRowEvents.removeAll() + modifierKeyStates.removeAll() + lastCapsLockTransitionTime = -1 + } + + func resolveVirtualKeyCode(nxCode: Int) -> UInt16? { + if Self.canonicalPreferredNXCodes.contains(nxCode) { + return Self.canonicalNXMap[nxCode] ?? Self.legacyNXMap[nxCode] + } + return Self.canonicalNXMap[nxCode] ?? Self.legacyNXMap[nxCode] + } + + func resolveKeyboardEvent( + rawKeyCode: UInt16, + charactersIgnoringModifiers: String?, + specialKeyRawValue: Int?, + isMappedKeyCode: Bool + ) -> KeyboardResolution { + if isMappedKeyCode { + return KeyboardResolution(keyCode: rawKeyCode, confidence: .high) + } + + if let specialKeyRawValue, + let functionKeyCode = Self.specialKeyRawValueToFunctionKeyCode[specialKeyRawValue] { + return KeyboardResolution(keyCode: functionKeyCode, confidence: .high) + } + + if let scalar = charactersIgnoringModifiers?.unicodeScalars.first, + let functionKeyCode = Self.functionCharacterToFunctionKeyCode[scalar.value] { + return KeyboardResolution(keyCode: functionKeyCode, confidence: .high) + } + + if let functionKeyCode = Self.trustedTopRowRawFunctionKeyCodeMap[rawKeyCode] { + return KeyboardResolution(keyCode: functionKeyCode, confidence: .high) + } + + return KeyboardResolution(keyCode: rawKeyCode, confidence: .unknown) + } + + func decodeKeyboardEvent( + rawKeyCode: UInt16, + isKeyDown: Bool, + isRepeat: Bool, + source: KeyboardEvent.Source = .eventTap, + charactersIgnoringModifiers: String?, + specialKeyRawValue: Int?, + isMappedKeyCode: Bool + ) -> KeyEvent? { + let resolution = resolveKeyboardEvent( + rawKeyCode: rawKeyCode, + charactersIgnoringModifiers: charactersIgnoringModifiers, + specialKeyRawValue: specialKeyRawValue, + isMappedKeyCode: isMappedKeyCode + ) + guard resolution.confidence == .high else { return nil } + return KeyEvent( + keyCode: resolution.keyCode, + isKeyDown: isKeyDown, + isRepeat: isRepeat, + source: source + ) + } + + mutating func resolveModifierFlagsChanged(keyCode: UInt16, flagIsSet: Bool) -> Bool? { + guard Self.modifierKeyCodes.contains(keyCode) else { return nil } + let previous = modifierKeyStates[keyCode] ?? false + + if previous == flagIsSet { + // Shared masks retain the flag while the opposite side remains + // held, so infer this key's release from counterpart state. + if previous, + let counterpart = Self.modifierCounterpartKeyCode[keyCode], + modifierKeyStates[counterpart] == true { + modifierKeyStates[keyCode] = false + return false + } + return nil + } + + modifierKeyStates[keyCode] = flagIsSet + return flagIsSet + } + + mutating func recordCapsLockTransition(at timestamp: TimeInterval) { + lastCapsLockTransitionTime = timestamp + } + + func capsLockEmitSequence(isKeyDown: Bool) -> [Bool] { + isKeyDown ? [true, false] : [false] + } + + func decodeSystemDefinedMediaEvent( + subtypeRawValue: Int, + data1: UInt32, + now: TimeInterval + ) -> MediaTransition? { + guard subtypeRawValue == 8 else { return nil } + + let nxKeyCode = Int((data1 & 0xFFFF0000) >> 16) + let keyState = Int((data1 & 0x0000FF00) >> 8) + let isKeyDown = keyState == 0x0A || keyState == 0x00 + let isKeyUp = keyState == 0x0B + guard isKeyDown || isKeyUp else { return nil } + + // Some keyboards report Caps Lock through NX code 4 immediately after + // the real flags event. It must never masquerade as top-row activity. + if nxKeyCode == 4, + lastCapsLockTransitionTime >= 0, + now - lastCapsLockTransitionTime < capsLockSystemEventGuardWindow { + return nil + } + + guard let virtualKeyCode = resolveVirtualKeyCode(nxCode: nxKeyCode) else { return nil } + return MediaTransition(keyCode: virtualKeyCode, isKeyDown: isKeyDown) + } + + mutating func recordTrustedKeyboardTopRowEvent( + canonicalKeyCode: UInt16, + isKeyDown: Bool, + now: TimeInterval + ) { + guard Self.topRowFunctionKeyCodes.contains(canonicalKeyCode) else { return } + recentTrustedKeyboardTopRowEvents[isKeyDown] = (canonicalKeyCode, now) + } + + mutating func shouldDedupeMediaEvent( + canonicalKeyCode: UInt16, + isKeyDown: Bool, + source: MediaEventSource, + now: TimeInterval + ) -> Bool { + let dedupeKey = (UInt32(canonicalKeyCode) << 1) | (isKeyDown ? 1 : 0) + + if shouldSuppressMediaEventForRecentTrustedKeyboardTopRow( + keyCode: canonicalKeyCode, + isKeyDown: isKeyDown, + now: now + ) { + return true + } + + switch source { + case .hid: + if let lastHID = recentHIDMediaEventTimes[dedupeKey], now - lastHID < mediaDedupWindow { + return true + } + recentHIDMediaEventTimes[dedupeKey] = now + + case .systemDefined: + // Prefer HID if both sources report the same transition. + if let lastHID = recentHIDMediaEventTimes[dedupeKey], now - lastHID < mediaDedupWindow { + return true + } + if let lastSystem = recentSystemMediaEventTimes[dedupeKey], now - lastSystem < mediaDedupWindow { + return true + } + recentSystemMediaEventTimes[dedupeKey] = now + } + + recentMediaEventTimes[dedupeKey] = now + pruneMediaHistory(now: now) + return false + } + + private mutating func shouldSuppressMediaEventForRecentTrustedKeyboardTopRow( + keyCode: UInt16, + isKeyDown: Bool, + now: TimeInterval + ) -> Bool { + guard Self.topRowFunctionKeyCodes.contains(keyCode) else { return false } + guard let recent = recentTrustedKeyboardTopRowEvents[isKeyDown] else { return false } + guard now - recent.timestamp <= keyboardTopRowSourceWindow else { + recentTrustedKeyboardTopRowEvents.removeValue(forKey: isKeyDown) + return false + } + + // Preserve the trusted keyboard transition over mismatched NX/HID + // aliases reported for the same physical press/release window. + return true + } + + private mutating func pruneMediaHistory(now: TimeInterval) { + if recentMediaEventTimes.count > 64 { + recentMediaEventTimes = recentMediaEventTimes.filter { + now - $0.value < mediaDedupWindow * 2 + } + } + if recentHIDMediaEventTimes.count > 64 { + recentHIDMediaEventTimes = recentHIDMediaEventTimes.filter { + now - $0.value < mediaDedupWindow * 2 + } + } + if recentSystemMediaEventTimes.count > 64 { + recentSystemMediaEventTimes = recentSystemMediaEventTimes.filter { + now - $0.value < mediaDedupWindow * 2 + } + } + } +} diff --git a/KeyLight/Services/KeyboardMonitor.swift b/KeyLight/Services/KeyboardMonitor.swift index 3e9ea02..af4cc93 100644 --- a/KeyLight/Services/KeyboardMonitor.swift +++ b/KeyLight/Services/KeyboardMonitor.swift @@ -11,91 +11,62 @@ private let enableDebugLogging = false #endif private let systemDefinedEventRawValue: UInt32 = 14 -/// Represents a keyboard event with key code, state, position and width -struct KeyEvent: Sendable { - let keyCode: UInt16 - let isKeyDown: Bool - let horizontalPosition: CGFloat - let keyWidth: CGFloat -} - -/// Monitors global keyboard events using CGEventTap -/// SAFETY: KeyboardMonitor state is confined to the main run loop. -/// C callbacks only interact with the instance via main-run-loop scheduled work. +/// Monitors global keyboard events using a listen-only CGEventTap. +/// +/// The event tap, decoder, and narrowly allow-listed HID fallback are confined +/// to one dedicated serial CFRunLoop thread. Only normalized value events cross +/// to the main actor, keeping AppKit layout and rendering out of the event-tap +/// timeout path. final class KeyboardMonitor: @unchecked Sendable { - private enum MediaEventSource { - case systemDefined - case hid - } - - private enum KeyboardResolutionConfidence { - case high - case unknown - } - - private struct KeyboardResolution { - let keyCode: UInt16 - let confidence: KeyboardResolutionConfidence - } + /// The privacy-critical tap mode is a runtime contract, not merely a + /// source-code convention. Keeping it in one value lets tests verify the + /// actual option passed to Core Graphics without reading protected source + /// folders or requesting Files & Folders access. + static let eventTapOptions: CGEventTapOptions = .listenOnly private var eventTap: CFMachPort? private var runLoopSource: CFRunLoopSource? - private var callback: ((KeyEvent) -> Void)? + private var callback: (@MainActor (KeyEvent) -> Void)? + private var onStreamReset: ((KeyboardMonitor) -> Void)? + private var onBecameUnavailable: ((KeyboardMonitor) -> Void)? private var hidManager: IOHIDManager? - - private var recentMediaEventTimes: [UInt32: CFAbsoluteTime] = [:] - private var recentHIDMediaEventTimes: [UInt32: CFAbsoluteTime] = [:] - private var recentSystemMediaEventTimes: [UInt32: CFAbsoluteTime] = [:] - private var recentTrustedKeyboardTopRowEvents: [Bool: (keyCode: UInt16, timestamp: CFAbsoluteTime)] = [:] - private var modifierKeyStates: [UInt16: Bool] = [:] - private var lastCapsLockTransitionTime: CFAbsoluteTime = -1 - private let mediaDedupWindow: CFAbsoluteTime = 0.03 - private let keyboardTopRowSourceWindow: CFAbsoluteTime = 0.04 - private let capsLockSystemEventGuardWindow: CFAbsoluteTime = 0.08 + private var decoder = KeyboardEventDecoder() private let capsLockPulseDuration: TimeInterval = 0.1 - - // Legacy compatibility mapping for system-defined media key events. - private static let legacyNXMap: [Int: UInt16] = [ - 0: 500, // Brightness Down - 1: 501, // Brightness Up - 2: 502, // Mission Control - 3: 503, // Spotlight/Launchpad - 7: 507, // Legacy F8 media position - 16: 516, // Play/Pause - 17: 517, // Next - 18: 518, // Mute - ] - - // Canonical NX_* mapping from ev_keymap.h. - private static let canonicalNXMap: [Int: UInt16] = [ - 3: 500, // NX_KEYTYPE_BRIGHTNESS_DOWN - 2: 501, // NX_KEYTYPE_BRIGHTNESS_UP - 18: 506, // NX_KEYTYPE_PREVIOUS - 16: 516, // NX_KEYTYPE_PLAY - 17: 517, // NX_KEYTYPE_NEXT - 7: 518, // NX_KEYTYPE_MUTE - 1: 519, // NX_KEYTYPE_SOUND_DOWN - 0: 520, // NX_KEYTYPE_SOUND_UP - ] - private static let canonicalPreferredNXCodes: Set = [0, 1, 2, 3, 7, 16, 17, 18] - - // Consumer-page HID usages mapped to the same virtual key codes as systemDefined events. - private static let hidConsumerUsageMap: [UInt32: UInt16] = [ - UInt32(kHIDUsage_Csmr_DisplayBrightnessDecrement): 500, - UInt32(kHIDUsage_Csmr_DisplayBrightnessIncrement): 501, - UInt32(kHIDUsage_Csmr_KeyboardBrightnessDecrement): 500, - UInt32(kHIDUsage_Csmr_KeyboardBrightnessIncrement): 501, - UInt32(kHIDUsage_Csmr_ScanPreviousTrack): 506, - UInt32(kHIDUsage_Csmr_Rewind): 506, - UInt32(kHIDUsage_Csmr_Play): 516, - UInt32(kHIDUsage_Csmr_Pause): 516, - UInt32(kHIDUsage_Csmr_PlayOrPause): 516, - UInt32(kHIDUsage_Csmr_PlayOrSkip): 516, - UInt32(kHIDUsage_Csmr_ScanNextTrack): 517, - UInt32(kHIDUsage_Csmr_FastForward): 517, - UInt32(kHIDUsage_Csmr_Mute): 518, - UInt32(kHIDUsage_Csmr_VolumeDecrement): 519, - UInt32(kHIDUsage_Csmr_VolumeIncrement): 520, + private let lifecycleLock = NSLock() + private var eventThread: Thread? + private var eventRunLoop: CFRunLoop? + private var eventLoopStopped: DispatchSemaphore? + private var running = false + private var eventSequence: UInt64 = 0 + + private struct HIDUsage: Hashable { + let page: UInt32 + let usage: UInt32 + } + + // Keep the fallback privacy boundary narrow: declared media controls, the + // Generic Desktop Do Not Disturb usage, and the three physical Keyboard- + // page function usages Apple hardware may expose before Fn remapping. + private static let hidUsageMap: [HIDUsage: UInt16] = [ + HIDUsage(page: UInt32(kHIDPage_GenericDesktop), usage: UInt32(kHIDUsage_GD_DoNotDisturb)): 505, + HIDUsage(page: UInt32(kHIDPage_KeyboardOrKeypad), usage: UInt32(kHIDUsage_KeyboardF6)): 505, + HIDUsage(page: UInt32(kHIDPage_KeyboardOrKeypad), usage: UInt32(kHIDUsage_KeyboardF7)): 506, + HIDUsage(page: UInt32(kHIDPage_KeyboardOrKeypad), usage: UInt32(kHIDUsage_KeyboardF9)): 517, + HIDUsage(page: UInt32(kHIDPage_Consumer), usage: UInt32(kHIDUsage_Csmr_DisplayBrightnessDecrement)): 500, + HIDUsage(page: UInt32(kHIDPage_Consumer), usage: UInt32(kHIDUsage_Csmr_DisplayBrightnessIncrement)): 501, + HIDUsage(page: UInt32(kHIDPage_Consumer), usage: UInt32(kHIDUsage_Csmr_KeyboardBrightnessDecrement)): 500, + HIDUsage(page: UInt32(kHIDPage_Consumer), usage: UInt32(kHIDUsage_Csmr_KeyboardBrightnessIncrement)): 501, + HIDUsage(page: UInt32(kHIDPage_Consumer), usage: UInt32(kHIDUsage_Csmr_ScanPreviousTrack)): 506, + HIDUsage(page: UInt32(kHIDPage_Consumer), usage: UInt32(kHIDUsage_Csmr_Rewind)): 506, + HIDUsage(page: UInt32(kHIDPage_Consumer), usage: UInt32(kHIDUsage_Csmr_Play)): 516, + HIDUsage(page: UInt32(kHIDPage_Consumer), usage: UInt32(kHIDUsage_Csmr_Pause)): 516, + HIDUsage(page: UInt32(kHIDPage_Consumer), usage: UInt32(kHIDUsage_Csmr_PlayOrPause)): 516, + HIDUsage(page: UInt32(kHIDPage_Consumer), usage: UInt32(kHIDUsage_Csmr_PlayOrSkip)): 516, + HIDUsage(page: UInt32(kHIDPage_Consumer), usage: UInt32(kHIDUsage_Csmr_ScanNextTrack)): 517, + HIDUsage(page: UInt32(kHIDPage_Consumer), usage: UInt32(kHIDUsage_Csmr_FastForward)): 517, + HIDUsage(page: UInt32(kHIDPage_Consumer), usage: UInt32(kHIDUsage_Csmr_Mute)): 518, + HIDUsage(page: UInt32(kHIDPage_Consumer), usage: UInt32(kHIDUsage_Csmr_VolumeDecrement)): 519, + HIDUsage(page: UInt32(kHIDPage_Consumer), usage: UInt32(kHIDUsage_Csmr_VolumeIncrement)): 520, ] private static let modifierKeyFlagMaskByKeyCode: [UInt16: CGEventFlags] = [ @@ -111,77 +82,101 @@ final class KeyboardMonitor: @unchecked Sendable { 57: .maskAlphaShift ] - private static let modifierCounterpartKeyCode: [UInt16: UInt16] = [ - 55: 54, - 54: 55, - 58: 61, - 61: 58, - 59: 62, - 62: 59, - 56: 60, - 60: 56 - ] + init( + onStreamReset: ((KeyboardMonitor) -> Void)? = nil, + onBecameUnavailable: ((KeyboardMonitor) -> Void)? = nil, + callback: @escaping @MainActor (KeyEvent) -> Void + ) { + self.onStreamReset = onStreamReset + self.onBecameUnavailable = onBecameUnavailable + self.callback = callback + } - private static let functionCharacterToFunctionKeyCode: [UInt32: UInt16] = [ - 0xF704: 122, // F1 - 0xF705: 120, // F2 - 0xF706: 99, // F3 - 0xF707: 118, // F4 - 0xF708: 96, // F5 - 0xF709: 97, // F6 - 0xF70A: 98, // F7 - 0xF70B: 100, // F8 - 0xF70C: 101, // F9 - 0xF70D: 109, // F10 - 0xF70E: 103, // F11 - 0xF70F: 111 // F12 - ] + var isRunning: Bool { + lifecycleLock.withLock { running } + } - private static let specialKeyRawValueToFunctionKeyCode: [Int: UInt16] = [ - NSEvent.SpecialKey.f1.rawValue: 122, - NSEvent.SpecialKey.f2.rawValue: 120, - NSEvent.SpecialKey.f3.rawValue: 99, - NSEvent.SpecialKey.f4.rawValue: 118, - NSEvent.SpecialKey.f5.rawValue: 96, - NSEvent.SpecialKey.f6.rawValue: 97, - NSEvent.SpecialKey.f7.rawValue: 98, - NSEvent.SpecialKey.f8.rawValue: 100, - NSEvent.SpecialKey.f9.rawValue: 101, - NSEvent.SpecialKey.f10.rawValue: 109, - NSEvent.SpecialKey.f11.rawValue: 103, - NSEvent.SpecialKey.f12.rawValue: 111 - ] + @discardableResult + func start() -> Bool { + if isRunning { + return true + } + let canStart = lifecycleLock.withLock { + eventThread == nil && eventRunLoop == nil + } + guard canStart else { + return false + } - // Trusted raw keyboard codes observed on media-mode top-row keys. - // These are only used when specialKey/scalar metadata is absent. - private static let trustedTopRowRawFunctionKeyCodeMap: [UInt16: UInt16] = [ - 145: 122, // F1 - 144: 120, // F2 - 160: 99, // F3 - 131: 118, // F4 - 177: 96, // F5 - 176: 97, // F6 - 173: 98, // F7 - 174: 100, // F8 - 175: 101, // F9 - 74: 109, // F10 - 73: 103, // F11 - 72: 111 // F12 - ] + let started = DispatchSemaphore(value: 0) + let stopped = DispatchSemaphore(value: 0) + let thread = Thread { [weak self] in + self?.runEventLoop(started: started, stopped: stopped) + } + thread.name = "KeyLight Keyboard Event Loop" + thread.qualityOfService = .userInteractive + lifecycleLock.withLock { + eventThread = thread + eventLoopStopped = stopped + } + thread.start() + + guard started.wait(timeout: .now() + 2) == .success else { + KeyLightLogger.keyboardMonitor.error("Keyboard event loop did not start in time") + stop() + return false + } + return isRunning + } + + private func runEventLoop( + started: DispatchSemaphore, + stopped: DispatchSemaphore + ) { + autoreleasepool { + guard let runLoop = CFRunLoopGetCurrent() else { + lifecycleLock.withLock { + running = false + eventThread = nil + } + started.signal() + stopped.signal() + return + } + lifecycleLock.withLock { + eventRunLoop = runLoop + } + decoder.reset() - private static let topRowFunctionKeyCodes: Set = [122, 120, 99, 118, 96, 97, 98, 100, 101, 109, 103, 111] + guard installEventTap(on: runLoop) else { + lifecycleLock.withLock { + running = false + eventRunLoop = nil + eventThread = nil + } + started.signal() + stopped.signal() + return + } - init(callback: @escaping (KeyEvent) -> Void) { - self.callback = callback + startHIDMediaMonitoring(on: runLoop) + lifecycleLock.withLock { running = true } + started.signal() + KeyLightLogger.keyboardMonitor.notice("Keyboard monitor started") + CFRunLoopRun() + + tearDownEventSources(on: runLoop) + decoder.reset() + lifecycleLock.withLock { + running = false + eventRunLoop = nil + eventThread = nil + } + stopped.signal() + } } - func start() { - recentMediaEventTimes.removeAll() - recentHIDMediaEventTimes.removeAll() - recentSystemMediaEventTimes.removeAll() - recentTrustedKeyboardTopRowEvents.removeAll() - modifierKeyStates.removeAll() - lastCapsLockTransitionTime = -1 + private func installEventTap(on runLoop: CFRunLoop) -> Bool { let eventMask = (1 << CGEventType.keyDown.rawValue) | @@ -195,7 +190,7 @@ final class KeyboardMonitor: @unchecked Sendable { guard let tap = CGEvent.tapCreate( tap: .cgSessionEventTap, place: .headInsertEventTap, - options: .listenOnly, + options: Self.eventTapOptions, eventsOfInterest: CGEventMask(eventMask), callback: { (_, type, event, refcon) -> Unmanaged? in guard let refcon = refcon else { @@ -204,10 +199,15 @@ final class KeyboardMonitor: @unchecked Sendable { let monitor = Unmanaged.fromOpaque(refcon).takeUnretainedValue() - if type == .tapDisabledByTimeout { - if enableDebugLogging { print("KeyboardMonitor: Tap was disabled, re-enabling...") } + if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput { + KeyLightLogger.keyboardMonitor.notice("Event tap was disabled; attempting to re-enable it") if let tap = monitor.eventTap { CGEvent.tapEnable(tap: tap, enable: true) + monitor.reportEventTapRecoveryOutcome( + reenabled: CGEvent.tapIsEnabled(tap: tap) + ) + } else { + monitor.reportEventTapRecoveryOutcome(reenabled: false) } return Unmanaged.passUnretained(event) } @@ -225,65 +225,122 @@ final class KeyboardMonitor: @unchecked Sendable { if type == .keyDown || type == .keyUp { let rawKeyCode = UInt16(event.getIntegerValueField(.keyboardEventKeycode)) let isKeyDown = (type == .keyDown) + let isRepeat = isKeyDown && event.getIntegerValueField(.keyboardEventAutorepeat) != 0 if rawKeyCode == 57 { - monitor.lastCapsLockTransitionTime = CFAbsoluteTimeGetCurrent() + monitor.decoder.recordCapsLockTransition( + at: ProcessInfo.processInfo.systemUptime + ) } - let resolution = monitor.resolveKeyboardEventKeyCode(rawKeyCode: rawKeyCode, event: event) - guard resolution.confidence == .high else { + guard let decoded = monitor.decodeKeyboardEvent( + rawKeyCode: rawKeyCode, + isKeyDown: isKeyDown, + isRepeat: isRepeat + ) else { #if DEBUG if enableDebugLogging { - KeyLightLog("Skipping unresolved keyboard event keyCode \(rawKeyCode)") + KeyLightLogger.keyboardMonitor.debug("Skipping an unresolved keyboard event") } #endif return Unmanaged.passUnretained(event) } - let keyCode = resolution.keyCode - if monitor.isTopRowFunctionKeyCode(keyCode) { - monitor.recordTrustedKeyboardTopRowEvent(keyCode: keyCode, isKeyDown: isKeyDown) - } - monitor.emitMappedKeyEvent(keyCode: keyCode, isKeyDown: isKeyDown) + let canonicalKeyCode = KeyboardLayoutInfo.canonicalKeyCode(for: decoded.keyCode) + monitor.decoder.recordTrustedKeyboardTopRowEvent( + canonicalKeyCode: canonicalKeyCode, + isKeyDown: decoded.isKeyDown, + now: ProcessInfo.processInfo.systemUptime + ) + monitor.emitMappedKeyEvent(decoded) } return Unmanaged.passUnretained(event) }, userInfo: refcon ) else { - print("KeyboardMonitor: FAILED to create event tap!") - print("KeyboardMonitor: Make sure Input Monitoring permission is granted in System Settings") - return + KeyLightLogger.keyboardMonitor.error("Failed to create the keyboard event tap") + return false } - if enableDebugLogging { print("KeyboardMonitor: Event tap created successfully!") } eventTap = tap - runLoopSource = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0) - CFRunLoopAddSource(CFRunLoopGetMain(), runLoopSource, .commonModes) + guard let source = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0) else { + KeyLightLogger.keyboardMonitor.error("Failed to create the keyboard event tap run-loop source") + eventTap = nil + return false + } + runLoopSource = source + CFRunLoopAddSource(runLoop, source, .commonModes) CGEvent.tapEnable(tap: tap, enable: true) - if enableDebugLogging { print("KeyboardMonitor: Listening for keyboard events...") } - startHIDMediaMonitoring() + guard CGEvent.tapIsEnabled(tap: tap) else { + KeyLightLogger.keyboardMonitor.error("Keyboard event tap could not be enabled") + CFRunLoopRemoveSource(runLoop, source, .commonModes) + runLoopSource = nil + eventTap = nil + return false + } + return true } func stop() { + let state = lifecycleLock.withLock { + (eventRunLoop, eventLoopStopped, eventThread) + } + if let runLoop = state.0 { + if state.2 === Thread.current { + CFRunLoopStop(runLoop) + } else { + CFRunLoopPerformBlock(runLoop, CFRunLoopMode.commonModes.rawValue) { + CFRunLoopStop(runLoop) + } + CFRunLoopWakeUp(runLoop) + _ = state.1?.wait(timeout: .now() + 2) + } + } + lifecycleLock.withLock { + running = false + eventRunLoop = nil + eventThread = nil + eventLoopStopped = nil + } + callback = nil + onStreamReset = nil + onBecameUnavailable = nil + KeyLightLogger.keyboardMonitor.debug("Keyboard monitor stopped") + } + + private func tearDownEventSources(on runLoop: CFRunLoop) { if let tap = eventTap { CGEvent.tapEnable(tap: tap, enable: false) } if let source = runLoopSource { - CFRunLoopRemoveSource(CFRunLoopGetMain(), source, .commonModes) + CFRunLoopRemoveSource(runLoop, source, .commonModes) } - stopHIDMediaMonitoring() + stopHIDMediaMonitoring(from: runLoop) eventTap = nil runLoopSource = nil - recentMediaEventTimes.removeAll() - recentHIDMediaEventTimes.removeAll() - recentSystemMediaEventTimes.removeAll() - recentTrustedKeyboardTopRowEvents.removeAll() - modifierKeyStates.removeAll() - callback = nil } - private func startHIDMediaMonitoring() { + private func reportEventTapRecoveryOutcome(reenabled: Bool) { + guard reenabled else { + KeyLightLogger.keyboardMonitor.error("Event tap could not be re-enabled") + Task { @MainActor [weak self] in + guard let self else { return } + onBecameUnavailable?(self) + } + return + } + + // Modifier state and deduplication windows are stream-derived too; a + // timeout can make them stale even when the tap itself recovers. + decoder.reset() + Task { @MainActor [weak self] in + guard let self else { return } + onStreamReset?(self) + } + } + + private func startHIDMediaMonitoring(on runLoop: CFRunLoop) { let manager = IOHIDManagerCreate(kCFAllocatorDefault, IOOptionBits(kIOHIDOptionsTypeNone)) let context = Unmanaged.passUnretained(self).toOpaque() @@ -294,35 +351,81 @@ final class KeyboardMonitor: @unchecked Sendable { } IOHIDManagerRegisterInputValueCallback(manager, callback, context) + let allowedValueMatches: [[String: Int]] = Self.hidUsageMap.keys + .sorted { + if $0.page == $1.page { return $0.usage < $1.usage } + return $0.page < $1.page + } + .map { usage in + [ + kIOHIDElementUsagePageKey as String: Int(usage.page), + kIOHIDElementUsageKey as String: Int(usage.usage) + ] + } + IOHIDManagerSetInputValueMatchingMultiple( + manager, + allowedValueMatches as CFArray + ) IOHIDManagerSetDeviceMatching(manager, nil) - IOHIDManagerScheduleWithRunLoop(manager, CFRunLoopGetMain(), CFRunLoopMode.commonModes.rawValue) + IOHIDManagerScheduleWithRunLoop( + manager, + runLoop, + CFRunLoopMode.commonModes.rawValue + ) let openResult = IOHIDManagerOpen(manager, IOOptionBits(kIOHIDOptionsTypeNone)) if openResult != kIOReturnSuccess { - KeyLightLog("HID fallback unavailable (open result: \(openResult))") - IOHIDManagerUnscheduleFromRunLoop(manager, CFRunLoopGetMain(), CFRunLoopMode.commonModes.rawValue) + KeyLightLogger.keyboardMonitor.warning("Optional HID media-key fallback is unavailable") + IOHIDManagerUnscheduleFromRunLoop( + manager, + runLoop, + CFRunLoopMode.commonModes.rawValue + ) return } hidManager = manager } - private func stopHIDMediaMonitoring() { + private func stopHIDMediaMonitoring(from runLoop: CFRunLoop) { guard let manager = hidManager else { return } - IOHIDManagerUnscheduleFromRunLoop(manager, CFRunLoopGetMain(), CFRunLoopMode.commonModes.rawValue) + IOHIDManagerUnscheduleFromRunLoop( + manager, + runLoop, + CFRunLoopMode.commonModes.rawValue + ) IOHIDManagerClose(manager, IOOptionBits(kIOHIDOptionsTypeNone)) hidManager = nil } private func handleSystemDefinedCGEvent(_ event: CGEvent) { - guard let systemEvent = NSEvent(cgEvent: event) else { return } - handleMediaKeyEvent(systemEvent) + // NSEvent construction consults Text Input Services and is main-queue + // isolated on current macOS releases. Doing that work directly in the + // dedicated event-tap run loop triggers libdispatch's queue assertion + // after ordinary typing. Copy the immutable CGEvent, extract only the + // system-defined media metadata on the main queue, then return that + // value metadata to the event loop where decoder state is confined. + let eventBox = SendableCGEvent(event.copy() ?? event) + let eventTime = ProcessInfo.processInfo.systemUptime + DispatchQueue.main.async { [weak self, eventBox] in + guard let self, + let systemEvent = NSEvent(cgEvent: eventBox.value) else { + return + } + self.enqueueSystemDefinedMediaEvent( + subtypeRawValue: Int(systemEvent.subtype.rawValue), + data1: UInt32(truncatingIfNeeded: systemEvent.data1), + eventTime: eventTime + ) + } } private func handleFlagsChangedCGEvent(_ event: CGEvent) { let keyCode = UInt16(event.getIntegerValueField(.keyboardEventKeycode)) if keyCode == 57 { - lastCapsLockTransitionTime = CFAbsoluteTimeGetCurrent() + decoder.recordCapsLockTransition( + at: ProcessInfo.processInfo.systemUptime + ) } guard let isKeyDown = resolveModifierFlagsChanged(keyCode: keyCode, flags: event.flags) else { return } if keyCode == 57 { @@ -332,160 +435,158 @@ final class KeyboardMonitor: @unchecked Sendable { emitMappedKeyEvent(keyCode: keyCode, isKeyDown: isKeyDown) } - private func handleMediaKeyEvent(_ event: NSEvent) { - // Media keys arrive as system-defined subtype 8 events. - guard event.subtype.rawValue == 8 else { return } - - let data1 = UInt32(truncatingIfNeeded: event.data1) - let nxKeyCode = Int((data1 & 0xFFFF0000) >> 16) - let keyState = Int((data1 & 0x0000FF00) >> 8) - // Some keyboards emit 0x00 for key-down in system-defined events. - let isKeyDown = (keyState == 0x0A || keyState == 0x00) - let isKeyUp = (keyState == 0x0B) - - guard isKeyDown || isKeyUp else { return } - let now = CFAbsoluteTimeGetCurrent() - - // Some keyboards report Caps Lock transitions through NX code 4. - // Suppress those so they never masquerade as top-row media activity. - if nxKeyCode == 4, - lastCapsLockTransitionTime >= 0, - now - lastCapsLockTransitionTime < capsLockSystemEventGuardWindow { + private func enqueueSystemDefinedMediaEvent( + subtypeRawValue: Int, + data1: UInt32, + eventTime: TimeInterval + ) { + guard let runLoop = lifecycleLock.withLock({ eventRunLoop }) else { return } - - guard let virtualKeyCode = resolveVirtualKeyCode(nxCode: nxKeyCode) else { return } - emitMediaKeyEvent(keyCode: virtualKeyCode, isKeyDown: isKeyDown, source: .systemDefined) + CFRunLoopPerformBlock(runLoop, CFRunLoopMode.commonModes.rawValue) { + [weak self] in + self?.handleSystemDefinedMediaEvent( + subtypeRawValue: subtypeRawValue, + data1: data1, + eventTime: eventTime + ) + } + CFRunLoopWakeUp(runLoop) + } + + private func handleSystemDefinedMediaEvent( + subtypeRawValue: Int, + data1: UInt32, + eventTime: TimeInterval + ) { + guard let transition = decoder.decodeSystemDefinedMediaEvent( + subtypeRawValue: subtypeRawValue, + data1: data1, + now: eventTime + ) else { return } + emitMediaKeyEvent( + keyCode: transition.keyCode, + isKeyDown: transition.isKeyDown, + source: .systemDefined + ) } private func handleHIDInputValue(_ value: IOHIDValue) { let element = IOHIDValueGetElement(value) let usagePage = IOHIDElementGetUsagePage(element) - guard usagePage == UInt32(kHIDPage_Consumer) else { return } - let usage = IOHIDElementGetUsage(element) - guard let virtualKeyCode = Self.hidConsumerUsageMap[usage] else { return } + guard let virtualKeyCode = Self.hidUsageMap[HIDUsage(page: usagePage, usage: usage)] else { return } let isKeyDown = IOHIDValueGetIntegerValue(value) != 0 emitMediaKeyEvent(keyCode: virtualKeyCode, isKeyDown: isKeyDown, source: .hid) } - private func emitMediaKeyEvent(keyCode: UInt16, isKeyDown: Bool, source: MediaEventSource) { - let now = CFAbsoluteTimeGetCurrent() - if shouldDedupeMediaEvent(keyCode: keyCode, isKeyDown: isKeyDown, source: source, now: now) { + private func emitMediaKeyEvent( + keyCode: UInt16, + isKeyDown: Bool, + source: KeyboardEventDecoder.MediaEventSource + ) { + let now = ProcessInfo.processInfo.systemUptime + let canonicalKeyCode = KeyboardLayoutInfo.canonicalKeyCode(for: keyCode) + if decoder.shouldDedupeMediaEvent( + canonicalKeyCode: canonicalKeyCode, + isKeyDown: isKeyDown, + source: source, + now: now + ) { return } - emitMappedKeyEvent(keyCode: keyCode, isKeyDown: isKeyDown) + let normalizedSource: KeyboardEvent.Source + switch source { + case .systemDefined: + normalizedSource = .eventTap + case .hid: + normalizedSource = .consumerHID + } + emitMappedKeyEvent( + keyCode: canonicalKeyCode, + isKeyDown: isKeyDown, + source: normalizedSource + ) } - private func emitMappedKeyEvent(keyCode: UInt16, isKeyDown: Bool) { - // HID callbacks are scheduled on the same run loop as the event tap. - MainActor.assumeIsolated { - let canonicalKeyCode = KeyboardLayoutInfo.canonicalKeyCode(for: keyCode) - let keyInfo = KeyMapping.keyInfo(for: canonicalKeyCode) - let adjustedPosition = KeyPositionManager.shared.adjustedPosition( - for: canonicalKeyCode, - originalPosition: keyInfo.position - ) - let effectiveKeyWidth = KeyWidthManager.shared.effectiveWidth( - for: canonicalKeyCode, - defaultWidth: keyInfo.width - ) + private func emitMappedKeyEvent( + keyCode: UInt16, + isKeyDown: Bool, + isRepeat: Bool = false, + source: KeyboardEvent.Source = .eventTap + ) { + emitMappedKeyEvent(KeyEvent( + keyCode: keyCode, + isKeyDown: isKeyDown, + isRepeat: isRepeat, + source: source + )) + } - callback?(KeyEvent( - keyCode: canonicalKeyCode, - isKeyDown: isKeyDown, - horizontalPosition: adjustedPosition, - keyWidth: effectiveKeyWidth - )) + private func emitMappedKeyEvent(_ event: KeyEvent) { + let sequence = lifecycleLock.withLock { () -> UInt64 in + eventSequence &+= 1 + return eventSequence + } + KeyLightSignposts.eventReceived(sequence: sequence) + let normalized = KeyEvent( + keyCode: KeyboardLayoutInfo.canonicalKeyCode(for: event.keyCode), + isKeyDown: event.isKeyDown, + isRepeat: event.isRepeat, + source: event.source, + sequence: sequence + ) + KeyLightSignposts.eventNormalized(sequence: sequence) + Task { @MainActor [weak self] in + self?.callback?(normalized) } } private func emitCapsLockTransition(isKeyDown: Bool) { let keyCode: UInt16 = 57 - let sequence = capsLockEmitSequence(isKeyDown: isKeyDown) + let sequence = decoder.capsLockEmitSequence(isKeyDown: isKeyDown) guard let first = sequence.first else { return } emitMappedKeyEvent(keyCode: keyCode, isKeyDown: first) if sequence.count > 1 { - DispatchQueue.main.asyncAfter(deadline: .now() + capsLockPulseDuration) { [weak self] in - self?.emitMappedKeyEvent(keyCode: keyCode, isKeyDown: false) - } - } - } - - private func capsLockEmitSequence(isKeyDown: Bool) -> [Bool] { - isKeyDown ? [true, false] : [false] - } - - private func shouldDedupeMediaEvent( - keyCode: UInt16, - isKeyDown: Bool, - source: MediaEventSource, - now: CFAbsoluteTime - ) -> Bool { - let canonicalKeyCode = KeyboardLayoutInfo.canonicalKeyCode(for: keyCode) - let dedupeKey = (UInt32(canonicalKeyCode) << 1) | (isKeyDown ? 1 : 0) - - if shouldSuppressMediaEventForRecentTrustedKeyboardTopRow( - keyCode: canonicalKeyCode, - isKeyDown: isKeyDown, - now: now - ) { - return true - } - - switch source { - case .hid: - if let lastHID = recentHIDMediaEventTimes[dedupeKey], now - lastHID < mediaDedupWindow { - return true - } - recentHIDMediaEventTimes[dedupeKey] = now - case .systemDefined: - // Prefer HID media events if both sources report the same press/release in the dedupe window. - if let lastHID = recentHIDMediaEventTimes[dedupeKey], now - lastHID < mediaDedupWindow { - return true - } - if let lastSystem = recentSystemMediaEventTimes[dedupeKey], now - lastSystem < mediaDedupWindow { - return true + let pulseDuration = capsLockPulseDuration + Task { @MainActor [weak self] in + do { + try await Task.sleep(for: .seconds(pulseDuration)) + } catch { + return + } + self?.emitMappedKeyEvent( + keyCode: keyCode, + isKeyDown: false, + source: .eventTap + ) } - recentSystemMediaEventTimes[dedupeKey] = now - } - - recentMediaEventTimes[dedupeKey] = now - if recentMediaEventTimes.count > 64 { - recentMediaEventTimes = recentMediaEventTimes.filter { now - $0.value < mediaDedupWindow * 2 } - } - if recentHIDMediaEventTimes.count > 64 { - recentHIDMediaEventTimes = recentHIDMediaEventTimes.filter { now - $0.value < mediaDedupWindow * 2 } - } - if recentSystemMediaEventTimes.count > 64 { - recentSystemMediaEventTimes = recentSystemMediaEventTimes.filter { now - $0.value < mediaDedupWindow * 2 } } - return false } private func resolveVirtualKeyCode(nxCode: Int) -> UInt16? { - if Self.canonicalPreferredNXCodes.contains(nxCode) { - return Self.canonicalNXMap[nxCode] ?? Self.legacyNXMap[nxCode] - } - - return Self.canonicalNXMap[nxCode] ?? Self.legacyNXMap[nxCode] + decoder.resolveVirtualKeyCode(nxCode: nxCode) } - private func resolveKeyboardEventKeyCode(rawKeyCode: UInt16, event: CGEvent) -> KeyboardResolution { - let nsEvent = NSEvent(cgEvent: event) - let characters = nsEvent?.charactersIgnoringModifiers -#if compiler(>=5.3) - let specialKey = nsEvent?.specialKey -#else - let specialKey: NSEvent.SpecialKey? = nil -#endif - return resolveKeyboardEventKeyCode( + private func decodeKeyboardEvent( + rawKeyCode: UInt16, + isKeyDown: Bool, + isRepeat: Bool + ) -> KeyEvent? { + // The established key table and explicit Apple top-row raw-code map + // resolve every input KeyLight supports. Never materialize NSEvent or + // consult character metadata on this non-main event thread. + return decoder.decodeKeyboardEvent( rawKeyCode: rawKeyCode, - charactersIgnoringModifiers: characters, - specialKeyRawValue: specialKey?.rawValue + isKeyDown: isKeyDown, + isRepeat: isRepeat, + source: .eventTap, + charactersIgnoringModifiers: nil, + specialKeyRawValue: nil, + isMappedKeyCode: isMappedKeyCode(rawKeyCode) ) } @@ -493,90 +594,48 @@ final class KeyboardMonitor: @unchecked Sendable { rawKeyCode: UInt16, charactersIgnoringModifiers: String?, specialKeyRawValue: Int? - ) -> KeyboardResolution { - if isMappedKeyCode(rawKeyCode) { - return KeyboardResolution(keyCode: rawKeyCode, confidence: .high) - } - - if let specialKeyRawValue, - let functionKeyCode = Self.specialKeyRawValueToFunctionKeyCode[specialKeyRawValue] { - return KeyboardResolution(keyCode: functionKeyCode, confidence: .high) - } - - if let scalar = charactersIgnoringModifiers?.unicodeScalars.first, - let functionKeyCode = Self.functionCharacterToFunctionKeyCode[scalar.value] { - return KeyboardResolution(keyCode: functionKeyCode, confidence: .high) - } - - if let functionKeyCode = Self.trustedTopRowRawFunctionKeyCodeMap[rawKeyCode] { - return KeyboardResolution(keyCode: functionKeyCode, confidence: .high) - } - - return KeyboardResolution(keyCode: rawKeyCode, confidence: .unknown) + ) -> KeyboardEventDecoder.KeyboardResolution { + decoder.resolveKeyboardEvent( + rawKeyCode: rawKeyCode, + charactersIgnoringModifiers: charactersIgnoringModifiers, + specialKeyRawValue: specialKeyRawValue, + isMappedKeyCode: isMappedKeyCode(rawKeyCode) + ) } private func resolveModifierFlagsChanged(keyCode: UInt16, flags: CGEventFlags) -> Bool? { guard let mask = Self.modifierKeyFlagMaskByKeyCode[keyCode] else { return nil } - let isKeyDownFromFlags = flags.contains(mask) - let previous = modifierKeyStates[keyCode] ?? false - - if previous == isKeyDownFromFlags { - // Shared masks (left/right command/option/control/shift): when one side is released - // while the other side stays held, macOS keeps the mask set. Use counterpart state - // to infer that this key transitioned to key-up. - if previous, - let counterpart = Self.modifierCounterpartKeyCode[keyCode], - modifierKeyStates[counterpart] == true { - modifierKeyStates[keyCode] = false - return false - } - return nil - } - - modifierKeyStates[keyCode] = isKeyDownFromFlags - return isKeyDownFromFlags + return decoder.resolveModifierFlagsChanged( + keyCode: keyCode, + flagIsSet: flags.contains(mask) + ) } private func isMappedKeyCode(_ keyCode: UInt16) -> Bool { - MainActor.assumeIsolated { - KeyMapping.hasMappedKeyCode(keyCode) - } - } - - private func isTopRowFunctionKeyCode(_ keyCode: UInt16) -> Bool { - let canonicalKeyCode = KeyboardLayoutInfo.canonicalKeyCode(for: keyCode) - return Self.topRowFunctionKeyCodes.contains(canonicalKeyCode) + KeyMapping.hasMappedKeyCode(keyCode) } - private func recordTrustedKeyboardTopRowEvent(keyCode: UInt16, isKeyDown: Bool) { - let canonicalKeyCode = KeyboardLayoutInfo.canonicalKeyCode(for: keyCode) - recentTrustedKeyboardTopRowEvents[isKeyDown] = (canonicalKeyCode, CFAbsoluteTimeGetCurrent()) - } - - private func shouldSuppressMediaEventForRecentTrustedKeyboardTopRow( - keyCode: UInt16, - isKeyDown: Bool, - now: CFAbsoluteTime - ) -> Bool { - guard Self.topRowFunctionKeyCodes.contains(keyCode) else { return false } - guard let recent = recentTrustedKeyboardTopRowEvents[isKeyDown] else { return false } - guard now - recent.timestamp <= keyboardTopRowSourceWindow else { - recentTrustedKeyboardTopRowEvents.removeValue(forKey: isKeyDown) - return false +#if DEBUG + func _testReportEventTapRecoveryOutcome(reenabled: Bool) { + if reenabled { + decoder.reset() + onStreamReset?(self) + } else { + onBecameUnavailable?(self) } - - // Prefer the trusted keyboard event for this press/release window. - // This prevents mismatched NX/HID aliases from overriding the correct physical key. - return true } -#if DEBUG func _testResolveVirtualKeyCode(nxCode: Int) -> UInt16? { resolveVirtualKeyCode(nxCode: nxCode) } func _testShouldDedupeMediaEvent(keyCode: UInt16, isKeyDown: Bool, now: CFAbsoluteTime) -> Bool { - shouldDedupeMediaEvent(keyCode: keyCode, isKeyDown: isKeyDown, source: .systemDefined, now: now) + decoder.shouldDedupeMediaEvent( + canonicalKeyCode: KeyboardLayoutInfo.canonicalKeyCode(for: keyCode), + isKeyDown: isKeyDown, + source: .systemDefined, + now: now + ) } func _testShouldDedupeMediaEventWithSource( @@ -585,8 +644,21 @@ final class KeyboardMonitor: @unchecked Sendable { source: String, now: CFAbsoluteTime ) -> Bool { - let mappedSource: MediaEventSource = source == "hid" ? .hid : .systemDefined - return shouldDedupeMediaEvent(keyCode: keyCode, isKeyDown: isKeyDown, source: mappedSource, now: now) + let mappedSource: KeyboardEventDecoder.MediaEventSource = source == "hid" ? .hid : .systemDefined + return decoder.shouldDedupeMediaEvent( + canonicalKeyCode: KeyboardLayoutInfo.canonicalKeyCode(for: keyCode), + isKeyDown: isKeyDown, + source: mappedSource, + now: now + ) + } + + func _testResolveHIDConsumerUsage(_ usage: UInt32) -> UInt16? { + _testResolveHIDUsage(page: UInt32(kHIDPage_Consumer), usage: usage) + } + + func _testResolveHIDUsage(page: UInt32, usage: UInt32) -> UInt16? { + Self.hidUsageMap[HIDUsage(page: page, usage: usage)] } func _testResolveKeyboardEventKeyCode(rawKeyCode: UInt16, charactersIgnoringModifiers: String?) -> UInt16 { @@ -633,12 +705,24 @@ final class KeyboardMonitor: @unchecked Sendable { } } + func _testDecodeEventLoopKeyboardEvent( + rawKeyCode: UInt16, + isKeyDown: Bool, + isRepeat: Bool = false + ) -> KeyEvent? { + decodeKeyboardEvent( + rawKeyCode: rawKeyCode, + isKeyDown: isKeyDown, + isRepeat: isRepeat + ) + } + func _testResolveModifierFlagsChanged(keyCode: UInt16, flags: CGEventFlags) -> Bool? { resolveModifierFlagsChanged(keyCode: keyCode, flags: flags) } func _testCapsLockEmitSequence(isKeyDown: Bool) -> [Bool] { - capsLockEmitSequence(isKeyDown: isKeyDown) + decoder.capsLockEmitSequence(isKeyDown: isKeyDown) } #endif @@ -646,3 +730,22 @@ final class KeyboardMonitor: @unchecked Sendable { stop() } } + +/// Core Foundation event objects are immutable for KeyLight's use here. This +/// wrapper makes the intentional cross-queue ownership explicit under Swift 6 +/// without broadening KeyboardMonitor's unsafe surface. +private final class SendableCGEvent: @unchecked Sendable { + let value: CGEvent + + init(_ value: CGEvent) { + self.value = value + } +} + +private extension NSLock { + func withLock(_ operation: () throws -> T) rethrows -> T { + lock() + defer { unlock() } + return try operation() + } +} diff --git a/KeyLight/Services/LaunchAtLoginService.swift b/KeyLight/Services/LaunchAtLoginService.swift new file mode 100644 index 0000000..9cfc7b8 --- /dev/null +++ b/KeyLight/Services/LaunchAtLoginService.swift @@ -0,0 +1,148 @@ +import Foundation +import ServiceManagement + +/// The current state reported by macOS for KeyLight's main-app login item. +/// `enabled` is the only state treated as an active login item. +enum LaunchAtLoginStatus: Equatable, Sendable { + case disabled + case enabled + case requiresApproval + case unavailable + + var isEnabled: Bool { + self == .enabled + } +} + +enum LaunchAtLoginOperationFailure: Equatable, Sendable { + case registrationFailed + case unregistrationFailed +} + +enum LaunchAtLoginChangeOutcome: Equatable, Sendable { + case applied + case requiresApproval + case rejected + case failed(LaunchAtLoginOperationFailure) +} + +/// The result of a requested change, including the authoritative state read +/// back from macOS after the operation completes or fails. +struct LaunchAtLoginChangeResult: Equatable, Sendable { + let requestedEnabled: Bool + let status: LaunchAtLoginStatus + let outcome: LaunchAtLoginChangeOutcome + + var isApplied: Bool { + outcome == .applied + } +} + +@MainActor +protocol LaunchAtLoginServicing: AnyObject { + var status: LaunchAtLoginStatus { get } + + @discardableResult + func setEnabled(_ enabled: Bool) -> LaunchAtLoginChangeResult +} + +@MainActor +protocol LaunchAtLoginSystemClient: AnyObject { + var status: LaunchAtLoginStatus { get } + func register() throws + func unregister() throws +} + +/// Owns launch-at-login reconciliation without treating a requested value as +/// saved state. Every result is based on the service status read back from +/// macOS, including thrown operations and approval-required states. +@MainActor +final class LaunchAtLoginService: LaunchAtLoginServicing { + private let systemClient: any LaunchAtLoginSystemClient + + var status: LaunchAtLoginStatus { + systemClient.status + } + + init(systemClient: any LaunchAtLoginSystemClient = SMAppServiceSystemClient()) { + self.systemClient = systemClient + } + + @discardableResult + func setEnabled(_ enabled: Bool) -> LaunchAtLoginChangeResult { + let initialStatus = systemClient.status + if initialStatus == .requiresApproval, enabled { + return LaunchAtLoginChangeResult( + requestedEnabled: true, + status: .requiresApproval, + outcome: .requiresApproval + ) + } + let isAlreadySatisfied = (initialStatus == .enabled && enabled) || + (initialStatus == .disabled && !enabled) + if isAlreadySatisfied { + return LaunchAtLoginChangeResult( + requestedEnabled: enabled, + status: initialStatus, + outcome: .applied + ) + } + + do { + if enabled { + try systemClient.register() + } else { + try systemClient.unregister() + } + } catch { + return LaunchAtLoginChangeResult( + requestedEnabled: enabled, + status: systemClient.status, + outcome: .failed(enabled ? .registrationFailed : .unregistrationFailed) + ) + } + + let actualStatus = systemClient.status + let outcome: LaunchAtLoginChangeOutcome + if actualStatus == .requiresApproval { + outcome = .requiresApproval + } else if actualStatus.isEnabled == enabled, + actualStatus != .unavailable { + outcome = .applied + } else { + outcome = .rejected + } + + return LaunchAtLoginChangeResult( + requestedEnabled: enabled, + status: actualStatus, + outcome: outcome + ) + } +} + +@MainActor +private final class SMAppServiceSystemClient: LaunchAtLoginSystemClient { + var status: LaunchAtLoginStatus { + switch SMAppService.mainApp.status { + case .notRegistered: + return .disabled + case .enabled: + return .enabled + case .requiresApproval: + return .requiresApproval + case .notFound: + return .unavailable + @unknown default: + return .unavailable + } + } + + func register() throws { + try SMAppService.mainApp.register() + } + + func unregister() throws { + try SMAppService.mainApp.unregister() + } +} diff --git a/KeyLight/Services/LayoutProfileCodec.swift b/KeyLight/Services/LayoutProfileCodec.swift new file mode 100644 index 0000000..98365b6 --- /dev/null +++ b/KeyLight/Services/LayoutProfileCodec.swift @@ -0,0 +1,138 @@ +import Foundation + +/// Pure codec for KeyLight's established layout-profile JSON transfer format. +/// It validates and normalizes a complete value before returning it and owns no +/// defaults, UI, or live editor state. +enum LayoutProfileCodec { + private static let schemaVersion = 1 + private static let invalidProfileMessage = String( + localized: "The file is not a valid KeyLight layout profile." + ) + private static let allowedKeyCodes = Set(KeyboardLayoutInfo.allKeys.map(\.id)) + + private struct Payload: Codable { + var version: Int + var kind: String? + var name: String + var keyOffsets: [String: CGFloat] + var keyWidthOverrides: [String: CGFloat]? + } + + static func encode(_ profile: KeyMappingProfile) -> Data? { + guard let normalizedName = PersistenceValidation.normalizedName(profile.name) else { + return nil + } + + let offsets = normalizedValues( + profile.keyOffsets.reduce(into: [String: CGFloat]()) { result, pair in + result[String(pair.key)] = pair.value + }, + range: -0.5...0.5 + ) + let widths = normalizedValues( + profile.keyWidthOverrides.reduce(into: [String: CGFloat]()) { result, pair in + result[String(pair.key)] = pair.value + }, + range: 0.1...5.0 + ) + + let payload = Payload( + version: schemaVersion, + kind: "layoutProfile", + name: normalizedName, + keyOffsets: stringKeyed(offsets), + keyWidthOverrides: stringKeyed(widths) + ) + return try? JSONEncoder().encode(payload) + } + + static func decode(_ data: Data) throws -> KeyMappingProfile { + guard data.count <= PersistenceValidation.maximumLayoutImportSize else { + throw NSError(domain: "KeyLight", code: 20, userInfo: [ + NSLocalizedDescriptionKey: String(localized: "Layout profile file is too large (max 1MB).") + ]) + } + + let payload: Payload + do { + payload = try JSONDecoder().decode(Payload.self, from: data) + } catch { + throw invalidProfileError() + } + + guard payload.version <= schemaVersion else { + throw NSError(domain: "KeyLight", code: 22, userInfo: [ + NSLocalizedDescriptionKey: String(localized: "Unsupported layout profile version (\(payload.version)). Please update KeyLight.") + ]) + } + if let kind = payload.kind, kind != "layoutProfile" { + throw invalidProfileError() + } + + guard PersistenceValidation.layoutEntryCountIsValid( + offsetKeys: payload.keyOffsets.keys, + widthKeys: (payload.keyWidthOverrides ?? [:]).keys + ) else { + throw NSError(domain: "KeyLight", code: 25, userInfo: [ + NSLocalizedDescriptionKey: String(localized: "Layout profile contains too many key entries (max 512).") + ]) + } + + guard let normalizedName = PersistenceValidation.normalizedName(payload.name) else { + throw NSError(domain: "KeyLight", code: 23, userInfo: [ + NSLocalizedDescriptionKey: String(localized: "Layout profile name is missing.") + ]) + } + + return KeyMappingProfile( + name: normalizedName, + keyOffsets: normalizedValues(payload.keyOffsets, range: -0.5...0.5), + keyWidthOverrides: normalizedValues(payload.keyWidthOverrides ?? [:], range: 0.1...5.0) + ) + } + + private static func normalizedValues( + _ values: [String: CGFloat], + range: ClosedRange + ) -> [UInt16: CGFloat] { + var decoded: [UInt16: CGFloat] = [:] + decoded.reserveCapacity(values.count) + for (key, value) in values { + guard let keyCode = UInt16(key), value.isFinite else { continue } + decoded[keyCode] = value + } + + var canonicalValues: [UInt16: CGFloat] = [:] + var aliasFallbackValues: [UInt16: CGFloat] = [:] + for keyCode in decoded.keys.sorted() { + guard let value = decoded[keyCode], value.isFinite else { continue } + let canonicalKeyCode = KeyboardLayoutInfo.canonicalKeyCode(for: keyCode) + guard allowedKeyCodes.contains(canonicalKeyCode) else { continue } + + let clamped = min(max(value, range.lowerBound), range.upperBound) + if keyCode == canonicalKeyCode { + canonicalValues[canonicalKeyCode] = clamped + } else if aliasFallbackValues[canonicalKeyCode] == nil { + aliasFallbackValues[canonicalKeyCode] = clamped + } + } + + var normalized = aliasFallbackValues + for (keyCode, value) in canonicalValues { + normalized[keyCode] = value + } + return normalized + } + + private static func stringKeyed(_ values: [UInt16: CGFloat]) -> [String: CGFloat] { + values.reduce(into: [String: CGFloat]()) { result, pair in + result[String(pair.key)] = pair.value + } + } + + private static func invalidProfileError() -> NSError { + NSError(domain: "KeyLight", code: 21, userInfo: [ + NSLocalizedDescriptionKey: invalidProfileMessage + ]) + } +} diff --git a/KeyLight/Services/OverlayController.swift b/KeyLight/Services/OverlayController.swift new file mode 100644 index 0000000..3cbbd70 --- /dev/null +++ b/KeyLight/Services/OverlayController.swift @@ -0,0 +1,800 @@ +import AppKit +import ColorSync +import CoreGraphics + +enum OverlayDisplaySelection: Hashable, Sendable { + case automatic + case builtIn + case main + case specific(String) + + private static let specificPrefix = "display:" + + init(persistedValue: String?) { + switch persistedValue { + case "builtIn": + self = .builtIn + case "main": + self = .main + case let value? where value.hasPrefix(Self.specificPrefix): + let persistentID = String(value.dropFirst(Self.specificPrefix.count)) + self = persistentID.isEmpty ? .automatic : .specific(persistentID) + default: + self = .automatic + } + } + + var persistedValue: String { + switch self { + case .automatic: + return "automatic" + case .builtIn: + return "builtIn" + case .main: + return "main" + case .specific(let persistentID): + return Self.specificPrefix + persistentID + } + } +} + +struct OverlayDisplayDescriptor: Equatable, Identifiable, Sendable { + let id: String + let name: String + let isBuiltIn: Bool + let isMain: Bool +} + +struct OverlayDisplayCandidate: Equatable, Sendable { + let id: CGDirectDisplayID + let persistentID: String + let name: String + let isBuiltIn: Bool + let isMain: Bool + let frame: CGRect + + init( + id: CGDirectDisplayID, + persistentID: String? = nil, + name: String? = nil, + isBuiltIn: Bool, + isMain: Bool, + frame: CGRect = .zero + ) { + self.id = id + self.persistentID = persistentID ?? "display-\(id)" + self.name = name ?? "Display \(id)" + self.isBuiltIn = isBuiltIn + self.isMain = isMain + self.frame = frame + } + + var descriptor: OverlayDisplayDescriptor { + OverlayDisplayDescriptor( + id: persistentID, + name: name, + isBuiltIn: isBuiltIn, + isMain: isMain + ) + } +} + +enum OverlayDisplayResolver { + static func target( + in candidates: [OverlayDisplayCandidate], + selection: OverlayDisplaySelection = .automatic + ) -> OverlayDisplayCandidate? { + let automatic = candidates.first(where: \.isBuiltIn) + ?? candidates.first(where: \.isMain) + ?? candidates.first + + switch selection { + case .automatic: + return automatic + case .builtIn: + return candidates.first(where: \.isBuiltIn) ?? automatic + case .main: + return candidates.first(where: \.isMain) ?? automatic + case .specific(let persistentID): + return candidates.first(where: { $0.persistentID == persistentID }) ?? automatic + } + } + + static func targetID( + in candidates: [OverlayDisplayCandidate], + selection: OverlayDisplaySelection = .automatic + ) -> CGDirectDisplayID? { + target(in: candidates, selection: selection)?.id + } +} + +@MainActor +protocol OverlayPanel: AnyObject { + var glowRenderer: (any GlowRenderer)? { get } + var frame: NSRect { get } + + func setEffectStyle(_ requestedStyle: EffectStyle) + func setFrame(_ frameRect: NSRect, display flag: Bool) + func orderFrontRegardless() + func close() +} + +extension GlowOverlayWindow: OverlayPanel {} + +/// Owns KeyLight's passive panel collection, one central interaction state, +/// and the renderer configuration broadcast to every selected display. +/// The controller accepts normalized events and renderer-ready targets only; it +/// never decodes characters or reaches into persistence. +@MainActor +final class OverlayController { + typealias DisplayProvider = @MainActor () -> [OverlayDisplayCandidate] + typealias WindowFactory = @MainActor (NSRect) -> any OverlayPanel + + private let overlayHeight: CGFloat + private let displayProvider: DisplayProvider + private let windowFactory: WindowFactory + private let onPhysicalEvent: @MainActor (KeyboardEvent) -> Void + private var runtimeStatusHandler: (@MainActor (EffectRuntimeStatus) -> Void)? + + private struct PanelEntry { + let persistentID: String + let displayID: CGDirectDisplayID + let panel: any OverlayPanel + } + + private var panels: [String: PanelEntry] = [:] + private var primaryDisplayPersistentID: String? + private var mirroredDisplayIDs: Set = [] + private var perDisplayRendererStates: [String: GlowRendererRuntimeState] = [:] + private var interactionState = GlowInteractionState() + private var renderedTarget: GlowTarget? + private var effectStyle: EffectStyle = .classicGlow + private var configuration = RendererConfiguration.standard + private var isEnabled = true + private var displaySelection: OverlayDisplaySelection = .automatic + private var latestDisplayCandidates: [OverlayDisplayCandidate] = [] + + init( + overlayHeight: CGFloat = 120, + displayProvider: @escaping DisplayProvider = OverlayController.liveDisplays, + windowFactory: @escaping WindowFactory = { GlowOverlayWindow(contentRect: $0) }, + onPhysicalEvent: @escaping @MainActor (KeyboardEvent) -> Void = { _ in } + ) { + self.overlayHeight = overlayHeight + self.displayProvider = displayProvider + self.windowFactory = windowFactory + self.onPhysicalEvent = onPhysicalEvent + } + + var activeDisplayID: CGDirectDisplayID? { + guard let primaryDisplayPersistentID else { return nil } + return panels[primaryDisplayPersistentID]?.displayID + } + var activeDisplayPersistentID: String? { + primaryDisplayPersistentID + } + var activeDisplayPersistentIDs: [String] { + guard let primaryDisplayPersistentID, + panels[primaryDisplayPersistentID] != nil else { + return panels.keys.sorted() + } + return [primaryDisplayPersistentID] + + panels.keys + .filter { $0 != primaryDisplayPersistentID } + .sorted() + } + var availableDisplays: [OverlayDisplayDescriptor] { + latestDisplayCandidates.map(\.descriptor) + } + var activePreviewSources: [PreviewSource] { + interactionState.activePreviewSourcesInPriorityOrder + } + var resolvedTarget: GlowTarget? { interactionState.resolvedTarget } + + func setRuntimeStatusHandler( + _ handler: (@MainActor (EffectRuntimeStatus) -> Void)? + ) { + runtimeStatusHandler = handler + configureAllRuntimeStatusHandlers() + } + + func start() { + updateDisplayTopology(forceRecreation: panels.isEmpty) + } + + func setDisplaySelection(_ selection: OverlayDisplaySelection) { + guard selection != displaySelection else { return } + displaySelection = selection + updateDisplayTopology() + } + + func setMirroredDisplayIDs(_ persistentIDs: Set) { + let normalized = Set( + persistentIDs + .filter { !$0.isEmpty } + .sorted() + .prefix(16) + ) + guard normalized != mirroredDisplayIDs else { return } + mirroredDisplayIDs = normalized + updateDisplayTopology() + } + + func shutdown() { + let hadPhysicalInput = !interactionState.heldPhysicalKeyCodes.isEmpty + interactionState.clearAll() + if hadPhysicalInput { + publishPhysicalReset() + } + renderedTarget = nil + clearAndCloseAllPanels() + primaryDisplayPersistentID = nil + publishRuntimeStatus() + } + + func setEnabled(_ enabled: Bool) { + guard enabled != isEnabled else { return } + isEnabled = enabled + if enabled { + renderResolvedTarget(force: true) + } else { + let hadPhysicalInput = !interactionState.heldPhysicalKeyCodes.isEmpty + interactionState.clearAll() + if hadPhysicalInput { + publishPhysicalReset() + } + renderedTarget = nil + broadcastClear() + } + } + + func apply(effectStyle: EffectStyle, configuration: RendererConfiguration) { + self.effectStyle = effectStyle + self.configuration = configuration + + let resolvedStyle = configuration.resolvedEffectStyle(for: effectStyle) + for entry in panels.values { + let rendererBefore = entry.panel.glowRenderer + entry.panel.setEffectStyle(resolvedStyle) + guard let renderer = entry.panel.glowRenderer else { + perDisplayRendererStates[entry.persistentID] = GlowRendererRuntimeState( + readiness: .failed, + captureState: .idle, + fallbackReason: "Renderer unavailable" + ) + continue + } + configureRuntimeStatusHandler( + for: renderer, + persistentID: entry.persistentID + ) + renderer.apply(configuration) + let rendererChanged = rendererBefore.map { $0 !== renderer } ?? true + if rendererChanged { + renderResolvedState(on: renderer) + } + } + publishRuntimeStatus() + } + + func updateDisplayTopology(forceRecreation: Bool = false) { + let candidates = displayProvider() + latestDisplayCandidates = candidates + guard let primaryDisplay = OverlayDisplayResolver.target( + in: candidates, + selection: displaySelection + ) else { + let hadPhysicalInput = !interactionState.heldPhysicalKeyCodes.isEmpty + interactionState.clearPhysicalInput() + if hadPhysicalInput { + publishPhysicalReset() + } + renderedTarget = nil + clearAndCloseAllPanels() + primaryDisplayPersistentID = nil + publishRuntimeStatus() + return + } + + let previousPrimaryID = primaryDisplayPersistentID + let nextPrimaryID = primaryDisplay.persistentID + if let previousPrimaryID, previousPrimaryID != nextPrimaryID { + let hadPhysicalInput = !interactionState.heldPhysicalKeyCodes.isEmpty + interactionState.clearPhysicalInput() + if hadPhysicalInput { + publishPhysicalReset() + } + broadcastClear() + renderedTarget = nil + } + primaryDisplayPersistentID = nextPrimaryID + + var desiredByPersistentID: [String: OverlayDisplayCandidate] = [ + nextPrimaryID: primaryDisplay + ] + for candidate in candidates + where mirroredDisplayIDs.contains(candidate.persistentID) + && candidate.persistentID != nextPrimaryID { + desiredByPersistentID[candidate.persistentID] = candidate + } + + if forceRecreation { + clearAndCloseAllPanels() + } else { + let stalePanelIDs = panels.keys.filter { + desiredByPersistentID[$0] == nil + } + for persistentID in stalePanelIDs { + removePanel(persistentID: persistentID) + } + } + + let orderedIDs = [nextPrimaryID] + + desiredByPersistentID.keys + .filter { $0 != nextPrimaryID } + .sorted() + for persistentID in orderedIDs { + guard let candidate = desiredByPersistentID[persistentID] else { + continue + } + let frame = Self.panelFrame( + for: candidate.frame, + height: overlayHeight + ) + + if let existing = panels[persistentID], + existing.displayID == candidate.id { + let frameChanged = existing.panel.frame != frame + existing.panel.setFrame(frame, display: true) + if frameChanged, let renderer = existing.panel.glowRenderer { + renderer.clear() + renderer.apply(configuration) + renderResolvedState(on: renderer) + } + continue + } + + removePanel(persistentID: persistentID) + let replacement = windowFactory(frame) + replacement.setEffectStyle( + configuration.resolvedEffectStyle(for: effectStyle) + ) + let entry = PanelEntry( + persistentID: persistentID, + displayID: candidate.id, + panel: replacement + ) + panels[persistentID] = entry + configureRuntimeStatusHandler( + for: replacement.glowRenderer, + persistentID: persistentID + ) + replacement.glowRenderer?.apply(configuration) + replacement.orderFrontRegardless() + if let renderer = replacement.glowRenderer { + renderResolvedState(on: renderer) + } + } + + publishRuntimeStatus() + } + + func handle(_ event: KeyboardEvent, target: GlowTarget? = nil) { + defer { + KeyLightSignposts.rendererSubmitted(sequence: event.sequence) + } + // Calibration mirrors the same normalized stream as the renderer, + // including resets so transient pressed-state cannot become stale. + onPhysicalEvent(event) + + guard isEnabled else { + if event.action == .streamReset { + interactionState.clearPhysicalInput() + } + return + } + + let previous = interactionState.resolvedTarget + switch event.action { + case .down: + let transition = interactionState.handle(event, target: target) + guard let keyCode = event.canonicalKeyCode, + let physicalTarget = interactionState.target( + for: .physicalKey(keyCode) + ) else { + return + } + + if event.isRepeat, + broadcastRefresh(physicalTarget.id) { + renderedTarget = physicalTarget + return + } + + if let previous, case .preview = previous.id { + if case .preview(let source) = previous.id, source.isChordTest { + for chordTarget in interactionState.activeChordTestTargetsInSourceOrder { + broadcastHide(chordTarget.id) + } + } else { + broadcastHide(previous.id) + } + renderedTarget = nil + } + + if renderersSupportConcurrentTargets { + broadcastShow(physicalTarget) + renderedTarget = physicalTarget + } else { + renderResolvedTarget(force: transition.previous != transition.current) + } + + case .up: + if let keyCode = event.canonicalKeyCode { + broadcastHide(.physicalKey(keyCode)) + } + let transition = interactionState.handle(event) + + if renderersSupportConcurrentTargets { + if interactionState.activePhysicalTargetsInPressOrder.isEmpty { + renderedTarget = nil + renderResolvedTarget(force: true) + } else { + renderedTarget = interactionState.activePhysicalTargetsInPressOrder.last + } + } else if transition.current != transition.previous { + renderedTarget = nil + renderResolvedTarget(force: true) + } + + case .streamReset: + interactionState.handle(event) + broadcastClear() + renderedTarget = nil + renderResolvedTarget(force: true) + } + } + + func setPreview(_ target: GlowTarget, source: PreviewSource) { + guard !source.isChordTest else { return } + guard target.id == .preview(source) else { return } + guard isEnabled else { + interactionState.clearPreview(source) + return + } + let transition = interactionState.setPreview(target, for: source) + guard transition.current != transition.previous else { return } + if let previous = transition.previous { + broadcastHide(previous.id) + } + renderedTarget = nil + renderResolvedTarget(force: true) + } + + func clearPreview(_ source: PreviewSource) { + guard !source.isChordTest else { return } + let transition = interactionState.clearPreview(source) + guard isEnabled, transition.current != transition.previous else { return } + broadcastHide(.preview(source)) + renderedTarget = nil + renderResolvedTarget(force: true) + } + + func setChordPreview(_ targets: [GlowTarget]) { + let acceptedTargets = PreviewSource.chordTestSources.compactMap { source in + targets.first(where: { $0.id == .preview(source) }) + } + let oldTargets = interactionState.activeChordTestTargetsInSourceOrder + guard acceptedTargets != oldTargets else { return } + + let previous = interactionState.resolvedTarget + interactionState.replaceChordTestTargets(acceptedTargets) + for target in oldTargets { + broadcastHide(target.id) + } + + guard isEnabled, + interactionState.activePhysicalTargetsInPressOrder.isEmpty else { + return + } + if let previous, + case .preview(let source) = previous.id, + !source.isChordTest { + broadcastHide(previous.id) + } + renderedTarget = nil + renderResolvedTarget(force: true) + } + + func clearChordPreview() { + let oldTargets = interactionState.activeChordTestTargetsInSourceOrder + guard !oldTargets.isEmpty else { return } + interactionState.clearChordTestTargets() + for target in oldTargets { + broadcastHide(target.id) + } + guard isEnabled, + interactionState.activePhysicalTargetsInPressOrder.isEmpty else { + return + } + renderedTarget = nil + renderResolvedTarget(force: true) + } + + func clearPhysicalInput(source: KeyboardEvent.Source = .lifecycle) { + handle(.streamReset(source: source, timestamp: ProcessInfo.processInfo.systemUptime)) + } + + private func renderResolvedTarget(force: Bool) { + guard isEnabled, !activeRenderers.isEmpty else { + return + } + + let physicalTargets = interactionState.activePhysicalTargetsInPressOrder + if renderersSupportConcurrentTargets, !physicalTargets.isEmpty { + if !force, renderedTarget == physicalTargets.last { + return + } + for target in physicalTargets { + broadcastShow(target) + } + renderedTarget = physicalTargets.last + return + } + + let chordTargets = interactionState.activeChordTestTargetsInSourceOrder + if renderersSupportConcurrentTargets, !chordTargets.isEmpty { + if !force, renderedTarget == chordTargets.last { + return + } + for target in chordTargets { + broadcastShow(target) + } + renderedTarget = chordTargets.last + return + } + + guard let target = interactionState.resolvedTarget else { return } + if !force, renderedTarget == target { + return + } + broadcastShow(target) + renderedTarget = target + } + + private func renderResolvedState(on renderer: any GlowRenderer) { + guard isEnabled else { return } + let physicalTargets = interactionState.activePhysicalTargetsInPressOrder + if renderer.supportsConcurrentPhysicalTargets, + !physicalTargets.isEmpty { + for target in physicalTargets { + renderer.show(target) + } + return + } + + let chordTargets = interactionState.activeChordTestTargetsInSourceOrder + if renderer.supportsConcurrentPhysicalTargets, + !chordTargets.isEmpty { + for target in chordTargets { + renderer.show(target) + } + return + } + + if let target = interactionState.resolvedTarget { + renderer.show(target) + } + } + + private func publishPhysicalReset() { + onPhysicalEvent(.streamReset( + source: .lifecycle, + timestamp: ProcessInfo.processInfo.systemUptime + )) + } + + private var activeRenderers: [any GlowRenderer] { + activeDisplayPersistentIDs.compactMap { + panels[$0]?.panel.glowRenderer + } + } + + private var renderersSupportConcurrentTargets: Bool { + let renderers = activeRenderers + return !renderers.isEmpty + && renderers.allSatisfy(\.supportsConcurrentPhysicalTargets) + } + + private func broadcastShow(_ target: GlowTarget) { + for renderer in activeRenderers { + renderer.show(target) + } + } + + private func broadcastHide(_ id: GlowID) { + for renderer in activeRenderers { + renderer.hide(id) + } + } + + private func broadcastClear() { + for renderer in activeRenderers { + renderer.clear() + } + } + + private func broadcastRefresh(_ id: GlowID) -> Bool { + let renderers = activeRenderers + guard !renderers.isEmpty else { return false } + return renderers.map { $0.refresh(id) }.allSatisfy { $0 } + } + + private func removePanel(persistentID: String) { + guard let entry = panels.removeValue(forKey: persistentID) else { + return + } + entry.panel.glowRenderer?.clear() + entry.panel.close() + perDisplayRendererStates.removeValue(forKey: persistentID) + } + + private func clearAndCloseAllPanels() { + let persistentIDs = Array(panels.keys) + for persistentID in persistentIDs { + removePanel(persistentID: persistentID) + } + } + + private func configureAllRuntimeStatusHandlers() { + for entry in panels.values { + configureRuntimeStatusHandler( + for: entry.panel.glowRenderer, + persistentID: entry.persistentID + ) + } + publishRuntimeStatus() + } + + private func configureRuntimeStatusHandler( + for renderer: (any GlowRenderer)?, + persistentID: String + ) { + let selected = effectStyle + let powerSavingActive = configuration.automaticPowerSavingIsActive + && selected.supportedStyle == .physicalRefraction + let powerEnvironmentState = configuration.powerEnvironmentState + guard let renderer else { + perDisplayRendererStates[persistentID] = GlowRendererRuntimeState( + readiness: .failed, + captureState: .idle, + fallbackReason: "Renderer unavailable" + ) + publishRuntimeStatus() + return + } + + renderer.setRuntimeStatusHandler { [weak self] rendererState in + guard let self else { return } + var state = rendererState + if powerSavingActive { + state.readiness = .fallback + state.captureState = .idle + state.fallbackReason = powerEnvironmentState + .fallbackReason + .map { "Automatic Power Saving: \($0)" } + ?? "Automatic Power Saving is active" + } else if selected == .physicalRefraction, + !ScreenCaptureAuthorization.isGranted { + state.readiness = .fallback + state.captureState = .permissionRequired + state.fallbackReason = "Screen Recording permission is not allowed" + } + self.perDisplayRendererStates[persistentID] = state + self.publishRuntimeStatus() + } + } + + private func publishRuntimeStatus() { + guard let runtimeStatusHandler else { return } + let selected = effectStyle + let powerSavingActive = configuration.automaticPowerSavingIsActive + && selected.supportedStyle == .physicalRefraction + let activeIDs = activeDisplayPersistentIDs + let states = activeIDs.map { persistentID in + perDisplayRendererStates[persistentID] + ?? GlowRendererRuntimeState( + readiness: .failed, + captureState: .idle, + fallbackReason: "Renderer unavailable on \(persistentID)" + ) + } + let aggregate = states.max { left, right in + let leftRank = Self.readinessRank(left.readiness) + let rightRank = Self.readinessRank(right.readiness) + if leftRank != rightRank { + return leftRank < rightRank + } + return Self.captureRank(left.captureState) + < Self.captureRank(right.captureState) + } ?? GlowRendererRuntimeState( + readiness: .failed, + captureState: .idle, + fallbackReason: "No display is available" + ) + + runtimeStatusHandler(EffectRuntimeStatus( + selectedEffect: selected, + resolvedEffect: configuration.resolvedEffectStyle(for: selected), + rendererReadiness: aggregate.readiness, + captureState: aggregate.captureState, + fallbackReason: aggregate.fallbackReason, + powerSavingMode: configuration.powerSavingMode, + powerEnvironmentState: configuration.powerEnvironmentState, + automaticPowerSavingIsActive: powerSavingActive, + activeDisplayID: activeDisplayID, + activeDisplayPersistentIDs: activeIDs, + sampledStripHeight: selected == .physicalRefraction + && !powerSavingActive + && !activeIDs.isEmpty + ? Double(max(overlayHeight + 80, 180)) + : nil + )) + } + + private static func readinessRank(_ readiness: RendererReadiness) -> Int { + switch readiness { + case .ready: 0 + case .fallback: 1 + case .failed: 2 + } + } + + private static func captureRank(_ state: PhysicalCaptureState) -> Int { + switch state { + case .idle: 0 + case .stopping: 1 + case .gracePeriod: 2 + case .active: 3 + case .starting: 4 + case .permissionRequired: 5 + case .failed: 6 + } + } + + private static func liveDisplays() -> [OverlayDisplayCandidate] { + let mainScreen = NSScreen.main + return NSScreen.screens.compactMap { screen in + guard let id = screen.deviceDescription[ + NSDeviceDescriptionKey("NSScreenNumber") + ] as? CGDirectDisplayID else { + return nil + } + return OverlayDisplayCandidate( + id: id, + persistentID: persistentDisplayID(for: id), + name: screen.localizedName, + isBuiltIn: CGDisplayIsBuiltin(id) != 0, + isMain: mainScreen.map { screen === $0 } ?? false, + frame: screen.frame + ) + } + } + + private static func persistentDisplayID(for displayID: CGDirectDisplayID) -> String { + guard let unmanagedUUID = CGDisplayCreateUUIDFromDisplayID(displayID) else { + return "display-\(displayID)" + } + let uuid = unmanagedUUID.takeRetainedValue() + return CFUUIDCreateString(nil, uuid) as String + } + + private static func panelFrame(for screenFrame: CGRect, height: CGFloat) -> NSRect { + NSRect( + x: screenFrame.minX, + y: screenFrame.minY, + width: screenFrame.width, + height: height + ) + } +} diff --git a/KeyLight/Services/PermissionManager.swift b/KeyLight/Services/PermissionManager.swift index af2df1a..1055240 100644 --- a/KeyLight/Services/PermissionManager.swift +++ b/KeyLight/Services/PermissionManager.swift @@ -2,26 +2,130 @@ import Foundation import ApplicationServices import AppKit +enum InputMonitoringReconciliationAction: Equatable { + case requestPermission + case settle(state: InputMonitoringState, stopMonitor: Bool) + case startMonitor(stopExisting: Bool) +} + +enum InputMonitoringReconciliationResolver { + static func resolve( + installationIssue: String?, + authorized: Bool, + allowRequest: Bool, + isEnabled: Bool, + monitorExists: Bool, + monitorRunning: Bool + ) -> InputMonitoringReconciliationAction { + guard authorized else { + // `allowRequest` represents a deliberate user action in Setup or + // recovery UI. Consent is independent of whether the visual effect + // is currently enabled; authorization must not implicitly enable + // or start the keyboard monitor. + if allowRequest && installationIssue == nil { + return .requestPermission + } + return .settle(state: .permissionRequired, stopMonitor: monitorExists) + } + + guard isEnabled else { + return .settle(state: .authorized, stopMonitor: monitorExists) + } + + if monitorExists && monitorRunning { + return .settle(state: .active, stopMonitor: false) + } + + return .startMonitor(stopExisting: monitorExists) + } + + static func stateAfterMonitorStart(succeeded: Bool) -> InputMonitoringState { + succeeded ? .active : .monitorUnavailable + } +} + @MainActor final class PermissionManager { - static let shared = PermissionManager() + init() {} - private init() {} + var runningApplicationPath: String { + Bundle.main.bundleURL.standardizedFileURL.path + } + + /// TCC grants must be made to the installed, canonically named app rather + /// than a copy still mounted in a DMG or renamed by Finder during copying. + var installationIssue: String? { + Self.installationIssue( + for: Bundle.main.bundleURL, + bundleIdentifier: Bundle.main.bundleIdentifier ?? "com.keylight.app", + expectedBundleName: KeyLightApplicationIdentity.bundleName + ) + } + + static func installationIssue( + for bundleURL: URL, + bundleIdentifier: String = "com.keylight.app", + expectedBundleName: String = "KeyLight.app" + ) -> String? { + let standardizedURL = bundleURL.standardizedFileURL + let path = standardizedURL.path + let canonicalBundleName = expectedBundleName.hasSuffix(".app") + ? expectedBundleName + : "\(expectedBundleName).app" + let productName = String(canonicalBundleName.dropLast(4)) + if path == "/Volumes" || path.hasPrefix("/Volumes/") { + return "\(productName) is running from a disk image. Move it to /Applications/\(canonicalBundleName) before granting Input Monitoring." + } + + let bundleName = standardizedURL.lastPathComponent + if bundleName != canonicalBundleName { + return "\(productName) must be named \(canonicalBundleName) before granting Input Monitoring. The current app is named \(bundleName)." + } + + // Debug builds use their own bundle identifier so local development + // cannot alter the production app's TCC record. Production permission + // requests are restricted to the one canonical installation path. + if bundleIdentifier == "com.keylight.app.debug" { + return nil + } + + let canonicalURL = URL(fileURLWithPath: "/Applications/\(canonicalBundleName)").standardizedFileURL + if standardizedURL != canonicalURL { + return "\(productName) must run from /Applications/\(canonicalBundleName) before granting Input Monitoring. The current copy is at \(path)." + } + + return nil + } /// Check if we have Input Monitoring permission func hasInputMonitoringPermission() -> Bool { - return CGPreflightListenEventAccess() + let authorized = CGPreflightListenEventAccess() + KeyLightLogger.permissions.debug("Input Monitoring preflight result: \(authorized, privacy: .public)") + return authorized } /// Request Input Monitoring permission (shows system dialog if not granted) - func requestInputMonitoringPermission() { - if !hasInputMonitoringPermission() { - CGRequestListenEventAccess() + @discardableResult + func requestInputMonitoringPermission() -> Bool { + if installationIssue != nil { + KeyLightLogger.permissions.notice("Input Monitoring request blocked by the installation guard") + return false } + + if CGPreflightListenEventAccess() { + KeyLightLogger.permissions.debug("Input Monitoring request skipped because access is already authorized") + return true + } + + KeyLightLogger.permissions.notice("Requesting Input Monitoring access") + let authorized = CGRequestListenEventAccess() + KeyLightLogger.permissions.notice("Input Monitoring request result: \(authorized, privacy: .public)") + return authorized } /// Open System Settings to Input Monitoring pane func openInputMonitoringSettings() { + KeyLightLogger.permissions.notice("Opening Input Monitoring settings") if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_ListenEvent") { NSWorkspace.shared.open(url) } diff --git a/KeyLight/Services/PersistenceValidation.swift b/KeyLight/Services/PersistenceValidation.swift new file mode 100644 index 0000000..bb58ef7 --- /dev/null +++ b/KeyLight/Services/PersistenceValidation.swift @@ -0,0 +1,23 @@ +import Foundation + +/// Shared limits for names and the existing layout-profile transfer format. +enum PersistenceValidation { + static let maximumNameLength = 100 + static let maximumLayoutImportSize = 1_000_000 + static let maximumLayoutEntryCount = 512 + + static func normalizedName(_ value: String) -> String? { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + return String(trimmed.prefix(maximumNameLength)) + } + + static func layoutEntryCountIsValid( + offsetKeys: some Sequence, + widthKeys: some Sequence + ) -> Bool { + var uniqueKeys = Set(offsetKeys) + uniqueKeys.formUnion(widthKeys) + return uniqueKeys.count <= maximumLayoutEntryCount + } +} diff --git a/KeyLight/Services/PreferencesStore.swift b/KeyLight/Services/PreferencesStore.swift new file mode 100644 index 0000000..056e671 --- /dev/null +++ b/KeyLight/Services/PreferencesStore.swift @@ -0,0 +1,54 @@ +import Foundation + +/// Injectable adapter for KeyLight's existing UserDefaults persistence. +/// +/// The adapter deliberately mirrors only the operations used by +/// `SettingsManager`; it is not a second persistence system or a generic data +/// access framework. +final class PreferencesStore: @unchecked Sendable { + static let standard = PreferencesStore(userDefaults: .standard) + + private let userDefaults: UserDefaults + let usesSystemPreferences: Bool + + init( + userDefaults: UserDefaults, + usesSystemPreferences: Bool? = nil + ) { + self.userDefaults = userDefaults + self.usesSystemPreferences = usesSystemPreferences + ?? (userDefaults === UserDefaults.standard) + } + + func object(forKey key: String) -> Any? { + userDefaults.object(forKey: key) + } + + func string(forKey key: String) -> String? { + userDefaults.string(forKey: key) + } + + func data(forKey key: String) -> Data? { + userDefaults.data(forKey: key) + } + + func dictionary(forKey key: String) -> [String: Any]? { + userDefaults.dictionary(forKey: key) + } + + func bool(forKey key: String) -> Bool { + userDefaults.bool(forKey: key) + } + + func integer(forKey key: String) -> Int { + userDefaults.integer(forKey: key) + } + + func set(_ value: Any?, forKey key: String) { + userDefaults.set(value, forKey: key) + } + + func removeObject(forKey key: String) { + userDefaults.removeObject(forKey: key) + } +} diff --git a/KeyLight/Services/SettingsManager.swift b/KeyLight/Services/SettingsManager.swift index d2f3a27..ee89d6f 100644 --- a/KeyLight/Services/SettingsManager.swift +++ b/KeyLight/Services/SettingsManager.swift @@ -1,40 +1,82 @@ import Foundation -import SwiftUI -import ServiceManagement -import AppKit + +enum ConfigurationSnapshotError: LocalizedError, Equatable { + case invalidName + case nameConflict(String) + case snapshotNotFound + case invalidDocument + case unsupportedVersion(Int) + case importTooLarge + case persistentDataTooLarge + case invalidConfiguration(String) + case noPreviousSetup + case transactionFailed + + var errorDescription: String? { + switch self { + case .invalidName: + return String(localized: "Choose a name up to 100 characters.") + case .nameConflict(let name): + return String(localized: "A snapshot named \"\(name)\" already exists.") + case .snapshotNotFound: + return String(localized: "The configuration snapshot could not be found.") + case .invalidDocument: + return String(localized: "The file is not a valid KeyLight configuration snapshot.") + case .unsupportedVersion(let version): + return String(localized: "Snapshot version \(version) is not supported by this build.") + case .importTooLarge: + return String(localized: "The snapshot file is too large (maximum 1 MB).") + case .persistentDataTooLarge: + return String(localized: "The saved snapshot data is too large (maximum 500 KB).") + case .invalidConfiguration(let reason): + return reason + case .noPreviousSetup: + return String(localized: "There is no previous setup to restore.") + case .transactionFailed: + return String(localized: "The configuration could not be applied. Your previous setup was restored.") + } + } +} + +enum ConfigurationSnapshotImportPolicy { + case rejectConflict + case replace + case saveCopy +} /// Manages persistent storage of all app settings @MainActor final class SettingsManager { - static let shared = SettingsManager() - - private let defaults = UserDefaults.standard - private static let invalidThemeStringMessage = "Invalid theme string." - private static let themeStringPrefix = "keylight-theme-v1" - private static let defaultThemeString = "keylight-theme-v1;name=current;mode=positionGradient;color=68B8FF;opacity=0.8013;size=80.5536;width=1.0000;round=0.7069;hard=0.6046;fade=1.0004;gstart=68B8FF;gend=00E69A" - private static let maxThemeStringLength = 2_048 - private static let themeStringFieldOrder = [ - "name", "mode", "color", "opacity", "size", "width", "round", "hard", "fade", "gstart", "gend" - ] - private static let themeStringRequiredFields = Set(themeStringFieldOrder) + typealias SnapshotCommitVerifier = (ConfigurationSnapshotPayload) -> Bool + + private let defaults: PreferencesStore + private let launchAtLoginService: any LaunchAtLoginServicing + private let snapshotCommitVerifier: SnapshotCommitVerifier private static let defaultExperienceSeedVersion = 1 private static let defaultLayoutMigrationVersion = 1 private static let bundledLayoutProfilesSeedVersion = 1 + private static let stableSelectionMigrationVersion = 1 + static let currentOnboardingVersion = 1 private static let defaultSeededLayoutPresetID = "macbook-air-13-m4-default" private static let bundledMacBookProPresetID = "macbook-pro-14-m4" private static let defaultSeededLayoutName = "MacBook Air 13 M4 Default" - private static let layoutProfileSchemaVersion = 1 - private static let maxLayoutProfileImportSize = 1_000_000 - private static let invalidLayoutProfileMessage = "The file is not a valid KeyLight layout profile." + private static let invalidLayoutProfileMessage = String( + localized: "The file is not a valid KeyLight layout profile." + ) private static let defaultSolidHex = "68B8FF" private static let defaultGradientEndHex = "00E69A" private static let maxGradientPresetCount = 24 + static let maximumConfigurationSnapshotImportSize = + PersistenceValidation.maximumLayoutImportSize + static let maximumConfigurationSnapshotPersistentSize = 500_000 // Keys for UserDefaults private enum Keys { static let isEnabled = "isEnabled" + static let hasSeenPermissionExplanation = "hasSeenPermissionExplanation" static let glowColorHex = "glowColorHex" static let glowOpacity = "glowOpacity" + static let physicalRefractionStrength = "physicalRefractionStrength" static let glowSize = "glowSize" static let glowWidth = "glowWidth" static let glowRoundness = "glowRoundness" @@ -43,23 +85,43 @@ final class SettingsManager { static let fadeDurationDefaultMigratedV2 = "fadeDurationDefaultMigratedV2" static let launchAtLogin = "launchAtLogin" static let colorMode = "colorMode" + static let effectStyle = "effectStyle" + static let chordSurfaceStyle = "chordSurfaceStyle" + static let chordIntensityMultiplier = "chordIntensityMultiplier" + static let powerSavingMode = "powerSavingMode" + static let effectConfigurationsByStyle = + "effectConfigurationsByStyleV1" + static let surfaceShapeProfile = "surfaceShapeProfile" static let savedThemes = "savedThemes" static let currentThemeName = "currentThemeName" + static let activeThemeID = "activeThemeID" static let keyMappingProfiles = "keyMappingProfiles" static let currentKeyMappingProfileName = "currentKeyMappingProfileName" + static let activeLayoutID = "activeLayoutID" + static let overlayDisplaySelection = "overlayDisplaySelection" + static let mirroredDisplayIDs = "mirroredDisplayIDs" + static let displayLayoutProfileBindings = "displayLayoutProfileBindings" + static let globalShortcut = "globalShortcut" static let gradientStartHex = "gradientStartHex" static let gradientEndHex = "gradientEndHex" static let gradientPresets = "gradientPresets" static let defaultExperienceSeedVersion = "defaultExperienceSeedVersion" static let defaultLayoutMigrationVersion = "defaultLayoutMigrationVersion" static let bundledLayoutProfilesSeedVersion = "bundledLayoutProfilesSeedVersion" + static let stableSelectionMigrationVersion = "stableSelectionMigrationVersion" + static let onboardingCompletedVersion = "onboardingCompletedVersion" + static let onboardingDeferredVersion = "onboardingDeferredVersion" + static let configurationSnapshots = "configurationSnapshotsV1" + static let configurationSnapshotRecovery = + "configurationSnapshotRecoveryV1" } - #if DEBUG - static let _testUserDefaultsKeyContract: [String] = [ + private static let managedLocalPreferenceKeys: [String] = [ Keys.isEnabled, + Keys.hasSeenPermissionExplanation, Keys.glowColorHex, Keys.glowOpacity, + Keys.physicalRefractionStrength, Keys.glowSize, Keys.glowWidth, Keys.glowRoundness, @@ -68,26 +130,101 @@ final class SettingsManager { Keys.fadeDurationDefaultMigratedV2, Keys.launchAtLogin, Keys.colorMode, + Keys.effectStyle, + Keys.chordSurfaceStyle, + Keys.chordIntensityMultiplier, + Keys.powerSavingMode, + Keys.effectConfigurationsByStyle, + Keys.surfaceShapeProfile, Keys.savedThemes, Keys.currentThemeName, + Keys.activeThemeID, Keys.keyMappingProfiles, Keys.currentKeyMappingProfileName, + Keys.activeLayoutID, + Keys.overlayDisplaySelection, + Keys.mirroredDisplayIDs, + Keys.displayLayoutProfileBindings, + Keys.globalShortcut, Keys.gradientStartHex, Keys.gradientEndHex, Keys.gradientPresets, Keys.defaultExperienceSeedVersion, Keys.defaultLayoutMigrationVersion, Keys.bundledLayoutProfilesSeedVersion, - KeyPositionManager.offsetsKey, + Keys.stableSelectionMigrationVersion, + Keys.onboardingCompletedVersion, + Keys.onboardingDeferredVersion, + Keys.configurationSnapshots, + Keys.configurationSnapshotRecovery, + KeyLayoutStore.offsetsKey, "KeyWidthOverrides" ] + + /// The only persisted keys a snapshot application transaction may touch. + /// Keeping this registry separate from all managed preferences makes the + /// exclusions auditable and prevents imported JSON from becoming keys. + private static let configurationSnapshotStorageKeyRegistry: [String] = [ + Keys.glowColorHex, + Keys.glowOpacity, + Keys.physicalRefractionStrength, + Keys.glowSize, + Keys.glowWidth, + Keys.glowRoundness, + Keys.glowFullness, + Keys.fadeDuration, + Keys.colorMode, + Keys.effectStyle, + Keys.chordSurfaceStyle, + Keys.chordIntensityMultiplier, + Keys.powerSavingMode, + Keys.effectConfigurationsByStyle, + Keys.surfaceShapeProfile, + Keys.savedThemes, + Keys.currentThemeName, + Keys.activeThemeID, + Keys.keyMappingProfiles, + Keys.currentKeyMappingProfileName, + Keys.activeLayoutID, + Keys.overlayDisplaySelection, + Keys.mirroredDisplayIDs, + Keys.displayLayoutProfileBindings, + Keys.globalShortcut, + Keys.gradientStartHex, + Keys.gradientEndHex, + Keys.gradientPresets, + KeyLayoutStore.offsetsKey, + KeyLayoutStore.widthMultipliersKey + ] + + #if DEBUG + static let _testUserDefaultsKeyContract = managedLocalPreferenceKeys + static let _testConfigurationSnapshotStorageKeyRegistry = + configurationSnapshotStorageKeyRegistry + static let _testConfigurationSnapshotPayloadKeyRegistry = Set( + ConfigurationSnapshotPayload.CodingKeys.allCases.map(\.rawValue) + ) #endif - private init() { - migrateFadeDurationDefaultIfNeeded() + init( + preferencesStore: PreferencesStore = .standard, + launchAtLoginService: any LaunchAtLoginServicing = LaunchAtLoginService(), + snapshotCommitVerifier: @escaping SnapshotCommitVerifier = { _ in true } + ) { + defaults = preferencesStore + self.launchAtLoginService = launchAtLoginService + self.snapshotCommitVerifier = snapshotCommitVerifier + // Determine whether this is a fresh install before any migration writes + // a default value. Otherwise the migration-created fade-duration key + // makes the first-run seed incorrectly look like existing user data. + let wasFreshInstall = isFreshInstallForDefaultSeed seedDefaultExperienceIfNeeded() + migrateFadeDurationDefaultIfNeeded() + initializeEffectConfigurationsIfNeeded() applyDefaultLayoutIfMissingOnce() seedBundledLayoutProfilesIfNeededOnce() + migrateStableSelectionIDsIfNeeded() + initializeOnboardingState(wasFreshInstall: wasFreshInstall) } /// Sanitize a hex color string: keep only valid hex characters, pad to 6 chars with zeros @@ -104,10 +241,6 @@ final class SettingsManager { return min(max(value, range.lowerBound), range.upperBound) } - private func notifyStorageChanged() { - NotificationCenter.default.post(name: .settingsStorageChanged, object: nil) - } - /// One-time migration: treat legacy default-ish fade duration as "unset" and move to new default (1.0s). private func migrateFadeDurationDefaultIfNeeded() { guard !defaults.bool(forKey: Keys.fadeDurationDefaultMigratedV2) else { return } @@ -139,6 +272,33 @@ final class SettingsManager { set { defaults.set(newValue, forKey: Keys.isEnabled) } } + var hasSeenPermissionExplanation: Bool { + get { defaults.object(forKey: Keys.hasSeenPermissionExplanation) as? Bool ?? false } + set { defaults.set(newValue, forKey: Keys.hasSeenPermissionExplanation) } + } + + var shouldPresentOnboarding: Bool { + defaults.integer(forKey: Keys.onboardingCompletedVersion) + < Self.currentOnboardingVersion + && defaults.integer(forKey: Keys.onboardingDeferredVersion) + < Self.currentOnboardingVersion + } + + func completeOnboarding() { + defaults.set( + Self.currentOnboardingVersion, + forKey: Keys.onboardingCompletedVersion + ) + defaults.removeObject(forKey: Keys.onboardingDeferredVersion) + } + + func deferOnboarding() { + defaults.set( + Self.currentOnboardingVersion, + forKey: Keys.onboardingDeferredVersion + ) + } + var glowColorHex: String { get { defaults.string(forKey: Keys.glowColorHex) ?? Self.defaultSolidHex } set { defaults.set(newValue, forKey: Keys.glowColorHex) } @@ -149,8 +309,21 @@ final class SettingsManager { set { defaults.set(newValue, forKey: Keys.glowOpacity) } } + var physicalRefractionStrength: Double { + get { + validated( + defaults.object( + forKey: Keys.physicalRefractionStrength + ) as? Double ?? 1.0, + range: 0.5...2.5, + default: 1.0 + ) + } + set { defaults.set(newValue, forKey: Keys.physicalRefractionStrength) } + } + var glowSize: Double { - get { validated(defaults.object(forKey: Keys.glowSize) as? Double ?? 80.5536, range: 10.0...200.0, default: 80.5536) } + get { validated(defaults.object(forKey: Keys.glowSize) as? Double ?? 80.5536, range: 4.0...200.0, default: 80.5536) } set { defaults.set(newValue, forKey: Keys.glowSize) } } @@ -187,38 +360,45 @@ final class SettingsManager { // MARK: - Launch at Login var launchAtLogin: Bool { - get { defaults.bool(forKey: Keys.launchAtLogin) } + get { + guard defaults.usesSystemPreferences else { + return defaults.bool(forKey: Keys.launchAtLogin) + } + + let enabled = launchAtLoginService.status.isEnabled + defaults.set(enabled, forKey: Keys.launchAtLogin) + return enabled + } set { - defaults.set(newValue, forKey: Keys.launchAtLogin) - updateLaunchAtLogin(newValue) + setLaunchAtLogin(newValue) } } - private func updateLaunchAtLogin(_ enabled: Bool) { - if #available(macOS 13.0, *) { - do { - if enabled { - try SMAppService.mainApp.register() - } else { - try SMAppService.mainApp.unregister() - } - } catch { - KeyLightLog("Failed to update launch at login: \(error)") - // Revert the stored value since the system state didn't change - defaults.set(!enabled, forKey: Keys.launchAtLogin) - } + /// Applies a launch-at-login request and returns the system result for + /// coordinators that need to distinguish approval from operation failure. + /// The legacy preference mirrors only the authoritative resulting state. + @discardableResult + func setLaunchAtLogin(_ enabled: Bool) -> LaunchAtLoginChangeResult { + guard defaults.usesSystemPreferences else { + defaults.set(enabled, forKey: Keys.launchAtLogin) + return LaunchAtLoginChangeResult( + requestedEnabled: enabled, + status: enabled ? .enabled : .disabled, + outcome: .applied + ) } - } - // MARK: - Color Mode + let result = launchAtLoginService.setEnabled(enabled) + defaults.set(result.status.isEnabled, forKey: Keys.launchAtLogin) - enum ColorMode: String, CaseIterable, Codable { - case solid = "solid" - case positionGradient = "positionGradient" // Left to right gradient - case randomPerKey = "randomPerKey" - case rainbow = "rainbow" // Cycles through colors + if case .failed = result.outcome { + KeyLightLogger.storage.error("Launch-at-login change failed") + } + return result } + // MARK: - Color Mode + var colorMode: ColorMode { get { guard let rawValue = defaults.string(forKey: Keys.colorMode) else { @@ -232,130 +412,391 @@ final class SettingsManager { set { defaults.set(newValue.rawValue, forKey: Keys.colorMode) } } - // MARK: - Themes + var effectStyle: EffectStyle { + get { + guard let rawValue = defaults.string(forKey: Keys.effectStyle) else { + return .classicGlow + } + let parsed = EffectStyle(rawValue: rawValue) ?? .classicGlow + let supported = parsed.supportedStyle + if supported.rawValue != rawValue { + defaults.set(supported.rawValue, forKey: Keys.effectStyle) + } + return supported + } + set { + defaults.set( + newValue.supportedStyle.rawValue, + forKey: Keys.effectStyle + ) + } + } - struct Theme: Codable, Identifiable { - var id = UUID() - var name: String - var colorHex: String - var opacity: Double - var size: Double - var width: Double - var glowRoundness: Double - var glowFullness: Double - var fadeDuration: Double - var colorMode: ColorMode - var gradientStartHex: String? - var gradientEndHex: String? - - enum CodingKeys: String, CodingKey { - case id - case name - case colorHex - case opacity - case size - case width - case glowRoundness - case glowFullness - case fadeDuration - case colorMode - case gradientStartHex - case gradientEndHex - } - - init( - id: UUID = UUID(), - name: String, - colorHex: String, - opacity: Double, - size: Double, - width: Double, - glowRoundness: Double = 1.0, - glowFullness: Double = 0.5, - fadeDuration: Double, - colorMode: ColorMode, - gradientStartHex: String?, - gradientEndHex: String? - ) { - self.id = id - self.name = name - self.colorHex = colorHex - self.opacity = opacity - self.size = size - self.width = width - self.glowRoundness = glowRoundness - self.glowFullness = glowFullness - self.fadeDuration = fadeDuration - self.colorMode = colorMode - self.gradientStartHex = gradientStartHex - self.gradientEndHex = gradientEndHex - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - id = (try? container.decode(UUID.self, forKey: .id)) ?? UUID() - name = (try? container.decode(String.self, forKey: .name)) ?? "Imported" - colorHex = (try? container.decode(String.self, forKey: .colorHex)) ?? "68B8FF" - opacity = (try? container.decode(Double.self, forKey: .opacity)) ?? 0.8013 - size = (try? container.decode(Double.self, forKey: .size)) ?? 80.5536 - width = (try? container.decode(Double.self, forKey: .width)) ?? 1.0 - glowRoundness = (try? container.decode(Double.self, forKey: .glowRoundness)) ?? 0.7069 - glowFullness = (try? container.decode(Double.self, forKey: .glowFullness)) ?? 0.6046 - fadeDuration = (try? container.decode(Double.self, forKey: .fadeDuration)) ?? 1.0004 - - if let mode = try? container.decode(ColorMode.self, forKey: .colorMode) { - colorMode = mode - } else { - let rawMode = (try? container.decode(String.self, forKey: .colorMode)) ?? ColorMode.positionGradient.rawValue - colorMode = rawMode == "gradient" ? .positionGradient : (ColorMode(rawValue: rawMode) ?? .positionGradient) + var chordAppearance: ChordAppearance { + get { + ChordAppearance( + style: defaults.string(forKey: Keys.chordSurfaceStyle) + .flatMap(ChordSurfaceStyle.init(rawValue:)) + ?? .naturalMerge, + intensityMultiplier: validated( + defaults.object(forKey: Keys.chordIntensityMultiplier) + as? Double ?? 1, + range: ChordAppearance.intensityRange, + default: 1 + ) + ) + } + set { + let normalized = newValue.normalized + defaults.set( + normalized.style.rawValue, + forKey: Keys.chordSurfaceStyle + ) + defaults.set( + normalized.intensityMultiplier, + forKey: Keys.chordIntensityMultiplier + ) + } + } + + var powerSavingMode: PowerSavingMode { + get { + defaults.string(forKey: Keys.powerSavingMode) + .flatMap(PowerSavingMode.init(rawValue:)) + ?? .automatic + } + set { + defaults.set(newValue.rawValue, forKey: Keys.powerSavingMode) + } + } + + var surfaceShapeProfile: SurfaceShapeProfile { + get { + guard let rawValue = defaults.string(forKey: Keys.surfaceShapeProfile) else { + return .currentWave + } + return SurfaceShapeProfile.persistedValue(rawValue: rawValue) + ?? .currentWave + } + set { defaults.set(newValue.rawValue, forKey: Keys.surfaceShapeProfile) } + } + + /// Compatibility-safe value snapshot over the existing color preference keys. + var colorConfiguration: ColorConfiguration { + get { + ColorConfiguration( + mode: colorMode, + solidHex: glowColorHex, + gradientStartHex: gradientStartHex, + gradientEndHex: gradientEndHex + ) + } + set { + colorMode = newValue.mode + glowColorHex = newValue.solidHex + gradientStartHex = newValue.gradientStartHex + gradientEndHex = newValue.gradientEndHex + } + } + + /// Compatibility-safe value snapshot over the existing effect preference keys. + var effectConfiguration: EffectConfiguration { + get { + validatedEffectConfiguration(EffectConfiguration( + style: effectStyle, + shapeProfile: surfaceShapeProfile, + color: colorConfiguration, + opacity: glowOpacity, + refractionStrength: physicalRefractionStrength, + height: glowSize, + width: glowWidth, + roundness: glowRoundness, + hardness: glowFullness, + fadeDuration: fadeDuration + ), for: effectStyle) + } + set { + let normalized = validatedEffectConfiguration( + newValue, + for: newValue.style + ) + writeCurrentEffectConfiguration(normalized) + setEffectConfiguration(normalized, for: normalized.style) + } + } + + /// Returns the independently persisted controls for one supported effect. + /// Missing profiles receive a visible, route-appropriate default without + /// borrowing the currently selected effect's sliders. + func effectConfiguration(for requestedStyle: EffectStyle) -> EffectConfiguration { + let style = requestedStyle.supportedStyle + let profiles = loadEffectConfigurations() + return profiles[style.rawValue] + .map { validatedEffectConfiguration($0, for: style) } + ?? .defaultConfiguration(for: style) + } + + func setEffectConfiguration( + _ configuration: EffectConfiguration, + for requestedStyle: EffectStyle + ) { + let style = requestedStyle.supportedStyle + var profiles = loadEffectConfigurations() + profiles[style.rawValue] = validatedEffectConfiguration( + configuration, + for: style + ) + persistEffectConfigurations(profiles) + } + + private func initializeEffectConfigurationsIfNeeded() { + let selectedStyle = effectStyle.supportedStyle + var profiles = loadEffectConfigurations() + + // The established scalar keys remain the compatibility boundary for + // older builds. Only on first migration are they authoritative for the + // currently selected route; subsequent launches preserve every route's + // independently saved profile. + if profiles.isEmpty { + profiles[selectedStyle.rawValue] = validatedEffectConfiguration( + currentScalarEffectConfiguration(style: selectedStyle), + for: selectedStyle + ) + } + + for style in EffectStyle.allCases where profiles[style.rawValue] == nil { + profiles[style.rawValue] = .defaultConfiguration(for: style) + } + + persistEffectConfigurations(profiles) + if let selected = profiles[selectedStyle.rawValue] { + writeCurrentEffectConfiguration(selected) + } + } + + private func loadEffectConfigurations() -> [String: EffectConfiguration] { + guard let data = defaults.data( + forKey: Keys.effectConfigurationsByStyle + ), + data.count < Self.maxUserDefaultsDataSize, + let decoded = try? JSONDecoder().decode( + [String: EffectConfiguration].self, + from: data + ) else { + return [:] + } + + var normalized: [String: EffectConfiguration] = [:] + for (rawStyle, configuration) in decoded { + let style = ( + EffectStyle(rawValue: rawStyle) + ?? configuration.style + ).supportedStyle + normalized[style.rawValue] = validatedEffectConfiguration( + configuration, + for: style + ) + } + return normalized + } + + private func persistEffectConfigurations( + _ profiles: [String: EffectConfiguration] + ) { + let supportedProfiles = Dictionary(uniqueKeysWithValues: + EffectStyle.allCases.map { style in + let configuration = profiles[style.rawValue] + ?? .defaultConfiguration(for: style) + return ( + style.rawValue, + validatedEffectConfiguration(configuration, for: style) + ) } + ) + do { + defaults.set( + try JSONEncoder().encode(supportedProfiles), + forKey: Keys.effectConfigurationsByStyle + ) + } catch { + KeyLightLogger.storage.error( + "Effect settings profiles could not be encoded" + ) + } + } + + private func currentScalarEffectConfiguration( + style: EffectStyle + ) -> EffectConfiguration { + EffectConfiguration( + style: style.supportedStyle, + shapeProfile: surfaceShapeProfile, + color: colorConfiguration, + opacity: glowOpacity, + refractionStrength: physicalRefractionStrength, + height: glowSize, + width: glowWidth, + roundness: glowRoundness, + hardness: glowFullness, + fadeDuration: fadeDuration + ) + } + + private func writeCurrentEffectConfiguration( + _ configuration: EffectConfiguration + ) { + let normalized = validatedEffectConfiguration( + configuration, + for: configuration.style + ) + effectStyle = normalized.style + surfaceShapeProfile = normalized.shapeProfile + colorConfiguration = normalized.color + glowOpacity = normalized.opacity + physicalRefractionStrength = normalized.refractionStrength + glowSize = normalized.height + glowWidth = normalized.width + glowRoundness = normalized.roundness + glowFullness = normalized.hardness + fadeDuration = normalized.fadeDuration + } + + private func validatedEffectConfiguration( + _ configuration: EffectConfiguration, + for requestedStyle: EffectStyle + ) -> EffectConfiguration { + let style = requestedStyle.supportedStyle + return EffectConfiguration( + style: style, + shapeProfile: .currentWave, + color: ColorConfiguration( + mode: configuration.color.mode, + solidHex: sanitizedHex(configuration.color.solidHex), + gradientStartHex: sanitizedHex( + configuration.color.gradientStartHex + ), + gradientEndHex: sanitizedHex( + configuration.color.gradientEndHex + ) + ), + opacity: style == .solidBlack + ? 1.0 + : validated( + configuration.opacity, + range: 0.0...1.0, + default: 0.8013 + ), + refractionStrength: validated( + configuration.refractionStrength, + range: 0.5...2.5, + default: 1.0 + ), + height: validated( + configuration.height, + range: 4.0...200.0, + default: 80.5536 + ), + width: validated( + configuration.width, + range: 0.1...5.0, + default: 1.0 + ), + roundness: validated( + configuration.roundness, + range: 0.0...1.0, + default: 0.7069 + ), + hardness: validated( + configuration.hardness, + range: 0.0...1.0, + default: 0.6046 + ), + fadeDuration: validated( + configuration.fadeDuration, + range: 0.05...5.0, + default: 1.0004 + ) + ) + } - gradientStartHex = try? container.decode(String.self, forKey: .gradientStartHex) - gradientEndHex = try? container.decode(String.self, forKey: .gradientEndHex) - } - - static let defaultTheme = Theme( - name: "current", - colorHex: "68B8FF", - opacity: 0.8013, - size: 80.5536, - width: 1.0, - glowRoundness: 0.7069, - glowFullness: 0.6046, - fadeDuration: 1.0004, - colorMode: .positionGradient, - gradientStartHex: "68B8FF", - gradientEndHex: "00E69A" + /// A read-only value snapshot. Launch-at-login writes continue through the + /// established setter because they also reconcile the system login item. + var appPreferences: AppPreferences { + AppPreferences( + isEnabled: isEnabled, + launchAtLogin: launchAtLogin, + effect: effectConfiguration, + chordAppearance: chordAppearance, + powerSavingMode: powerSavingMode ) } + // MARK: - Themes + /// Maximum data size for UserDefaults JSON reads (guards against injection from other processes) private static let maxUserDefaultsDataSize = 500_000 // 500KB + private static func recordIndicesNeedingStableIDRepair(_ data: Data) -> Set? { + guard let records = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else { + return nil + } + + return Set(records.indices.filter { index in + guard let rawID = records[index]["id"] as? String else { return true } + return UUID(uuidString: rawID) == nil + }) + } + var savedThemes: [Theme] { get { - guard let data = defaults.data(forKey: Keys.savedThemes), - data.count < Self.maxUserDefaultsDataSize, - let themes = try? JSONDecoder().decode([Theme].self, from: data) else { - return [Theme.defaultTheme] - } - return themes + loadStoredThemes() ?? [Theme.defaultTheme] } set { do { let data = try JSONEncoder().encode(newValue) defaults.set(data, forKey: Keys.savedThemes) } catch { - KeyLightLog("Failed to save themes: \(error)") + KeyLightLogger.storage.error("Saved themes could not be encoded") } } } + private func loadStoredThemes() -> [Theme]? { + guard let data = defaults.data(forKey: Keys.savedThemes), + data.count < Self.maxUserDefaultsDataSize, + var themes = try? JSONDecoder().decode([Theme].self, from: data) else { + return nil + } + if let repairIndices = Self.recordIndicesNeedingStableIDRepair(data), !repairIndices.isEmpty { + themes = persistRepairedThemes(themes, repairIndices: repairIndices) + } + return themes + } + var currentThemeName: String { get { defaults.string(forKey: Keys.currentThemeName) ?? Theme.defaultTheme.name } set { defaults.set(newValue, forKey: Keys.currentThemeName) - notifyStorageChanged() + if let theme = savedThemes.first(where: { $0.name == newValue }) { + writeSelectionID(theme.id, forKey: Keys.activeThemeID) + } else { + writeSelectionID(nil, forKey: Keys.activeThemeID) + } + } + } + + var activeThemeID: UUID? { + get { + let themes = savedThemes + guard let id = selectionID(forKey: Keys.activeThemeID), + themes.contains(where: { $0.id == id }) else { + return nil + } + return id + } + set { + let selection = newValue.flatMap { id in savedThemes.first(where: { $0.id == id }) } + persistThemeSelection(selection) } } @@ -363,21 +804,233 @@ final class SettingsManager { get { defaults.string(forKey: Keys.currentKeyMappingProfileName) ?? "None" } set { defaults.set(newValue, forKey: Keys.currentKeyMappingProfileName) - notifyStorageChanged() + if let profile = savedKeyMappingProfiles.first(where: { $0.name == newValue }) { + writeSelectionID(profile.id, forKey: Keys.activeLayoutID) + } else { + writeSelectionID(nil, forKey: Keys.activeLayoutID) + } + } + } + + var activeLayoutID: UUID? { + get { + let profiles = savedKeyMappingProfiles + guard let id = selectionID(forKey: Keys.activeLayoutID), + profiles.contains(where: { $0.id == id }) else { + return nil + } + return id + } + set { + let selection = newValue.flatMap { id in savedKeyMappingProfiles.first(where: { $0.id == id }) } + persistLayoutSelection(selection) + } + } + + var overlayDisplaySelection: OverlayDisplaySelection { + get { OverlayDisplaySelection(persistedValue: defaults.string(forKey: Keys.overlayDisplaySelection)) } + set { defaults.set(newValue.persistedValue, forKey: Keys.overlayDisplaySelection) } + } + + var mirroredDisplayIDs: Set { + get { + let stored = defaults.object(forKey: Keys.mirroredDisplayIDs) + as? [String] ?? [] + return Set(stored.compactMap { value in + let trimmed = value.trimmingCharacters( + in: .whitespacesAndNewlines + ) + guard !trimmed.isEmpty, trimmed.count <= 200 else { + return nil + } + return trimmed + }.prefix(16)) + } + set { + let normalized = newValue.compactMap { value -> String? in + let trimmed = value.trimmingCharacters( + in: .whitespacesAndNewlines + ) + return !trimmed.isEmpty && trimmed.count <= 200 + ? trimmed + : nil + } + .sorted() + .prefix(16) + defaults.set(Array(normalized), forKey: Keys.mirroredDisplayIDs) + } + } + + var globalShortcut: GlobalShortcut { + get { + guard let data = defaults.data(forKey: Keys.globalShortcut), + data.count < 1_024, + let decoded = try? JSONDecoder().decode(GlobalShortcut.self, from: data), + let validated = GlobalShortcut( + keyCode: decoded.keyCode, + modifiers: decoded.modifiers + ) else { + return .default + } + return validated } + set { + guard let data = try? JSONEncoder().encode(newValue) else { return } + defaults.set(data, forKey: Keys.globalShortcut) + } + } + + var displayLayoutProfileBindings: [String: UUID] { + get { + guard let stored = defaults.dictionary(forKey: Keys.displayLayoutProfileBindings) else { + return [:] + } + let validProfileIDs = Set(savedKeyMappingProfiles.map(\.id)) + var bindings: [String: UUID] = [:] + for persistentDisplayID in stored.keys.sorted().prefix(32) { + guard !persistentDisplayID.isEmpty, + persistentDisplayID.count <= 200, + let value = stored[persistentDisplayID] as? String, + let profileID = UUID(uuidString: value), + validProfileIDs.contains(profileID) else { + continue + } + bindings[persistentDisplayID] = profileID + } + return bindings + } + set { + let validProfileIDs = Set(savedKeyMappingProfiles.map(\.id)) + var encoded: [String: String] = [:] + for persistentDisplayID in newValue.keys.sorted().prefix(32) { + guard !persistentDisplayID.isEmpty, + persistentDisplayID.count <= 200, + let profileID = newValue[persistentDisplayID], + validProfileIDs.contains(profileID) else { + continue + } + encoded[persistentDisplayID] = profileID.uuidString + } + if encoded.isEmpty { + defaults.removeObject(forKey: Keys.displayLayoutProfileBindings) + } else { + defaults.set(encoded, forKey: Keys.displayLayoutProfileBindings) + } + } + } + + func setLayoutProfileBinding(_ profileID: UUID?, forDisplay persistentDisplayID: String) { + var bindings = displayLayoutProfileBindings + bindings[persistentDisplayID] = profileID + displayLayoutProfileBindings = bindings + } + + private func selectionID(forKey key: String) -> UUID? { + defaults.string(forKey: key).flatMap(UUID.init(uuidString:)) + } + + private func writeSelectionID(_ id: UUID?, forKey key: String) { + if let id { + defaults.set(id.uuidString, forKey: key) + } else { + defaults.removeObject(forKey: key) + } + } + + private func persistThemeSelection(_ theme: Theme?) { + writeSelectionID(theme?.id, forKey: Keys.activeThemeID) + defaults.set(theme?.name ?? Theme.defaultTheme.name, forKey: Keys.currentThemeName) + } + + private func persistLayoutSelection(_ profile: KeyMappingProfile?) { + writeSelectionID(profile?.id, forKey: Keys.activeLayoutID) + defaults.set(profile?.name ?? "None", forKey: Keys.currentKeyMappingProfileName) + } + + private func reconcileThemeSelection(in themes: [Theme]) { + let storedThemeID = selectionID(forKey: Keys.activeThemeID) + let legacyThemeName = defaults.string(forKey: Keys.currentThemeName) ?? Theme.defaultTheme.name + let selectedTheme = storedThemeID.flatMap { id in themes.first(where: { $0.id == id }) } + ?? themes.first(where: { $0.name == legacyThemeName }) + ?? themes.first + persistThemeSelection(selectedTheme) + } + + private func reconcileLayoutSelection(in profiles: [KeyMappingProfile]) { + let storedLayoutID = selectionID(forKey: Keys.activeLayoutID) + let legacyLayoutName = defaults.string(forKey: Keys.currentKeyMappingProfileName) ?? "None" + let selectedProfile = storedLayoutID.flatMap { id in profiles.first(where: { $0.id == id }) } + ?? profiles.first(where: { $0.name == legacyLayoutName }) + ?? profiles.first + persistLayoutSelection(selectedProfile) + } + + private func persistRepairedThemes(_ decodedThemes: [Theme], repairIndices: Set) -> [Theme] { + var themes = decodedThemes + let storedThemeID = selectionID(forKey: Keys.activeThemeID) + let legacyThemeName = defaults.string(forKey: Keys.currentThemeName) ?? Theme.defaultTheme.name + if let storedThemeID, + !themes.contains(where: { $0.id == storedThemeID }), + let selectedIndex = themes.firstIndex(where: { $0.name == legacyThemeName }), + repairIndices.contains(selectedIndex) { + // If a previous current build had already selected this legacy + // record, keep that established identity when an older build strips + // the UUID field. Other missing IDs use the deterministic fallback. + themes[selectedIndex].id = storedThemeID + } + + do { + defaults.set(try JSONEncoder().encode(themes), forKey: Keys.savedThemes) + reconcileThemeSelection(in: themes) + } catch { + KeyLightLogger.storage.error("Legacy theme identities could not be repaired") + } + return themes + } + + private func persistRepairedLayoutProfiles( + _ decodedProfiles: [KeyMappingProfile], + repairIndices: Set + ) -> [KeyMappingProfile] { + var profiles = decodedProfiles + let storedLayoutID = selectionID(forKey: Keys.activeLayoutID) + let legacyLayoutName = defaults.string(forKey: Keys.currentKeyMappingProfileName) ?? "None" + if let storedLayoutID, + !profiles.contains(where: { $0.id == storedLayoutID }), + let selectedIndex = profiles.firstIndex(where: { $0.name == legacyLayoutName }), + repairIndices.contains(selectedIndex) { + profiles[selectedIndex].id = storedLayoutID + } + + do { + defaults.set(try JSONEncoder().encode(profiles), forKey: Keys.keyMappingProfiles) + reconcileLayoutSelection(in: profiles) + } catch { + KeyLightLogger.storage.error("Legacy layout identities could not be repaired") + } + return profiles } func saveTheme(_ theme: Theme) { var theme = theme - theme.name = theme.name.trimmingCharacters(in: .whitespacesAndNewlines) - guard !theme.name.isEmpty else { return } + guard let normalizedName = PersistenceValidation.normalizedName(theme.name) else { return } + theme.name = normalizedName theme.colorHex = sanitizedHex(theme.colorHex) theme.opacity = validated(theme.opacity, range: 0.0...1.0, default: 0.8013) - theme.size = validated(theme.size, range: 10.0...200.0, default: 80.5536) + theme.refractionStrength = validated( + theme.refractionStrength, + range: 0.5...2.5, + default: 1.0 + ) + theme.size = validated(theme.size, range: 4.0...200.0, default: 80.5536) theme.width = validated(theme.width, range: 0.1...5.0, default: 1.0) theme.glowRoundness = validated(theme.glowRoundness, range: 0.0...1.0, default: 0.7069) theme.glowFullness = validated(theme.glowFullness, range: 0.0...1.0, default: 0.6046) theme.fadeDuration = validated(theme.fadeDuration, range: 0.05...5.0, default: 1.0004) + theme.effectStyle = ( + EffectStyle(rawValue: theme.effectStyle.rawValue) ?? .classicGlow + ).supportedStyle + theme.shapeProfile = .currentWave if let startHex = theme.gradientStartHex { theme.gradientStartHex = sanitizedHex(startHex) } @@ -387,27 +1040,37 @@ final class SettingsManager { var themes = savedThemes if let index = themes.firstIndex(where: { $0.name == theme.name }) { + theme.id = themes[index].id + themes[index] = theme + } else if let index = themes.firstIndex(where: { $0.id == theme.id }) { + let lowercasedName = theme.name.lowercased() + guard !themes.contains(where: { $0.id != theme.id && $0.name.lowercased() == lowercasedName }) else { + return + } themes[index] = theme } else { themes.append(theme) } savedThemes = themes - notifyStorageChanged() + if activeThemeID == theme.id || currentThemeName == theme.name { + persistThemeSelection(theme) + } } func deleteTheme(named name: String) { var themes = savedThemes + let removedIDs = Set(themes.filter { $0.name == name }.map(\.id)) + let storedActiveID = selectionID(forKey: Keys.activeThemeID) + let wasActive = currentThemeName == name || storedActiveID.map(removedIDs.contains) == true themes.removeAll { $0.name == name } if themes.isEmpty { themes = [Theme.defaultTheme] } savedThemes = themes - // Reset current theme if the deleted one was active - if currentThemeName == name { - currentThemeName = themes.first?.name ?? Theme.defaultTheme.name + if wasActive { + persistThemeSelection(themes.first) } - notifyStorageChanged() } func restoreTheme(_ theme: Theme, at index: Int, makeCurrent: Bool) { @@ -417,192 +1080,49 @@ final class SettingsManager { themes.insert(theme, at: safeIndex) savedThemes = themes if makeCurrent { - currentThemeName = theme.name + persistThemeSelection(theme) } - notifyStorageChanged() } - func renameTheme(from oldName: String, to newName: String) { + @discardableResult + func renameTheme(from oldName: String, to newName: String) -> Bool { var themes = savedThemes - guard let index = themes.firstIndex(where: { $0.name == oldName }) else { return } - let trimmed = newName.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return } + guard let index = themes.firstIndex(where: { $0.name == oldName }) else { return false } + guard let trimmed = PersistenceValidation.normalizedName(newName) else { return false } let oldNameLower = oldName.lowercased() let trimmedLower = trimmed.lowercased() let hasCollision = themes.contains { theme in theme.name.lowercased() == trimmedLower && theme.name.lowercased() != oldNameLower } - guard !hasCollision else { return } + guard !hasCollision else { return false } + let renamedThemeID = themes[index].id + let wasActive = selectionID(forKey: Keys.activeThemeID) == renamedThemeID || currentThemeName == oldName themes[index].name = trimmed savedThemes = themes - // Update current theme name if it was the renamed theme - if currentThemeName == oldName { - currentThemeName = trimmed + if wasActive { + persistThemeSelection(themes[index]) } - notifyStorageChanged() - } - - private func sanitizedThemeForImport(_ theme: Theme, fallbackName: String = "Imported Theme") -> Theme { - var sanitized = theme - let trimmedName = theme.name.trimmingCharacters(in: .whitespacesAndNewlines) - sanitized.name = trimmedName.isEmpty ? fallbackName : String(trimmedName.prefix(100)) - sanitized.colorHex = sanitizedHex(theme.colorHex) - sanitized.opacity = validated(theme.opacity, range: 0.0...1.0, default: 0.8013) - sanitized.size = validated(theme.size, range: 10.0...200.0, default: 80.5536) - sanitized.width = validated(theme.width, range: 0.1...5.0, default: 1.0) - sanitized.glowRoundness = validated(theme.glowRoundness, range: 0.0...1.0, default: 0.7069) - sanitized.glowFullness = validated(theme.glowFullness, range: 0.0...1.0, default: 0.6046) - sanitized.fadeDuration = validated(theme.fadeDuration, range: 0.05...5.0, default: 1.0004) - sanitized.gradientStartHex = sanitizedHex(theme.gradientStartHex ?? Self.defaultSolidHex) - sanitized.gradientEndHex = sanitizedHex(theme.gradientEndHex ?? Self.defaultGradientEndHex) - return sanitized + return true } func exportThemeString(_ theme: Theme) -> String? { - let sanitized = sanitizedThemeForImport(theme) - let encodedName = percentEncodeThemeName(sanitized.name) - let mode = sanitized.colorMode.rawValue - let color = sanitized.colorHex.uppercased() - let opacity = formatThemeNumber(sanitized.opacity) - let size = formatThemeNumber(sanitized.size) - let width = formatThemeNumber(sanitized.width) - let roundness = formatThemeNumber(sanitized.glowRoundness) - let hardness = formatThemeNumber(sanitized.glowFullness) - let fade = formatThemeNumber(sanitized.fadeDuration) - let gstart = (sanitized.gradientStartHex ?? Self.defaultSolidHex).uppercased() - let gend = (sanitized.gradientEndHex ?? Self.defaultGradientEndHex).uppercased() - - return [ - Self.themeStringPrefix, - "name=\(encodedName)", - "mode=\(mode)", - "color=\(color)", - "opacity=\(opacity)", - "size=\(size)", - "width=\(width)", - "round=\(roundness)", - "hard=\(hardness)", - "fade=\(fade)", - "gstart=\(gstart)", - "gend=\(gend)" - ].joined(separator: ";") + ThemeStringCodec.encode(theme) } func importThemeString(_ value: String) throws -> Theme { - let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { - throw invalidThemeStringError() - } - guard trimmed.count <= Self.maxThemeStringLength else { - throw NSError(domain: "KeyLight", code: 11, userInfo: [ - NSLocalizedDescriptionKey: "Theme string is too large." - ]) - } + try ThemeStringCodec.decode(value) + } - let segments = trimmed.split(separator: ";", omittingEmptySubsequences: false).map(String.init) - guard segments.first == Self.themeStringPrefix else { - throw invalidThemeStringError() - } + // MARK: - Gradient Presets - var fields: [String: String] = [:] - for segment in segments.dropFirst() { - guard !segment.isEmpty, - let splitIndex = segment.firstIndex(of: "="), - splitIndex != segment.startIndex else { - throw invalidThemeStringError() - } - - let key = String(segment[.. NSError { - NSError(domain: "KeyLight", code: 10, userInfo: [ - NSLocalizedDescriptionKey: Self.invalidThemeStringMessage - ]) - } - - private func formatThemeNumber(_ value: Double) -> String { - String(format: "%.4f", value) - } - - private func parseThemeNumber(_ raw: String?) -> Double? { - guard let raw, let parsed = Double(raw), parsed.isFinite else { return nil } - return parsed - } - - private func percentEncodeThemeName(_ value: String) -> String { - let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_.~")) - return value.addingPercentEncoding(withAllowedCharacters: allowed) ?? value - } - - // MARK: - Gradient Presets - - struct GradientPreset: Codable, Identifiable, Equatable { - var id = UUID() - var startHex: String - var endHex: String - var name: String? - } - - private static let defaultGradientPresets: [GradientPreset] = [ - GradientPreset(startHex: defaultSolidHex, endHex: defaultGradientEndHex, name: "Ocean"), - GradientPreset(startHex: "C77DFF", endHex: "FF6B9D", name: "Neon"), - GradientPreset(startHex: "FF6B6B", endHex: "FFD93D", name: "Sunset"), - GradientPreset(startHex: "00D2FF", endHex: "C77DFF", name: "Sky"), - GradientPreset(startHex: "FF6B6B", endHex: "3399FF", name: "Fire-Ice") - ] + static let defaultGradientPresets: [GradientPreset] = [ + GradientPreset(startHex: defaultSolidHex, endHex: defaultGradientEndHex, name: "Ocean"), + GradientPreset(startHex: "C77DFF", endHex: "FF6B9D", name: "Neon"), + GradientPreset(startHex: "FF6B6B", endHex: "FFD93D", name: "Sunset"), + GradientPreset(startHex: "00D2FF", endHex: "C77DFF", name: "Sky"), + GradientPreset(startHex: "FF6B6B", endHex: "3399FF", name: "Fire-Ice") + ] var savedGradientPresets: [GradientPreset] { get { @@ -626,7 +1146,7 @@ final class SettingsManager { let data = try JSONEncoder().encode(sanitized) defaults.set(data, forKey: Keys.gradientPresets) } catch { - KeyLightLog("Failed to save gradient presets: \(error)") + KeyLightLogger.storage.error("Gradient presets could not be encoded") } } } @@ -682,7 +1202,7 @@ final class SettingsManager { } guard manifest.version == 1 else { - KeyLightLog("Unsupported bundled preset manifest version: \(manifest.version)") + KeyLightLogger.imports.warning("Bundled layout manifest version is unsupported") return [] } @@ -696,9 +1216,8 @@ final class SettingsManager { func importBundledLayoutPreset(_ preset: BundledLayoutPreset, forcedName: String? = nil) throws -> KeyMappingProfile { let data = try loadBundledPresetData(resourcePath: preset.resourcePath) var profile = try importLayoutProfileData(data) - let preferredName = (forcedName ?? preset.displayName).trimmingCharacters(in: .whitespacesAndNewlines) - if !preferredName.isEmpty { - profile.name = String(preferredName.prefix(100)) + if let preferredName = PersistenceValidation.normalizedName(forcedName ?? preset.displayName) { + profile.name = preferredName } return profile } @@ -719,7 +1238,7 @@ final class SettingsManager { guard let baseURL = Bundle.main.resourceURL?.appendingPathComponent("VariantPresets", isDirectory: true) else { throw NSError(domain: "KeyLight", code: 24, userInfo: [ - NSLocalizedDescriptionKey: "Bundled presets are unavailable in this build." + NSLocalizedDescriptionKey: String(localized: "Bundled presets are unavailable in this build.") ]) } @@ -738,104 +1257,78 @@ final class SettingsManager { } #endif throw NSError(domain: "KeyLight", code: 24, userInfo: [ - NSLocalizedDescriptionKey: "Bundled preset file not found: \(normalizedPath)." + NSLocalizedDescriptionKey: String(localized: "Bundled preset file not found: \(normalizedPath).") ]) } } // MARK: - Key Mapping Profiles - struct KeyMappingProfile: Codable, Identifiable { - var id = UUID() - var name: String - var keyOffsets: [UInt16: CGFloat] - var keyWidthOverrides: [UInt16: CGFloat] - - enum CodingKeys: String, CodingKey { - case id, name, keyOffsets, keyWidthOverrides - } - - func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(id, forKey: .id) - try container.encode(name, forKey: .name) - // Convert UInt16 keys to String for JSON compatibility - let stringKeyedOffsets = keyOffsets.reduce(into: [String: CGFloat]()) { result, pair in - result[String(pair.key)] = pair.value - } - try container.encode(stringKeyedOffsets, forKey: .keyOffsets) - - let stringKeyedWidths = keyWidthOverrides.reduce(into: [String: CGFloat]()) { result, pair in - result[String(pair.key)] = pair.value - } - try container.encode(stringKeyedWidths, forKey: .keyWidthOverrides) - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - id = try container.decode(UUID.self, forKey: .id) - name = try container.decode(String.self, forKey: .name) - let stringKeyed = try container.decode([String: CGFloat].self, forKey: .keyOffsets) - keyOffsets = stringKeyed.reduce(into: [UInt16: CGFloat]()) { result, pair in - if let keyCode = UInt16(pair.key) { - result[keyCode] = pair.value - } - } - - let widthKeyed = (try? container.decode([String: CGFloat].self, forKey: .keyWidthOverrides)) ?? [:] - keyWidthOverrides = widthKeyed.reduce(into: [UInt16: CGFloat]()) { result, pair in - if let keyCode = UInt16(pair.key) { - result[keyCode] = pair.value - } - } - } - - init(name: String, keyOffsets: [UInt16: CGFloat], keyWidthOverrides: [UInt16: CGFloat] = [:]) { - self.name = name - self.keyOffsets = keyOffsets - self.keyWidthOverrides = keyWidthOverrides - } - } - var savedKeyMappingProfiles: [KeyMappingProfile] { get { - guard let data = defaults.data(forKey: Keys.keyMappingProfiles), - data.count < Self.maxUserDefaultsDataSize, - let profiles = try? JSONDecoder().decode([KeyMappingProfile].self, from: data) else { - return [] - } - return profiles + loadStoredLayoutProfiles() ?? [] } set { do { let data = try JSONEncoder().encode(newValue) defaults.set(data, forKey: Keys.keyMappingProfiles) } catch { - KeyLightLog("Failed to save key mapping profiles: \(error)") + KeyLightLogger.storage.error("Saved layouts could not be encoded") } } } - func saveKeyMappingProfile(_ profile: KeyMappingProfile) { + private func loadStoredLayoutProfiles() -> [KeyMappingProfile]? { + guard let data = defaults.data(forKey: Keys.keyMappingProfiles), + data.count < Self.maxUserDefaultsDataSize, + var profiles = try? JSONDecoder().decode([KeyMappingProfile].self, from: data) else { + return nil + } + if let repairIndices = Self.recordIndicesNeedingStableIDRepair(data), !repairIndices.isEmpty { + profiles = persistRepairedLayoutProfiles(profiles, repairIndices: repairIndices) + } + return profiles + } + + @discardableResult + func saveKeyMappingProfile(_ profile: KeyMappingProfile) -> KeyMappingProfile? { + guard let normalizedName = PersistenceValidation.normalizedName(profile.name) else { return nil } + var profile = profile + profile.name = normalizedName + let normalizedLayout = KeyLayoutStore.normalized(KeyLayout( + offsets: profile.keyOffsets, + widthMultipliers: profile.keyWidthOverrides + )) + profile.keyOffsets = normalizedLayout.offsets + profile.keyWidthOverrides = normalizedLayout.widthMultipliers var profiles = savedKeyMappingProfiles if let index = profiles.firstIndex(where: { $0.name == profile.name }) { + profile.id = profiles[index].id + profiles[index] = profile + } else if let index = profiles.firstIndex(where: { $0.id == profile.id }) { + let lowercasedName = profile.name.lowercased() + guard !profiles.contains(where: { $0.id != profile.id && $0.name.lowercased() == lowercasedName }) else { + return nil + } profiles[index] = profile } else { profiles.append(profile) } savedKeyMappingProfiles = profiles - currentKeyMappingProfileName = profile.name - notifyStorageChanged() + persistLayoutSelection(profile) + return profile } func deleteKeyMappingProfile(named name: String) { var profiles = savedKeyMappingProfiles + let removedIDs = Set(profiles.filter { $0.name == name }.map(\.id)) + let storedActiveID = selectionID(forKey: Keys.activeLayoutID) + let wasActive = currentKeyMappingProfileName == name || storedActiveID.map(removedIDs.contains) == true profiles.removeAll { $0.name == name } savedKeyMappingProfiles = profiles - if currentKeyMappingProfileName == name { - currentKeyMappingProfileName = profiles.first?.name ?? "None" + if wasActive { + persistLayoutSelection(profiles.first) } - notifyStorageChanged() } func restoreKeyMappingProfile(_ profile: KeyMappingProfile, at index: Int, makeCurrent: Bool) { @@ -845,151 +1338,79 @@ final class SettingsManager { profiles.insert(profile, at: safeIndex) savedKeyMappingProfiles = profiles if makeCurrent { - currentKeyMappingProfileName = profile.name + persistLayoutSelection(profile) } - notifyStorageChanged() } - func renameKeyMappingProfile(from oldName: String, to newName: String) { + @discardableResult + func renameKeyMappingProfile(from oldName: String, to newName: String) -> Bool { var profiles = savedKeyMappingProfiles - guard let index = profiles.firstIndex(where: { $0.name == oldName }) else { return } - let trimmed = newName.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return } + guard let index = profiles.firstIndex(where: { $0.name == oldName }) else { return false } + guard let trimmed = PersistenceValidation.normalizedName(newName) else { return false } let oldNameLower = oldName.lowercased() let trimmedLower = trimmed.lowercased() let hasCollision = profiles.contains { profile in profile.name.lowercased() == trimmedLower && profile.name.lowercased() != oldNameLower } - guard !hasCollision else { return } + guard !hasCollision else { return false } + let renamedProfileID = profiles[index].id + let wasActive = selectionID(forKey: Keys.activeLayoutID) == renamedProfileID || currentKeyMappingProfileName == oldName profiles[index].name = trimmed savedKeyMappingProfiles = profiles - if currentKeyMappingProfileName == oldName { - currentKeyMappingProfileName = trimmed + if wasActive { + persistLayoutSelection(profiles[index]) } - notifyStorageChanged() + return true } - private struct LayoutProfileTransferData: Codable { - var version: Int - var kind: String? - var name: String - var keyOffsets: [String: CGFloat] - var keyWidthOverrides: [String: CGFloat]? + func exportLayoutProfileData(_ profile: KeyMappingProfile) -> Data? { + LayoutProfileCodec.encode(profile) } - private func normalizedImportedWidthOverrides(from overrides: [String: CGFloat]) -> [UInt16: CGFloat] { - var decoded: [UInt16: CGFloat] = [:] - decoded.reserveCapacity(overrides.count) - for (key, value) in overrides { - guard let keyCode = UInt16(key), value.isFinite else { continue } - decoded[keyCode] = value - } - - let allowedKeyCodes = Set(KeyboardLayoutInfo.allKeys.map(\.id)) - var canonicalValues: [UInt16: CGFloat] = [:] - var aliasFallbackValues: [UInt16: CGFloat] = [:] - - for keyCode in decoded.keys.sorted() { - guard let value = decoded[keyCode], value.isFinite else { continue } - let canonicalKeyCode = KeyboardLayoutInfo.canonicalKeyCode(for: keyCode) - guard allowedKeyCodes.contains(canonicalKeyCode) else { continue } - - let clamped = min(max(value, 0.1), 5.0) - if keyCode == canonicalKeyCode { - canonicalValues[canonicalKeyCode] = clamped - } else if aliasFallbackValues[canonicalKeyCode] == nil { - aliasFallbackValues[canonicalKeyCode] = clamped - } - } - - var normalized: [UInt16: CGFloat] = aliasFallbackValues - for (keyCode, value) in canonicalValues { - normalized[keyCode] = value - } - - return normalized + func importLayoutProfileData(_ data: Data) throws -> KeyMappingProfile { + try LayoutProfileCodec.decode(data) } - func exportLayoutProfileData(_ profile: KeyMappingProfile) -> Data? { - let trimmedName = profile.name.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmedName.isEmpty else { return nil } - - let canonicalOffsets = KeyPositionManager.normalizedImportedOffsets( - from: profile.keyOffsets.reduce(into: [String: CGFloat]()) { result, pair in - result[String(pair.key)] = pair.value - } - ) - let canonicalWidths = normalizedImportedWidthOverrides( - from: profile.keyWidthOverrides.reduce(into: [String: CGFloat]()) { result, pair in - result[String(pair.key)] = pair.value - } - ) - - let payload = LayoutProfileTransferData( - version: Self.layoutProfileSchemaVersion, - kind: "layoutProfile", - name: String(trimmedName.prefix(100)), - keyOffsets: canonicalOffsets, - keyWidthOverrides: canonicalWidths.reduce(into: [String: CGFloat]()) { result, pair in - result[String(pair.key)] = pair.value - } - ) - - do { - return try JSONEncoder().encode(payload) - } catch { - KeyLightLog("Failed to encode layout profile export data: \(error)") - return nil + /// Adds stable selection identities once by matching the established legacy + /// names. Both forms remain persisted so older KeyLight builds continue to + /// understand the active selections. + private func migrateStableSelectionIDsIfNeeded() { + // Always decode both collections. Their load paths repair any records + // whose UUID field was stripped after the one-time migration ran. + // Keep corrupt/oversized storage untouched, matching the legacy path. + let themes: [Theme]? + if defaults.data(forKey: Keys.savedThemes) == nil { + themes = [Theme.defaultTheme] + } else { + themes = loadStoredThemes() } - } - func importLayoutProfileData(_ data: Data) throws -> KeyMappingProfile { - guard data.count <= Self.maxLayoutProfileImportSize else { - throw NSError(domain: "KeyLight", code: 20, userInfo: [ - NSLocalizedDescriptionKey: "Layout profile file is too large (max 1MB)." - ]) + let profiles: [KeyMappingProfile]? + if defaults.data(forKey: Keys.keyMappingProfiles) == nil { + profiles = [] + } else { + profiles = loadStoredLayoutProfiles() } - let payload: LayoutProfileTransferData - do { - payload = try JSONDecoder().decode(LayoutProfileTransferData.self, from: data) - } catch { - throw NSError(domain: "KeyLight", code: 21, userInfo: [ - NSLocalizedDescriptionKey: Self.invalidLayoutProfileMessage - ]) + guard defaults.integer(forKey: Keys.stableSelectionMigrationVersion) < Self.stableSelectionMigrationVersion else { + return } - guard payload.version <= Self.layoutProfileSchemaVersion else { - throw NSError(domain: "KeyLight", code: 22, userInfo: [ - NSLocalizedDescriptionKey: "Unsupported layout profile version (\(payload.version)). Please update KeyLight." - ]) - } - if let kind = payload.kind, kind != "layoutProfile" { - throw NSError(domain: "KeyLight", code: 21, userInfo: [ - NSLocalizedDescriptionKey: Self.invalidLayoutProfileMessage - ]) + if let themes { + // Re-encoding upgrades legacy records that predate UUIDs without + // changing the shape understood by current builds. + savedThemes = themes + reconcileThemeSelection(in: themes) } - let trimmedName = payload.name.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmedName.isEmpty else { - throw NSError(domain: "KeyLight", code: 23, userInfo: [ - NSLocalizedDescriptionKey: "Layout profile name is missing." - ]) + if let profiles { + // Re-encoding likewise stabilizes any legacy profile UUIDs. + savedKeyMappingProfiles = profiles + reconcileLayoutSelection(in: profiles) } - let normalizedOffsets = KeyPositionManager.normalizedImportedOffsets(from: payload.keyOffsets) - .reduce(into: [UInt16: CGFloat]()) { result, pair in - if let keyCode = UInt16(pair.key) { - result[keyCode] = pair.value - } - } - let normalizedWidths = normalizedImportedWidthOverrides(from: payload.keyWidthOverrides ?? [:]) - return KeyMappingProfile( - name: String(trimmedName.prefix(100)), - keyOffsets: normalizedOffsets, - keyWidthOverrides: normalizedWidths - ) + defaults.set(Self.stableSelectionMigrationVersion, forKey: Keys.stableSelectionMigrationVersion) } private func seedDefaultExperienceIfNeeded() { @@ -999,21 +1420,24 @@ final class SettingsManager { guard isFreshInstallForDefaultSeed else { return } do { - let seededTheme = try importThemeString(Self.defaultThemeString) + let seededTheme = try importThemeString(ThemeStringCodec.defaultThemeString) savedThemes = [seededTheme] currentThemeName = seededTheme.name glowColorHex = seededTheme.colorHex glowOpacity = seededTheme.opacity + physicalRefractionStrength = seededTheme.refractionStrength glowSize = seededTheme.size glowWidth = seededTheme.width glowRoundness = seededTheme.glowRoundness glowFullness = seededTheme.glowFullness fadeDuration = seededTheme.fadeDuration colorMode = seededTheme.colorMode + effectStyle = seededTheme.effectStyle + surfaceShapeProfile = seededTheme.shapeProfile gradientStartHex = seededTheme.gradientStartHex ?? Self.defaultSolidHex gradientEndHex = seededTheme.gradientEndHex ?? Self.defaultGradientEndHex } catch { - KeyLightLog("Failed to seed default theme: \(error)") + KeyLightLogger.storage.error("Default theme could not be seeded") return } @@ -1025,6 +1449,20 @@ final class SettingsManager { defaults.set(Self.defaultExperienceSeedVersion, forKey: Keys.defaultExperienceSeedVersion) } + private func initializeOnboardingState(wasFreshInstall: Bool) { + guard !wasFreshInstall, + defaults.object(forKey: Keys.onboardingCompletedVersion) == nil, + defaults.object(forKey: Keys.onboardingDeferredVersion) == nil else { + return + } + // The new chooser must not replace an existing installation's selected + // effect. Setup remains manually accessible from the menu. + defaults.set( + Self.currentOnboardingVersion, + forKey: Keys.onboardingCompletedVersion + ) + } + /// One-time layout migration: /// Apply bundled default layout if and only if no layout profile/geometry exists yet. /// Never overwrite user-defined layout state. @@ -1040,7 +1478,7 @@ final class SettingsManager { profiles.contains(where: { $0.name == activeName }) let hasSavedProfiles = !profiles.isEmpty - let hasOffsetData = !(defaults.dictionary(forKey: KeyPositionManager.offsetsKey) ?? [:]).isEmpty + let hasOffsetData = !(defaults.dictionary(forKey: KeyLayoutStore.offsetsKey) ?? [:]).isEmpty let hasWidthData = !(defaults.dictionary(forKey: "KeyWidthOverrides") ?? [:]).isEmpty let hasPersistedGeometry = hasOffsetData || hasWidthData @@ -1113,19 +1551,16 @@ final class SettingsManager { } defaults.set(Self.bundledLayoutProfilesSeedVersion, forKey: Keys.bundledLayoutProfilesSeedVersion) - if didChange { - notifyStorageChanged() - } } private func persistActiveLayoutProfile(_ profile: KeyMappingProfile) { savedKeyMappingProfiles = [profile] - currentKeyMappingProfileName = profile.name + persistLayoutSelection(profile) let offsets = profile.keyOffsets.reduce(into: [String: CGFloat]()) { result, pair in result[String(pair.key)] = pair.value } - defaults.set(offsets, forKey: KeyPositionManager.offsetsKey) + defaults.set(offsets, forKey: KeyLayoutStore.offsetsKey) let widths = profile.keyWidthOverrides.reduce(into: [String: CGFloat]()) { result, pair in result[String(pair.key)] = pair.value @@ -1136,26 +1571,855 @@ final class SettingsManager { private var isFreshInstallForDefaultSeed: Bool { let keysToCheck = [ Keys.isEnabled, + Keys.hasSeenPermissionExplanation, Keys.glowColorHex, Keys.glowOpacity, + Keys.physicalRefractionStrength, Keys.glowSize, Keys.glowWidth, Keys.glowRoundness, Keys.glowFullness, Keys.fadeDuration, + Keys.launchAtLogin, Keys.colorMode, + Keys.effectStyle, + Keys.chordSurfaceStyle, + Keys.chordIntensityMultiplier, + Keys.powerSavingMode, + Keys.effectConfigurationsByStyle, + Keys.surfaceShapeProfile, Keys.gradientStartHex, Keys.gradientEndHex, Keys.savedThemes, Keys.currentThemeName, + Keys.activeThemeID, Keys.keyMappingProfiles, Keys.currentKeyMappingProfileName, - KeyPositionManager.offsetsKey, + Keys.activeLayoutID, + Keys.overlayDisplaySelection, + Keys.mirroredDisplayIDs, + Keys.displayLayoutProfileBindings, + Keys.globalShortcut, + Keys.gradientPresets, + KeyLayoutStore.offsetsKey, "KeyWidthOverrides" ] return keysToCheck.allSatisfy { defaults.object(forKey: $0) == nil } } + // MARK: - Complete Configuration Snapshots + + var configurationSnapshots: [ConfigurationSnapshotDocument] { + loadConfigurationSnapshots() + } + + var hasPreviousConfigurationSnapshot: Bool { + defaults.data(forKey: Keys.configurationSnapshotRecovery) != nil + } + + @discardableResult + func saveCurrentConfigurationSnapshot( + named requestedName: String + ) throws -> ConfigurationSnapshotDocument { + let name = try validatedSnapshotName(requestedName) + var snapshots = loadConfigurationSnapshots() + guard !snapshots.contains(where: { + $0.name.caseInsensitiveCompare(name) == .orderedSame + }) else { + throw ConfigurationSnapshotError.nameConflict(name) + } + + let document = ConfigurationSnapshotDocument( + name: name, + configuration: try currentConfigurationSnapshotPayload() + ) + snapshots.append(document) + try persistConfigurationSnapshots(snapshots) + return document + } + + @discardableResult + func renameConfigurationSnapshot( + id: UUID, + to requestedName: String + ) throws -> ConfigurationSnapshotDocument { + let name = try validatedSnapshotName(requestedName) + var snapshots = loadConfigurationSnapshots() + guard let index = snapshots.firstIndex(where: { $0.id == id }) else { + throw ConfigurationSnapshotError.snapshotNotFound + } + guard !snapshots.contains(where: { + $0.id != id + && $0.name.caseInsensitiveCompare(name) == .orderedSame + }) else { + throw ConfigurationSnapshotError.nameConflict(name) + } + + snapshots[index].name = name + try persistConfigurationSnapshots(snapshots) + return snapshots[index] + } + + @discardableResult + func deleteConfigurationSnapshot( + id: UUID + ) throws -> (document: ConfigurationSnapshotDocument, index: Int) { + var snapshots = loadConfigurationSnapshots() + guard let index = snapshots.firstIndex(where: { $0.id == id }) else { + throw ConfigurationSnapshotError.snapshotNotFound + } + let document = snapshots.remove(at: index) + try persistConfigurationSnapshots(snapshots) + return (document, index) + } + + func restoreDeletedConfigurationSnapshot( + _ document: ConfigurationSnapshotDocument, + at index: Int + ) throws { + var snapshots = loadConfigurationSnapshots() + guard !snapshots.contains(where: { + $0.id == document.id + || $0.name.caseInsensitiveCompare(document.name) == .orderedSame + }) else { + throw ConfigurationSnapshotError.nameConflict(document.name) + } + let normalized = try normalizedSnapshotDocument(document) + snapshots.insert(normalized, at: min(max(index, 0), snapshots.count)) + try persistConfigurationSnapshots(snapshots) + } + + func decodeConfigurationSnapshotDocument( + _ data: Data + ) throws -> ConfigurationSnapshotDocument { + guard data.count <= Self.maximumConfigurationSnapshotImportSize else { + throw ConfigurationSnapshotError.importTooLarge + } + guard let document = try? JSONDecoder().decode( + ConfigurationSnapshotDocument.self, + from: data + ) else { + throw ConfigurationSnapshotError.invalidDocument + } + return try normalizedSnapshotDocument(document) + } + + func configurationSnapshotNameConflicts( + with document: ConfigurationSnapshotDocument + ) -> Bool { + loadConfigurationSnapshots().contains { + $0.name.caseInsensitiveCompare(document.name) == .orderedSame + } + } + + @discardableResult + func importConfigurationSnapshot( + _ document: ConfigurationSnapshotDocument, + policy: ConfigurationSnapshotImportPolicy + ) throws -> ConfigurationSnapshotDocument { + var imported = try normalizedSnapshotDocument(document) + var snapshots = loadConfigurationSnapshots() + let conflictIndex = snapshots.firstIndex { + $0.name.caseInsensitiveCompare(imported.name) == .orderedSame + } + + switch (conflictIndex, policy) { + case (.some, .rejectConflict): + throw ConfigurationSnapshotError.nameConflict(imported.name) + case (.some(let index), .replace): + imported.id = snapshots[index].id + snapshots[index] = imported + case (.some, .saveCopy): + imported.id = UUID() + imported.createdAt = Date() + imported.name = uniqueSnapshotCopyName( + for: imported.name, + in: snapshots + ) + snapshots.append(imported) + case (.none, _): + if snapshots.contains(where: { $0.id == imported.id }) { + imported.id = UUID() + } + snapshots.append(imported) + } + + try persistConfigurationSnapshots(snapshots) + return imported + } + + func exportConfigurationSnapshotData(id: UUID) throws -> Data { + guard let document = loadConfigurationSnapshots().first(where: { + $0.id == id + }) else { + throw ConfigurationSnapshotError.snapshotNotFound + } + let data = try encodedSnapshotDocument(document, prettyPrinted: true) + guard data.count <= Self.maximumConfigurationSnapshotImportSize else { + throw ConfigurationSnapshotError.importTooLarge + } + return data + } + + func applyConfigurationSnapshot(id: UUID) throws { + guard let document = loadConfigurationSnapshots().first(where: { + $0.id == id + }) else { + throw ConfigurationSnapshotError.snapshotNotFound + } + try applyConfigurationSnapshot(document) + } + + func applyConfigurationSnapshot( + _ document: ConfigurationSnapshotDocument + ) throws { + let normalized = try normalizedSnapshotDocument(document) + let previous = ConfigurationSnapshotDocument( + name: "Previous Setup", + configuration: try currentConfigurationSnapshotPayload() + ) + let recoveryData = try encodedSnapshotDocument( + previous, + prettyPrinted: false + ) + guard recoveryData.count <= Self.maximumConfigurationSnapshotPersistentSize else { + throw ConfigurationSnapshotError.persistentDataTooLarge + } + + try replaceConfiguration(with: normalized.configuration) + defaults.set(recoveryData, forKey: Keys.configurationSnapshotRecovery) + } + + func restorePreviousConfigurationSnapshot() throws { + guard let recoveryData = defaults.data( + forKey: Keys.configurationSnapshotRecovery + ) else { + throw ConfigurationSnapshotError.noPreviousSetup + } + guard recoveryData.count <= Self.maximumConfigurationSnapshotPersistentSize, + let decoded = try? JSONDecoder().decode( + ConfigurationSnapshotDocument.self, + from: recoveryData + ) else { + throw ConfigurationSnapshotError.invalidDocument + } + + let recovery = try normalizedSnapshotDocument(decoded) + let current = ConfigurationSnapshotDocument( + name: "Previous Setup", + configuration: try currentConfigurationSnapshotPayload() + ) + let swappedRecoveryData = try encodedSnapshotDocument( + current, + prettyPrinted: false + ) + guard swappedRecoveryData.count <= Self.maximumConfigurationSnapshotPersistentSize else { + throw ConfigurationSnapshotError.persistentDataTooLarge + } + + try replaceConfiguration(with: recovery.configuration) + defaults.set( + swappedRecoveryData, + forKey: Keys.configurationSnapshotRecovery + ) + } + + private func currentConfigurationSnapshotPayload() throws + -> ConfigurationSnapshotPayload { + let liveLayout = KeyLayoutStore.normalized(KeyLayout( + offsets: storedLayoutValues(forKey: KeyLayoutStore.offsetsKey), + widthMultipliers: storedLayoutValues( + forKey: KeyLayoutStore.widthMultipliersKey + ) + )) + return try normalizedSnapshotPayload(ConfigurationSnapshotPayload( + currentEffect: effectConfiguration, + effectConfigurations: loadEffectConfigurations(), + chordAppearance: chordAppearance, + powerSavingMode: powerSavingMode, + themes: savedThemes, + currentThemeName: currentThemeName, + activeThemeID: activeThemeID, + layoutProfiles: savedKeyMappingProfiles, + currentLayoutName: currentKeyMappingProfileName, + activeLayoutID: activeLayoutID, + currentCalibration: ConfigurationSnapshotCalibration( + offsets: liveLayout.offsets, + widthMultipliers: liveLayout.widthMultipliers + ), + primaryDisplaySelection: overlayDisplaySelection.persistedValue, + mirroredDisplayIDs: mirroredDisplayIDs.sorted(), + displayLayoutProfileBindings: displayLayoutProfileBindings, + globalShortcut: globalShortcut, + gradientPresets: savedGradientPresets + )) + } + + private func replaceConfiguration( + with proposed: ConfigurationSnapshotPayload + ) throws { + let normalized = try normalizedSnapshotPayload(proposed) + let backup = captureSnapshotStorageBackup() + + do { + writeSnapshotPayload(normalized) + let persisted = try currentConfigurationSnapshotPayload() + guard persisted == normalized, + snapshotCommitVerifier(persisted) else { + throw ConfigurationSnapshotError.transactionFailed + } + } catch { + restoreSnapshotStorageBackup(backup) + throw ConfigurationSnapshotError.transactionFailed + } + } + + private func writeSnapshotPayload( + _ payload: ConfigurationSnapshotPayload + ) { + savedThemes = payload.themes + savedKeyMappingProfiles = payload.layoutProfiles + savedGradientPresets = payload.gradientPresets + persistEffectConfigurations(payload.effectConfigurations) + writeCurrentEffectConfiguration(payload.currentEffect) + chordAppearance = payload.chordAppearance + powerSavingMode = payload.powerSavingMode + + writeSelectionID(payload.activeThemeID, forKey: Keys.activeThemeID) + defaults.set(payload.currentThemeName, forKey: Keys.currentThemeName) + writeSelectionID(payload.activeLayoutID, forKey: Keys.activeLayoutID) + defaults.set( + payload.currentLayoutName, + forKey: Keys.currentKeyMappingProfileName + ) + + overlayDisplaySelection = OverlayDisplaySelection( + persistedValue: payload.primaryDisplaySelection + ) + mirroredDisplayIDs = Set(payload.mirroredDisplayIDs) + displayLayoutProfileBindings = payload.displayLayoutProfileBindings + globalShortcut = payload.globalShortcut + + defaults.set( + stringKeyed(payload.currentCalibration.offsets), + forKey: KeyLayoutStore.offsetsKey + ) + defaults.set( + stringKeyed(payload.currentCalibration.widthMultipliers), + forKey: KeyLayoutStore.widthMultipliersKey + ) + } + + private func normalizedSnapshotDocument( + _ document: ConfigurationSnapshotDocument + ) throws -> ConfigurationSnapshotDocument { + guard document.kind == ConfigurationSnapshotDocument.documentKind else { + throw ConfigurationSnapshotError.invalidDocument + } + guard document.version == ConfigurationSnapshotDocument.currentVersion else { + throw ConfigurationSnapshotError.unsupportedVersion(document.version) + } + var normalized = document + normalized.name = try validatedSnapshotName(document.name) + normalized.configuration = try normalizedSnapshotPayload( + document.configuration + ) + return normalized + } + + private func normalizedSnapshotPayload( + _ payload: ConfigurationSnapshotPayload + ) throws -> ConfigurationSnapshotPayload { + let currentEffect = try normalizedSnapshotEffect( + payload.currentEffect, + for: payload.currentEffect.style + ) + + var effectProfiles = ConfigurationSnapshotPayload + .defaultEffectConfigurations + for style in EffectStyle.allCases { + if let proposed = payload.effectConfigurations[style.rawValue] { + effectProfiles[style.rawValue] = try normalizedSnapshotEffect( + proposed, + for: style + ) + } + } + effectProfiles[currentEffect.style.rawValue] = currentEffect + + let themes = try normalizedSnapshotThemes(payload.themes) + let layouts = try normalizedSnapshotLayouts(payload.layoutProfiles) + let themeIDs = Set(themes.map(\.id)) + let layoutIDs = Set(layouts.map(\.id)) + + var activeThemeID = payload.activeThemeID + var currentThemeName = try validatedSnapshotName( + payload.currentThemeName + ) + if activeThemeID == nil { + activeThemeID = themes.first(where: { + $0.name.caseInsensitiveCompare(currentThemeName) == .orderedSame + })?.id + } + if let activeThemeID { + guard themeIDs.contains(activeThemeID), + let activeTheme = themes.first(where: { + $0.id == activeThemeID + }) else { + throw ConfigurationSnapshotError.invalidConfiguration( + String(localized: "The selected theme is not present in the snapshot library.") + ) + } + currentThemeName = activeTheme.name + } + + var activeLayoutID = payload.activeLayoutID + var currentLayoutName = payload.currentLayoutName + .trimmingCharacters(in: .whitespacesAndNewlines) + guard !currentLayoutName.isEmpty, + currentLayoutName.count <= PersistenceValidation.maximumNameLength else { + throw ConfigurationSnapshotError.invalidConfiguration( + String(localized: "The selected layout name is invalid.") + ) + } + if activeLayoutID == nil { + activeLayoutID = layouts.first(where: { + $0.name.caseInsensitiveCompare(currentLayoutName) == .orderedSame + })?.id + } + if let activeLayoutID { + guard layoutIDs.contains(activeLayoutID), + let activeLayout = layouts.first(where: { + $0.id == activeLayoutID + }) else { + throw ConfigurationSnapshotError.invalidConfiguration( + String(localized: "The selected keyboard layout is not present in the snapshot library.") + ) + } + currentLayoutName = activeLayout.name + } + + let liveLayout = KeyLayoutStore.normalized(KeyLayout( + offsets: payload.currentCalibration.offsets, + widthMultipliers: payload.currentCalibration.widthMultipliers + )) + let primarySelection = try validatedDisplaySelection( + payload.primaryDisplaySelection + ) + let mirrors = try normalizedSnapshotDisplayIDs( + payload.mirroredDisplayIDs, + maximumCount: 16 + ) + let bindings = try normalizedSnapshotBindings( + payload.displayLayoutProfileBindings, + validLayoutIDs: layoutIDs + ) + guard let shortcut = GlobalShortcut( + keyCode: payload.globalShortcut.keyCode, + modifiers: payload.globalShortcut.modifiers + ) else { + throw ConfigurationSnapshotError.invalidConfiguration( + String(localized: "The global shortcut is invalid.") + ) + } + let gradients = try normalizedSnapshotGradients( + payload.gradientPresets + ) + + let normalized = ConfigurationSnapshotPayload( + currentEffect: currentEffect, + effectConfigurations: effectProfiles, + chordAppearance: payload.chordAppearance.normalized, + powerSavingMode: payload.powerSavingMode, + themes: themes, + currentThemeName: currentThemeName, + activeThemeID: activeThemeID, + layoutProfiles: layouts, + currentLayoutName: currentLayoutName, + activeLayoutID: activeLayoutID, + currentCalibration: ConfigurationSnapshotCalibration( + offsets: liveLayout.offsets, + widthMultipliers: liveLayout.widthMultipliers + ), + primaryDisplaySelection: primarySelection, + mirroredDisplayIDs: mirrors, + displayLayoutProfileBindings: bindings, + globalShortcut: shortcut, + gradientPresets: gradients + ) + guard let data = try? JSONEncoder().encode(normalized), + data.count <= Self.maximumConfigurationSnapshotPersistentSize else { + throw ConfigurationSnapshotError.persistentDataTooLarge + } + return normalized + } + + private func normalizedSnapshotEffect( + _ proposed: EffectConfiguration, + for requestedStyle: EffectStyle + ) throws -> EffectConfiguration { + let values = [ + proposed.opacity, + proposed.refractionStrength, + proposed.height, + proposed.width, + proposed.roundness, + proposed.hardness, + proposed.fadeDuration + ] + guard values.allSatisfy(\.isFinite), + isValidSnapshotHex(proposed.color.solidHex), + isValidSnapshotHex(proposed.color.gradientStartHex), + isValidSnapshotHex(proposed.color.gradientEndHex) else { + throw ConfigurationSnapshotError.invalidConfiguration( + String(localized: "The snapshot contains invalid visual settings.") + ) + } + return validatedEffectConfiguration(proposed, for: requestedStyle) + } + + private func normalizedSnapshotThemes( + _ proposed: [Theme] + ) throws -> [Theme] { + let source = proposed.isEmpty ? [Theme.defaultTheme] : proposed + guard source.count <= 128 else { + throw ConfigurationSnapshotError.invalidConfiguration( + String(localized: "The snapshot contains too many themes.") + ) + } + var seenIDs = Set() + var seenNames = Set() + return try source.map { theme in + var normalized = theme + normalized.name = try validatedSnapshotName(theme.name) + let foldedName = normalized.name.folding( + options: [.caseInsensitive, .diacriticInsensitive], + locale: .current + ) + guard seenIDs.insert(theme.id).inserted, + seenNames.insert(foldedName).inserted, + isValidSnapshotHex(theme.colorHex), + theme.gradientStartHex.map(isValidSnapshotHex) ?? true, + theme.gradientEndHex.map(isValidSnapshotHex) ?? true else { + throw ConfigurationSnapshotError.invalidConfiguration( + String(localized: "The snapshot contains duplicate or invalid themes.") + ) + } + let effect = try normalizedSnapshotEffect( + EffectConfiguration( + style: theme.effectStyle, + shapeProfile: theme.shapeProfile, + color: ColorConfiguration( + mode: theme.colorMode, + solidHex: theme.colorHex, + gradientStartHex: theme.gradientStartHex + ?? theme.colorHex, + gradientEndHex: theme.gradientEndHex + ?? Self.defaultGradientEndHex + ), + opacity: theme.opacity, + refractionStrength: theme.refractionStrength, + height: theme.size, + width: theme.width, + roundness: theme.glowRoundness, + hardness: theme.glowFullness, + fadeDuration: theme.fadeDuration + ), + for: theme.effectStyle + ) + normalized.colorHex = effect.color.solidHex + normalized.opacity = effect.opacity + normalized.refractionStrength = effect.refractionStrength + normalized.size = effect.height + normalized.width = effect.width + normalized.glowRoundness = effect.roundness + normalized.glowFullness = effect.hardness + normalized.fadeDuration = effect.fadeDuration + normalized.colorMode = effect.color.mode + normalized.effectStyle = effect.style + normalized.shapeProfile = effect.shapeProfile + if theme.gradientStartHex != nil { + normalized.gradientStartHex = effect.color.gradientStartHex + } + if theme.gradientEndHex != nil { + normalized.gradientEndHex = effect.color.gradientEndHex + } + return normalized + } + } + + private func normalizedSnapshotLayouts( + _ proposed: [KeyMappingProfile] + ) throws -> [KeyMappingProfile] { + guard proposed.count <= 128 else { + throw ConfigurationSnapshotError.invalidConfiguration( + String(localized: "The snapshot contains too many keyboard layouts.") + ) + } + var seenIDs = Set() + var seenNames = Set() + return try proposed.map { profile in + var normalized = profile + normalized.name = try validatedSnapshotName(profile.name) + let foldedName = normalized.name.folding( + options: [.caseInsensitive, .diacriticInsensitive], + locale: .current + ) + guard seenIDs.insert(profile.id).inserted, + seenNames.insert(foldedName).inserted else { + throw ConfigurationSnapshotError.invalidConfiguration( + String(localized: "The snapshot contains duplicate keyboard layouts.") + ) + } + let layout = KeyLayoutStore.normalized(KeyLayout( + offsets: profile.keyOffsets, + widthMultipliers: profile.keyWidthOverrides + )) + normalized.keyOffsets = layout.offsets + normalized.keyWidthOverrides = layout.widthMultipliers + return normalized + } + } + + private func normalizedSnapshotGradients( + _ proposed: [GradientPreset] + ) throws -> [GradientPreset] { + let source = proposed.isEmpty ? Self.defaultGradientPresets : proposed + guard source.count <= Self.maxGradientPresetCount else { + throw ConfigurationSnapshotError.invalidConfiguration( + String(localized: "The snapshot contains too many gradient presets.") + ) + } + var seenIDs = Set() + return try source.map { preset in + guard seenIDs.insert(preset.id).inserted, + isValidSnapshotHex(preset.startHex), + isValidSnapshotHex(preset.endHex) else { + throw ConfigurationSnapshotError.invalidConfiguration( + String(localized: "The snapshot contains invalid gradient presets.") + ) + } + let name = preset.name.map { + String($0.trimmingCharacters(in: .whitespacesAndNewlines).prefix(40)) + } + return GradientPreset( + id: preset.id, + startHex: sanitizedHex(preset.startHex), + endHex: sanitizedHex(preset.endHex), + name: name?.isEmpty == false ? name : nil + ) + } + } + + private func normalizedSnapshotDisplayIDs( + _ proposed: [String], + maximumCount: Int + ) throws -> [String] { + guard proposed.count <= maximumCount else { + throw ConfigurationSnapshotError.invalidConfiguration( + String(localized: "The snapshot contains too many display selections.") + ) + } + let normalized = proposed.map { + $0.trimmingCharacters(in: .whitespacesAndNewlines) + } + guard normalized.allSatisfy({ !$0.isEmpty && $0.count <= 200 }) else { + throw ConfigurationSnapshotError.invalidConfiguration( + String(localized: "The snapshot contains an invalid display identifier.") + ) + } + return Array(Set(normalized)).sorted() + } + + private func normalizedSnapshotBindings( + _ proposed: [String: UUID], + validLayoutIDs: Set + ) throws -> [String: UUID] { + guard proposed.count <= 32, + proposed.allSatisfy({ key, value in + !key.isEmpty + && key.count <= 200 + && validLayoutIDs.contains(value) + }) else { + throw ConfigurationSnapshotError.invalidConfiguration( + String(localized: "The snapshot contains invalid display layout bindings.") + ) + } + return proposed + } + + private func validatedDisplaySelection(_ rawValue: String) throws + -> String { + switch rawValue { + case "automatic", "builtIn", "main": + return rawValue + default: + let prefix = "display:" + guard rawValue.hasPrefix(prefix) else { + throw ConfigurationSnapshotError.invalidConfiguration( + String(localized: "The primary display selection is invalid.") + ) + } + let identifier = String(rawValue.dropFirst(prefix.count)) + guard !identifier.isEmpty, identifier.count <= 200 else { + throw ConfigurationSnapshotError.invalidConfiguration( + String(localized: "The primary display identifier is invalid.") + ) + } + return rawValue + } + } + + private func validatedSnapshotName(_ rawValue: String) throws -> String { + let trimmed = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, + trimmed.count <= PersistenceValidation.maximumNameLength else { + throw ConfigurationSnapshotError.invalidName + } + return trimmed + } + + private func isValidSnapshotHex(_ value: String) -> Bool { + value.count == 6 + && value.allSatisfy { "0123456789ABCDEFabcdef".contains($0) } + } + + private func storedLayoutValues(forKey key: String) -> [UInt16: CGFloat] { + guard let stored = defaults.dictionary(forKey: key) else { return [:] } + return stored.reduce(into: [:]) { result, pair in + guard let keyCode = UInt16(pair.key), + let number = pair.value as? NSNumber else { + return + } + result[keyCode] = CGFloat(truncating: number) + } + } + + private func stringKeyed( + _ values: [UInt16: CGFloat] + ) -> [String: CGFloat] { + values.reduce(into: [:]) { result, pair in + result[String(pair.key)] = pair.value + } + } + + private struct SnapshotStorageBackup { + var values: [String: Any] + var absentKeys: Set + } + + private func captureSnapshotStorageBackup() -> SnapshotStorageBackup { + var values: [String: Any] = [:] + var absentKeys = Set() + for key in Self.configurationSnapshotStorageKeyRegistry { + if let value = defaults.object(forKey: key) { + values[key] = value + } else { + absentKeys.insert(key) + } + } + return SnapshotStorageBackup(values: values, absentKeys: absentKeys) + } + + private func restoreSnapshotStorageBackup( + _ backup: SnapshotStorageBackup + ) { + for key in Self.configurationSnapshotStorageKeyRegistry { + if backup.absentKeys.contains(key) { + defaults.removeObject(forKey: key) + } else { + defaults.set(backup.values[key], forKey: key) + } + } + } + + private func loadConfigurationSnapshots() + -> [ConfigurationSnapshotDocument] { + guard let data = defaults.data(forKey: Keys.configurationSnapshots), + data.count <= Self.maximumConfigurationSnapshotPersistentSize, + let decoded = try? JSONDecoder().decode( + [ConfigurationSnapshotDocument].self, + from: data + ) else { + return [] + } + + var seenNames = Set() + var seenIDs = Set() + return decoded.compactMap { document in + guard let normalized = try? normalizedSnapshotDocument(document) else { + return nil + } + let name = normalized.name.folding( + options: [.caseInsensitive, .diacriticInsensitive], + locale: .current + ) + guard seenNames.insert(name).inserted, + seenIDs.insert(normalized.id).inserted else { + return nil + } + return normalized + } + } + + private func persistConfigurationSnapshots( + _ snapshots: [ConfigurationSnapshotDocument] + ) throws { + let normalized = try snapshots.map(normalizedSnapshotDocument) + guard let data = try? JSONEncoder().encode(normalized), + data.count <= Self.maximumConfigurationSnapshotPersistentSize else { + throw ConfigurationSnapshotError.persistentDataTooLarge + } + defaults.set(data, forKey: Keys.configurationSnapshots) + } + + private func encodedSnapshotDocument( + _ document: ConfigurationSnapshotDocument, + prettyPrinted: Bool + ) throws -> Data { + let encoder = JSONEncoder() + encoder.outputFormatting = prettyPrinted + ? [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] + : [.sortedKeys, .withoutEscapingSlashes] + do { + return try encoder.encode(try normalizedSnapshotDocument(document)) + } catch let error as ConfigurationSnapshotError { + throw error + } catch { + throw ConfigurationSnapshotError.invalidDocument + } + } + + private func uniqueSnapshotCopyName( + for originalName: String, + in snapshots: [ConfigurationSnapshotDocument] + ) -> String { + let existing = Set(snapshots.map { + $0.name.folding( + options: [.caseInsensitive, .diacriticInsensitive], + locale: .current + ) + }) + var copyNumber = 1 + while true { + let suffix = copyNumber == 1 ? " Copy" : " Copy \(copyNumber)" + let maximumBaseLength = max( + 1, + PersistenceValidation.maximumNameLength - suffix.count + ) + let candidate = String(originalName.prefix(maximumBaseLength)) + + suffix + let folded = candidate.folding( + options: [.caseInsensitive, .diacriticInsensitive], + locale: .current + ) + if !existing.contains(folded) { + return candidate + } + copyNumber += 1 + } + } + #if DEBUG func _testApplyDefaultExperienceSeedIfNeeded() { seedDefaultExperienceIfNeeded() diff --git a/KeyLight/Services/ThemeStringCodec.swift b/KeyLight/Services/ThemeStringCodec.swift new file mode 100644 index 0000000..d8d24cb --- /dev/null +++ b/KeyLight/Services/ThemeStringCodec.swift @@ -0,0 +1,256 @@ +import Foundation + +/// Pure codec for KeyLight's established shareable theme-string formats. +/// +/// Field names, ordering, formatting, validation, and NSError values are part +/// of the v1/v2 compatibility contract and must remain stable. V3's retired +/// shape field remains in the wire format so preview-era themes still import; +/// V4 adds the independently adjustable physical-refraction path length. V5 +/// keeps the same fields and accepts the retired Classic+ value only so those +/// preview-era payloads can migrate to Classic Glow. +enum ThemeStringCodec { + static let defaultThemeString = "keylight-theme-v5;name=current;mode=positionGradient;effect=classicGlow;shape=currentWave;refraction=1.0000;color=68B8FF;opacity=0.8013;size=80.5536;width=1.0000;round=0.7069;hard=0.6046;fade=1.0004;gstart=68B8FF;gend=00E69A" + + private static let prefixV1 = "keylight-theme-v1" + private static let prefixV2 = "keylight-theme-v2" + private static let prefixV3 = "keylight-theme-v3" + private static let prefixV4 = "keylight-theme-v4" + private static let prefixV5 = "keylight-theme-v5" + private static let maximumLength = 2_048 + private static let fieldOrderV1 = [ + "name", "mode", "color", "opacity", "size", "width", "round", "hard", "fade", "gstart", "gend" + ] + private static let fieldOrderV2 = [ + "name", "mode", "effect", "color", "opacity", "size", "width", "round", "hard", "fade", "gstart", "gend" + ] + private static let fieldOrderV3 = [ + "name", "mode", "effect", "shape", "color", "opacity", "size", "width", "round", "hard", "fade", "gstart", "gend" + ] + private static let fieldOrderV4 = [ + "name", "mode", "effect", "shape", "refraction", "color", "opacity", "size", "width", "round", "hard", "fade", "gstart", "gend" + ] + private static let requiredFieldsV1 = Set(fieldOrderV1) + private static let requiredFieldsV2 = Set(fieldOrderV2) + private static let requiredFieldsV3 = Set(fieldOrderV3) + private static let requiredFieldsV4 = Set(fieldOrderV4) + private static let requiredFieldsV5 = Set(fieldOrderV4) + private static let defaultSolidHex = "68B8FF" + private static let defaultGradientEndHex = "00E69A" + + static func encode(_ theme: Theme) -> String { + let theme = sanitized(theme) + let encodedName = percentEncodedName(theme.name) + let gradientStart = (theme.gradientStartHex ?? defaultSolidHex).uppercased() + let gradientEnd = (theme.gradientEndHex ?? defaultGradientEndHex).uppercased() + + return [ + prefixV5, + "name=\(encodedName)", + "mode=\(theme.colorMode.rawValue)", + "effect=\(theme.effectStyle.rawValue)", + "shape=\(theme.shapeProfile.rawValue)", + "refraction=\(formatted(theme.refractionStrength))", + "color=\(theme.colorHex.uppercased())", + "opacity=\(formatted(theme.opacity))", + "size=\(formatted(theme.size))", + "width=\(formatted(theme.width))", + "round=\(formatted(theme.glowRoundness))", + "hard=\(formatted(theme.glowFullness))", + "fade=\(formatted(theme.fadeDuration))", + "gstart=\(gradientStart)", + "gend=\(gradientEnd)" + ].joined(separator: ";") + } + + static func decode(_ value: String) throws -> Theme { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + throw invalidThemeError() + } + guard trimmed.count <= maximumLength else { + throw NSError(domain: "KeyLight", code: 11, userInfo: [ + NSLocalizedDescriptionKey: String(localized: "Theme string is too large.") + ]) + } + + let segments = trimmed.split(separator: ";", omittingEmptySubsequences: false).map(String.init) + guard let prefix = segments.first, + prefix == prefixV1 + || prefix == prefixV2 + || prefix == prefixV3 + || prefix == prefixV4 + || prefix == prefixV5 else { + throw invalidThemeError() + } + + let requiredFields: Set + switch prefix { + case prefixV5: + requiredFields = requiredFieldsV5 + case prefixV4: + requiredFields = requiredFieldsV4 + case prefixV3: + requiredFields = requiredFieldsV3 + case prefixV2: + requiredFields = requiredFieldsV2 + default: + requiredFields = requiredFieldsV1 + } + var fields: [String: String] = [:] + for segment in segments.dropFirst() { + guard !segment.isEmpty, + let splitIndex = segment.firstIndex(of: "="), + splitIndex != segment.startIndex else { + throw invalidThemeError() + } + + let key = String(segment[.. Theme { + var sanitized = theme + sanitized.name = PersistenceValidation.normalizedName(theme.name) ?? "Imported Theme" + sanitized.colorHex = sanitizedHex(theme.colorHex) + sanitized.opacity = validated(theme.opacity, range: 0.0...1.0, default: 0.8013) + sanitized.refractionStrength = validated( + theme.refractionStrength, + range: 0.5...2.5, + default: 1.0 + ) + sanitized.size = validated(theme.size, range: 4.0...200.0, default: 80.5536) + sanitized.width = validated(theme.width, range: 0.1...5.0, default: 1.0) + sanitized.glowRoundness = validated(theme.glowRoundness, range: 0.0...1.0, default: 0.7069) + sanitized.glowFullness = validated(theme.glowFullness, range: 0.0...1.0, default: 0.6046) + sanitized.fadeDuration = validated(theme.fadeDuration, range: 0.05...5.0, default: 1.0004) + sanitized.effectStyle = ( + EffectStyle(rawValue: theme.effectStyle.rawValue) ?? .classicGlow + ).supportedStyle + sanitized.shapeProfile = .currentWave + sanitized.gradientStartHex = sanitizedHex(theme.gradientStartHex ?? defaultSolidHex) + sanitized.gradientEndHex = sanitizedHex(theme.gradientEndHex ?? defaultGradientEndHex) + return sanitized + } + + private static func sanitizedHex(_ hex: String) -> String { + let valid = hex.prefix(6).filter { "0123456789ABCDEFabcdef".contains($0) } + guard !valid.isEmpty else { return defaultSolidHex } + return String(valid).padding(toLength: 6, withPad: "0", startingAt: 0) + } + + private static func validated( + _ value: Double, + range: ClosedRange, + default defaultValue: Double + ) -> Double { + guard value.isFinite else { return defaultValue } + return min(max(value, range.lowerBound), range.upperBound) + } + + private static func formatted(_ value: Double) -> String { + String(format: "%.4f", value) + } + + private static func parsedNumber(_ raw: String?) -> Double? { + guard let raw, let parsed = Double(raw), parsed.isFinite else { return nil } + return parsed + } + + private static func percentEncodedName(_ value: String) -> String { + let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_.~")) + return value.addingPercentEncoding(withAllowedCharacters: allowed) ?? value + } + + private static func invalidThemeError() -> NSError { + NSError(domain: "KeyLight", code: 10, userInfo: [ + NSLocalizedDescriptionKey: String(localized: "Invalid theme string.") + ]) + } +} diff --git a/KeyLight/Services/UpdateService.swift b/KeyLight/Services/UpdateService.swift new file mode 100644 index 0000000..ef9b33c --- /dev/null +++ b/KeyLight/Services/UpdateService.swift @@ -0,0 +1,187 @@ +import Foundation +import Observation +import Sparkle + +enum UpdateAvailabilityState: Equatable, Sendable { + case unavailableConfiguration + case ready + case checking + case upToDate + case updateAvailable(version: String) + case failed + + var displayName: String { + switch self { + case .unavailableConfiguration: + return String(localized: "Unavailable in This Build") + case .ready: + return String(localized: "Ready") + case .checking: + return String(localized: "Checking…") + case .upToDate: + return String(localized: "Up to Date") + case .updateAvailable(let version): + return String(localized: "Version \(version) Available") + case .failed: + return String(localized: "Check Failed") + } + } +} + +/// Testable boundary around Sparkle. The service never creates a request until +/// `start()` has validated an HTTPS feed and a non-empty EdDSA public key. +/// Automatic checks are initially disabled by Info.plist and change only in +/// response to the explicit settings/onboarding toggle. +@MainActor +protocol UpdateServicing: AnyObject { + var status: UpdateAvailabilityState { get } + var isConfigured: Bool { get } + var canCheckForUpdates: Bool { get } + var automaticallyChecksForUpdates: Bool { get set } + + func start() + func checkForUpdates() +} + +@MainActor +@Observable +final class UpdateService: NSObject, UpdateServicing { + private static let automaticConsentKey = + "KeyLightUpdateAutomaticChecksExplicitlyEnabled" + + private(set) var status: UpdateAvailabilityState = .unavailableConfiguration + private(set) var isConfigured = false + private var hasStarted = false + + @ObservationIgnored + private lazy var updaterController = SPUStandardUpdaterController( + startingUpdater: false, + updaterDelegate: self, + userDriverDelegate: nil + ) + + var canCheckForUpdates: Bool { + hasStarted && updaterController.updater.canCheckForUpdates + } + + var automaticallyChecksForUpdates: Bool { + get { + guard isConfigured, + UserDefaults.standard.bool( + forKey: Self.automaticConsentKey + ) else { return false } + return updaterController.updater.automaticallyChecksForUpdates + } + set { + guard isConfigured else { return } + UserDefaults.standard.set( + newValue, + forKey: Self.automaticConsentKey + ) + updaterController.updater.automaticallyChecksForUpdates = newValue + if status == .unavailableConfiguration { + status = .ready + } + } + } + + override init() { + super.init() + isConfigured = Self.hasSecureConfiguration(in: .main) + status = isConfigured ? .ready : .unavailableConfiguration + } + + func start() { + guard isConfigured, !hasStarted else { return } + let updater = updaterController.updater + if !UserDefaults.standard.bool(forKey: Self.automaticConsentKey) { + updater.automaticallyChecksForUpdates = false + } + // Fail closed even if stale Sparkle defaults from a beta or manual + // defaults edit attempted to enable profiling or silent downloads. + updater.sendsSystemProfile = false + updater.automaticallyDownloadsUpdates = false + hasStarted = true + updaterController.startUpdater() + status = .ready + } + + func checkForUpdates() { + guard canCheckForUpdates else { return } + status = .checking + updaterController.checkForUpdates(nil) + } + + func eraseLocalUpdatePreferences() { + automaticallyChecksForUpdates = false + let keys = [ + "SULastCheckTime", + "SULastProfileSubmitDate", + "SUSkippedVersion", + "SUEnableAutomaticChecks", + "SUAutomaticallyUpdate", + "SUSendProfileInfo", + Self.automaticConsentKey + ] + for key in keys { + UserDefaults.standard.removeObject(forKey: key) + } + status = isConfigured ? .ready : .unavailableConfiguration + } + + nonisolated static func hasSecureConfiguration(in bundle: Bundle) -> Bool { + hasSecureConfiguration( + feed: bundle.object(forInfoDictionaryKey: "SUFeedURL") as? String, + publicKey: bundle.object( + forInfoDictionaryKey: "SUPublicEDKey" + ) as? String + ) + } + + nonisolated static func hasSecureConfiguration( + feed: String?, + publicKey: String? + ) -> Bool { + guard let feed, + let feedURL = URL(string: feed), + feedURL.scheme?.lowercased() == "https", + feedURL.host != nil, + feedURL.user == nil, + feedURL.password == nil, + let publicKey, + let publicKeyData = Data( + base64Encoded: publicKey.trimmingCharacters( + in: .whitespacesAndNewlines + ) + ), + publicKeyData.count == 32 else { + return false + } + return true + } +} + +extension UpdateService: SPUUpdaterDelegate { + func feedParameters( + for updater: SPUUpdater, + sendingSystemProfile sendingProfile: Bool + ) -> [[String: String]] { + // Do not append custom identifiers or profile fields to the appcast. + [] + } + + func updater( + _ updater: SPUUpdater, + didFindValidUpdate item: SUAppcastItem + ) { + status = .updateAvailable(version: item.displayVersionString) + } + + func updaterDidNotFindUpdate(_ updater: SPUUpdater) { + status = .upToDate + } + + func updater(_ updater: SPUUpdater, didAbortWithError error: any Error) { + status = .failed + } +} diff --git a/KeyLight/Views/GlowOverlayWindow.swift b/KeyLight/Views/GlowOverlayWindow.swift index 6effc3e..01fb65a 100644 --- a/KeyLight/Views/GlowOverlayWindow.swift +++ b/KeyLight/Views/GlowOverlayWindow.swift @@ -1,35 +1,45 @@ import AppKit -/// Transparent overlay window that displays at the bottom of the screen +/// Transparent overlay window that displays at the bottom of the screen. @MainActor final class GlowOverlayWindow: NSPanel { - private var _glowView: GlowView? + private var classicGlowView: GlowView? + private var systemGlassRenderer: (any GlowRenderer)? + private var physicalRefractionRenderer: (any GlowRenderer)? + private var solidBlackRenderer: (any GlowRenderer)? + private var activeRenderer: (any GlowRenderer)? + private var activeEffectStyle: EffectStyle? + private var activePhysicalCaptureAccess = false + private let screenCaptureAccessProvider: @MainActor () -> Bool - init(contentRect: NSRect) { + init( + contentRect: NSRect, + screenCaptureAccessProvider: @escaping @MainActor () -> Bool = { + ScreenCaptureAuthorization.isGranted + } + ) { + self.screenCaptureAccessProvider = screenCaptureAccessProvider super.init( contentRect: contentRect, styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, - defer: true // Defer to avoid layout during init + defer: true ) configureWindow() } private func configureWindow() { - // Transparent background isOpaque = false backgroundColor = .clear hasShadow = false - // Float above most windows level = .statusBar - - // Click-through - mouse events pass to windows below ignoresMouseEvents = true + setAccessibilityElement(false) + setAccessibilityChildren([]) - // Visible on all Spaces and over fullscreen apps collectionBehavior = [ .canJoinAllSpaces, .fullScreenAuxiliary, @@ -37,19 +47,123 @@ final class GlowOverlayWindow: NSPanel { .ignoresCycle ] - // Setup content view after window is configured + setEffectStyle(.classicGlow) + } + + var glowRenderer: (any GlowRenderer)? { + activeRenderer + } + + func setEffectStyle(_ requestedStyle: EffectStyle) { + let resolvedStyle = requestedStyle.supportedStyle.resolvedForCurrentSystem + let physicalCaptureAccess = resolvedStyle == .physicalRefraction + && screenCaptureAccessProvider() + guard activeEffectStyle != resolvedStyle + || activeRenderer == nil + || activePhysicalCaptureAccess != physicalCaptureAccess else { + return + } + + activeRenderer?.clear() + + let renderer: any GlowRenderer + switch resolvedStyle { + case .classicGlow: + renderer = classicRenderer() + case .classicPlus: + renderer = classicRenderer() + case .liquidGlass: + renderer = systemGlassRendererIfAvailable() ?? classicRenderer() + case .systemGlass: + renderer = systemGlassRendererIfAvailable() ?? classicRenderer() + case .physicalRefraction: + renderer = physicalCaptureAccess + ? (physicalRefractionRendererIfAvailable() ?? classicRenderer()) + : (systemGlassRendererIfAvailable() ?? classicRenderer()) + case .solidBlack: + renderer = solidBlackRendererIfAvailable() ?? classicRenderer() + } + + renderer.view.frame = contentLayoutRect + renderer.view.autoresizingMask = [.width, .height] + renderer.view.setAccessibilityElement(false) + renderer.view.setAccessibilityChildren([]) + contentView = renderer.view + activeRenderer = renderer + activeEffectStyle = resolvedStyle + activePhysicalCaptureAccess = physicalCaptureAccess + } + + private func physicalRefractionRendererIfAvailable() -> (any GlowRenderer)? { + if let physicalRefractionRenderer { + return physicalRefractionRenderer + } + + #if compiler(>=6.2) + if #available(macOS 26.0, *) { + let view = LiquidGlassGlowView( + frame: contentLayoutRect, + presentationMode: .physicalRefraction + ) + view.autoresizingMask = [.width, .height] + physicalRefractionRenderer = view + return view + } + #endif + + return nil + } + + private func systemGlassRendererIfAvailable() -> (any GlowRenderer)? { + if let systemGlassRenderer { + return systemGlassRenderer + } + + #if compiler(>=6.2) + if #available(macOS 26.0, *) { + let view = LiquidGlassGlowView( + frame: contentLayoutRect, + presentationMode: .systemGlass + ) + view.autoresizingMask = [.width, .height] + systemGlassRenderer = view + return view + } + #endif + + return nil + } + + private func classicRenderer() -> GlowView { + if let classicGlowView { + return classicGlowView + } let view = GlowView(frame: contentLayoutRect) view.autoresizingMask = [.width, .height] - _glowView = view - contentView = view + classicGlowView = view + return view } - /// Access the glow view for showing/hiding effects - var glowView: GlowView? { - _glowView + private func solidBlackRendererIfAvailable() -> (any GlowRenderer)? { + if let solidBlackRenderer { + return solidBlackRenderer + } + + #if compiler(>=6.2) + if #available(macOS 26.0, *) { + let view = LiquidGlassGlowView( + frame: contentLayoutRect, + presentationMode: .solidBlack + ) + view.autoresizingMask = [.width, .height] + solidBlackRenderer = view + return view + } + #endif + + return nil } - // Prevent window from becoming key or main override var canBecomeKey: Bool { false } override var canBecomeMain: Bool { false } } diff --git a/KeyLight/Views/GlowRenderer.swift b/KeyLight/Views/GlowRenderer.swift new file mode 100644 index 0000000..4da5d93 --- /dev/null +++ b/KeyLight/Views/GlowRenderer.swift @@ -0,0 +1,75 @@ +import AppKit + +enum RendererReadiness: String, Equatable, Sendable { + case ready + case fallback + case failed +} + +struct GlowRendererRuntimeState: Equatable, Sendable { + var readiness: RendererReadiness + var captureState: PhysicalCaptureState + var fallbackReason: String? + + static let ready = GlowRendererRuntimeState( + readiness: .ready, + captureState: .idle, + fallbackReason: nil + ) +} + +struct EffectRuntimeStatus: Equatable, Sendable { + var selectedEffect: EffectStyle + var resolvedEffect: EffectStyle + var rendererReadiness: RendererReadiness + var captureState: PhysicalCaptureState + var fallbackReason: String? + var powerSavingMode: PowerSavingMode + var powerEnvironmentState: PowerEnvironmentState + var automaticPowerSavingIsActive: Bool + var activeDisplayID: UInt32? + var activeDisplayPersistentIDs: [String] + var sampledStripHeight: Double? + + static let initial = EffectRuntimeStatus( + selectedEffect: .classicGlow, + resolvedEffect: .classicGlow, + rendererReadiness: .ready, + captureState: .idle, + fallbackReason: nil, + powerSavingMode: .automatic, + powerEnvironmentState: .normal, + automaticPowerSavingIsActive: false, + activeDisplayID: nil, + activeDisplayPersistentIDs: [], + sampledStripHeight: nil + ) +} + +/// Atomic rendering boundary shared by Classic Glow and the surface effects. +/// Interaction ordering, previews, displays, and persistence belong upstream. +@MainActor +protocol GlowRenderer: AnyObject { + var view: NSView { get } + var supportsConcurrentPhysicalTargets: Bool { get } + + func apply(_ configuration: RendererConfiguration) + func show(_ target: GlowTarget) + @discardableResult + func refresh(_ id: GlowID) -> Bool + func hide(_ id: GlowID) + func clear() + func setRuntimeStatusHandler( + _ handler: (@MainActor (GlowRendererRuntimeState) -> Void)? + ) +} + +extension GlowRenderer { + var supportsConcurrentPhysicalTargets: Bool { false } + + func setRuntimeStatusHandler( + _ handler: (@MainActor (GlowRendererRuntimeState) -> Void)? + ) { + handler?(.ready) + } +} diff --git a/KeyLight/Views/GlowView.swift b/KeyLight/Views/GlowView.swift index 270a4a8..65bcb28 100644 --- a/KeyLight/Views/GlowView.swift +++ b/KeyLight/Views/GlowView.swift @@ -15,39 +15,40 @@ private final class FadeOutDelegate: NSObject, CAAnimationDelegate { } } +/// One identity-scoped Classic Glow surface. A surface remains reusable after +/// its fade completes so ordinary sequential typing can keep the established +/// slide animation without keeping inactive layers in the render tree. +private final class ClassicGlowSurface { + var id: GlowID + var target: GlowTarget + let layer: CALayer + var isAlive = false + var fadeDelegate: FadeOutDelegate? + + init(id: GlowID, target: GlowTarget, layer: CALayer) { + self.id = id + self.target = target + self.layer = layer + } +} + // MARK: - GlowView /// View that renders smooth, blurry glow effects at the bottom edge. -/// Uses a single persistent glow layer that slides between key positions. +/// Physically held keys own independent surfaces, while inactive surfaces are +/// reused so single-key transitions retain the original slide/pop/fade motion. @MainActor -final class GlowView: NSView { - - // MARK: - Single Glow State - - /// The single persistent glow layer (lazily created) - private var glowLayer: CALayer? = nil - - /// Keys currently held down - private var heldKeys: Set = [] - - /// Timestamps for each held key (for stale key detection) - private var keyTimestamps: [UInt16: CFTimeInterval] = [:] +final class GlowView: NSView, GlowRenderer { - /// Maximum time a key can stay in heldKeys without being refreshed (seconds) - private let staleKeyThreshold: CFTimeInterval = 0.5 + var view: NSView { self } + var supportsConcurrentPhysicalTargets: Bool { true } - /// The keyCode the glow is currently targeting - private var currentTargetKeyCode: UInt16? = nil + // MARK: - Identity-Scoped Glow State - /// Current glow position (0.0-1.0 horizontal) - private var currentPosition: CGFloat = 0 - - /// Current glow key width - private var currentKeyWidth: CGFloat = 1.0 - - /// Whether the glow is currently visible (opacity > 0). - /// True from fade-in start, false only when fade-out naturally completes. - private var glowIsAlive: Bool = false + private var activeTargets: [GlowID: GlowTarget] = [:] + private var activeTargetOrder: [GlowID] = [] + private var surfaces: [GlowID: ClassicGlowSurface] = [:] + private var surfaceOrder: [GlowID] = [] /// Duration for the slide animation between key positions private let slideDuration: CFTimeInterval = 0.07 @@ -63,21 +64,24 @@ final class GlowView: NSView { private var cachedColorArrays: [[CGColor]] = [] private var colorCacheValid = false - // MARK: - Configurable Settings + // MARK: - Configuration - var glowColor: NSColor = NSColor(red: 0.2, green: 0.6, blue: 1.0, alpha: 1.0) { - didSet { colorCacheValid = false } - } - var baseKeyWidth: CGFloat = 60 - var glowHeight: CGFloat = 60 - var widthMultiplier: CGFloat = 1.0 - var maxOpacity: Float = 0.7 - var fadeOutDuration: CFTimeInterval = 1.5 - var glowRoundness: CGFloat = 1.0 - var glowFullness: CGFloat = 0.5 { - didSet { colorCacheValid = false } + private var configuration = RendererConfiguration.standard + + private var glowColor: NSColor { configuration.solidColor } + private var baseKeyWidth: CGFloat { configuration.baseKeyWidth } + private var glowHeight: CGFloat { configuration.glowHeight } + private var widthMultiplier: CGFloat { configuration.widthMultiplier } + private var selectedMaxOpacity: Float { configuration.maximumOpacity } + private var maxOpacity: Float { + configuration.chordAppearance.opacity( + selectedMaxOpacity, + activeMemberCount: activeChordMemberCount + ) } - var colorResolver: ((CGFloat) -> NSColor)? = nil + private var fadeOutDuration: CFTimeInterval { configuration.fadeDuration } + private var glowRoundness: CGFloat { configuration.roundness } + private var glowFullness: CGFloat { configuration.fullness } private let edgeEmergenceFraction: CGFloat = 0.5 private let baseVerticalInset: CGFloat = 2.0 @@ -122,35 +126,56 @@ final class GlowView: NSView { // MARK: - Public API - func showGlow(at horizontalPosition: CGFloat, keyCode: UInt16, keyWidth: CGFloat) { - // Purge stale keys that may have missed their keyUp event - purgeStaleKeys() - - // Update timestamp for this key - keyTimestamps[keyCode] = CACurrentMediaTime() - - // Key repeat: same key firing again while held — just keep it alive - if heldKeys.contains(keyCode) && currentTargetKeyCode == keyCode { - let container = ensureGlowLayer() - // Only intervene if something is wrong (e.g. a fade-out snuck in). - // Otherwise leave the layer alone to avoid disrupting animations. - if container.opacity != maxOpacity && container.animation(forKey: "fadeOut") != nil { - CATransaction.begin() - CATransaction.setDisableActions(true) - container.removeAllAnimations() - container.opacity = maxOpacity - CATransaction.commit() - } - glowIsAlive = true - return + func apply(_ configuration: RendererConfiguration) { + guard self.configuration != configuration else { return } + let wasReducingMotion = self.configuration.reduceMotion + self.configuration = configuration + colorCacheValid = false + + if configuration.reduceMotion && !wasReducingMotion { + freezeGeometryAnimationsAtCurrentState() } - heldKeys.insert(keyCode) - currentTargetKeyCode = keyCode + refreshVisibleConfiguration() + } - let container = ensureGlowLayer() + func show(_ target: GlowTarget) { + let id = target.id + let previousTarget = activeTargets[id] + let wasActive = previousTarget != nil + let hadActiveTargets = !activeTargets.isEmpty + let surface = surface( + for: target, + mayReuseVisibleRetreat: !hadActiveTargets + ) - if glowIsAlive { + activeTargets[id] = target + if !wasActive { + activeTargetOrder.append(id) + } + surface.target = target + refreshActiveChordOpacity() + + switch target.id { + case .physicalKey: + if wasActive, previousTarget == target, refresh(id) { + return + } + showAnimated(target, on: surface) + case .preview: + updatePreview(target, on: surface) + } + } + + private func showAnimated( + _ target: GlowTarget, + on surface: ClassicGlowSurface + ) { + let horizontalPosition = CGFloat(target.horizontalPosition) + let keyWidth = CGFloat(target.keyWidth) + let container = surface.layer + + if surface.isAlive { // CASE A: Glow is still visible — slide to new position // 1. Capture current visual state from presentation layer @@ -160,12 +185,13 @@ final class GlowView: NSView { // 2. Cancel all in-progress animations (fade-out, previous slides) container.removeAllAnimations() + surface.fadeDelegate = nil // 3. Compute new frame and update content let newFrame = computeFrame(for: horizontalPosition, keyWidth: keyWidth) let effectiveWidth = newFrame.width let flatHeight = flatGlowHeight - let perKeyColor: NSColor? = colorResolver?(horizontalPosition) + let perKeyColor = configuration.resolvedColorOverride(for: target) // 4. Set model values and animate — all within disabled-actions transaction CATransaction.begin() @@ -175,22 +201,26 @@ final class GlowView: NSView { container.opacity = maxOpacity updateGlowSublayers(container: container, width: effectiveWidth, height: flatHeight, color: perKeyColor) - // 5. Animate position (slide) - let slidePosition = CABasicAnimation(keyPath: "position") - slidePosition.fromValue = presentationPosition - slidePosition.toValue = container.position - slidePosition.duration = slideDuration - slidePosition.timingFunction = easeOutTiming - - // 6. Animate bounds (handles width changes between different keys) - let slideBounds = CABasicAnimation(keyPath: "bounds") - slideBounds.fromValue = presentationBounds - slideBounds.toValue = container.bounds - slideBounds.duration = slideDuration - slideBounds.timingFunction = easeOutTiming - - container.add(slidePosition, forKey: "slidePosition") - container.add(slideBounds, forKey: "slideBounds") + if RendererMotionPolicy.allowsGeometryAnimation( + reduceMotion: configuration.reduceMotion + ) { + // 5. Animate position (slide) + let slidePosition = CABasicAnimation(keyPath: "position") + slidePosition.fromValue = presentationPosition + slidePosition.toValue = container.position + slidePosition.duration = slideDuration + slidePosition.timingFunction = easeOutTiming + + // 6. Animate bounds (handles width changes between different keys) + let slideBounds = CABasicAnimation(keyPath: "bounds") + slideBounds.fromValue = presentationBounds + slideBounds.toValue = container.bounds + slideBounds.duration = slideDuration + slideBounds.timingFunction = easeOutTiming + + container.add(slidePosition, forKey: "slidePosition") + container.add(slideBounds, forKey: "slideBounds") + } // 7. Restore opacity smoothly if it was mid-fade if presentationOpacity < maxOpacity { @@ -207,11 +237,14 @@ final class GlowView: NSView { } else { // CASE B: Glow fully faded — appear fresh at new position + container.removeAllAnimations() + surface.fadeDelegate = nil + // 1. Position instantly (no animation) let newFrame = computeFrame(for: horizontalPosition, keyWidth: keyWidth) let effectiveWidth = newFrame.width let flatHeight = flatGlowHeight - let perKeyColor: NSColor? = colorResolver?(horizontalPosition) + let perKeyColor = configuration.resolvedColorOverride(for: target) CATransaction.begin() CATransaction.setDisableActions(true) @@ -237,25 +270,30 @@ final class GlowView: NSView { y: finalPosition.y - (finalBounds.height - startHeight) * 0.5 ) - let popBounds = CABasicAnimation(keyPath: "bounds") - popBounds.fromValue = startBounds - popBounds.toValue = finalBounds - popBounds.duration = popInDuration - popBounds.timingFunction = easeOutTiming - - let popPosition = CABasicAnimation(keyPath: "position") - popPosition.fromValue = startPosition - popPosition.toValue = finalPosition - popPosition.duration = popInDuration - popPosition.timingFunction = easeOutTiming - let fadeIn = CABasicAnimation(keyPath: "opacity") fadeIn.fromValue = 0.0 fadeIn.toValue = maxOpacity fadeIn.duration = popInDuration fadeIn.timingFunction = easeOutTiming - container.add(popBounds, forKey: "popBounds") - container.add(popPosition, forKey: "popPosition") + + if RendererMotionPolicy.allowsGeometryAnimation( + reduceMotion: configuration.reduceMotion + ) { + let popBounds = CABasicAnimation(keyPath: "bounds") + popBounds.fromValue = startBounds + popBounds.toValue = finalBounds + popBounds.duration = popInDuration + popBounds.timingFunction = easeOutTiming + + let popPosition = CABasicAnimation(keyPath: "position") + popPosition.fromValue = startPosition + popPosition.toValue = finalPosition + popPosition.duration = popInDuration + popPosition.timingFunction = easeOutTiming + + container.add(popBounds, forKey: "popBounds") + container.add(popPosition, forKey: "popPosition") + } container.add(fadeIn, forKey: "fadeIn") container.bounds = finalBounds @@ -263,50 +301,103 @@ final class GlowView: NSView { container.opacity = maxOpacity CATransaction.commit() - glowIsAlive = true } - // Update tracking - currentPosition = horizontalPosition - currentKeyWidth = keyWidth + surface.isAlive = true + surface.target = target + } + + /// Refresh the visible identity without changing geometry, color, or + /// animation energy. Nonvisible identities are deliberately rejected. + @discardableResult + func refresh(_ id: GlowID) -> Bool { + guard activeTargets[id] != nil else { return false } + guard let surface = surfaces[id] else { return false } + let container = surface.layer + guard container.superlayer != nil, + surface.isAlive else { + return false + } + + // Only intervene if something is wrong (e.g. a fade-out snuck in). + // Otherwise leave the layer alone to avoid disrupting animations. + if container.opacity != maxOpacity && container.animation(forKey: "fadeOut") != nil { + CATransaction.begin() + CATransaction.setDisableActions(true) + container.removeAllAnimations() + container.opacity = maxOpacity + CATransaction.commit() + surface.fadeDelegate = nil + } + surface.isAlive = true + return true } /// Update the position of the glow instantly (for live preview during drag in key position editor) - func updateGlowPosition(at horizontalPosition: CGFloat, keyCode: UInt16, keyWidth: CGFloat) { - let container = ensureGlowLayer() + private func updatePreview( + _ target: GlowTarget, + on surface: ClassicGlowSurface + ) { + let container = surface.layer + redrawImmediately(target, in: container) + surface.fadeDelegate = nil + surface.isAlive = true + surface.target = target + } + + /// Repaints the currently visible target after an atomic configuration + /// update. This deliberately does not call `show` or mutate held-key state: + /// target priority and retreat ordering belong to `OverlayController`. + private func refreshVisibleConfiguration() { + for id in activeTargetOrder { + guard let target = activeTargets[id], + let surface = surfaces[id], + surface.layer.superlayer != nil, + surface.isAlive else { + continue + } - // Cancel any animations for instant repositioning + redrawImmediately(target, in: surface.layer) + surface.fadeDelegate = nil + surface.target = target + } + } + + /// Applies the current pixels without introducing a new transition. This is + /// also the established behavior for the continuously tracking previews. + private func redrawImmediately(_ target: GlowTarget, in container: CALayer) { + let horizontalPosition = CGFloat(target.horizontalPosition) + let keyWidth = CGFloat(target.keyWidth) + + // Cancel any animations so the complete configuration becomes visible + // as one transaction rather than mixing old geometry with new content. container.removeAllAnimations() let newFrame = computeFrame(for: horizontalPosition, keyWidth: keyWidth) let effectiveWidth = newFrame.width let flatHeight = flatGlowHeight - let perKeyColor: NSColor? = colorResolver?(horizontalPosition) + let perKeyColor = configuration.resolvedColorOverride(for: target) CATransaction.begin() CATransaction.setDisableActions(true) container.frame = newFrame container.opacity = maxOpacity - updateGlowSublayers(container: container, width: effectiveWidth, height: flatHeight, color: perKeyColor) + updateGlowSublayers( + container: container, + width: effectiveWidth, + height: flatHeight, + color: perKeyColor + ) CATransaction.commit() - - glowIsAlive = true - currentPosition = horizontalPosition - currentKeyWidth = keyWidth - currentTargetKeyCode = keyCode - heldKeys.insert(keyCode) } - func hideGlow(keyCode: UInt16) { - heldKeys.remove(keyCode) - keyTimestamps.removeValue(forKey: keyCode) - - // Only fade out when ALL keys are released - if !heldKeys.isEmpty { - return - } - - guard let container = glowLayer, glowIsAlive else { return } + func hide(_ id: GlowID) { + guard let target = activeTargets.removeValue(forKey: id) else { return } + activeTargetOrder.removeAll { $0 == id } + refreshActiveChordOpacity() + guard let surface = surfaces[id], surface.isAlive else { return } + surface.target = target + let container = surface.layer // Capture the current visual opacity before touching animations let currentVisualOpacity = container.presentation()?.opacity ?? container.opacity @@ -333,6 +424,7 @@ final class GlowView: NSView { container.bounds = presentationBounds } container.removeAllAnimations() + surface.fadeDelegate = nil // Start fade-out let fadeAnim = CABasicAnimation(keyPath: "opacity") @@ -343,58 +435,162 @@ final class GlowView: NSView { fadeAnim.fillMode = .forwards fadeAnim.isRemovedOnCompletion = false - // Use delegate to reliably detect completion vs. cancellation - fadeAnim.delegate = FadeOutDelegate { [weak self] finished in - if finished { - self?.glowIsAlive = false + // Use a retained delegate to reliably distinguish natural completion + // from a surface being reused by a later key press. + let fadeDelegate = FadeOutDelegate { [weak self, weak surface] finished in + guard finished, + let self, + let surface, + self.surfaces[id] === surface, + self.activeTargets[id] == nil else { + return } + surface.isAlive = false + surface.fadeDelegate = nil + surface.layer.removeFromSuperlayer() } + surface.fadeDelegate = fadeDelegate + fadeAnim.delegate = fadeDelegate container.add(fadeAnim, forKey: "fadeOut") container.opacity = 0.0 CATransaction.commit() - - currentTargetKeyCode = nil } - // MARK: - Key State Management + /// Clears the rendered identity and all in-flight visual state. + func clear() { + activeTargets.removeAll(keepingCapacity: true) + activeTargetOrder.removeAll(keepingCapacity: true) - /// Removes keys from heldKeys that haven't been refreshed recently. - /// Protects against missed keyUp events (e.g., focus change, app switching). - private func purgeStaleKeys() { - let now = CACurrentMediaTime() - let staleKeys = heldKeys.filter { key in - guard let timestamp = keyTimestamps[key] else { return true } - return (now - timestamp) > staleKeyThreshold - } - for key in staleKeys { - heldKeys.remove(key) - keyTimestamps.removeValue(forKey: key) + CATransaction.begin() + CATransaction.setDisableActions(true) + for surface in surfaces.values { + surface.layer.removeAllAnimations() + surface.layer.opacity = 0 + surface.layer.removeFromSuperlayer() + surface.isAlive = false + surface.fadeDelegate = nil } - } + CATransaction.commit() - /// Clears all held key state. Call on wake from sleep or app reactivation - /// to prevent stale keys from blocking fade-out. - func clearHeldKeys() { - heldKeys.removeAll() - keyTimestamps.removeAll() + surfaces.removeAll(keepingCapacity: true) + surfaceOrder.removeAll(keepingCapacity: true) } // MARK: - Helpers - /// Lazily creates the single glow layer or returns the existing one - private func ensureGlowLayer() -> CALayer { - if let existing = glowLayer { - if existing.superlayer == nil { - layer?.addSublayer(existing) + private var activeChordMemberCount: Int { + activeTargets.keys.reduce(into: 0) { count, id in + switch id { + case .physicalKey: + count += 1 + case .preview(let source) where source.isChordTest: + count += 1 + case .preview: + break + } + } + } + + /// Chord membership can change without a settings transaction. Update the + /// model opacity of every surviving identity immediately while preserving + /// its geometry and other in-flight animation state. + private func refreshActiveChordOpacity() { + let opacity = maxOpacity + CATransaction.begin() + CATransaction.setDisableActions(true) + for id in activeTargetOrder { + guard let surface = surfaces[id], surface.isAlive else { continue } + surface.layer.removeAnimation(forKey: "fadeIn") + surface.layer.removeAnimation(forKey: "opacityRestore") + surface.layer.opacity = opacity + } + CATransaction.commit() + } + + /// Returns the target's existing surface or reuses an inactive surface. + /// A still-visible retreat is reused only when no other target is active; + /// that retains Classic Glow's original sequential slide behavior without + /// stealing a layer from a physically held chord member. + private func surface( + for target: GlowTarget, + mayReuseVisibleRetreat: Bool + ) -> ClassicGlowSurface { + if let existing = surfaces[target.id] { + if activeTargets[target.id] == nil { + surfaceOrder.removeAll { $0 == target.id } + surfaceOrder.append(target.id) + } + if existing.layer.superlayer == nil { + layer?.addSublayer(existing.layer) } return existing } + + let reusableID = surfaceOrder.reversed().first { candidateID in + guard activeTargets[candidateID] == nil, + let candidate = surfaces[candidateID] else { + return false + } + return !candidate.isAlive || mayReuseVisibleRetreat + } + + if let reusableID, + let reusable = surfaces.removeValue(forKey: reusableID) { + surfaceOrder.removeAll { $0 == reusableID } + reusable.id = target.id + reusable.target = target + surfaces[target.id] = reusable + surfaceOrder.append(target.id) + if reusable.layer.superlayer == nil { + layer?.addSublayer(reusable.layer) + } + return reusable + } + let container = createEmptyGlowContainer() layer?.addSublayer(container) - glowLayer = container - return container + let created = ClassicGlowSurface( + id: target.id, + target: target, + layer: container + ) + surfaces[target.id] = created + surfaceOrder.append(target.id) + return created + } + + private func freezeGeometryAnimationsAtCurrentState() { + for surface in surfaces.values { + freezeGeometryAnimations(on: surface.layer) + } + } + + private func freezeGeometryAnimations(on container: CALayer) { + let hasPositionAnimation = + container.animation(forKey: "slidePosition") != nil || + container.animation(forKey: "popPosition") != nil + let hasBoundsAnimation = + container.animation(forKey: "slideBounds") != nil || + container.animation(forKey: "popBounds") != nil + + guard hasPositionAnimation || hasBoundsAnimation else { return } + let presentation = container.presentation() + + CATransaction.begin() + CATransaction.setDisableActions(true) + if hasPositionAnimation, let position = presentation?.position { + container.position = position + } + if hasBoundsAnimation, let bounds = presentation?.bounds { + container.bounds = bounds + } + container.removeAnimation(forKey: "slidePosition") + container.removeAnimation(forKey: "popPosition") + container.removeAnimation(forKey: "slideBounds") + container.removeAnimation(forKey: "popBounds") + CATransaction.commit() } /// Computes the frame rect for a glow at the given position and key width @@ -609,14 +805,24 @@ final class GlowView: NSView { } private func refreshGlowLayerForDisplayScale() { - guard let container = glowLayer else { return } - let effectiveWidth = baseKeyWidth * currentKeyWidth * 2.5 * widthMultiplier - let perKeyColor: NSColor? = colorResolver?(currentPosition) + guard !surfaces.isEmpty else { return } CATransaction.begin() CATransaction.setDisableActions(true) - applyRenderingQuality(to: container) - updateGlowSublayers(container: container, width: effectiveWidth, height: flatGlowHeight, color: perKeyColor) + for surface in surfaces.values where surface.layer.superlayer != nil { + let target = activeTargets[surface.id] ?? surface.target + let effectiveWidth = baseKeyWidth + * CGFloat(target.keyWidth) + * 2.5 + * widthMultiplier + applyRenderingQuality(to: surface.layer) + updateGlowSublayers( + container: surface.layer, + width: effectiveWidth, + height: flatGlowHeight, + color: configuration.resolvedColorOverride(for: target) + ) + } CATransaction.commit() } } diff --git a/KeyLight/Views/GuidedCalibrationView.swift b/KeyLight/Views/GuidedCalibrationView.swift new file mode 100644 index 0000000..1776ffa --- /dev/null +++ b/KeyLight/Views/GuidedCalibrationView.swift @@ -0,0 +1,535 @@ +import AppKit +import SwiftUI + +struct GuidedCalibrationAnchor: Identifiable, Equatable { + let keyCode: UInt16 + let label: String + let row: Int + let column: Int + + var id: UInt16 { keyCode } +} + +/// Pure calibration math kept separate from the scene so fitting, cancellation, +/// and persistence boundaries can be tested without presenting a window. +@MainActor +struct GuidedCalibrationDraft: Equatable { + static let anchors: [GuidedCalibrationAnchor] = [ + GuidedCalibrationAnchor(keyCode: 18, label: "1", row: 1, column: 0), + GuidedCalibrationAnchor(keyCode: 22, label: "6", row: 1, column: 1), + GuidedCalibrationAnchor(keyCode: 24, label: "=", row: 1, column: 2), + GuidedCalibrationAnchor(keyCode: 0, label: "A", row: 3, column: 0), + GuidedCalibrationAnchor(keyCode: 4, label: "H", row: 3, column: 1), + GuidedCalibrationAnchor(keyCode: 36, label: "Return", row: 3, column: 2), + GuidedCalibrationAnchor(keyCode: 55, label: "Left Command", row: 5, column: 0), + GuidedCalibrationAnchor(keyCode: 49, label: "Space", row: 5, column: 1), + GuidedCalibrationAnchor(keyCode: 124, label: "Right Arrow", row: 5, column: 2) + ] + + let baseline: KeyLayout + private(set) var alignedPositions: [UInt16: CGFloat] + + init(baseline: KeyLayout) { + self.baseline = KeyLayoutStore.normalized(baseline) + alignedPositions = Dictionary(uniqueKeysWithValues: Self.anchors.map { anchor in + (anchor.keyCode, Self.baselinePosition(for: anchor.keyCode, in: baseline)) + }) + } + + mutating func setAlignedPosition(_ position: CGFloat, for keyCode: UInt16) { + guard Self.anchors.contains(where: { $0.keyCode == keyCode }) else { return } + let finite = position.isFinite ? position : Self.baselinePosition( + for: keyCode, + in: baseline + ) + alignedPositions[keyCode] = min(max(finite, 0.02), 0.98) + } + + func alignedPosition(for keyCode: UInt16) -> CGFloat { + alignedPositions[keyCode] ?? Self.baselinePosition(for: keyCode, in: baseline) + } + + var fittedLayout: KeyLayout { + let bands = Self.referenceRows.map { referenceRow in + Self.makeBand( + row: referenceRow, + baseline: baseline, + alignedPositions: alignedPositions + ) + } + + var offsets: [UInt16: CGFloat] = [:] + var widths: [UInt16: CGFloat] = [:] + for key in KeyboardLayoutInfo.allKeys { + let baselinePosition = Self.baselinePosition(for: key.id, in: baseline) + let vertical = Self.verticalBands(for: key.row) + let lower = bands[vertical.lowerIndex] + let upper = bands[vertical.upperIndex] + let lowerPosition = lower.mappedPosition(for: baselinePosition) + let upperPosition = upper.mappedPosition(for: baselinePosition) + let mappedPosition = Self.interpolate( + lowerPosition, + upperPosition, + fraction: vertical.fraction + ) + let newOffset = min(max(mappedPosition, 0), 1) - key.position + if abs(newOffset) > 0.000_001 { + offsets[key.id] = newOffset + } + + let lowerScale = lower.scale(at: baselinePosition) + let upperScale = upper.scale(at: baselinePosition) + let localScale = Self.interpolate( + lowerScale, + upperScale, + fraction: vertical.fraction + ) + let baselineWidth = baseline.widthMultipliers[key.id] ?? 1 + let fittedWidth = baselineWidth * localScale + if abs(fittedWidth - 1) > 0.000_001 { + widths[key.id] = fittedWidth + } + } + return KeyLayoutStore.normalized(KeyLayout( + offsets: offsets, + widthMultipliers: widths + )) + } + + private struct Band { + let source: [CGFloat] + let target: [CGFloat] + + func mappedPosition(for position: CGFloat) -> CGFloat { + let segment = position <= source[1] ? 0 : 1 + let denominator = max(source[segment + 1] - source[segment], 0.000_001) + let fraction = (position - source[segment]) / denominator + return target[segment] + (target[segment + 1] - target[segment]) * fraction + } + + func scale(at position: CGFloat) -> CGFloat { + let segment = position <= source[1] ? 0 : 1 + let sourceDistance = max(source[segment + 1] - source[segment], 0.000_001) + return max((target[segment + 1] - target[segment]) / sourceDistance, 0.05) + } + } + + private static let referenceRows = [1, 3, 5] + + private static func makeBand( + row: Int, + baseline: KeyLayout, + alignedPositions: [UInt16: CGFloat] + ) -> Band { + let rowAnchors = anchors + .filter { $0.row == row } + .sorted { $0.column < $1.column } + let source = rowAnchors.map { baselinePosition(for: $0.keyCode, in: baseline) } + let requested = rowAnchors.map { + alignedPositions[$0.keyCode] ?? baselinePosition(for: $0.keyCode, in: baseline) + } + let left = min(max(requested[0], 0.02), 0.92) + let center = min(max(requested[1], left + 0.02), 0.96) + let right = min(max(requested[2], center + 0.02), 0.98) + return Band(source: source, target: [left, center, right]) + } + + private static func verticalBands( + for row: Int + ) -> (lowerIndex: Int, upperIndex: Int, fraction: CGFloat) { + if row <= referenceRows[0] { return (0, 0, 0) } + if row >= referenceRows[2] { return (2, 2, 0) } + if row <= referenceRows[1] { + let fraction = CGFloat(row - referenceRows[0]) + / CGFloat(referenceRows[1] - referenceRows[0]) + return (0, 1, fraction) + } + let fraction = CGFloat(row - referenceRows[1]) + / CGFloat(referenceRows[2] - referenceRows[1]) + return (1, 2, fraction) + } + + private static func baselinePosition(for keyCode: UInt16, in layout: KeyLayout) -> CGFloat { + guard let key = KeyboardLayoutInfo.allKeys.first(where: { $0.id == keyCode }) else { + return 0.5 + } + let offset = layout.offsets[keyCode] ?? 0 + return min(max(key.position + offset, 0), 1) + } + + private static func interpolate( + _ start: CGFloat, + _ end: CGFloat, + fraction: CGFloat + ) -> CGFloat { + start + (end - start) * min(max(fraction, 0), 1) + } +} + +@MainActor +struct GuidedCalibrationSceneRoot: View { + let model: KeyLightModel + let settings: SettingsManager + @ObservedObject var layoutStore: KeyLayoutStore + + @Environment(\.dismiss) private var dismiss + @State private var draft: GuidedCalibrationDraft + @State private var stepIndex = 0 + @State private var isReviewing = false + @State private var profileName: String + @State private var errorMessage: String? + @State private var detectedCurrentAnchor = false + @State private var chordPreviewTask: Task? + + init( + model: KeyLightModel, + settings: SettingsManager, + layoutStore: KeyLayoutStore + ) { + self.model = model + self.settings = settings + _layoutStore = ObservedObject(wrappedValue: layoutStore) + _draft = State(initialValue: GuidedCalibrationDraft(baseline: layoutStore.layout)) + _profileName = State(initialValue: Self.suggestedProfileName(in: settings)) + } + + private var currentAnchor: GuidedCalibrationAnchor { + GuidedCalibrationDraft.anchors[stepIndex] + } + + var body: some View { + VStack(spacing: 0) { + HStack { + VStack(alignment: .leading, spacing: 3) { + Text("Guided Keyboard Calibration") + .font(.title2.bold()) + Text(isReviewing ? "Review and save a new layout profile" : "Align nine reference glows; KeyLight fills in the rest") + .foregroundStyle(.secondary) + } + Spacer() + if !isReviewing { + Text("\(stepIndex + 1) of \(GuidedCalibrationDraft.anchors.count)") + .monospacedDigit() + .foregroundStyle(.secondary) + } + } + .padding(20) + + Divider() + + if isReviewing { + reviewContent + } else { + anchorContent + } + + Divider() + + HStack { + Button("Cancel") { + close() + } + .keyboardShortcut(.cancelAction) + + Spacer() + + if isReviewing { + Button("Back") { + isReviewing = false + postCurrentPreview() + } + Button("Save New Profile") { + saveProfile() + } + .buttonStyle(.borderedProminent) + .keyboardShortcut(.defaultAction) + .disabled(profileValidationError != nil) + } else { + Button("Back") { + stepIndex = max(stepIndex - 1, 0) + } + .disabled(stepIndex == 0) + Button(stepIndex == GuidedCalibrationDraft.anchors.count - 1 ? "Review" : "Next") { + if stepIndex == GuidedCalibrationDraft.anchors.count - 1 { + isReviewing = true + model.clearPreview(.guidedCalibration) + } else { + stepIndex += 1 + } + } + .buttonStyle(.borderedProminent) + .keyboardShortcut(.defaultAction) + } + } + .padding(20) + } + .frame(minWidth: 680, minHeight: 520) + .onAppear { postCurrentPreview() } + .onChange(of: stepIndex) { _, _ in + detectedCurrentAnchor = false + postCurrentPreview() + } + .onChange(of: model.physicalKeyActivity) { _, activity in + guard let activity, activity.isDown else { return } + detectedCurrentAnchor = KeyboardLayoutInfo.canonicalKeyCode(for: activity.keyCode) + == currentAnchor.keyCode + } + .onDisappear { clearTransientPreviews() } + } + + private var anchorContent: some View { + VStack(spacing: 22) { + ProgressView( + value: Double(stepIndex + 1), + total: Double(GuidedCalibrationDraft.anchors.count) + ) + .frame(maxWidth: 460) + + VStack(spacing: 8) { + Text("Align \(currentAnchor.label)") + .font(.system(size: 30, weight: .semibold)) + Text("Move the slider until the glow sits beneath the matching physical key. Use the arrow keys while the slider is focused for fine adjustment.") + .multilineTextAlignment(.center) + .foregroundStyle(.secondary) + .frame(maxWidth: 520) + } + + Slider( + value: Binding( + get: { Double(draft.alignedPosition(for: currentAnchor.keyCode)) }, + set: { value in + draft.setAlignedPosition(CGFloat(value), for: currentAnchor.keyCode) + postCurrentPreview() + } + ), + in: 0.02 ... 0.98 + ) + .frame(maxWidth: 520) + .accessibilityLabel("Horizontal position for \(currentAnchor.label)") + + HStack(spacing: 18) { + Button { + nudgeCurrentAnchor(by: -0.001) + } label: { + Label("Nudge Left", systemImage: "arrow.left") + } + Button { + nudgeCurrentAnchor(by: 0.001) + } label: { + Label("Nudge Right", systemImage: "arrow.right") + } + } + .controlSize(.small) + + Label( + detectedCurrentAnchor ? "Reference key detected" : "Press the reference key to verify identification (optional)", + systemImage: detectedCurrentAnchor ? "checkmark.circle.fill" : "keyboard" + ) + .foregroundStyle(detectedCurrentAnchor ? Color.green : Color.secondary) + + Spacer(minLength: 0) + } + .padding(24) + } + + private var reviewContent: some View { + VStack(alignment: .leading, spacing: 18) { + GuidedCalibrationKeyboardPreview(layout: draft.fittedLayout) + .frame(height: 245) + + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("Multi-Key Review") + .font(.headline) + Text("Temporarily preview A–S–D–F using the fitted layout.") + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + Button("Test Four Keys") { + startChordPreview() + } + .disabled(!model.isEnabled) + } + + Divider() + + VStack(alignment: .leading, spacing: 6) { + Text("New Profile Name") + .font(.headline) + TextField("Layout profile name", text: $profileName) + .textFieldStyle(.roundedBorder) + if let validation = errorMessage ?? profileValidationError { + Text(validation) + .font(.caption) + .foregroundStyle(.red) + } else { + Text("Finishing creates and activates a new profile. Existing profiles are not changed.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + Spacer(minLength: 0) + } + .padding(24) + } + + private var profileValidationError: String? { + guard let normalized = PersistenceValidation.normalizedName(profileName) else { + return "Enter a profile name." + } + if settings.savedKeyMappingProfiles.contains(where: { + $0.name.caseInsensitiveCompare(normalized) == .orderedSame + }) { + return "A layout named \"\(normalized)\" already exists." + } + return nil + } + + private func nudgeCurrentAnchor(by delta: CGFloat) { + draft.setAlignedPosition( + draft.alignedPosition(for: currentAnchor.keyCode) + delta, + for: currentAnchor.keyCode + ) + postCurrentPreview() + } + + private func postCurrentPreview() { + guard !isReviewing, model.isEnabled, + let key = KeyboardLayoutInfo.allKeys.first(where: { $0.id == currentAnchor.keyCode }) else { + model.clearPreview(.guidedCalibration) + return + } + let baselineWidth = draft.baseline.widthMultipliers[key.id] ?? 1 + model.setPreview( + .preview( + .guidedCalibration, + colorReferenceKeyCode: key.id, + horizontalPosition: Double(draft.alignedPosition(for: key.id)), + keyWidth: Double(key.width * baselineWidth) + ), + source: .guidedCalibration + ) + } + + private func startChordPreview() { + chordPreviewTask?.cancel() + let layout = draft.fittedLayout + let keyCodes: [UInt16] = [0, 1, 2, 3] + let targets = zip(PreviewSource.chordTestSources, keyCodes).compactMap { pair -> GlowTarget? in + let (source, keyCode) = pair + guard let key = KeyboardLayoutInfo.allKeys.first(where: { $0.id == keyCode }) else { + return nil + } + let offset = layout.offsets[keyCode] ?? 0 + let width = layout.widthMultipliers[keyCode] ?? 1 + return GlowTarget.preview( + source, + colorReferenceKeyCode: keyCode, + horizontalPosition: Double(min(max(key.position + offset, 0), 1)), + keyWidth: Double(key.width * width) + ) + } + model.setChordPreview(targets) + chordPreviewTask = Task { @MainActor in + do { + try await Task.sleep(for: .seconds(1.5)) + } catch { + return + } + guard !Task.isCancelled else { return } + model.clearChordPreview() + chordPreviewTask = nil + } + } + + private func saveProfile() { + guard profileValidationError == nil, + let normalizedName = PersistenceValidation.normalizedName(profileName) else { + return + } + let layout = draft.fittedLayout + let profile = KeyMappingProfile( + name: normalizedName, + keyOffsets: layout.offsets, + keyWidthOverrides: layout.widthMultipliers + ) + guard let saved = settings.saveKeyMappingProfile(profile) else { + errorMessage = "The profile could not be saved." + return + } + layoutStore.reloadSavedProfiles(from: settings) + guard layoutStore.selectSavedProfile(id: saved.id) else { + errorMessage = "The profile was saved but could not be activated." + return + } + layoutStore.flush() + model.feedback = UserFeedback( + severity: .success, + title: String(localized: "Calibration Saved"), + detail: String(localized: "Created and activated \"\(saved.name)\".") + ) + close() + } + + private func close() { + clearTransientPreviews() + dismiss() + } + + private func clearTransientPreviews() { + chordPreviewTask?.cancel() + chordPreviewTask = nil + model.clearPreview(.guidedCalibration) + model.clearChordPreview() + } + + private static func suggestedProfileName(in settings: SettingsManager) -> String { + let base = "Guided Calibration" + let names = Set(settings.savedKeyMappingProfiles.map { $0.name.lowercased() }) + guard names.contains(base.lowercased()) else { return base } + var suffix = 2 + while names.contains("\(base) \(suffix)".lowercased()) { + suffix += 1 + } + return "\(base) \(suffix)" + } +} + +private struct GuidedCalibrationKeyboardPreview: View { + let layout: KeyLayout + + var body: some View { + GeometryReader { proxy in + ZStack { + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Color.primary.opacity(0.035)) + ForEach(KeyboardLayoutInfo.allKeys) { key in + let position = min(max(key.position + (layout.offsets[key.id] ?? 0), 0), 1) + let widthScale = layout.widthMultipliers[key.id] ?? 1 + Text(key.label) + .font(.system(size: 7, weight: .medium)) + .lineLimit(1) + .frame( + width: max(14, 22 * key.width * widthScale), + height: 23 + ) + .background( + RoundedRectangle(cornerRadius: 4) + .fill(Color(NSColor.controlBackgroundColor)) + ) + .overlay( + RoundedRectangle(cornerRadius: 4) + .stroke(Color.primary.opacity(0.22), lineWidth: 1) + ) + .position( + x: position * proxy.size.width, + y: 22 + CGFloat(key.row) * 35 + ) + } + } + } + .accessibilityElement(children: .ignore) + .accessibilityLabel("Preview of the fitted keyboard layout") + } +} diff --git a/KeyLight/Views/KeyPositionEditorView.swift b/KeyLight/Views/KeyPositionEditorView.swift index a3c96ca..e5a1892 100644 --- a/KeyLight/Views/KeyPositionEditorView.swift +++ b/KeyLight/Views/KeyPositionEditorView.swift @@ -1,41 +1,99 @@ import SwiftUI import AppKit -/// Window that displays the key position editor @MainActor -final class KeyPositionEditorWindow: NSWindow { - init() { - let screenFrame = NSScreen.main?.frame ?? NSRect(x: 0, y: 0, width: 1440, height: 900) - let windowWidth: CGFloat = min(screenFrame.width * 0.9, 1200) - let windowHeight: CGFloat = 460 - - let contentRect = NSRect( - x: (screenFrame.width - windowWidth) / 2, - y: (screenFrame.height - windowHeight) / 2, - width: windowWidth, - height: windowHeight - ) +final class KeyEditorGlowPreviewSession { + typealias ShowHandler = @MainActor (UInt16, CGFloat, CGFloat) -> Void + typealias HideHandler = @MainActor () -> Void + + private var hideTask: Task? + private let showHandler: ShowHandler + private let hideHandler: HideHandler + + init( + show: @escaping ShowHandler, + hide: @escaping HideHandler + ) { + showHandler = show + hideHandler = hide + } - super.init( - contentRect: contentRect, - styleMask: [.titled, .closable, .resizable], - backing: .buffered, - defer: false - ) + func show(keyCode: UInt16, position: CGFloat, keyWidth: CGFloat) { + hideTask?.cancel() + hideTask = nil + showHandler(keyCode, position, keyWidth) + } + + func scheduleHide(after delay: TimeInterval) { + hideTask?.cancel() + hideTask = Task { @MainActor [weak self] in + do { + try await Task.sleep(for: .seconds(delay)) + } catch { + return + } + guard !Task.isCancelled else { return } + self?.hideTask = nil + self?.hideHandler() + } + } + + func stop() { + hideTask?.cancel() + hideTask = nil + hideHandler() + } +} - title = "Adjust Key Positions" - isReleasedWhenClosed = false - minSize = NSSize(width: 800, height: 380) +/// SwiftUI scene root used by the native singleton calibration window. +/// The preview session is scene-owned so delayed hides cannot outlive the window. +@MainActor +struct KeyPositionEditorSceneRoot: View { + let model: KeyLightModel + + @State private var previewSession: KeyEditorGlowPreviewSession + @ObservedObject private var layoutStore: KeyLayoutStore + + init(model: KeyLightModel, layoutStore: KeyLayoutStore) { + self.model = model + _layoutStore = ObservedObject(wrappedValue: layoutStore) + _previewSession = State(initialValue: KeyEditorGlowPreviewSession( + show: { [weak model] keyCode, position, keyWidth in + model?.setPreview( + .preview( + .keyEditor, + colorReferenceKeyCode: keyCode, + horizontalPosition: Double(position), + keyWidth: Double(keyWidth) + ), + source: .keyEditor + ) + }, + hide: { [weak model] in + model?.clearPreview(.keyEditor) + } + )) + } - let hostingView = NSHostingView(rootView: KeyPositionEditorView()) - contentView = hostingView + var body: some View { + KeyPositionEditorView( + model: model, + previewSession: previewSession, + layoutStore: layoutStore + ) + .onDisappear { + layoutStore.endGestureTransaction() + previewSession.stop() + } } } /// Main view for adjusting key positions by dragging struct KeyPositionEditorView: View { - @ObservedObject private var positionManager = KeyPositionManager.shared - @ObservedObject private var keyWidthManager = KeyWidthManager.shared + let model: KeyLightModel + let previewSession: KeyEditorGlowPreviewSession + + @ObservedObject var layoutStore: KeyLayoutStore @State private var selectedKey: UInt16? = nil @State private var showResetConfirmation = false @@ -49,32 +107,32 @@ struct KeyPositionEditorView: View { .foregroundColor(.secondary) Spacer() - Button(action: { positionManager.undo() }) { + Button(action: { layoutStore.undo() }) { Image(systemName: "arrow.uturn.backward") } - .disabled(!positionManager.canUndo) + .disabled(!layoutStore.canUndo) .help("Undo (Cmd+Z)") .keyboardShortcut("z", modifiers: .command) + .accessibilityLabel("Undo keyboard calibration") - Button(action: { positionManager.redo() }) { + Button(action: { layoutStore.redo() }) { Image(systemName: "arrow.uturn.forward") } - .disabled(!positionManager.canRedo) + .disabled(!layoutStore.canRedo) .help("Redo (Cmd+Shift+Z)") .keyboardShortcut("z", modifiers: [.command, .shift]) + .accessibilityLabel("Redo keyboard calibration") Button("Reset All") { showResetConfirmation = true } - .focusable(false) .confirmationDialog( "Reset all key positions to defaults?", isPresented: $showResetConfirmation, titleVisibility: .visible ) { Button("Reset All", role: .destructive) { - positionManager.resetAllKeys() - keyWidthManager.resetAllKeys() + layoutStore.resetAll() } Button("Cancel", role: .cancel) {} } @@ -131,6 +189,8 @@ struct KeyPositionEditorView: View { containerWidth: geometry.size.width, selectedKey: $selectedKey, pressedKeys: pressedKeys, + previewSession: previewSession, + layoutStore: layoutStore, showArrowSubRow: row == KeyboardLayoutInfo.maxRow ) } @@ -145,7 +205,9 @@ struct KeyPositionEditorView: View { VStack(alignment: .leading, spacing: 8) { if let keyCode = selectedKey, let keyInfo = KeyboardLayoutInfo.allKeys.first(where: { $0.id == keyCode }) { - let effectiveOffset = positionManager.effectiveOffset(for: keyCode) + let effectiveOffset = layoutStore.effectiveOffset(for: keyCode) + let isModified = effectiveOffset != 0 || + layoutStore.effectiveWidthMultiplier(for: keyCode) != 1 HStack { Text("Selected: \(keyInfo.label)") .font(.subheadline) @@ -153,13 +215,21 @@ struct KeyPositionEditorView: View { Text("Offset: \(String(format: "%.1f%%", effectiveOffset * 100))") .font(.subheadline) .foregroundColor(.secondary) + Label( + isModified ? "Modified" : "Default", + systemImage: isModified ? "pencil.circle.fill" : "checkmark.circle" + ) + .font(.caption) + .foregroundStyle(isModified ? Color.orange : Color.secondary) + .accessibilityLabel(isModified ? "Selected key is modified" : "Selected key uses defaults") Spacer() Button("Reset This Key") { - positionManager.resetKey(keyCode) - keyWidthManager.resetKey(keyCode) + layoutStore.resetKey(keyCode) + postWidthPreview(for: keyCode) + scheduleHidePreview() } .buttonStyle(.link) - .focusable(false) + .accessibilityHint("Restores this key's position and glow width") } HStack(spacing: 8) { @@ -167,23 +237,27 @@ struct KeyPositionEditorView: View { .font(.caption) Slider( value: Binding( - get: { keyWidthManager.effectiveWidthMultiplier(for: keyCode) }, + get: { layoutStore.effectiveWidthMultiplier(for: keyCode) }, set: { newValue in - keyWidthManager.setWidthMultiplier(newValue, for: keyCode) + layoutStore.setWidthMultiplier(newValue, for: keyCode) postWidthPreview(for: keyCode) } ), in: 0.3...3.0, onEditingChanged: { isEditing in if isEditing { + layoutStore.beginGestureTransaction() postWidthPreview(for: keyCode) } else { + layoutStore.endGestureTransaction() scheduleHidePreview() } } ) .frame(width: 200) - Text("\(Int(keyWidthManager.effectiveWidthMultiplier(for: keyCode) * 100))%") + .accessibilityLabel("Glow width for \(keyInfo.label)") + .accessibilityValue("\(Int(layoutStore.effectiveWidthMultiplier(for: keyCode) * 100)) percent") + Text("\(Int(layoutStore.effectiveWidthMultiplier(for: keyCode) * 100))%") .font(.caption) .foregroundColor(.secondary) .monospacedDigit() @@ -198,14 +272,13 @@ struct KeyPositionEditorView: View { .padding() .background(Color(NSColor.windowBackgroundColor)) } - .onReceive(NotificationCenter.default.publisher(for: .physicalKeyDown)) { notification in - handlePhysicalKeyDown(notification) - } - .onReceive(NotificationCenter.default.publisher(for: .physicalKeyUp)) { notification in - handlePhysicalKeyUp(notification) + .onChange(of: model.physicalKeyActivity) { _, activity in + handlePhysicalKeyActivity(activity) } .onDisappear { + layoutStore.endGestureTransaction() pressedKeys.removeAll() + previewSession.stop() } } @@ -213,8 +286,8 @@ struct KeyPositionEditorView: View { guard let keyInfo = KeyboardLayoutInfo.allKeys.first(where: { $0.id == keyCode }) else { return } - let position = positionManager.adjustedPosition(for: keyCode, originalPosition: keyInfo.position) - let keyWidth = keyWidthManager.effectiveWidth(for: keyCode, defaultWidth: keyInfo.width) + let position = layoutStore.adjustedPosition(for: keyCode, originalPosition: keyInfo.position) + let keyWidth = layoutStore.effectiveWidth(for: keyCode, defaultWidth: keyInfo.width) postPreview(keyCode: keyCode, position: position, keyWidth: keyWidth) } @@ -223,48 +296,27 @@ struct KeyPositionEditorView: View { } private func postPreview(keyCode: UInt16, position: CGFloat, keyWidth: CGFloat) { - NotificationCenter.default.post( - name: .showGlowPreview, - object: nil, - userInfo: [ - "keyCode": keyCode, - "position": position, - "keyWidth": keyWidth - ] - ) + previewSession.show(keyCode: keyCode, position: position, keyWidth: keyWidth) } private func postHidePreview(after delay: TimeInterval) { - DispatchQueue.main.asyncAfter(deadline: .now() + delay) { - NotificationCenter.default.post(name: .hideGlowPreview, object: nil) - } - } - - private func handlePhysicalKeyDown(_ notification: Notification) { - guard let keyCode = physicalKeyCode(from: notification) else { return } - pressedKeys.insert(keyCode) + previewSession.scheduleHide(after: delay) } - private func handlePhysicalKeyUp(_ notification: Notification) { - guard let keyCode = physicalKeyCode(from: notification) else { return } - pressedKeys.remove(keyCode) - } - - private func physicalKeyCode(from notification: Notification) -> UInt16? { - let rawKeyCode: UInt16? - if let direct = notification.userInfo?["keyCode"] as? UInt16 { - rawKeyCode = direct - } else if let number = notification.userInfo?["keyCode"] as? NSNumber { - rawKeyCode = number.uint16Value - } else if let intCode = notification.userInfo?["keyCode"] as? Int, - intCode >= 0, - intCode <= Int(UInt16.max) { - rawKeyCode = UInt16(intCode) + private func handlePhysicalKeyActivity(_ activity: PhysicalKeyActivity?) { + guard let activity else { + pressedKeys.removeAll() + return + } + let keyCode = KeyboardLayoutInfo.canonicalKeyCode(for: activity.keyCode) + if activity.isDown { + pressedKeys.insert(keyCode) + if KeyboardLayoutInfo.allKeys.contains(where: { $0.id == keyCode }) { + selectedKey = keyCode + } } else { - rawKeyCode = nil + pressedKeys.remove(keyCode) } - guard let rawKeyCode else { return nil } - return KeyboardLayoutInfo.canonicalKeyCode(for: rawKeyCode) } } @@ -274,6 +326,8 @@ struct KeyRow: View { let containerWidth: CGFloat @Binding var selectedKey: UInt16? let pressedKeys: Set + let previewSession: KeyEditorGlowPreviewSession + @ObservedObject var layoutStore: KeyLayoutStore let showArrowSubRow: Bool var body: some View { @@ -289,6 +343,8 @@ struct KeyRow: View { containerWidth: containerWidth, isSelected: selectedKey == key.id, isPressed: pressedKeys.contains(key.id), + previewSession: previewSession, + layoutStore: layoutStore, onSelect: { selectedKey = key.id } ) } @@ -300,6 +356,8 @@ struct KeyRow: View { containerWidth: containerWidth, isSelected: selectedKey == key.id, isPressed: pressedKeys.contains(key.id), + previewSession: previewSession, + layoutStore: layoutStore, onSelect: { selectedKey = key.id }, verticalOffset: key.id == 126 ? -12 : 12 ) @@ -316,11 +374,11 @@ struct DraggableKeyView: View { let containerWidth: CGFloat let isSelected: Bool let isPressed: Bool + let previewSession: KeyEditorGlowPreviewSession + @ObservedObject var layoutStore: KeyLayoutStore let onSelect: () -> Void var verticalOffset: CGFloat = 0 - @ObservedObject private var positionManager = KeyPositionManager.shared - @ObservedObject private var keyWidthManager = KeyWidthManager.shared @State private var dragOffset: CGFloat = 0 private var keyWidth: CGFloat { @@ -328,19 +386,19 @@ struct DraggableKeyView: View { } private var currentOffset: CGFloat { - positionManager.effectiveOffset(for: key.id) + layoutStore.effectiveOffset(for: key.id) } private var hasWidthOverride: Bool { - keyWidthManager.hasDirectOverride(for: key.id) + layoutStore.hasWidthMultiplierOverride(for: key.id) } private var effectiveWidthMultiplier: CGFloat { - keyWidthManager.effectiveWidthMultiplier(for: key.id) + layoutStore.effectiveWidthMultiplier(for: key.id) } private var effectiveKeyWidth: CGFloat { - keyWidthManager.effectiveWidth(for: key.id, defaultWidth: key.width) + layoutStore.effectiveWidth(for: key.id, defaultWidth: key.width) } private var adjustedPosition: CGFloat { @@ -351,29 +409,46 @@ struct DraggableKeyView: View { currentOffset != 0 || effectiveWidthMultiplier != 1.0 } + private var accessibilityValue: String { + let offset = String(format: "%.1f percent", currentOffset * 100) + let width = Int(effectiveWidthMultiplier * 100) + return "Offset \(offset), glow width \(width) percent\(isModified ? ", modified" : "")" + } + private func postPreview(position: CGFloat) { - NotificationCenter.default.post( - name: .showGlowPreview, - object: nil, - userInfo: [ - "keyCode": key.id, - "position": position, - "keyWidth": effectiveKeyWidth - ] + previewSession.show( + keyCode: key.id, + position: position, + keyWidth: effectiveKeyWidth ) } private func scheduleHidePreview(after delay: TimeInterval) { - DispatchQueue.main.asyncAfter(deadline: .now() + delay) { - NotificationCenter.default.post(name: .hideGlowPreview, object: nil) - } + previewSession.scheduleHide(after: delay) + } + + private func selectAndPreview() { + onSelect() + postPreview(position: key.position + currentOffset) + scheduleHidePreview(after: 1.0) + } + + private func nudge(_ direction: MoveCommandDirection, isLargeStep: Bool) { + guard direction == .left || direction == .right else { return } + onSelect() + let step: CGFloat = isLargeStep ? 0.01 : 0.001 + let signedStep = direction == .left ? -step : step + layoutStore.setOffset(currentOffset + signedStep, for: key.id) + postPreview(position: key.position + layoutStore.effectiveOffset(for: key.id)) + scheduleHidePreview(after: 0.5) } var body: some View { GeometryReader { geometry in let xPosition = adjustedPosition * containerWidth - Text(key.label) + Button(action: selectAndPreview) { + Text(key.label) .font(.system(size: key.width > 1.5 ? 10 : 11, weight: .medium)) .foregroundColor(isSelected ? .white : .primary) .frame(width: keyWidth, height: verticalOffset != 0 ? 20 : 32) @@ -398,13 +473,16 @@ struct DraggableKeyView: View { .fill(Color.purple) .frame(width: 6, height: 6) .offset(x: -3, y: 3) - } + } } + } + .buttonStyle(.plain) .position(x: xPosition, y: geometry.size.height / 2 + verticalOffset) .gesture( DragGesture() .onChanged { value in onSelect() + layoutStore.beginGestureTransaction() dragOffset = value.translation.width let previewPosition = adjustedPosition @@ -412,16 +490,29 @@ struct DraggableKeyView: View { } .onEnded { value in let newOffset = currentOffset + value.translation.width / containerWidth - positionManager.setOffset(newOffset, for: key.id) + layoutStore.setOffset(newOffset, for: key.id) + layoutStore.endGestureTransaction() dragOffset = 0 scheduleHidePreview(after: 0.5) } ) - .onTapGesture { - onSelect() - postPreview(position: key.position + currentOffset) - scheduleHidePreview(after: 1.0) + .onMoveCommand { direction in + nudge(direction, isLargeStep: NSEvent.modifierFlags.contains(.shift)) + } + .accessibilityLabel(key.label) + .accessibilityValue(accessibilityValue) + .accessibilityHint("Press to preview. Use Left and Right Arrow to adjust position; hold Shift for larger steps.") + .accessibilityAddTraits(isSelected ? .isSelected : []) + .accessibilityAdjustableAction { direction in + switch direction { + case .increment: + nudge(.right, isLargeStep: false) + case .decrement: + nudge(.left, isLargeStep: false) + @unknown default: + break + } } } } diff --git a/KeyLight/Views/LiquidGlassGlowRenderer.swift b/KeyLight/Views/LiquidGlassGlowRenderer.swift new file mode 100644 index 0000000..a4feaf1 --- /dev/null +++ b/KeyLight/Views/LiquidGlassGlowRenderer.swift @@ -0,0 +1,1574 @@ +import AppKit +import Observation +import SwiftUI + +#if compiler(>=6.2) +@available(macOS 26.0, *) +enum LiquidGlassPresentationMode: Equatable { + case systemGlass + case solidBlack + case physicalRefraction + + /// A glass shape must be closed, so place that closure below the clipped + /// screen edge for routes where a visible bottom optical rule is unwanted. + var extendsGlassBelowVisibleBaseline: Bool { + self == .systemGlass || self == .physicalRefraction + } +} + +@available(macOS 26.0, *) +struct LiquidGlassBellShape: Shape { + var emergence: CGFloat + var smoothness: CGFloat = 0.7069 + var flow: CGFloat = 0 + var profile: SurfaceShapeProfile = .currentWave + var minimumRise: CGFloat = 0.5 + var extendsBelowBaseline = false + + var animatableData: AnimatablePair, CGFloat> { + get { AnimatablePair(AnimatablePair(emergence, smoothness), flow) } + set { + emergence = newValue.first.first + smoothness = newValue.first.second + flow = newValue.second + } + } + + nonisolated func path(in rect: CGRect) -> Path { + Self.makePath( + in: rect, + emergence: emergence, + smoothness: smoothness, + flow: flow, + profile: profile, + minimumRise: minimumRise, + extendsBelowBaseline: extendsBelowBaseline, + closesBase: true + ) + } + + nonisolated static func edgePath( + in rect: CGRect, + emergence: CGFloat, + smoothness: CGFloat, + flow: CGFloat, + profile: SurfaceShapeProfile = .currentWave, + minimumRise: CGFloat = 0.5 + ) -> Path { + makePath( + in: rect, + emergence: emergence, + smoothness: smoothness, + flow: flow, + profile: profile, + minimumRise: minimumRise, + extendsBelowBaseline: false, + closesBase: false + ) + } + + private nonisolated static func makePath( + in rect: CGRect, + emergence: CGFloat, + smoothness: CGFloat, + flow: CGFloat, + profile _: SurfaceShapeProfile, + minimumRise: CGFloat, + extendsBelowBaseline: Bool, + closesBase: Bool + ) -> Path { + currentWavePath( + in: rect, + emergence: emergence, + smoothness: smoothness, + flow: flow, + minimumRise: minimumRise, + extendsBelowBaseline: extendsBelowBaseline, + closesBase: closesBase + ) + } + + /// The original KeyLight wave. Keep this geometry stable so selecting the + /// default profile is visually identical to prior builds. + private nonisolated static func currentWavePath( + in rect: CGRect, + emergence: CGFloat, + smoothness: CGFloat, + flow: CGFloat, + minimumRise: CGFloat, + extendsBelowBaseline: Bool, + closesBase: Bool + ) -> Path { + let progress = Self.unitValue(emergence, default: 0) + let softness = Self.unitValue(smoothness, default: 0.7069) + let directionalFlow = Self.signedUnitValue(flow) + let materialWidth = rect.width * (0.28 + 0.72 * progress) + let minX = rect.midX - materialWidth * 0.5 + let maxX = rect.midX + materialWidth * 0.5 + + // The renderer places 28% of its frame below the display. This line is + // therefore the exact physical screen edge in the SwiftUI coordinate + // space. Even the collapsed path leaves a half-point lens at that edge, + // while its opacity begins at zero, so the material grows from the bezel + // instead of appearing one frame above it. + let screenEdgeY = rect.height * 0.72 + let safeMinimumRise = minimumRise.isFinite + ? max(minimumRise, 0) + : 0.5 + let rise = max(screenEdgeY * progress, safeMinimumRise) + let topY = screenEdgeY - rise + + // Smoothness changes only the horizontal shoulder falloff. Keeping the + // range below one half per side guarantees a real flat plateau at 100%. + let shoulderShare = Self.shoulderShare(for: softness) + let leadingAdjustment = 0.055 * abs(directionalFlow) + let trailingAdjustment = 0.085 * abs(directionalFlow) + let leftShoulderShare = directionalFlow >= 0 + ? shoulderShare + trailingAdjustment + : shoulderShare - leadingAdjustment + let rightShoulderShare = directionalFlow >= 0 + ? shoulderShare - leadingAdjustment + : shoulderShare + trailingAdjustment + let leftShoulder = materialWidth * min(max(leftShoulderShare, 0.12), 0.46) + let rightShoulder = materialWidth * min(max(rightShoulderShare, 0.12), 0.46) + let topBias = materialWidth * 0.045 * directionalFlow * progress + let leftTopX = min( + minX + leftShoulder + topBias, + rect.midX + topBias - 0.5 + ) + let rightTopX = max( + maxX - rightShoulder + topBias, + rect.midX + topBias + 0.5 + ) + let leftFirstTangent = leftShoulder * (0.18 + 0.06 * softness) + let leftSecondTangent = leftShoulder * (0.70 + 0.08 * softness) + let rightFirstTangent = rightShoulder * (0.18 + 0.06 * softness) + let rightSecondTangent = rightShoulder * (0.70 + 0.08 * softness) + + var path = Path() + path.move(to: CGPoint(x: minX, y: screenEdgeY)) + path.addCurve( + to: CGPoint(x: leftTopX, y: topY), + control1: CGPoint(x: minX + leftFirstTangent, y: screenEdgeY), + control2: CGPoint(x: minX + leftSecondTangent + topBias, y: topY) + ) + path.addLine(to: CGPoint(x: rightTopX, y: topY)) + path.addCurve( + to: CGPoint(x: maxX, y: screenEdgeY), + control1: CGPoint(x: maxX - rightSecondTangent + topBias, y: topY), + control2: CGPoint(x: maxX - rightFirstTangent, y: screenEdgeY) + ) + if closesBase { + if extendsBelowBaseline { + // Physical glass is cropped by the screen edge rather than + // terminated there. Put the closure below the visible view so + // system glass cannot produce a horizontal bezel highlight. + path.addLine(to: CGPoint(x: maxX, y: rect.maxY)) + path.addLine(to: CGPoint(x: minX, y: rect.maxY)) + } + path.closeSubpath() + } + return path + } + + private nonisolated static func unitValue( + _ value: CGFloat, + default defaultValue: CGFloat + ) -> CGFloat { + guard value.isFinite else { return defaultValue } + return min(max(value, 0), 1) + } + + nonisolated static func shoulderShare(for smoothness: CGFloat) -> CGFloat { + let safeSmoothness = unitValue(smoothness, default: 0.7069) + let curved = CGFloat(pow(Double(safeSmoothness), 1.55)) + return 0.12 + 0.30 * curved + } + + private nonisolated static func signedUnitValue(_ value: CGFloat) -> CGFloat { + guard value.isFinite else { return 0 } + return min(max(value, -1), 1) + } +} + +@available(macOS 26.0, *) +struct LiquidGlassCohesiveBridge { + nonisolated static func sag( + averageHeight: CGFloat, + smoothness: CGFloat + ) -> CGFloat { + let safeHeight = averageHeight.isFinite ? max(averageHeight, 0) : 0 + let safeSmoothness = smoothness.isFinite + ? min(max(smoothness, 0), 1) + : 0.7069 + return min( + max(safeHeight * (0.10 - 0.04 * safeSmoothness), 0.75), + 3.2 + ) + } + + nonisolated static func path( + start: CGPoint, + end: CGPoint, + baselineY: CGFloat, + averageHeight: CGFloat, + smoothness: CGFloat + ) -> Path { + let distance = end.x - start.x + guard start.x.isFinite, + start.y.isFinite, + end.x.isFinite, + end.y.isFinite, + baselineY.isFinite, + distance > 0.5 else { + return Path() + } + + let saddleY = max(start.y, end.y) + sag( + averageHeight: averageHeight, + smoothness: smoothness + ) + var bridge = Path() + bridge.move(to: CGPoint(x: start.x, y: baselineY)) + bridge.addLine(to: start) + bridge.addCurve( + to: end, + control1: CGPoint( + x: start.x + distance * 0.34, + y: saddleY + ), + control2: CGPoint( + x: end.x - distance * 0.34, + y: saddleY + ) + ) + bridge.addLine(to: CGPoint(x: end.x, y: baselineY)) + bridge.closeSubpath() + return bridge + } +} + +@available(macOS 26.0, *) +struct LiquidGlassSolidBlackMaterial { + static let fillOpacity = 1.0 + + nonisolated static func isPresent( + isVisible: Bool, + emergence: CGFloat + ) -> Bool { + isVisible && emergence.isFinite && emergence > 0.000_1 + } +} + +@available(macOS 26.0, *) +struct LiquidGlassSurfaceSnapshot: Equatable { + let id: GlowID + let frame: CGRect + let opacity: Double + let visibility: Double + let edgeOpacity: Double + let lensOpacity: Double + let dimmingOpacity: Double + let chromaticDisplacement: CGFloat + let emergence: CGFloat + let smoothness: CGFloat + let horizontalVelocity: CGFloat + let isVisible: Bool +} + +private typealias LiquidGlassSurfaceState = SurfaceMotionState +private typealias LiquidGlassSurfaceVelocity = SurfaceMotionVelocity +private typealias LiquidGlassSurfaceSample = SurfaceMotionSample +private typealias LiquidGlassSurfaceTransition = SurfaceMotionTransition +private typealias LiquidGlassSurfaceTrack = SurfaceMotionTrack + +@available(macOS 26.0, *) +private extension LiquidGlassSurfaceSample { + func snapshot( + materialOpacity: Double, + edgeOpacity: Double, + lensOpacity: Double, + dimmingOpacity: Double, + chromaticDisplacement: CGFloat + ) -> LiquidGlassSurfaceSnapshot { + let safeVisibility = min(max(state.visibility, 0), 1) + return LiquidGlassSurfaceSnapshot( + id: state.id, + frame: state.frame, + opacity: safeVisibility * min(max(materialOpacity, 0), 1), + visibility: safeVisibility, + edgeOpacity: safeVisibility * min(max(edgeOpacity, 0), 1), + lensOpacity: safeVisibility * min(max(lensOpacity, 0), 1), + dimmingOpacity: safeVisibility * min(max(dimmingOpacity, 0), 1), + chromaticDisplacement: max(chromaticDisplacement, 0), + emergence: state.emergence, + smoothness: state.smoothness, + horizontalVelocity: velocity.frame.origin.x + + velocity.frame.width * 0.5, + isVisible: state.isVisible + ) + } +} + +@available(macOS 26.0, *) +@MainActor +@Observable +private final class LiquidGlassSurfaceModel { + // Native glass and custom grouping join only after silhouettes genuinely + // overlap. Positive spacing made nearby keys attract each other's outside + // shoulders before their material touched. + var spacing: CGFloat = 0 + private(set) var presentationMode: LiquidGlassPresentationMode = .systemGlass + private(set) var shapeProfile: SurfaceShapeProfile = .currentWave + private(set) var physicalCaptureIsReady = false + private(set) var physicalCaptureStopGeneration: UInt = 0 + private(set) var physicalCaptureState: PhysicalCaptureState = .idle + private(set) var materialOpacity: Double = 0.7 + private(set) var prismaticEdgeOpacity: Double = 0.5 + private(set) var refractionStrength: Double = 1.0 + private(set) var clearLensOpacity: Double = 0.8 + private(set) var localizedDimmingOpacity: Double = 0.1 + private(set) var chromaticDisplacement: CGFloat = 1.2 + private(set) var chordSurfaceStyle: ChordSurfaceStyle = .naturalMerge + private(set) var chordIntensityMultiplier: Double = 1 + private(set) var activeChordMemberCount = 0 + private(set) var isTimelineActive = false + private var motionEngine: SurfaceMotionEngine + @ObservationIgnored + private var runtimeStatusHandler: + (@MainActor (GlowRendererRuntimeState) -> Void)? + + init(clock: any SurfaceMotionClock = SystemSurfaceMotionClock()) { + motionEngine = SurfaceMotionEngine(clock: clock) + } + + var tracks: [LiquidGlassSurfaceTrack] { motionEngine.tracks } + var currentTime: TimeInterval { motionEngine.currentTime } + + func setPresentation( + mode: LiquidGlassPresentationMode, + shapeProfile: SurfaceShapeProfile + ) { + if presentationMode != mode { + physicalCaptureIsReady = false + } + presentationMode = mode + self.shapeProfile = shapeProfile + } + + func setPhysicalCaptureReady(_ ready: Bool) { + physicalCaptureIsReady = ready + } + + func setChordAppearance( + _ appearance: ChordAppearance, + activeMemberCount: Int + ) { + let normalized = appearance.normalized + chordSurfaceStyle = normalized.style + chordIntensityMultiplier = normalized.intensityMultiplier + activeChordMemberCount = max(activeMemberCount, 0) + } + + var solidBlackFillOpacity: Double { + guard activeChordMemberCount >= 2 else { + return LiquidGlassSolidBlackMaterial.fillOpacity + } + return min(max(chordIntensityMultiplier, 0), 1) + } + + func setPhysicalCaptureState(_ state: PhysicalCaptureState) { + physicalCaptureState = state + publishRuntimeStatus() + } + + func setRuntimeStatusHandler( + _ handler: (@MainActor (GlowRendererRuntimeState) -> Void)? + ) { + runtimeStatusHandler = handler + publishRuntimeStatus() + } + + private func publishRuntimeStatus() { + let readiness: RendererReadiness + switch physicalCaptureState { + case .failed: + readiness = .fallback + default: + readiness = .ready + } + runtimeStatusHandler?(GlowRendererRuntimeState( + readiness: readiness, + captureState: physicalCaptureState, + fallbackReason: physicalCaptureState == .failed + ? "Physical Refraction is using System Glass fallback" + : nil + )) + } + + func stopPhysicalCaptureImmediately() { + physicalCaptureIsReady = false + physicalCaptureStopGeneration &+= 1 + } + + func setMaterial( + bodyOpacity: Float, + edgeOpacity: Float, + refractionStrength: CGFloat, + lensOpacity: Float, + dimmingOpacity: Float, + chromaticDisplacement: CGFloat + ) { + materialOpacity = Double( + bodyOpacity.isFinite ? min(max(bodyOpacity, 0), 1) : 0.7 + ) + prismaticEdgeOpacity = Double( + edgeOpacity.isFinite ? min(max(edgeOpacity, 0), 1) : 0.5 + ) + self.refractionStrength = Double( + refractionStrength.isFinite + ? min(max(refractionStrength, 0.5), 2.5) + : 1 + ) + clearLensOpacity = Double( + lensOpacity.isFinite ? min(max(lensOpacity, 0), 1) : 0.8 + ) + localizedDimmingOpacity = Double( + dimmingOpacity.isFinite ? min(max(dimmingOpacity, 0), 1) : 0.1 + ) + self.chromaticDisplacement = chromaticDisplacement.isFinite + ? max(chromaticDisplacement, 0) + : 1.2 + } + + func setTracks(_ tracks: [LiquidGlassSurfaceTrack]) { + motionEngine.setTracks(tracks) + isTimelineActive = motionEngine.hasActiveTransitions + } + + func samples(at time: TimeInterval) -> [LiquidGlassSurfaceSample] { + motionEngine.samples(at: time) + } +} + +@available(macOS 26.0, *) +private struct LiquidGlassSurfaceRoot: View { + let model: LiquidGlassSurfaceModel + @Namespace private var glassNamespace + + var body: some View { + TimelineView(.animation(minimumInterval: nil, paused: !model.isTimelineActive)) { _ in + let timestamp = model.currentTime + let samples = model.samples(at: timestamp) + + GeometryReader { proxy in + ZStack { + if model.presentationMode == .solidBlack { + Canvas { graphics, size in + drawSolidBlack( + in: &graphics, + size: size, + samples: samples + ) + } + } else if model.presentationMode == .physicalRefraction { + if !model.physicalCaptureIsReady { + nativeSurfaceLayers( + size: proxy.size, + samples: samples + ) + } + PhysicalRefractionSurfaceView( + snapshots: samples.map { + $0.snapshot( + materialOpacity: model.materialOpacity, + edgeOpacity: model.prismaticEdgeOpacity, + lensOpacity: model.clearLensOpacity, + dimmingOpacity: model.localizedDimmingOpacity, + chromaticDisplacement: model.chromaticDisplacement + ) + }, + bodyOpacity: model.materialOpacity, + edgeStrength: model.prismaticEdgeOpacity, + refractionStrength: model.refractionStrength, + stopGeneration: model.physicalCaptureStopGeneration, + onCaptureReadinessChanged: { ready in + model.setPhysicalCaptureReady(ready) + }, + onCaptureStateChanged: { state in + model.setPhysicalCaptureState(state) + } + ) + } else { + nativeSurfaceLayers( + size: proxy.size, + samples: samples + ) + } + } + } + } + .clipped() + .ignoresSafeArea() + .allowsHitTesting(false) + .accessibilityHidden(true) + } + + @ViewBuilder + private func nativeSurfaceLayers( + size: CGSize, + samples: [LiquidGlassSurfaceSample] + ) -> some View { + if model.chordSurfaceStyle == .naturalMerge { + // Every held key has a stable system-glass identity, but all of + // them live inside one container so adjacent lenses can merge. + GlassEffectContainer(spacing: model.spacing) { + nativeSurfaceNodes(size: size, samples: samples) + } + } else { + // Separate containers are an explicit material boundary: stable + // key identities remain, but native glass cannot form bridges. + ZStack { + ForEach(samples, id: \.state.id) { sample in + GlassEffectContainer(spacing: 0) { + nativeSurfaceNode(size: size, sample: sample) + } + } + } + .frame(width: size.width, height: size.height) + } + } + + private func nativeSurfaceNodes( + size: CGSize, + samples: [LiquidGlassSurfaceSample] + ) -> some View { + ZStack { + ForEach(samples, id: \.state.id) { sample in + nativeSurfaceNode(size: size, sample: sample) + } + } + .frame(width: size.width, height: size.height) + } + + private func nativeSurfaceNode( + size: CGSize, + sample: LiquidGlassSurfaceSample + ) -> some View { + let surface = sample.state + let visibility = unitValue(surface.visibility) + let glassShape = LiquidGlassBellShape( + emergence: surface.emergence, + smoothness: surface.smoothness, + flow: normalizedFlow(for: sample), + profile: model.shapeProfile, + extendsBelowBaseline: + model.presentationMode.extendsGlassBelowVisibleBaseline + ) + + return Color.clear + .glassEffect(.clear, in: glassShape) + .glassEffectID(surface.id, in: glassNamespace) + .glassEffectTransition(.matchedGeometry) + .opacity(visibility * model.clearLensOpacity) + .frame( + width: max(surface.frame.width, 1), + height: max(surface.frame.height, 1) + ) + .position( + x: surface.frame.midX, + y: size.height - surface.frame.midY + ) + } + + private func drawSolidBlack( + in graphics: inout GraphicsContext, + size: CGSize, + samples: [LiquidGlassSurfaceSample] + ) { + let groups = model.chordSurfaceStyle == .naturalMerge + ? connectedGroups(in: samples, visibilityPolicy: .geometryOnly) + : independentGroups(in: samples, visibilityPolicy: .geometryOnly) + for group in groups { + graphics.fill( + combinedPath(for: group, canvasHeight: size.height), + with: .color( + .black.opacity(model.solidBlackFillOpacity) + ) + ) + } + } + + private func independentGroups( + in samples: [LiquidGlassSurfaceSample], + visibilityPolicy: SurfaceVisibilityPolicy + ) -> [[LiquidGlassSurfaceSample]] { + samples.compactMap { sample in + guard sample.state.isVisible else { return nil } + switch visibilityPolicy { + case .materialOpacity: + guard unitValue(sample.state.visibility) > 0.000_1 else { + return nil + } + case .geometryOnly: + guard LiquidGlassSolidBlackMaterial.isPresent( + isVisible: sample.state.isVisible, + emergence: sample.state.emergence + ) else { + return nil + } + } + return [sample] + } + } + + private func connectedGroups( + in samples: [LiquidGlassSurfaceSample], + visibilityPolicy: SurfaceVisibilityPolicy = .materialOpacity + ) -> [[LiquidGlassSurfaceSample]] { + let visible = samples.filter { + guard $0.state.isVisible else { return false } + switch visibilityPolicy { + case .materialOpacity: + return unitValue($0.state.visibility) > 0.000_1 + case .geometryOnly: + return LiquidGlassSolidBlackMaterial.isPresent( + isVisible: $0.state.isVisible, + emergence: $0.state.emergence + ) + } + } + let groups = LiquidGlassTransitionMath.connectedFrameGroups( + visible.map(\.state.frame), + spacing: model.spacing + ) + return groups.map { indices in indices.map { visible[$0] } } + } + + private enum SurfaceVisibilityPolicy { + case materialOpacity + case geometryOnly + } + + private func combinedPath( + for samples: [LiquidGlassSurfaceSample], + canvasHeight: CGFloat + ) -> Path { + let orderedSamples = samples.sorted { + $0.state.frame.midX < $1.state.frame.midX + } + let members = orderedSamples.map { sample in + ( + sample: sample, + path: surfacePath(for: sample, canvasHeight: canvasHeight) + ) + } + var result = Path() + var hasPath = false + + for member in members { + if hasPath { + result = result.union(member.path) + } else { + result = member.path + hasPath = true + } + } + + // The native glass nodes merge inside GlassEffectContainer, while this + // shallow saddle makes the custom backing and refractive perimeter read + // as the same cohesive material. Because the bridge follows the live + // presentation frames, it expands during a neighboring press and + // contracts toward the surviving key during release. + if members.count > 1 { + for index in 0..<(members.count - 1) { + let left = members[index] + let right = members[index + 1] + guard LiquidGlassTransitionMath.shouldMerge( + left.sample.state.frame, + with: right.sample.state.frame, + spacing: model.spacing + ) else { + continue + } + let bridge = cohesiveBridgePath( + from: left, + to: right, + canvasHeight: canvasHeight + ) + if !bridge.isEmpty { + result = result.union(bridge) + } + } + } + + return result + } + + private func surfacePath( + for sample: LiquidGlassSurfaceSample, + canvasHeight: CGFloat + ) -> Path { + let surface = sample.state + let localRect = CGRect( + origin: .zero, + size: CGSize( + width: max(surface.frame.width, 1), + height: max(surface.frame.height, 1) + ) + ) + let localPath = LiquidGlassBellShape( + emergence: surface.emergence, + smoothness: surface.smoothness, + flow: normalizedFlow(for: sample), + profile: model.shapeProfile, + minimumRise: model.presentationMode == .solidBlack ? 0 : 0.5 + ).path(in: localRect) + return localPath.applying(CGAffineTransform( + translationX: surface.frame.minX, + y: canvasHeight - surface.frame.maxY + )) + } + + private func cohesiveBridgePath( + from left: (sample: LiquidGlassSurfaceSample, path: Path), + to right: (sample: LiquidGlassSurfaceSample, path: Path), + canvasHeight: CGFloat + ) -> Path { + let startX = left.sample.state.frame.midX + let endX = right.sample.state.frame.midX + let distance = endX - startX + guard startX.isFinite, + endX.isFinite, + canvasHeight.isFinite, + distance > 0.5 else { + return Path() + } + + let leftBounds = left.path.boundingRect + let rightBounds = right.path.boundingRect + let materialOverlap = min(leftBounds.maxX, rightBounds.maxX) + - max(leftBounds.minX, rightBounds.minX) + guard materialOverlap > 0.5 else { return Path() } + + let startY = leftBounds.minY + let endY = rightBounds.minY + guard startY.isFinite, endY.isFinite else { return Path() } + + let averageHeight = ( + leftBounds.height + rightBounds.height + ) * 0.5 + let averageSmoothness = min(max( + ( + left.sample.state.smoothness + + right.sample.state.smoothness + ) * 0.5, + 0 + ), 1) + return LiquidGlassCohesiveBridge.path( + start: CGPoint(x: startX, y: startY), + end: CGPoint(x: endX, y: endY), + baselineY: canvasHeight, + averageHeight: averageHeight, + smoothness: averageSmoothness + ) + } + + private func normalizedFlow(for sample: LiquidGlassSurfaceSample) -> CGFloat { + let centerVelocity = sample.velocity.frame.origin.x + + sample.velocity.frame.width * 0.5 + let flowScale = max(sample.state.frame.width * 4.5, 240) + return min(max(centerVelocity / flowScale, -1), 1) + } + + private func unitValue(_ value: Double) -> Double { + guard value.isFinite else { return 0 } + return min(max(value, 0), 1) + } +} + +@available(macOS 26.0, *) +@MainActor +final class LiquidGlassGlowView: NSView, GlowRenderer { + private let surfaceModel: LiquidGlassSurfaceModel + private let presentationMode: LiquidGlassPresentationMode + private var completionTask: Task? + private var hostingView: NSView? + + private var activeTargets: [GlowID: GlowTarget] = [:] + private var activeTargetOrder: [GlowID] = [] + private var configuration = RendererConfiguration.standard + + var view: NSView { self } + var supportsConcurrentPhysicalTargets: Bool { true } + + private var baseKeyWidth: CGFloat { configuration.baseKeyWidth } + private var glowHeight: CGFloat { configuration.glowHeight } + private var widthMultiplier: CGFloat { configuration.widthMultiplier } + private var maxOpacity: Float { configuration.maximumOpacity } + private var smoothness: CGFloat { configuration.roundness } + private var reduceMotionEnabled: Bool { configuration.reduceMotion } + private var reduceTransparencyEnabled: Bool { configuration.reduceTransparency } + private var increaseContrastEnabled: Bool { configuration.increaseContrast } + private var motionProfile: LiquidGlassMotionProfile { + LiquidGlassMotionProfile(fadeDuration: configuration.fadeDuration) + } + + init( + frame frameRect: NSRect, + presentationMode: LiquidGlassPresentationMode + ) { + self.presentationMode = presentationMode + surfaceModel = LiquidGlassSurfaceModel() + super.init(frame: frameRect) + surfaceModel.setPresentation( + mode: presentationMode, + shapeProfile: configuration.shapeProfile + ) + surfaceModel.setChordAppearance( + configuration.chordAppearance, + activeMemberCount: 0 + ) + configureView() + refreshMaterial() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + completionTask?.cancel() + } + + private func configureView() { + wantsLayer = true + layer?.backgroundColor = NSColor.clear.cgColor + + let host = NSHostingView(rootView: LiquidGlassSurfaceRoot(model: surfaceModel)) + host.frame = bounds + host.autoresizingMask = [.width, .height] + host.wantsLayer = true + host.layer?.backgroundColor = NSColor.clear.cgColor + host.setAccessibilityElement(false) + host.setAccessibilityChildren([]) + addSubview(host) + hostingView = host + } + + func apply(_ configuration: RendererConfiguration) { + guard self.configuration != configuration else { return } + let previous = self.configuration + self.configuration = configuration + surfaceModel.setPresentation( + mode: presentationMode, + shapeProfile: configuration.shapeProfile + ) + surfaceModel.setChordAppearance( + configuration.chordAppearance, + activeMemberCount: activeChordMemberCount + ) + refreshMaterial() + + if previous.fadeDuration != configuration.fadeDuration, + let previewID = activeTargetOrder.first(where: { + if case .preview = $0 { return true } + return false + }) { + retimeVisiblePreview(previewID) + return + } + + guard changesGlassGeometry(from: previous, to: configuration) else { return } + refreshVisibleGeometry() + } + + func setRuntimeStatusHandler( + _ handler: (@MainActor (GlowRendererRuntimeState) -> Void)? + ) { + if presentationMode == .physicalRefraction { + surfaceModel.setRuntimeStatusHandler(handler) + } else { + handler?(.ready) + } + } + + func show(_ target: GlowTarget) { + let now = currentTime + finalizeCompletedTransitions(at: now) + let position = sanitizedPosition(CGFloat(target.horizontalPosition)) + let keyWidth = sanitizedKeyWidth(CGFloat(target.keyWidth)) + let targetFrame = frameForGlass(at: position, keyWidth: keyWidth) + let hadActiveTargets = !activeTargetOrder.isEmpty + let existingTrackIndex = surfaceModel.tracks.firstIndex { + $0.id == target.id + } + activeTargets[target.id] = target + if !activeTargetOrder.contains(target.id) { + activeTargetOrder.append(target.id) + } + syncChordAppearance() + + if let existingTrackIndex { + let sample = surfaceModel.tracks[existingTrackIndex].sample(at: now) + retargetSurface(sample, to: targetFrame, at: now) + return + } + + // Preserve the fluid one-key travel from the prior renderer. Once the + // previous key has been released and no chord remains, its presentation + // state can be handed to the new identity instead of blinking out and + // growing a second lens from zero. + if !hadActiveTargets, + let transferIndex = nearestFadingTrackIndex( + to: targetFrame, + at: now + ) { + var tracks = surfaceModel.tracks + let fading = tracks.remove(at: transferIndex).sample(at: now) + surfaceModel.setTracks(tracks) + retargetSurface( + reidentified(fading, as: target.id), + to: targetFrame, + at: now + ) + return + } + + revealSurface(target.id, at: targetFrame, time: now) + } + + private func retargetSurface( + _ sample: LiquidGlassSurfaceSample, + to targetFrame: CGRect, + at time: TimeInterval, + durationOverride: TimeInterval? = nil + ) { + var start = sample + let nearby = LiquidGlassTransitionMath.shouldMerge( + start.state.frame, + with: targetFrame, + spacing: surfaceModel.spacing + ) + let travelDistance = abs(start.state.frame.midX - targetFrame.midX) + let normalizedDistance = travelDistance / max(bounds.width * 0.65, 1) + let naturalDuration = nearby + ? motionProfile.nearbyMorphDuration + : motionProfile.travelDuration(normalizedDistance: normalizedDistance) + let duration = durationOverride ?? naturalDuration + + if reduceMotionEnabled { + start.state.frame = targetFrame + start.state.emergence = 1 + start.state.smoothness = smoothness + start.velocity = .zero + } else if !nearby { + let expansionVelocity = LiquidGlassTransitionMath.flowExpansionVelocity( + distance: travelDistance, + duration: naturalDuration, + containerWidth: bounds.width + ) + start.velocity.frame.size.width += expansionVelocity + // Expand around the presentation center instead of kicking it in + // the travel direction. + start.velocity.frame.origin.x -= expansionVelocity * 0.5 + } + + var destination = start.state + destination.frame = targetFrame + destination.visibility = 1 + destination.emergence = 1 + destination.smoothness = smoothness + destination.isVisible = true + + var tracks = surfaceModel.tracks + let track = transitionTrack( + from: start, + to: destination, + startTime: time, + duration: reduceMotionEnabled ? motionProfile.configurationDuration : duration + ) + replaceOrAppend(track, in: &tracks) + surfaceModel.setTracks(tracks) + scheduleCompletionSweep() + } + + private func revealSurface( + _ id: GlowID, + at targetFrame: CGRect, + time: TimeInterval + ) { + let expandedAtStart = reduceMotionEnabled + let start = LiquidGlassSurfaceState( + id: id, + frame: targetFrame, + visibility: 0, + emergence: expandedAtStart ? 1 : 0, + smoothness: smoothness, + isVisible: true + ) + var destination = start + destination.visibility = 1 + destination.emergence = 1 + let sample = LiquidGlassSurfaceSample(state: start, velocity: .zero) + let track = transitionTrack( + from: sample, + to: destination, + startTime: time, + duration: motionProfile.revealDuration + ) + + var tracks = surfaceModel.tracks + replaceOrAppend(track, in: &tracks) + surfaceModel.setTracks(tracks) + scheduleCompletionSweep() + } + + @discardableResult + func refresh(_ id: GlowID) -> Bool { + guard activeTargets[id] != nil else { return false } + let now = currentTime + finalizeCompletedTransitions(at: now) + guard let trackIndex = surfaceModel.tracks.firstIndex(where: { + $0.id == id + }) else { + return false + } + var start = surfaceModel.tracks[trackIndex].sample(at: now) + guard start.state.isVisible else { return false } + + if reduceMotionEnabled { + start.state.emergence = 1 + start.state.smoothness = smoothness + start.velocity = .zero + } + var destination = start.state + destination.visibility = 1 + destination.emergence = 1 + destination.smoothness = smoothness + destination.isVisible = true + + var tracks = surfaceModel.tracks + tracks[trackIndex] = transitionTrack( + from: start, + to: destination, + startTime: now, + duration: motionProfile.configurationDuration + ) + surfaceModel.setTracks(tracks) + scheduleCompletionSweep() + return true + } + + func hide(_ id: GlowID) { + activeTargets.removeValue(forKey: id) + activeTargetOrder.removeAll { $0 == id } + syncChordAppearance() + + let now = currentTime + finalizeCompletedTransitions(at: now) + guard let trackIndex = surfaceModel.tracks.firstIndex(where: { + $0.id == id + }) else { return } + var start = surfaceModel.tracks[trackIndex].sample(at: now) + guard start.state.isVisible else { return } + if reduceMotionEnabled && presentationMode == .solidBlack { + var tracks = surfaceModel.tracks + tracks.remove(at: trackIndex) + surfaceModel.setTracks(tracks) + return + } + if reduceMotionEnabled { + start.velocity.frame = .zero + start.velocity.emergence = 0 + } + + var destination = start.state + destination.visibility = 0 + destination.emergence = reduceMotionEnabled + ? start.state.emergence + : 0 + destination.smoothness = smoothness + destination.isVisible = false + + var tracks = surfaceModel.tracks + tracks[trackIndex] = transitionTrack( + from: start, + to: destination, + startTime: now, + duration: motionProfile.fadeOutDuration + ) + surfaceModel.setTracks(tracks) + scheduleCompletionSweep() + } + + func clear() { + activeTargets.removeAll(keepingCapacity: true) + activeTargetOrder.removeAll(keepingCapacity: true) + syncChordAppearance() + hideAllSurfacesImmediately() + if presentationMode == .physicalRefraction { + surfaceModel.stopPhysicalCaptureImmediately() + } + } + + private func refreshVisibleGeometry() { + guard !activeTargetOrder.isEmpty else { return } + let now = currentTime + finalizeCompletedTransitions(at: now) + var tracks = surfaceModel.tracks + + for id in activeTargetOrder { + guard let target = activeTargets[id], + let trackIndex = tracks.firstIndex(where: { $0.id == id }) else { + continue + } + var start = tracks[trackIndex].sample(at: now) + let targetFrame = frameForGlass( + at: sanitizedPosition(CGFloat(target.horizontalPosition)), + keyWidth: sanitizedKeyWidth(CGFloat(target.keyWidth)) + ) + if reduceMotionEnabled { + start.state.frame = targetFrame + start.state.emergence = 1 + start.state.smoothness = smoothness + start.velocity = .zero + } + + var destination = start.state + destination.frame = targetFrame + destination.visibility = 1 + destination.emergence = 1 + destination.smoothness = smoothness + destination.isVisible = true + tracks[trackIndex] = transitionTrack( + from: start, + to: destination, + startTime: now, + duration: motionProfile.configurationDuration + ) + } + + surfaceModel.setTracks(tracks) + scheduleCompletionSweep() + } + + private func retimeVisiblePreview(_ id: GlowID) { + guard let target = activeTargets[id], + case .preview = id else { + return + } + + let now = currentTime + finalizeCompletedTransitions(at: now) + let targetFrame = frameForGlass( + at: sanitizedPosition(CGFloat(target.horizontalPosition)), + keyWidth: sanitizedKeyWidth(CGFloat(target.keyWidth)) + ) + guard let trackIndex = surfaceModel.tracks.firstIndex(where: { + $0.id == id + }) else { + revealSurface(id, at: targetFrame, time: now) + return + } + var start = surfaceModel.tracks[trackIndex].sample(at: now) + guard start.state.isVisible else { + revealSurface(id, at: targetFrame, time: now) + return + } + + if reduceMotionEnabled { + start.state.frame = targetFrame + start.state.emergence = 1 + start.state.smoothness = smoothness + start.velocity = .zero + } else { + // Changing the tempo should be visible without restarting opacity or + // snapping the shape back into the bezel. Give the current surface a + // center-preserving width impulse so it takes one gentle breath at + // the newly selected tempo, then settles onto the same key. + let duration = max(motionProfile.revealDuration, 0.000_001) + let presentationCenterVelocity = start.velocity.frame.origin.x + + start.velocity.frame.width * 0.5 + let widthImpulse = max(start.state.frame.width, targetFrame.width) + * 0.82 / duration + let combinedWidthVelocity = start.velocity.frame.width + widthImpulse + start.velocity.frame.size.width = combinedWidthVelocity + start.velocity.frame.origin.x = presentationCenterVelocity + - combinedWidthVelocity * 0.5 + } + + var destination = start.state + destination.frame = targetFrame + destination.visibility = 1 + destination.emergence = 1 + destination.smoothness = smoothness + destination.isVisible = true + + var tracks = surfaceModel.tracks + tracks[trackIndex] = transitionTrack( + from: start, + to: destination, + startTime: now, + duration: motionProfile.revealDuration + ) + surfaceModel.setTracks(tracks) + scheduleCompletionSweep() + } + + private func nearestFadingTrackIndex( + to targetFrame: CGRect, + at time: TimeInterval + ) -> Int? { + surfaceModel.tracks.indices + .filter { activeTargets[surfaceModel.tracks[$0].id] == nil } + .min { left, right in + let leftDistance = abs( + surfaceModel.tracks[left].sample(at: time).state.frame.midX + - targetFrame.midX + ) + let rightDistance = abs( + surfaceModel.tracks[right].sample(at: time).state.frame.midX + - targetFrame.midX + ) + return leftDistance < rightDistance + } + } + + private func reidentified( + _ sample: LiquidGlassSurfaceSample, + as id: GlowID + ) -> LiquidGlassSurfaceSample { + var state = sample.state + state = LiquidGlassSurfaceState( + id: id, + frame: state.frame, + visibility: state.visibility, + emergence: state.emergence, + smoothness: state.smoothness, + isVisible: state.isVisible + ) + return LiquidGlassSurfaceSample(state: state, velocity: sample.velocity) + } + + private func replaceOrAppend( + _ track: LiquidGlassSurfaceTrack, + in tracks: inout [LiquidGlassSurfaceTrack] + ) { + if let index = tracks.firstIndex(where: { $0.id == track.id }) { + tracks[index] = track + } else { + tracks.append(track) + } + } + + private func transitionTrack( + from start: LiquidGlassSurfaceSample, + to destination: LiquidGlassSurfaceState, + startTime: TimeInterval, + duration: TimeInterval + ) -> LiquidGlassSurfaceTrack { + let safeDuration = duration.isFinite ? max(duration, 0) : 0 + guard safeDuration > 0 else { + return LiquidGlassSurfaceTrack(state: destination) + } + let initialVelocity = boundedVelocity( + start.velocity, + destination: destination, + duration: safeDuration + ) + return LiquidGlassSurfaceTrack( + state: start.state, + transition: LiquidGlassSurfaceTransition( + start: start.state, + destination: destination, + initialVelocity: initialVelocity, + startTime: startTime, + duration: safeDuration + ) + ) + } + + private func setTracksImmediately(_ tracks: [LiquidGlassSurfaceTrack]) { + completionTask?.cancel() + completionTask = nil + surfaceModel.setTracks(tracks) + } + + private func hideAllSurfacesImmediately() { + setTracksImmediately([]) + } + + private func finalizeCompletedTransitions(at time: TimeInterval) { + var tracks: [LiquidGlassSurfaceTrack] = [] + var changed = false + for track in surfaceModel.tracks { + guard let transition = track.transition, + transition.isComplete(at: time) else { + tracks.append(track) + continue + } + changed = true + if transition.destination.isVisible { + tracks.append( + LiquidGlassSurfaceTrack(state: transition.destination) + ) + } + } + if changed { + surfaceModel.setTracks(tracks) + } + } + + private func scheduleCompletionSweep() { + completionTask?.cancel() + completionTask = nil + let now = currentTime + let nextEnd = surfaceModel.tracks.compactMap(\.transition?.endTime).min() + guard let nextEnd else { return } + let delay = max(nextEnd - now, 0) + completionTask = Task { @MainActor [weak self] in + do { + try await Task.sleep(for: .seconds(delay)) + } catch { + return + } + guard let self else { return } + self.finalizeCompletedTransitions(at: self.currentTime) + self.scheduleCompletionSweep() + } + } + + private var currentTime: TimeInterval { + surfaceModel.currentTime + } + + private var clampedUserOpacity: Float { + let opacity = configuration.chordAppearance.opacity( + maxOpacity, + activeMemberCount: activeChordMemberCount + ) + guard opacity.isFinite else { return 0.7 } + return min(max(opacity, 0), 1) + } + + private var activeChordMemberCount: Int { + activeTargets.keys.reduce(into: 0) { count, id in + switch id { + case .physicalKey: + count += 1 + case .preview(let source) where source.isChordTest: + count += 1 + case .preview: + break + } + } + } + + private func syncChordAppearance() { + surfaceModel.setChordAppearance( + configuration.chordAppearance, + activeMemberCount: activeChordMemberCount + ) + refreshMaterial() + } + + private var effectiveDisplayOpacity: Float { + LiquidGlassMaterialMath.displayOpacity( + userOpacity: clampedUserOpacity, + reduceTransparency: reduceTransparencyEnabled, + increaseContrast: increaseContrastEnabled + ) + } + + private var effectivePrismaticEdgeOpacity: Float { + LiquidGlassMaterialMath.prismaticEdgeOpacity( + userOpacity: clampedUserOpacity, + reduceTransparency: reduceTransparencyEnabled, + increaseContrast: increaseContrastEnabled + ) + } + + private var effectiveClearLensOpacity: Float { + LiquidGlassMaterialMath.clearLensOpacity( + userOpacity: clampedUserOpacity, + reduceTransparency: reduceTransparencyEnabled, + increaseContrast: increaseContrastEnabled + ) + } + + private var effectiveLocalizedDimmingOpacity: Float { + LiquidGlassMaterialMath.localizedDimmingOpacity( + userOpacity: clampedUserOpacity, + reduceTransparency: reduceTransparencyEnabled, + increaseContrast: increaseContrastEnabled + ) + } + + private func refreshMaterial() { + let edgeOpacity = effectivePrismaticEdgeOpacity + surfaceModel.setMaterial( + bodyOpacity: effectiveDisplayOpacity, + edgeOpacity: edgeOpacity, + refractionStrength: configuration.refractionStrength, + lensOpacity: effectiveClearLensOpacity, + dimmingOpacity: effectiveLocalizedDimmingOpacity, + chromaticDisplacement: LiquidGlassMaterialMath.chromaticDisplacement( + edgeOpacity: edgeOpacity + ) + ) + } + + private func sanitizedPosition(_ position: CGFloat) -> CGFloat { + guard position.isFinite else { return 0.5 } + return min(max(position, -0.05), 1.05) + } + + private func sanitizedKeyWidth(_ keyWidth: CGFloat) -> CGFloat { + guard keyWidth.isFinite else { return 1 } + return min(max(keyWidth, 0.05), 5) + } + + private func frameForGlass(at position: CGFloat, keyWidth: CGFloat) -> CGRect { + LiquidGlassTransitionMath.bezelFrame( + in: bounds, + position: position, + baseKeyWidth: baseKeyWidth, + keyWidth: keyWidth, + widthMultiplier: widthMultiplier, + glowHeight: glowHeight, + smoothness: smoothness + ) + } + + private func changesGlassGeometry( + from old: RendererConfiguration, + to new: RendererConfiguration + ) -> Bool { + old.baseKeyWidth != new.baseKeyWidth + || old.glowHeight != new.glowHeight + || old.widthMultiplier != new.widthMultiplier + || old.roundness != new.roundness + || old.shapeProfile != new.shapeProfile + || old.reduceMotion != new.reduceMotion + } + + private func boundedVelocity( + _ velocity: LiquidGlassSurfaceVelocity, + destination: LiquidGlassSurfaceState, + duration: TimeInterval + ) -> LiquidGlassSurfaceVelocity { + guard duration.isFinite, duration > 0 else { return .zero } + + let horizontalLimit = max(bounds.width * 6, 600) + let widthLimit = max(bounds.width * 8, 800) + let verticalLimit = max(bounds.height * 8, 240) + let widthVelocity = clamped( + velocity.frame.width, + magnitude: widthLimit + ) + let centerVelocity = clamped( + velocity.frame.origin.x + velocity.frame.width * 0.5, + magnitude: horizontalLimit + ) + let destinationIsVisible = destination.isVisible + + return LiquidGlassSurfaceVelocity( + frame: CGRect( + x: centerVelocity - widthVelocity * 0.5, + y: clamped(velocity.frame.origin.y, magnitude: verticalLimit), + width: widthVelocity, + height: clamped(velocity.frame.height, magnitude: verticalLimit) + ), + visibility: destinationIsVisible + ? clamped(velocity.visibility, magnitude: 8) + : min(clamped(velocity.visibility, magnitude: 8), 0), + emergence: clamped(velocity.emergence, magnitude: 8), + smoothness: clamped(velocity.smoothness, magnitude: 8) + ) + } + + private func clamped(_ value: CGFloat, magnitude: CGFloat) -> CGFloat { + guard value.isFinite else { return 0 } + return min(max(value, -magnitude), magnitude) + } + + private func clamped(_ value: Double, magnitude: Double) -> Double { + guard value.isFinite else { return 0 } + return min(max(value, -magnitude), magnitude) + } + + // Stable test seams: SwiftUI intentionally hides the private system glass + // implementation, so tests inspect the bounded source state and timeline. + var testSurfaceSnapshots: [LiquidGlassSurfaceSnapshot] { + surfaceModel.samples(at: currentTime).map { + $0.snapshot( + materialOpacity: surfaceModel.materialOpacity, + edgeOpacity: surfaceModel.prismaticEdgeOpacity, + lensOpacity: surfaceModel.clearLensOpacity, + dimmingOpacity: surfaceModel.localizedDimmingOpacity, + chromaticDisplacement: surfaceModel.chromaticDisplacement + ) + } + } + + var testActiveTransitionDurations: [TimeInterval] { + surfaceModel.tracks.compactMap(\.transition?.duration) + } + + var testTimelineIsActive: Bool { surfaceModel.isTimelineActive } + var testUsesNativeBellShape: Bool { true } + var testHostingViewCount: Int { hostingView == nil ? 0 : 1 } + var testMotionProfile: LiquidGlassMotionProfile { motionProfile } + var testUsesSystemSelectedTimelineCadence: Bool { true } + var testActiveTargetIDs: [GlowID] { activeTargetOrder } + var testPresentationMode: LiquidGlassPresentationMode { presentationMode } + var testExtendsGlassBelowVisibleBaseline: Bool { + presentationMode.extendsGlassBelowVisibleBaseline + } + var testShapeProfile: SurfaceShapeProfile { configuration.shapeProfile } + var testSolidBlackFillOpacity: Double? { + guard presentationMode == .solidBlack, + surfaceModel.samples(at: currentTime).contains(where: { + LiquidGlassSolidBlackMaterial.isPresent( + isVisible: $0.state.isVisible, + emergence: $0.state.emergence + ) + }) else { + return nil + } + return surfaceModel.solidBlackFillOpacity + } + var testPhysicalCaptureIsReady: Bool { + surfaceModel.physicalCaptureIsReady + } + var testRefractionStrength: Double { + surfaceModel.refractionStrength + } + var testChordSurfaceStyle: ChordSurfaceStyle { + surfaceModel.chordSurfaceStyle + } + var testActiveChordMemberCount: Int { + surfaceModel.activeChordMemberCount + } + var testMaterialOpacity: Double { + surfaceModel.materialOpacity + } + var testVisibleSurfaceGroupCount: Int { + let frames: [CGRect] = surfaceModel.samples(at: currentTime).compactMap { sample in + guard sample.state.isVisible, sample.state.visibility > 0.000_1 else { + return nil + } + return sample.state.frame + } + if surfaceModel.chordSurfaceStyle == .independent { + return frames.count + } + return LiquidGlassTransitionMath.connectedFrameGroups( + frames, + spacing: surfaceModel.spacing + ).count + } +} +#endif diff --git a/KeyLight/Views/LiquidGlassTransitionMath.swift b/KeyLight/Views/LiquidGlassTransitionMath.swift new file mode 100644 index 0000000..ae06760 --- /dev/null +++ b/KeyLight/Views/LiquidGlassTransitionMath.swift @@ -0,0 +1,378 @@ +import CoreGraphics +import Foundation +import QuartzCore + +protocol SurfaceMotionClock: Sendable { + func now() -> TimeInterval +} + +struct SystemSurfaceMotionClock: SurfaceMotionClock { + func now() -> TimeInterval { + CACurrentMediaTime() + } +} + +struct ClosureSurfaceMotionClock: SurfaceMotionClock { + private let reader: @Sendable () -> TimeInterval + + init(_ reader: @escaping @Sendable () -> TimeInterval) { + self.reader = reader + } + + func now() -> TimeInterval { + reader() + } +} + +/// A single tempo control for every phase of the Liquid Glass interaction. +/// +/// `fadeDuration` remains the literal final fade-out duration for compatibility, +/// while the shorter reveal, retarget, and travel phases scale with the same +/// value. The sublinear exponent keeps the upper end expressive without making +/// ordinary typing feel heavy. +struct LiquidGlassMotionProfile: Equatable { + let fadeOutDuration: CFTimeInterval + let revealDuration: CFTimeInterval + let nearbyMorphDuration: CFTimeInterval + let configurationDuration: CFTimeInterval + let minimumTravelDuration: CFTimeInterval + let maximumTravelDuration: CFTimeInterval + + init(fadeDuration: CFTimeInterval) { + let safeFade = min(max(fadeDuration.isFinite ? fadeDuration : 1, 0.05), 5) + let tempo = pow(safeFade, 0.55) + + fadeOutDuration = safeFade + revealDuration = Self.clamped(0.14 * tempo, to: 0.055...0.42) + nearbyMorphDuration = Self.clamped(0.10 * tempo, to: 0.045...0.30) + configurationDuration = Self.clamped(0.07 * tempo, to: 0.035...0.18) + minimumTravelDuration = Self.clamped(0.13 * tempo, to: 0.06...0.38) + maximumTravelDuration = Self.clamped(0.24 * tempo, to: 0.075...0.64) + } + + func travelDuration(normalizedDistance: CGFloat) -> CFTimeInterval { + let distance: Double + if normalizedDistance.isFinite { + distance = min(max(Double(normalizedDistance), 0), 1) + } else { + distance = 0.5 + } + let easedDistance = distance * distance * (3 - 2 * distance) + return minimumTravelDuration + + (maximumTravelDuration - minimumTravelDuration) * easedDistance + } + + private static func clamped( + _ value: CFTimeInterval, + to range: ClosedRange + ) -> CFTimeInterval { + min(max(value, range.lowerBound), range.upperBound) + } +} + +enum LiquidGlassTransitionMath { + /// Builds a shallow, bottom-connected glass bump. Smoothness deliberately + /// controls both the shoulder curve and the physical footprint: the low end + /// becomes a compact key-sized lens, while the high end retains the broad + /// flowing wave used for softer transitions. + static func bezelFrame( + in bounds: CGRect, + position: CGFloat, + baseKeyWidth: CGFloat, + keyWidth: CGFloat, + widthMultiplier: CGFloat, + glowHeight: CGFloat, + smoothness: CGFloat = 0.7069 + ) -> CGRect { + let containerWidth = finite(bounds.width, default: 0, minimum: 0) + let containerHeight = finite(bounds.height, default: 0, minimum: 0) + let safePosition = min(max(finite(position, default: 0.5), -0.05), 1.05) + let safeBaseWidth = finite(baseKeyWidth, default: 60, minimum: 1) + let safeKeyWidth = min(max(finite(keyWidth, default: 1), 0.05), 5) + let safeWidthMultiplier = min(max(finite(widthMultiplier, default: 1), 0.05), 5) + let safeGlowHeight = min(max(finite(glowHeight, default: 60), 4), 200) + let profileWidthScale = smoothnessWidthScale(smoothness) + + let maximumWidth = max(containerWidth * 1.25, 64) + let requestedWidth = safeBaseWidth + * safeKeyWidth + * 4.2 + * safeWidthMultiplier + * profileWidthScale + let height = bezelHeight( + glowHeight: safeGlowHeight, + containerHeight: containerHeight + ) + let minimumAspectRatio = 2.4 + 3.1 * profileWidthScale + let minimumProfileWidth = max(height * minimumAspectRatio, 36) + let width = min( + max(requestedWidth.isFinite ? requestedWidth : maximumWidth, minimumProfileWidth), + maximumWidth + ) + let centerX = containerWidth * safePosition + + return CGRect( + x: centerX - width * 0.5, + y: -height * 0.28, + width: width, + height: height + ) + } + + /// Uses most of the slider travel for compact-to-medium profiles and keeps + /// the broadest wave at the far right. This makes the low end materially + /// tighter instead of merely changing the curvature inside the same frame. + static func smoothnessWidthScale(_ smoothness: CGFloat) -> CGFloat { + let safeSmoothness = min(max(finite(smoothness, default: 0.7069), 0), 1) + let curved = CGFloat(pow(Double(safeSmoothness), 2.15)) + return 0.36 + 0.64 * curved + } + + /// Maps the established saved height value onto the smaller Liquid Glass + /// visual scale. Existing themes therefore keep their data while the + /// default 80-point setting becomes a subtle ~23-point bump. The four-point + /// input endpoint remains a genuinely tiny four-point effect. + static func bezelHeight(glowHeight: CGFloat, containerHeight: CGFloat) -> CGFloat { + let safeGlowHeight = min(max(finite(glowHeight, default: 60), 4), 200) + let safeContainerHeight = finite(containerHeight, default: 120, minimum: 0) + let requestedHeight = 3 + safeGlowHeight * 0.25 + let maximumHeight = max(safeContainerHeight * 0.48, 4) + return min(max(requestedHeight, 4), maximumHeight) + } + + static func shouldMerge(_ sourceFrame: CGRect, with destinationFrame: CGRect, spacing: CGFloat) -> Bool { + let values = [ + sourceFrame.minX, + sourceFrame.minY, + sourceFrame.width, + sourceFrame.height, + destinationFrame.minX, + destinationFrame.minY, + destinationFrame.width, + destinationFrame.height, + spacing + ] + guard values.allSatisfy(\.isFinite) else { return false } + + let centerDistance = abs(sourceFrame.midX - destinationFrame.midX) + let horizontalGap = centerDistance - (sourceFrame.width + destinationFrame.width) * 0.5 + return horizontalGap <= max(spacing, 0) + } + + /// Returns transitive left-to-right groups for surfaces whose glass + /// boundaries touch or fall within the container spacing. The renderer + /// uses the same groups for its neutral backing and chromatic perimeter, so + /// adjacent held keys have one outside border and no doubled inner seam. + static func connectedFrameGroups( + _ frames: [CGRect], + spacing: CGFloat + ) -> [[Int]] { + guard !frames.isEmpty else { return [] } + + var parents = Array(frames.indices) + + func root(of index: Int) -> Int { + var current = index + while parents[current] != current { + current = parents[current] + } + return current + } + + for left in frames.indices { + for right in frames.indices where right > left { + guard shouldMerge( + frames[left], + with: frames[right], + spacing: spacing + ) else { + continue + } + + let leftRoot = root(of: left) + let rightRoot = root(of: right) + if leftRoot != rightRoot { + parents[rightRoot] = leftRoot + } + } + } + + var grouped: [Int: [Int]] = [:] + for index in frames.indices { + grouped[root(of: index), default: []].append(index) + } + + let orderedGroups = grouped.values.map { indices in + indices.sorted { left, right in + let leftX = frames[left].minX.isFinite + ? frames[left].minX + : .infinity + let rightX = frames[right].minX.isFinite + ? frames[right].minX + : .infinity + if leftX == rightX { + return left < right + } + return leftX < rightX + } + } + + return orderedGroups.sorted { lhs, rhs in + let leftX = lhs + .map { frames[$0].minX } + .filter(\.isFinite) + .min() ?? .infinity + let rightX = rhs + .map { frames[$0].minX } + .filter(\.isFinite) + .min() ?? .infinity + if leftX == rightX { + return (lhs.min() ?? 0) < (rhs.min() ?? 0) + } + return leftX < rightX + } + } + + /// Initial width velocity for a distant retarget. Hermite integration turns + /// this into a bounded mid-flight stretch that expands around the surface + /// center and relaxes into the destination key. + static func flowExpansionVelocity( + distance: CGFloat, + duration: CFTimeInterval, + containerWidth: CGFloat + ) -> CGFloat { + guard distance.isFinite, + duration.isFinite, + duration > 0, + containerWidth.isFinite, + containerWidth > 0 else { + return 0 + } + + let safeDistance = max(abs(distance), 0) + let desired = safeDistance * 4.2 / duration + let bounded = containerWidth * 3.0 / duration + return min(desired, bounded) + } + + private static func finite( + _ value: CGFloat, + default defaultValue: CGFloat, + minimum: CGFloat? = nil + ) -> CGFloat { + guard value.isFinite else { return defaultValue } + guard let minimum else { return value } + return max(value, minimum) + } +} + +enum LiquidGlassMaterialMath { + static let increasedContrastMaterialFloor: Float = 0.30 + static let reducedTransparencyMaterialFloor: Float = 0.45 + + static func accessibilityFloor( + reduceTransparency: Bool, + increaseContrast: Bool + ) -> Float { + if reduceTransparency { + return reducedTransparencyMaterialFloor + } + if increaseContrast { + return increasedContrastMaterialFloor + } + return 0 + } + + /// The normal opacity path is deliberately literal: the renderer's visible + /// output matches the saved slider value. Accessibility options may raise + /// that output to their required minimum, but never compress the remainder + /// of the slider range through a permanent material floor. + static func displayOpacity( + userOpacity: Float, + reduceTransparency: Bool, + increaseContrast: Bool + ) -> Float { + let safeOpacity = userOpacity.isFinite ? min(max(userOpacity, 0), 1) : 0.7 + let floor = accessibilityFloor( + reduceTransparency: reduceTransparency, + increaseContrast: increaseContrast + ) + return max(safeOpacity, floor) + } + + /// Keeps optical edge energy independent from the frosted body. A low body + /// opacity therefore reads as a clear lens with pronounced chromatic + /// refraction instead of making the complete surface disappear. + static func prismaticEdgeOpacity( + userOpacity: Float, + reduceTransparency: Bool, + increaseContrast: Bool + ) -> Float { + let safeOpacity = userOpacity.isFinite ? min(max(userOpacity, 0), 1) : 0.7 + let inverseOpacity = pow(1 - safeOpacity, 0.65) + var edgeOpacity = 0.29 + 0.71 * inverseOpacity + + if increaseContrast { + edgeOpacity += 0.08 + } + if reduceTransparency { + edgeOpacity = max(edgeOpacity, 0.48) + } + return min(max(edgeOpacity, 0), 1) + } + + /// Clear glass carries the actual backdrop lensing. It remains strong as + /// body opacity falls, so "transparent" means a clear, refractive lens + /// rather than an effect that simply disappears. + static func clearLensOpacity( + userOpacity: Float, + reduceTransparency: Bool, + increaseContrast: Bool + ) -> Float { + let safeOpacity = userOpacity.isFinite ? min(max(userOpacity, 0), 1) : 0.7 + let inverseOpacity = pow(1 - safeOpacity, 0.55) + var lensOpacity = 0.66 + 0.34 * inverseOpacity + + if increaseContrast { + lensOpacity = max(lensOpacity, 0.76) + } + if reduceTransparency { + lensOpacity = min(lensOpacity, 0.72) + } + return min(max(lensOpacity, 0), 1) + } + + /// Clear glass needs a localized neutral backing at higher body-opacity + /// settings. The backing is stable rather than backdrop-adaptive, avoiding + /// the regular material's gray-to-white appearance changes while typing. + static func localizedDimmingOpacity( + userOpacity: Float, + reduceTransparency: Bool, + increaseContrast: Bool + ) -> Float { + let bodyOpacity = displayOpacity( + userOpacity: userOpacity, + reduceTransparency: reduceTransparency, + increaseContrast: increaseContrast + ) + var dimmingOpacity = 0.015 + 0.17 * pow(bodyOpacity, 1.25) + + if increaseContrast { + dimmingOpacity = max(dimmingOpacity, 0.12) + } + if reduceTransparency { + dimmingOpacity = max(dimmingOpacity, 0.28) + } + return min(max(dimmingOpacity, 0), 0.36) + } + + /// Chromatic dispersion is represented by small opposing edge offsets, + /// never by a color band stretched across the surface. + static func chromaticDisplacement(edgeOpacity: Float) -> CGFloat { + let safeEdgeOpacity = edgeOpacity.isFinite + ? min(max(edgeOpacity, 0), 1) + : 0.5 + return 0.55 + 1.80 * CGFloat(pow(safeEdgeOpacity, 1.25)) + } + +} diff --git a/KeyLight/Views/MenuBarView.swift b/KeyLight/Views/MenuBarView.swift index 51ffd2c..a42a657 100644 --- a/KeyLight/Views/MenuBarView.swift +++ b/KeyLight/Views/MenuBarView.swift @@ -4,364 +4,166 @@ import AppKit // MARK: - Menu Bar Menu struct MenuBarMenuView: View { - @EnvironmentObject var appState: AppState + let model: KeyLightModel + @Environment(\.openWindow) private var openWindow + @Environment(\.openSettings) private var openSettings var body: some View { - Button(appState.isEnabled ? "Disable KeyLight" : "Enable KeyLight") { - appState.isEnabled.toggle() + Button(model.isEnabled ? "Disable \(KeyLightApplicationIdentity.displayName)" : "Enable \(KeyLightApplicationIdentity.displayName)") { + model.isEnabled.toggle() + } + + if inputMonitoringNeedsAttention { + Divider() + + Label(inputMonitoringStatusTitle, systemImage: inputMonitoringStatusIcon) + .foregroundStyle(inputMonitoringStatusColor) + .accessibilityLabel("Input Monitoring: \(inputMonitoringStatusTitle)") + + if model.inputMonitoringInstallationIssue != nil { + Button("Resolve Installation…") { + KeyLightWindowActivation.present(.setup) { + openWindow(id: KeyLightSceneID.setup) + } + } + .accessibilityHint("Explains how to install KeyLight before allowing Input Monitoring") + } else if model.inputMonitoringState == .permissionRequired { + Button("Allow Input Monitoring…") { + if model.hasSeenPermissionExplanation { + model.requestInputMonitoringPermission() + } else { + KeyLightWindowActivation.present(.setup) { + openWindow(id: KeyLightSceneID.setup) + } + } + } + .accessibilityHint("Requests Input Monitoring permission for KeyLight") + + Button("Open System Settings") { + model.openInputMonitoringSettings() + } + .accessibilityHint("Opens the Input Monitoring privacy settings") + } else if model.inputMonitoringState == .monitorUnavailable { + Button("Check Again") { + model.retryInputMonitoring() + } + .accessibilityHint("Checks Input Monitoring and restarts key detection") + + Button("Open System Settings") { + model.openInputMonitoringSettings() + } + .accessibilityHint("Opens the Input Monitoring privacy settings") + } } - .keyboardShortcut("k", modifiers: [.command, .shift]) Divider() - Button("Open Settings...") { - NotificationCenter.default.post(name: .openSettingsWindow, object: nil) + Button("Settings…") { + KeyLightWindowActivation.present(.settings) { + openSettings() + } } .keyboardShortcut(",", modifiers: .command) - Button("Adjust Key Positions...") { - NotificationCenter.default.post(name: .openKeyPositionEditor, object: nil) + Button("Calibrate Keyboard…") { + KeyLightWindowActivation.present(.keyEditor) { + openWindow(id: KeyLightSceneID.keyEditor) + } + } + + Button("About \(KeyLightApplicationIdentity.displayName)") { + NSApp.activate(ignoringOtherApps: true) + NSApplication.shared.orderFrontStandardAboutPanel(nil) } Divider() - Button("Quit KeyLight") { + Button("Quit \(KeyLightApplicationIdentity.displayName)") { NSApplication.shared.terminate(nil) } .keyboardShortcut("q", modifiers: .command) } -} - -struct MenuBarLabel: View { - @EnvironmentObject var appState: AppState - - var body: some View { - Label("KeyLight", systemImage: appState.isEnabled ? "keyboard" : "keyboard.badge.ellipsis") - } -} - -// MARK: - Color Helpers - -func interpolateColor(from: NSColor, to: NSColor, fraction: CGFloat) -> NSColor { - let t = max(0.0, min(1.0, fraction)) - let fromColor = from.usingColorSpace(.sRGB) ?? from - let toColor = to.usingColorSpace(.sRGB) ?? to - - var fr: CGFloat = 0 - var fg: CGFloat = 0 - var fb: CGFloat = 0 - var fa: CGFloat = 0 - var tr: CGFloat = 0 - var tg: CGFloat = 0 - var tb: CGFloat = 0 - var ta: CGFloat = 0 - - fromColor.getRed(&fr, green: &fg, blue: &fb, alpha: &fa) - toColor.getRed(&tr, green: &tg, blue: &tb, alpha: &ta) - - return NSColor( - red: fr + (tr - fr) * t, - green: fg + (tg - fg) * t, - blue: fb + (tb - fb) * t, - alpha: fa + (ta - fa) * t - ) -} - -extension Color { - init?(hex: String) { - var hexSanitized = hex.trimmingCharacters(in: .whitespacesAndNewlines) - hexSanitized = hexSanitized.replacingOccurrences(of: "#", with: "") - - guard hexSanitized.count == 6 else { return nil } - - var rgb: UInt64 = 0 - guard Scanner(string: hexSanitized).scanHexInt64(&rgb) else { return nil } - - self.init( - red: Double((rgb & 0xFF0000) >> 16) / 255.0, - green: Double((rgb & 0x00FF00) >> 8) / 255.0, - blue: Double(rgb & 0x0000FF) / 255.0 - ) - } - func toHex() -> String? { - guard let components = NSColor(self).usingColorSpace(.sRGB)?.cgColor.components else { - return nil + private var inputMonitoringNeedsAttention: Bool { + model.inputMonitoringInstallationIssue != nil || + model.inputMonitoringState == .permissionRequired || + model.inputMonitoringState == .monitorUnavailable + } + + private var inputMonitoringStatusTitle: String { + switch model.inputMonitoringState { + case .checking: + return "Checking Input Monitoring" + case .permissionRequired: + return "Input Monitoring Needed" + case .authorized: + return "Input Monitoring Allowed" + case .starting: + return "Input Monitoring Starting" + case .active: + return "Input Monitoring Active" + case .monitorUnavailable: + return "Input Monitoring Unavailable" + } + } + + private var inputMonitoringStatusIcon: String { + switch model.inputMonitoringState { + case .checking, .starting: + return "clock" + case .permissionRequired: + return "exclamationmark.triangle" + case .authorized: + return "checkmark.shield" + case .active: + return "checkmark.circle" + case .monitorUnavailable: + return "xmark.circle" + } + } + + private var inputMonitoringStatusColor: Color { + switch model.inputMonitoringState { + case .checking, .starting: + return .secondary + case .permissionRequired: + return .orange + case .authorized, .active: + return .green + case .monitorUnavailable: + return .red } - - let r = components.count > 0 ? components[0] : 0 - let g = components.count > 1 ? components[1] : 0 - let b = components.count > 2 ? components[2] : 0 - - return String(format: "%02X%02X%02X", Int(r * 255), Int(g * 255), Int(b * 255)) } } +struct MenuBarLabel: View { + let model: KeyLightModel + @Environment(\.openWindow) private var openWindow -// MARK: - App State - -@MainActor -final class AppState: ObservableObject { - private let settings = SettingsManager.shared - private var isLoading = true - - private var persistWorkItem: DispatchWorkItem? - private var notifyWorkItem: DispatchWorkItem? - private var randomColorCache: [UInt16: NSColor] = [:] - private let persistDebounceInterval: TimeInterval = 0.1 - private let notifyDebounceInterval: TimeInterval = 0.016 - - @Published var isEnabled: Bool = true { - didSet { - if !isLoading { - settings.isEnabled = isEnabled - debouncedNotify() - } - } - } - - @Published var glowColor: Color = Color(hex: "68B8FF") ?? Color(red: 0.41, green: 0.72, blue: 1.0) { - didSet { - glowNSColor = NSColor(glowColor) - if !isLoading { - debouncedPersist() - debouncedNotify() - } - } - } - - @Published var glowOpacity: Double = 0.8013 { - didSet { - if !isLoading { - debouncedPersist() - debouncedNotify() - } - } - } - - @Published var glowSize: Double = 80.5536 { - didSet { - if !isLoading { - debouncedPersist() - debouncedNotify() - } - } - } - - @Published var glowWidth: Double = 1.0 { - didSet { - if !isLoading { - debouncedPersist() - debouncedNotify() - } - } - } - - @Published var glowRoundness: Double = 0.7069 { - didSet { - if !isLoading { - debouncedPersist() - debouncedNotify() - } - } - } - - @Published var glowFullness: Double = 0.6046 { - didSet { - if !isLoading { - debouncedPersist() - debouncedNotify() - } - } - } - - @Published var fadeDuration: Double = 1.0004 { - didSet { - if !isLoading { - debouncedPersist() - debouncedNotify() - } - } - } - - @Published var launchAtLogin: Bool = false { - didSet { - if !isLoading { - settings.launchAtLogin = launchAtLogin - } - } - } - - @Published var colorMode: SettingsManager.ColorMode = .positionGradient { - didSet { - if !isLoading { - debouncedPersist() - debouncedNotify() - } - } - } - - @Published var gradientStartColor: Color = Color(hex: "68B8FF") ?? .blue { - didSet { - gradientStartNSColor = NSColor(gradientStartColor) - if !isLoading { - debouncedPersist() - debouncedNotify() - } - } - } - - @Published var gradientEndColor: Color = Color(hex: "00E69A") ?? .green { - didSet { - gradientEndNSColor = NSColor(gradientEndColor) - if !isLoading { - debouncedPersist() - debouncedNotify() + var body: some View { + Label(KeyLightApplicationIdentity.displayName, systemImage: statusItemSymbol) + .onAppear(perform: presentSetupIfRequested) + .onChange(of: model.permissionSetupPresentationRequested) { _, requested in + guard requested else { return } + presentSetupIfRequested() } - } - } - - private(set) var glowNSColor: NSColor = NSColor(red: 0.41, green: 0.72, blue: 1.0, alpha: 1.0) - private(set) var gradientStartNSColor: NSColor = NSColor(red: 0.41, green: 0.72, blue: 1.0, alpha: 1.0) - private(set) var gradientEndNSColor: NSColor = NSColor(red: 0.0, green: 0.90, blue: 0.60, alpha: 1.0) - - init() { - loadSettings() - isLoading = false } - private func debouncedPersist() { - persistWorkItem?.cancel() - let workItem = DispatchWorkItem { [weak self] in - self?.persistAllSettings() + private var statusItemSymbol: String { + guard model.isEnabled else { return "keyboard.badge.ellipsis" } + switch model.inputMonitoringState { + case .active: + return "keyboard" + case .permissionRequired, .monitorUnavailable: + return "exclamationmark.triangle" + case .checking, .authorized, .starting: + return "keyboard.badge.ellipsis" } - persistWorkItem = workItem - DispatchQueue.main.asyncAfter(deadline: .now() + persistDebounceInterval, execute: workItem) } - private func debouncedNotify() { - notifyWorkItem?.cancel() - let workItem = DispatchWorkItem { - NotificationCenter.default.post(name: .glowSettingsChanged, object: nil) + private func presentSetupIfRequested() { + guard model.consumePermissionSetupPresentationRequest() else { return } + KeyLightWindowActivation.present(.setup) { + openWindow(id: KeyLightSceneID.setup) } - notifyWorkItem = workItem - DispatchQueue.main.asyncAfter(deadline: .now() + notifyDebounceInterval, execute: workItem) - } - - private func persistAllSettings() { - settings.glowColorHex = glowColor.toHex() ?? "68B8FF" - settings.glowOpacity = glowOpacity - settings.glowSize = glowSize - settings.glowWidth = glowWidth - settings.glowRoundness = glowRoundness - settings.glowFullness = glowFullness - settings.fadeDuration = fadeDuration - settings.colorMode = colorMode - settings.gradientStartHex = gradientStartColor.toHex() ?? "68B8FF" - settings.gradientEndHex = gradientEndColor.toHex() ?? "00E69A" - } - - func flushPendingPersist() { - persistWorkItem?.cancel() - persistWorkItem = nil - persistAllSettings() - } - - func loadSettings() { - isLoading = true - isEnabled = settings.isEnabled - glowColor = Color(hex: settings.glowColorHex) ?? Color(hex: "68B8FF") ?? Color(red: 0.41, green: 0.72, blue: 1.0) - glowOpacity = settings.glowOpacity - glowSize = settings.glowSize - glowWidth = settings.glowWidth - glowRoundness = settings.glowRoundness - glowFullness = settings.glowFullness - fadeDuration = settings.fadeDuration - launchAtLogin = settings.launchAtLogin - colorMode = settings.colorMode - gradientStartColor = Color(hex: settings.gradientStartHex) ?? Color(hex: "68B8FF") ?? .blue - gradientEndColor = Color(hex: settings.gradientEndHex) ?? Color(hex: "00E69A") ?? .green - isLoading = false - } - - func applyTheme(_ theme: SettingsManager.Theme) { - notifyWorkItem?.cancel() - notifyWorkItem = nil - - isLoading = true - glowColor = Color(hex: theme.colorHex) ?? glowColor - glowOpacity = theme.opacity - glowSize = theme.size - glowWidth = theme.width - glowRoundness = theme.glowRoundness - glowFullness = theme.glowFullness - fadeDuration = theme.fadeDuration - colorMode = theme.colorMode - gradientStartColor = Color(hex: theme.gradientStartHex ?? "68B8FF") ?? gradientStartColor - gradientEndColor = Color(hex: theme.gradientEndHex ?? "00E69A") ?? gradientEndColor - isLoading = false - - persistWorkItem?.cancel() - persistAllSettings() - settings.currentThemeName = theme.name - - NotificationCenter.default.post(name: .glowSettingsChanged, object: nil) } - - func currentTheme() -> SettingsManager.Theme { - SettingsManager.Theme( - name: settings.currentThemeName, - colorHex: settings.glowColorHex, - opacity: glowOpacity, - size: glowSize, - width: glowWidth, - glowRoundness: glowRoundness, - glowFullness: glowFullness, - fadeDuration: fadeDuration, - colorMode: colorMode, - gradientStartHex: settings.gradientStartHex, - gradientEndHex: settings.gradientEndHex - ) - } - - func resolvedNSColor(at horizontalPosition: CGFloat) -> NSColor { - let clamped = max(0.0, min(1.0, horizontalPosition)) - switch colorMode { - case .solid: - return glowNSColor - case .positionGradient: - return interpolateColor(from: gradientStartNSColor, to: gradientEndNSColor, fraction: clamped) - case .randomPerKey: - return NSColor(hue: clamped, saturation: 0.8, brightness: 1.0, alpha: 1.0) - case .rainbow: - return NSColor(hue: clamped, saturation: 0.9, brightness: 1.0, alpha: 1.0) - } - } - - func randomPerKeyNSColor(for keyCode: UInt16) -> NSColor { - if let cached = randomColorCache[keyCode] { - return cached - } - - let seed = UInt32(keyCode) &* 1_103_515_245 &+ 12_345 - let hue = CGFloat(seed % 10_000) / 10_000.0 - let color = NSColor(hue: hue, saturation: 0.85, brightness: 1.0, alpha: 1.0) - randomColorCache[keyCode] = color - return color - } -} - -// MARK: - Notifications - -extension Notification.Name { - static let glowSettingsChanged = Notification.Name("glowSettingsChanged") - static let settingsStorageChanged = Notification.Name("settingsStorageChanged") - static let openKeyPositionEditor = Notification.Name("openKeyPositionEditor") - static let openSettingsWindow = Notification.Name("openSettingsWindow") - static let permissionStatusChanged = Notification.Name("permissionStatusChanged") - static let keyPositionsChanged = Notification.Name("keyPositionsChanged") - static let keyWidthsChanged = Notification.Name("keyWidthsChanged") - static let showGlowPreview = Notification.Name("showGlowPreview") - static let hideGlowPreview = Notification.Name("hideGlowPreview") - static let physicalKeyDown = Notification.Name("physicalKeyDown") - static let physicalKeyUp = Notification.Name("physicalKeyUp") } diff --git a/KeyLight/Views/PermissionSetupView.swift b/KeyLight/Views/PermissionSetupView.swift new file mode 100644 index 0000000..3e7d241 --- /dev/null +++ b/KeyLight/Views/PermissionSetupView.swift @@ -0,0 +1,424 @@ +import AppKit +import SwiftUI + +/// Resumable, consent-first setup. Permission prompts and update traffic occur +/// only after their explicit buttons or toggles are used. +struct PermissionSetupView: View { + private enum Stage { + case welcome + case installation + case inputExplanation + case waitingForInputPermission + case monitorRecovery + case keyVerification + case effectChoice + case physicalPermission + case updates + case complete + } + + let model: KeyLightModel + let updateService: UpdateService + @Environment(\.dismiss) private var dismiss + @State private var stage: Stage = .welcome + @State private var screenCaptureAccessGranted = + ScreenCaptureAuthorization.isGranted + + var body: some View { + VStack(alignment: .leading, spacing: 18) { + Label(title, systemImage: symbolName) + .font(.title2.weight(.semibold)) + + content + + Spacer(minLength: 0) + + HStack { + if stage != .complete { + Button("Not Now") { + model.deferOnboarding() + dismiss() + } + .keyboardShortcut(.cancelAction) + .accessibilityHint( + "Keeps Classic Glow as the safe default and does not reopen this setup automatically" + ) + } + + Spacer() + actions + } + } + .padding(24) + .frame(minWidth: 540, idealWidth: 560, minHeight: 440) + .onAppear { + screenCaptureAccessGranted = ScreenCaptureAuthorization.isGranted + if model.inputMonitoringInstallationIssue != nil { + stage = .installation + } + } + .onChange(of: model.inputMonitoringState) { _, _ in + synchronizePermissionStage() + } + .onChange(of: model.inputMonitoringInstallationIssue) { _, issue in + if issue != nil { + stage = .installation + } else { + synchronizePermissionStage() + } + } + .onChange(of: model.physicalKeyActivity) { _, activity in + guard stage == .keyVerification, activity?.isDown == true else { + return + } + stage = .effectChoice + model.announce(UserFeedback( + severity: .success, + title: String(localized: "Key Detected"), + detail: String(localized: "Input Monitoring verification succeeded. Choose an effect.") + )) + } + } + + private var title: String { + switch stage { + case .welcome: String(localized: "Welcome to KeyLight") + case .installation: String(localized: "Verify Installation") + case .inputExplanation: String(localized: "Allow Input Monitoring") + case .waitingForInputPermission: String(localized: "Finish in System Settings") + case .monitorRecovery: String(localized: "Input Monitoring Needs Attention") + case .keyVerification: String(localized: "Verify a Physical Key") + case .effectChoice: String(localized: "Choose Your Effect") + case .physicalPermission: String(localized: "Optional Screen Recording") + case .updates: String(localized: "Software Updates") + case .complete: String(localized: "KeyLight Is Ready") + } + } + + private var symbolName: String { + switch stage { + case .welcome: "keyboard" + case .installation: "app.badge.checkmark" + case .inputExplanation, .waitingForInputPermission, .monitorRecovery: + "keyboard.badge.ellipsis" + case .keyVerification: "keyboard.fill" + case .effectChoice: "sparkles" + case .physicalPermission: "rectangle.on.rectangle" + case .updates: "arrow.triangle.2.circlepath" + case .complete: "checkmark.circle.fill" + } + } + + @ViewBuilder + private var content: some View { + switch stage { + case .welcome: + VStack(alignment: .leading, spacing: 12) { + Text("KeyLight turns physical key presses into a fluid light surface along the bottom of your display.") + Label("No typing history, analytics, accounts, or cloud sync", systemImage: "hand.raised.fill") + Label("Ordinary key events are reduced to identity and press/release metadata", systemImage: "keyboard") + Label("Screen Recording is optional and used only by Physical Refraction", systemImage: "rectangle.on.rectangle.slash") + } + .foregroundStyle(.secondary) + + case .installation: + VStack(alignment: .leading, spacing: 10) { + Text(model.inputMonitoringInstallationIssue ?? "KeyLight is running from a stable installed location.") + Text(model.inputMonitoringAppPath) + .font(.caption.monospaced()) + .textSelection(.enabled) + } + .foregroundStyle(.secondary) + + case .inputExplanation: + VStack(alignment: .leading, spacing: 12) { + Text("KeyLight needs Input Monitoring to receive global key press and release events and position the effect under the corresponding physical key.") + Text("Typed characters are not retained, logged, exported, or sent anywhere.") + .font(.callout.weight(.medium)) + } + .foregroundStyle(.secondary) + + case .waitingForInputPermission: + Text("Turn on KeyLight in Privacy & Security › Input Monitoring, then return here. KeyLight checks permission without showing another prompt.") + .foregroundStyle(.secondary) + + case .monitorRecovery: + Text("Input Monitoring is allowed, but the keyboard event loop could not start. Retry it or review the installed KeyLight entry in System Settings.") + .foregroundStyle(.secondary) + + case .keyVerification: + VStack(alignment: .leading, spacing: 10) { + Text("Press and release any physical key once.") + Text("The completion screen stays open until you choose Done; setup never dismisses itself automatically.") + .font(.caption) + } + .foregroundStyle(.secondary) + + case .effectChoice: + VStack(alignment: .leading, spacing: 8) { + ForEach(EffectStyle.allCases, id: \.self) { effect in + effectRow(effect) + } + } + + case .physicalPermission: + VStack(alignment: .leading, spacing: 12) { + Label( + screenCaptureAccessGranted + ? "Screen Recording is allowed" + : "Screen Recording is not yet allowed", + systemImage: screenCaptureAccessGranted + ? "checkmark.shield.fill" + : "hand.raised.fill" + ) + .foregroundStyle(screenCaptureAccessGranted ? .green : .orange) + + Text("Physical Refraction starts capture only when a visible key surface first appears. It samples one bottom strip at up to 30 fps, retains one latest GPU-backed frame, and stops two seconds after the final retraction.") + .foregroundStyle(.secondary) + Text("Selecting the effect never requests permission. The button below is the explicit consent action. System Glass is the capture-free alternative.") + .font(.caption) + .foregroundStyle(.secondary) + } + + case .updates: + VStack(alignment: .leading, spacing: 12) { + Toggle( + "Automatically check for updates", + isOn: Binding( + get: { updateService.automaticallyChecksForUpdates }, + set: { updateService.automaticallyChecksForUpdates = $0 } + ) + ) + .disabled(!updateService.isConfigured) + Text( + updateService.isConfigured + ? "Off by default. When enabled, KeyLight contacts only its signed HTTPS update feed and sends no system profile or analytics." + : "This local preview has no production update feed or public signing key, so it cannot make update requests." + ) + .font(.caption) + .foregroundStyle(.secondary) + } + + case .complete: + VStack(alignment: .leading, spacing: 10) { + Text("Setup is complete. Your selected effect and update choice are saved.") + LabeledContent("Effect") { + Text(model.effectStyle.displayName) + } + LabeledContent("Input Monitoring") { + Text(model.inputMonitoringState == .active ? "Active" : "Needs attention") + } + } + .foregroundStyle(.secondary) + } + } + + @ViewBuilder + private var actions: some View { + switch stage { + case .welcome: + Button("Continue") { + advanceFromWelcome() + } + .keyboardShortcut(.defaultAction) + + case .installation: + Button("Open Applications") { + NSWorkspace.shared.open( + URL(fileURLWithPath: "/Applications", isDirectory: true) + ) + } + Button("Check Again") { + if model.inputMonitoringInstallationIssue == nil { + advanceFromWelcome() + } else { + model.retryInputMonitoring() + } + } + .keyboardShortcut(.defaultAction) + + case .inputExplanation: + Button("Allow Input Monitoring…") { + model.markPermissionExplanationSeen() + stage = .waitingForInputPermission + model.requestInputMonitoringPermission() + } + .keyboardShortcut(.defaultAction) + + case .waitingForInputPermission: + Button("Open System Settings") { + model.openInputMonitoringSettings() + } + Button("Check Again") { + model.retryInputMonitoring() + } + .keyboardShortcut(.defaultAction) + + case .monitorRecovery: + Button("Open System Settings") { + model.openInputMonitoringSettings() + } + Button("Retry Monitor") { + model.retryInputMonitoring() + } + .keyboardShortcut(.defaultAction) + + case .keyVerification: + Button("Retry Monitor") { + model.retryInputMonitoring() + } + + case .effectChoice: + Button("Continue") { + stage = model.effectStyle == .physicalRefraction + ? .physicalPermission + : .updates + } + .keyboardShortcut(.defaultAction) + + case .physicalPermission: + if !screenCaptureAccessGranted { + Button("Continue with Fallback") { + stage = .updates + } + Button("Use System Glass Instead") { + model.selectEffect(.systemGlass) + stage = .updates + } + Button("Allow Screen Recording…") { + screenCaptureAccessGranted = + ScreenCaptureAuthorization.requestAccess() + } + .keyboardShortcut(.defaultAction) + } else { + Button("Continue") { + stage = .updates + } + .keyboardShortcut(.defaultAction) + } + + case .updates: + Button("Continue") { + stage = .complete + model.announce(UserFeedback( + severity: .success, + title: String(localized: "Setup Complete"), + detail: String(localized: "KeyLight is ready. Choose Done to close setup.") + )) + } + .keyboardShortcut(.defaultAction) + + case .complete: + Button("Done") { + model.completeOnboarding() + dismiss() + } + .keyboardShortcut(.defaultAction) + } + } + + private func effectRow(_ effect: EffectStyle) -> some View { + let available = effect.isAvailableOnCurrentSystem + return Button { + guard available else { return } + model.selectEffect(effect) + } label: { + HStack(alignment: .top, spacing: 10) { + Image(systemName: model.effectStyle == effect + ? "checkmark.circle.fill" + : "circle") + .foregroundStyle( + model.effectStyle == effect + ? Color.accentColor + : Color.secondary + ) + VStack(alignment: .leading, spacing: 3) { + HStack { + Text(effect.displayName).fontWeight(.medium) + Text(effect.usesScreenCapture + ? "Uses Optional Screen Recording" + : "No Screen Capture") + .font(.caption2) + .foregroundStyle(.secondary) + if effect.requiresMacOS26 { + Text("macOS 26+") + .font(.caption2) + .foregroundStyle(.secondary) + } + } + Text(effectDescription(effect)) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + } + .padding(8) + .background( + model.effectStyle == effect + ? Color.accentColor.opacity(0.09) + : Color.secondary.opacity(0.04), + in: RoundedRectangle(cornerRadius: 8, style: .continuous) + ) + } + .buttonStyle(.plain) + .disabled(!available) + .accessibilityLabel( + "\(effect.displayName), \(effect.usesScreenCapture ? "uses optional Screen Recording" : "no screen capture")" + ) + } + + private func effectDescription(_ effect: EffectStyle) -> String { + switch effect { + case .classicGlow: + String(localized: "The original single-target KeyLight glow with unchanged pixels and timing.") + case .classicPlus: + String(localized: "Retired preview effect; migrated to Classic Glow.") + case .liquidGlass: + String(localized: "Retired preview effect; migrated to System Glass.") + case .systemGlass: + String(localized: "Capture-free optics controlled entirely by Apple's compositor.") + case .physicalRefraction: + String(localized: "Physically modeled backdrop refraction with System Glass fallback.") + case .solidBlack: + String(localized: "An opaque black silhouette that retracts geometrically.") + } + } + + private func advanceFromWelcome() { + if model.inputMonitoringInstallationIssue != nil { + stage = .installation + } else if !model.hasSeenPermissionExplanation { + stage = .inputExplanation + } else { + synchronizePermissionStage(force: true) + } + } + + private func synchronizePermissionStage(force: Bool = false) { + let permissionStages: Set = [ + .welcome, + .installation, + .inputExplanation, + .waitingForInputPermission, + .monitorRecovery, + .keyVerification + ] + guard force || permissionStages.contains(stage) else { return } + if model.inputMonitoringInstallationIssue != nil { + stage = .installation + return + } + guard model.hasSeenPermissionExplanation else { + if force { stage = .inputExplanation } + return + } + switch model.inputMonitoringState { + case .active: + stage = .keyVerification + case .monitorUnavailable: + stage = .monitorRecovery + case .checking, .permissionRequired, .authorized, .starting: + stage = .waitingForInputPermission + } + } +} diff --git a/KeyLight/Views/PhysicalRefractionRenderer.swift b/KeyLight/Views/PhysicalRefractionRenderer.swift new file mode 100644 index 0000000..08dbd59 --- /dev/null +++ b/KeyLight/Views/PhysicalRefractionRenderer.swift @@ -0,0 +1,1157 @@ +import AppKit +import CoreMedia +import CoreVideo +import MetalKit +@preconcurrency import ScreenCaptureKit +import SwiftUI + +/// Screen capture is requested only after the user selects the physical effect +/// and explicitly presses the permission button in Settings. +enum ScreenCaptureAuthorization { + static var isGranted: Bool { + CGPreflightScreenCaptureAccess() + } + + @MainActor + @discardableResult + static func requestAccess() -> Bool { + CGRequestScreenCaptureAccess() + } + + @MainActor + static func openSettings() { + guard let url = URL( + string: "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture" + ) else { + return + } + NSWorkspace.shared.open(url) + } +} + +enum PhysicalCaptureState: String, CaseIterable, Codable, Sendable { + case idle + case starting + case active + case gracePeriod + case stopping + case permissionRequired + case failed + + var displayName: String { + switch self { + case .idle: String(localized: "Idle") + case .starting: String(localized: "Starting") + case .active: String(localized: "Active") + case .gracePeriod: String(localized: "Stopping soon") + case .stopping: String(localized: "Stopping") + case .permissionRequired: String(localized: "Permission required") + case .failed: String(localized: "Fallback") + } + } +} + +#if compiler(>=6.2) +enum PhysicalRefractionOptics { + static let defaultStrength = 1.0 + static let strengthRange = 0.5...2.5 + static let baselineTransmissionLimit: CGFloat = 26 + static let reflectionLimit: CGFloat = 20 + + static func sanitizedStrength(_ value: Double) -> Double { + guard value.isFinite else { return defaultStrength } + return min( + max(value, strengthRange.lowerBound), + strengthRange.upperBound + ) + } + + /// Increasing strength represents a longer path through the same N-BK7 + /// material. The sampling bound grows with that path so the new upper range + /// does not simply clamp back to the original 26-point displacement. + static func transmissionLimit(for strength: Double) -> CGFloat { + baselineTransmissionLimit * sanitizedStrength(strength) + } + + static func opticalMargin(for strength: Double) -> CGFloat { + max( + transmissionLimit(for: strength), + reflectionLimit + ) + 2 + } +} + +@available(macOS 26.0, *) +struct PhysicalRefractionSurfaceView: NSViewRepresentable { + let snapshots: [LiquidGlassSurfaceSnapshot] + let bodyOpacity: Double + let edgeStrength: Double + let refractionStrength: Double + let stopGeneration: UInt + let onCaptureReadinessChanged: @MainActor (Bool) -> Void + let onCaptureStateChanged: @MainActor (PhysicalCaptureState) -> Void + + func makeNSView(context: Context) -> PhysicalRefractionMetalView { + let view = PhysicalRefractionMetalView(frame: .zero) + view.onCaptureReadinessChanged = onCaptureReadinessChanged + view.onCaptureStateChanged = onCaptureStateChanged + view.update( + snapshots: snapshots, + bodyOpacity: bodyOpacity, + edgeStrength: edgeStrength, + refractionStrength: refractionStrength, + stopGeneration: stopGeneration + ) + return view + } + + func updateNSView( + _ nsView: PhysicalRefractionMetalView, + context: Context + ) { + nsView.onCaptureReadinessChanged = onCaptureReadinessChanged + nsView.onCaptureStateChanged = onCaptureStateChanged + nsView.update( + snapshots: snapshots, + bodyOpacity: bodyOpacity, + edgeStrength: edgeStrength, + refractionStrength: refractionStrength, + stopGeneration: stopGeneration + ) + } + + static func dismantleNSView( + _ nsView: PhysicalRefractionMetalView, + coordinator: Void + ) { + nsView.stopCapture() + } +} + +@available(macOS 26.0, *) +@MainActor +final class PhysicalRefractionMetalView: MTKView, MTKViewDelegate { + private struct Uniforms { + // width, height, captured strip height, saved opacity control + var viewport = SIMD4(repeating: 0) + // optical depth scale, edge strength, drawable width, drawable height + var optics = SIMD4(repeating: 0) + // path-length multiplier, transmission offset limit, reserved, reserved + var tuning = SIMD4(repeating: 0) + // active surface count, reserved, reserved, reserved + var counts = SIMD4(repeating: 0) + } + + private struct Surface { + // x, top y, width, height in overlay points (top-left coordinates) + var frame = SIMD4(repeating: 0) + // visibility, emergence, smoothness, horizontal flow + var optical = SIMD4(repeating: 0) + } + + private struct FrameResources { + let uniforms: any MTLBuffer + let surfaces: any MTLBuffer + } + + private static let maximumSurfaceCount = 32 + private static let bufferedFrameCount = 3 + private static let uniformStride = 256 + + private let commandQueue: (any MTLCommandQueue)? + private let pipelineState: (any MTLRenderPipelineState)? + private let textureCache: CVMetalTextureCache? + private var initializationFailure: String? + private let capture = ScreenBackdropCapture() + private let frameResourceSemaphore = DispatchSemaphore( + value: bufferedFrameCount + ) + private var frameResources: [FrameResources] = [] + private var frameResourceIndex = 0 + + private var snapshots: [LiquidGlassSurfaceSnapshot] = [] + private var bodyOpacity: Double = 0.7 + private var edgeStrength: Double = 0.5 + private var refractionStrength = PhysicalRefractionOptics.defaultStrength + private var captureIsReady = false + private var captureDisplayID: CGDirectDisplayID? + private var captureOverlayHeight: CGFloat = 0 + private var geometryUpdateIsScheduled = false + private var forceCaptureOnNextGeometryUpdate = false + private var captureStopTask: Task? + private var captureState: PhysicalCaptureState = .idle + private var cachedFrameSequence: UInt64? + private var cachedImageTexture: CVMetalTexture? + private var cachedTexture: (any MTLTexture)? + private var lastPresentedFrameSequence: UInt64? + private var lastStopGeneration: UInt? + + var onCaptureReadinessChanged: (@MainActor (Bool) -> Void)? + var onCaptureStateChanged: (@MainActor (PhysicalCaptureState) -> Void)? + + override var isOpaque: Bool { false } + + var rendererIsReady: Bool { initializationFailure == nil } + var currentCaptureState: PhysicalCaptureState { captureState } + + #if DEBUG + var testHasPendingCaptureStop: Bool { captureStopTask != nil } + + /// Arms only the lifecycle state for deterministic tests. It never creates + /// an SCStream, captures a frame, or requests Screen Recording access. + func testArmCaptureAsActive() { + captureStopTask?.cancel() + captureStopTask = nil + captureDisplayID = 1 + captureIsReady = true + setCaptureState(.active) + } + + func testBeginCaptureGracePeriod() { + beginCaptureGracePeriod() + } + #endif + + override init(frame frameRect: NSRect, device: (any MTLDevice)? = nil) { + let metalDevice = device ?? MTLCreateSystemDefaultDevice() + var queue: (any MTLCommandQueue)? + var pipeline: (any MTLRenderPipelineState)? + var cache: CVMetalTextureCache? + var failure: String? + + if let metalDevice { + queue = metalDevice.makeCommandQueue() + guard queue != nil else { + commandQueue = nil + pipelineState = nil + textureCache = nil + initializationFailure = "Metal command queue unavailable" + super.init(frame: frameRect, device: metalDevice) + configureFrameResources() + configureMetalView() + return + } + + if let library = Self.makeShaderLibrary(device: metalDevice), + let vertex = library.makeFunction(name: "keyLightRefractionVertex"), + let fragment = library.makeFunction(name: "keyLightRefractionFragment") { + let descriptor = MTLRenderPipelineDescriptor() + descriptor.label = "KeyLight Physical Refraction" + descriptor.vertexFunction = vertex + descriptor.fragmentFunction = fragment + descriptor.colorAttachments[0].pixelFormat = .bgra8Unorm + descriptor.colorAttachments[0].isBlendingEnabled = true + descriptor.colorAttachments[0].rgbBlendOperation = .add + descriptor.colorAttachments[0].alphaBlendOperation = .add + descriptor.colorAttachments[0].sourceRGBBlendFactor = .one + descriptor.colorAttachments[0].sourceAlphaBlendFactor = .one + descriptor.colorAttachments[0].destinationRGBBlendFactor = .oneMinusSourceAlpha + descriptor.colorAttachments[0].destinationAlphaBlendFactor = .oneMinusSourceAlpha + do { + pipeline = try metalDevice.makeRenderPipelineState( + descriptor: descriptor + ) + } catch { + failure = "Metal pipeline unavailable: \(error.localizedDescription)" + } + } else { + failure = "Compiled Physical Refraction shaders unavailable" + } + + if failure == nil { + let status = CVMetalTextureCacheCreate( + kCFAllocatorDefault, + nil, + metalDevice, + nil, + &cache + ) + if status != kCVReturnSuccess || cache == nil { + failure = "Metal texture cache unavailable" + } + } + } else { + failure = "Metal device unavailable" + } + + commandQueue = queue + pipelineState = pipeline + textureCache = cache + initializationFailure = failure + super.init(frame: frameRect, device: metalDevice) + configureFrameResources() + configureMetalView() + } + + private func configureFrameResources() { + guard initializationFailure == nil, + let device else { return } + let uniformLength = Self.uniformStride * Self.maximumSurfaceCount + let surfaceLength = MemoryLayout.stride + * Self.maximumSurfaceCount + var resources: [FrameResources] = [] + for index in 0.. (any MTLLibrary)? { + try? device.makeDefaultLibrary(bundle: .main) + } + + @available(*, unavailable) + required init(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + captureStopTask?.cancel() + capture.stop() + } + + private func configureMetalView() { + delegate = self + colorPixelFormat = .bgra8Unorm + clearColor = MTLClearColorMake(0, 0, 0, 0) + framebufferOnly = true + preferredFramesPerSecond = Self.preferredFrameRate(for: window?.screen) + enableSetNeedsDisplay = true + isPaused = true + autoResizeDrawable = false + wantsLayer = true + layer?.backgroundColor = NSColor.clear.cgColor + layer?.isOpaque = false + setAccessibilityElement(false) + setAccessibilityChildren([]) + } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + if window == nil { + stopCapture() + } else { + preferredFramesPerSecond = Self.preferredFrameRate(for: window?.screen) + scheduleGeometryUpdate(forceCapture: !snapshots.isEmpty) + } + } + + override func layout() { + super.layout() + // MTKView can ask AppKit to reconcile its backing drawable after + // drawableSize changes. Defer that mutation until this layout pass has + // completed so it cannot recursively enter layoutSubtreeIfNeeded. + scheduleGeometryUpdate(forceCapture: false) + } + + func update( + snapshots: [LiquidGlassSurfaceSnapshot], + bodyOpacity: Double, + edgeStrength: Double, + refractionStrength: Double = PhysicalRefractionOptics.defaultStrength, + stopGeneration: UInt = 0 + ) { + if let lastStopGeneration, lastStopGeneration != stopGeneration { + stopCapture() + } + lastStopGeneration = stopGeneration + let previouslyHadVisibleSurfaces = !self.snapshots.isEmpty + self.snapshots = Array( + snapshots + .filter { $0.isVisible && $0.visibility > 0.000_1 } + .prefix(Self.maximumSurfaceCount) + ) + self.bodyOpacity = bodyOpacity.isFinite + ? min(max(bodyOpacity, 0), 1) + : 0.7 + self.edgeStrength = edgeStrength.isFinite + ? min(max(edgeStrength, 0), 1) + : 0.5 + self.refractionStrength = + PhysicalRefractionOptics.sanitizedStrength(refractionStrength) + + if self.snapshots.isEmpty { + if previouslyHadVisibleSurfaces { + // Present one transparent frame before pausing so the last + // refracted silhouette cannot remain in the drawable. + setNeedsDisplay(bounds) + beginCaptureGracePeriod() + } + } else { + captureStopTask?.cancel() + captureStopTask = nil + if captureState == .gracePeriod { + setCaptureState(captureIsReady ? .active : .starting) + } + scheduleGeometryUpdate(forceCapture: captureDisplayID == nil) + setNeedsDisplay(bounds) + } + } + + func stopCapture() { + let hadCapture = captureDisplayID != nil + captureStopTask?.cancel() + captureStopTask = nil + setCaptureState(.stopping) + captureDisplayID = nil + captureOverlayHeight = 0 + capture.stop() + releaseCachedTexture() + lastPresentedFrameSequence = nil + setCaptureReady(false) + setCaptureState(.idle) + if hadCapture { + KeyLightSignposts.captureStopped() + } + } + + private func beginCaptureGracePeriod() { + guard captureDisplayID != nil else { return } + captureStopTask?.cancel() + setCaptureState(.gracePeriod) + captureStopTask = Task { @MainActor [weak self] in + do { + try await Task.sleep(for: .seconds(2)) + } catch { + return + } + guard let self, self.snapshots.isEmpty else { return } + self.stopCapture() + } + } + + nonisolated static func preferredFrameRate(for screen: NSScreen?) -> Int { + guard let maximum = screen?.maximumFramesPerSecond else { return 60 } + return maximum >= 100 ? 120 : 60 + } + + private func resizeDrawable() { + let backingScale = window?.backingScaleFactor + ?? NSScreen.main?.backingScaleFactor + ?? 2 + // The silhouette is procedural, so rendering it below the window's + // backing scale exposes the Metal pixel grid as visible stair steps. + // Keep the captured strip bandwidth-efficient, but rasterize the + // optical boundary at the display's actual pixel density. + let renderScale = Self.renderScale(for: backingScale) + layer?.contentsScale = renderScale + drawableSize = CGSize( + width: max(bounds.width * renderScale, 1), + height: max(bounds.height * renderScale, 1) + ) + } + + nonisolated static func renderScale(for backingScale: CGFloat) -> CGFloat { + guard backingScale.isFinite else { return 2 } + return max(backingScale, 1) + } + + private func scheduleGeometryUpdate(forceCapture: Bool) { + forceCaptureOnNextGeometryUpdate = + forceCaptureOnNextGeometryUpdate || forceCapture + guard !geometryUpdateIsScheduled else { return } + geometryUpdateIsScheduled = true + + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.geometryUpdateIsScheduled = false + guard self.window != nil else { return } + let shouldForceCapture = self.forceCaptureOnNextGeometryUpdate + self.forceCaptureOnNextGeometryUpdate = false + self.resizeDrawable() + if !self.snapshots.isEmpty { + self.startCaptureIfNeeded(force: shouldForceCapture) + } + } + } + + private func startCaptureIfNeeded(force: Bool) { + guard rendererIsReady else { + setCaptureState(.failed) + setCaptureReady(false) + return + } + guard ScreenCaptureAuthorization.isGranted else { + setCaptureState(.permissionRequired) + setCaptureReady(false) + return + } + guard let screen = window?.screen, + let number = screen.deviceDescription[ + NSDeviceDescriptionKey("NSScreenNumber") + ] as? NSNumber else { + return + } + let displayID = CGDirectDisplayID(number.uint32Value) + let overlayHeight = max(bounds.height, 1) + let heightChanged = abs(overlayHeight - captureOverlayHeight) > 2 + guard force || captureDisplayID != displayID || heightChanged else { + return + } + + captureDisplayID = displayID + captureOverlayHeight = overlayHeight + setCaptureState(.starting) + setCaptureReady(false) + KeyLightSignposts.captureStarted() + capture.start( + displayID: displayID, + overlayHeightPoints: overlayHeight, + readinessHandler: { [weak self] ready in + Task { @MainActor [weak self] in + self?.setCaptureReady(ready, failure: !ready) + } + }, frameHandler: { [weak self] in + Task { @MainActor [weak self] in + guard let self, !self.snapshots.isEmpty else { return } + self.setNeedsDisplay(self.bounds) + } + } + ) + } + + private func setCaptureReady(_ ready: Bool, failure: Bool = false) { + guard captureIsReady != ready else { + if failure, + captureDisplayID != nil, + captureState != .permissionRequired, + captureState != .stopping { + handleCaptureFailure() + } + return + } + captureIsReady = ready + if ready { + setCaptureState(snapshots.isEmpty ? .gracePeriod : .active) + } else if failure, + captureDisplayID != nil, + captureState != .permissionRequired, + captureState != .stopping { + handleCaptureFailure() + } + onCaptureReadinessChanged?(ready) + } + + private func handleCaptureFailure() { + let hadCapture = captureDisplayID != nil + captureDisplayID = nil + captureOverlayHeight = 0 + capture.stop() + releaseCachedTexture() + lastPresentedFrameSequence = nil + setCaptureState(.failed) + if hadCapture { + KeyLightSignposts.captureStopped() + } + } + + private func setCaptureState(_ state: PhysicalCaptureState) { + guard captureState != state else { return } + captureState = state + onCaptureStateChanged?(state) + } + + func mtkView( + _ view: MTKView, + drawableSizeWillChange size: CGSize + ) {} + + func draw(in view: MTKView) { + guard let renderPass = currentRenderPassDescriptor, + let drawable = currentDrawable, + let commandQueue, + let pipelineState, + !frameResources.isEmpty else { + return + } + guard frameResourceSemaphore.wait(timeout: .now()) == .success else { + return + } + var committed = false + defer { + if !committed { + frameResourceSemaphore.signal() + } + } + let resources = frameResources[frameResourceIndex] + frameResourceIndex = (frameResourceIndex + 1) % frameResources.count + guard + let commandBuffer = commandQueue.makeCommandBuffer(), + let encoder = commandBuffer.makeRenderCommandEncoder( + descriptor: renderPass + ) else { + return + } + + encoder.label = "KeyLight Physical Refraction Pass" + encoder.setRenderPipelineState(pipelineState) + var encodedFrameSequence: UInt64? + + if let capturedFrame = capture.latestFrame(), + let capturedTexture = texture(for: capturedFrame) { + encodedFrameSequence = capturedFrame.sequence + let surfaces = makeSurfaces() + var surfaceOffset = 0 + var uniformOffset = 0 + for group in makeSurfaceGroups(from: surfaces) { + var uniforms = Uniforms( + viewport: SIMD4( + Float(max(bounds.width, 1)), + Float(max(bounds.height, 1)), + capturedFrame.captureHeightPoints, + Float(bodyOpacity) + ), + optics: SIMD4( + Float(0.95 + edgeStrength * 1.10), + Float(edgeStrength), + Float(max(drawableSize.width, 1)), + Float(max(drawableSize.height, 1)) + ), + tuning: SIMD4( + Float(refractionStrength), + Float( + PhysicalRefractionOptics.transmissionLimit( + for: refractionStrength + ) + ), + 0, + 0 + ), + counts: SIMD4( + UInt32(group.surfaces.count), + 0, + 0, + 0 + ) + ) + let uniformLength = MemoryLayout.stride + withUnsafeBytes(of: &uniforms) { bytes in + guard let source = bytes.baseAddress else { return } + resources.uniforms.contents() + .advanced(by: uniformOffset) + .copyMemory(from: source, byteCount: uniformLength) + } + encoder.setFragmentBuffer( + resources.uniforms, + offset: uniformOffset, + index: 0 + ) + let groupByteCount = group.surfaces.count + * MemoryLayout.stride + group.surfaces.withUnsafeBytes { bytes in + if let baseAddress = bytes.baseAddress { + resources.surfaces.contents() + .advanced(by: surfaceOffset) + .copyMemory( + from: baseAddress, + byteCount: groupByteCount + ) + } + } + encoder.setFragmentBuffer( + resources.surfaces, + offset: surfaceOffset, + index: 1 + ) + encoder.setFragmentTexture(capturedTexture, index: 0) + if let scissor = makeScissorRect(for: group.surfaces) { + encoder.setScissorRect(scissor) + } + encoder.drawPrimitives( + type: .triangle, + vertexStart: 0, + vertexCount: 3 + ) + uniformOffset += Self.uniformStride + surfaceOffset += groupByteCount + } + } + + encoder.endEncoding() + let semaphore = frameResourceSemaphore + commandBuffer.addCompletedHandler { _ in + semaphore.signal() + } + commandBuffer.present(drawable) + commandBuffer.commit() + committed = true + if let encodedFrameSequence, + encodedFrameSequence != lastPresentedFrameSequence { + if let lastPresentedFrameSequence, + encodedFrameSequence > lastPresentedFrameSequence + 1 { + KeyLightSignposts.frameDropped( + sequence: encodedFrameSequence + ) + } + lastPresentedFrameSequence = encodedFrameSequence + KeyLightSignposts.framePresented(sequence: encodedFrameSequence) + } + } + + private func makeSurfaces() -> [Surface] { + let viewHeight = max(bounds.height, 1) + return snapshots.map { snapshot in + let frame = snapshot.frame + let centerVelocity = snapshot.horizontalVelocity + let flowScale = max(frame.width * 4.5, 240) + let flow = min(max(centerVelocity / flowScale, -1), 1) + return Surface( + frame: SIMD4( + Float(frame.minX), + Float(viewHeight - frame.maxY), + Float(max(frame.width, 1)), + Float(max(frame.height, 1)) + ), + optical: SIMD4( + Float(min(max(snapshot.visibility, 0), 1)), + Float(min(max(snapshot.emergence, 0), 1)), + Float(min(max(snapshot.smoothness, 0), 1)), + Float(flow) + ) + ) + } + } + + private func makeScissorRect( + for surfaces: [Surface] + ) -> MTLScissorRect? { + guard !surfaces.isEmpty, + bounds.width > 0, + bounds.height > 0, + drawableSize.width > 0, + drawableSize.height > 0 else { + return nil + } + + let opticalMargin = PhysicalRefractionOptics.opticalMargin( + for: refractionStrength + ) + var pointBounds = CGRect.null + for surface in surfaces { + pointBounds = pointBounds.union(CGRect( + x: CGFloat(surface.frame.x), + y: CGFloat(surface.frame.y), + width: CGFloat(surface.frame.z), + height: CGFloat(surface.frame.w) + )) + } + pointBounds = pointBounds + .insetBy(dx: -opticalMargin, dy: -opticalMargin) + .intersection(bounds) + guard !pointBounds.isNull, + !pointBounds.isEmpty else { + return nil + } + + let scaleX = drawableSize.width / bounds.width + let scaleY = drawableSize.height / bounds.height + let minX = max(Int(floor(pointBounds.minX * scaleX)), 0) + let minY = max(Int(floor(pointBounds.minY * scaleY)), 0) + let maxX = min( + Int(ceil(pointBounds.maxX * scaleX)), + Int(drawableSize.width) + ) + let maxY = min( + Int(ceil(pointBounds.maxY * scaleY)), + Int(drawableSize.height) + ) + guard maxX > minX, maxY > minY else { return nil } + return MTLScissorRect( + x: minX, + y: minY, + width: maxX - minX, + height: maxY - minY + ) + } + + private struct SurfaceGroup { + var surfaces: [Surface] + var bounds: CGRect + } + + /// Groups only surfaces whose expanded optical footprints overlap. Each + /// group gets its own draw and scissor, so distant chords never shade the + /// empty pixels between their keys. + private func makeSurfaceGroups(from surfaces: [Surface]) -> [SurfaceGroup] { + let margin = PhysicalRefractionOptics.opticalMargin( + for: refractionStrength + ) + var groups: [SurfaceGroup] = [] + + for surface in surfaces { + let frame = CGRect( + x: CGFloat(surface.frame.x), + y: CGFloat(surface.frame.y), + width: CGFloat(surface.frame.z), + height: CGFloat(surface.frame.w) + ).insetBy(dx: -margin, dy: -margin) + let touchingIndices = groups.indices.filter { + groups[$0].bounds.intersects(frame) + } + guard let first = touchingIndices.first else { + groups.append(SurfaceGroup(surfaces: [surface], bounds: frame)) + continue + } + + groups[first].surfaces.append(surface) + groups[first].bounds = groups[first].bounds.union(frame) + for index in touchingIndices.dropFirst().reversed() { + groups[first].surfaces.append(contentsOf: groups[index].surfaces) + groups[first].bounds = groups[first].bounds.union(groups[index].bounds) + groups.remove(at: index) + } + } + return groups + } + + private func texture( + for frame: CapturedBackdropFrame + ) -> (any MTLTexture)? { + if cachedFrameSequence == frame.sequence { + return cachedTexture + } + guard let textureCache else { return nil } + let pixelBuffer = frame.pixelBuffer + let width = CVPixelBufferGetWidth(pixelBuffer) + let height = CVPixelBufferGetHeight(pixelBuffer) + guard width > 0, height > 0 else { return nil } + + var imageTexture: CVMetalTexture? + let status = CVMetalTextureCacheCreateTextureFromImage( + kCFAllocatorDefault, + textureCache, + pixelBuffer, + nil, + .bgra8Unorm, + width, + height, + 0, + &imageTexture + ) + guard status == kCVReturnSuccess, + let imageTexture, + let texture = CVMetalTextureGetTexture(imageTexture) else { + CVMetalTextureCacheFlush(textureCache, 0) + releaseCachedTexture() + return nil + } + cachedFrameSequence = frame.sequence + cachedImageTexture = imageTexture + cachedTexture = texture + return texture + } + + private func releaseCachedTexture() { + cachedFrameSequence = nil + cachedImageTexture = nil + cachedTexture = nil + if let textureCache { + CVMetalTextureCacheFlush(textureCache, 0) + } + } +} + +private struct CapturedBackdropFrame { + let pixelBuffer: CVPixelBuffer + let captureHeightPoints: Float + let sequence: UInt64 +} + +/// Captures only the display's bottom strip. Frames stay IOSurface-backed and +/// cross into Metal through CVMetalTextureCache; KeyLight never reads, stores, +/// logs, OCRs, or transmits their pixels. +private final class ScreenBackdropCapture: + NSObject, + SCStreamOutput, + SCStreamDelegate, + @unchecked Sendable +{ + private static let transparentBlack = CGColor( + red: 0, + green: 0, + blue: 0, + alpha: 0 + ) + + private let lock = NSLock() + private let outputQueue = DispatchQueue( + label: "com.keylight.physical-refraction.capture", + qos: .userInteractive + ) + + private var stream: SCStream? + private var frame: CapturedBackdropFrame? + private var generation: UInt = 0 + private var readinessHandler: (@Sendable (Bool) -> Void)? + private var frameHandler: (@Sendable () -> Void)? + private var hasDeliveredReadyFrame = false + private var frameSequence: UInt64 = 0 + + func start( + displayID: CGDirectDisplayID, + overlayHeightPoints: CGFloat, + readinessHandler: @escaping @Sendable (Bool) -> Void, + frameHandler: @escaping @Sendable () -> Void + ) { + stop() + guard ScreenCaptureAuthorization.isGranted else { + readinessHandler(false) + return + } + + let activeGeneration = lock.withLock { () -> UInt in + generation &+= 1 + self.readinessHandler = readinessHandler + self.frameHandler = frameHandler + hasDeliveredReadyFrame = false + frame = nil + return generation + } + + Task { [weak self] in + guard let self else { return } + do { + try await self.startStream( + displayID: displayID, + overlayHeightPoints: overlayHeightPoints, + generation: activeGeneration + ) + } catch { + self.publishReadiness( + false, + generation: activeGeneration + ) + } + } + } + + func stop() { + let oldStream = lock.withLock { () -> SCStream? in + generation &+= 1 + let oldStream = stream + stream = nil + frame = nil + hasDeliveredReadyFrame = false + readinessHandler = nil + frameHandler = nil + return oldStream + } + if let oldStream { + Task { + try? await oldStream.stopCapture() + } + } + } + + func latestFrame() -> CapturedBackdropFrame? { + lock.withLock { frame } + } + + private func startStream( + displayID: CGDirectDisplayID, + overlayHeightPoints: CGFloat, + generation activeGeneration: UInt + ) async throws { + let content = try await SCShareableContent.excludingDesktopWindows( + false, + onScreenWindowsOnly: true + ) + guard let display = content.displays.first(where: { + $0.displayID == displayID + }) else { + throw CaptureError.displayUnavailable + } + + let currentPID = getpid() + let ownApplications = content.applications.filter { + $0.processID == currentPID + } + let ownWindows = content.windows.filter { + $0.owningApplication?.processID == currentPID + } + let filter: SCContentFilter + if !ownApplications.isEmpty { + filter = SCContentFilter( + display: display, + excludingApplications: ownApplications, + exceptingWindows: [] + ) + } else if !ownWindows.isEmpty { + // Some agent-style LSUIElement processes are absent from the + // application list even though their overlay window is shareable. + // Exclude those windows directly so a stale optical ridge cannot + // feed back into the next captured frame. + filter = SCContentFilter( + display: display, + excludingWindows: ownWindows + ) + } else { + throw CaptureError.currentProcessUnavailable + } + + let displayWidth = CGFloat(display.width) + let displayHeight = CGFloat(display.height) + let captureHeight = min( + max(overlayHeightPoints + 80, 180), + displayHeight + ) + let sourceRect = CGRect( + x: 0, + y: max(displayHeight - captureHeight, 0), + width: displayWidth, + height: captureHeight + ) + + let configuration = SCStreamConfiguration() + configuration.sourceRect = sourceRect + // One output pixel per logical point is half Retina on the common 2x + // display while remaining crisp enough for a 120-point optical strip. + configuration.width = max(Int(displayWidth.rounded(.up)), 1) + configuration.height = max(Int(captureHeight.rounded(.up)), 1) + configuration.minimumFrameInterval = CMTime( + value: 1, + timescale: 30 + ) + configuration.queueDepth = 2 + configuration.pixelFormat = kCVPixelFormatType_32BGRA + configuration.showsCursor = false + configuration.capturesAudio = false + configuration.scalesToFit = true + configuration.preservesAspectRatio = false + configuration.colorSpaceName = CGColorSpace.sRGB + configuration.backgroundColor = Self.transparentBlack + configuration.shouldBeOpaque = false + + let stream = SCStream( + filter: filter, + configuration: configuration, + delegate: self + ) + try stream.addStreamOutput( + self, + type: .screen, + sampleHandlerQueue: outputQueue + ) + + let shouldStart = lock.withLock { () -> Bool in + guard generation == activeGeneration else { return false } + self.stream = stream + return true + } + guard shouldStart else { + throw CancellationError() + } + try await stream.startCapture() + } + + func stream( + _ stream: SCStream, + didOutputSampleBuffer sampleBuffer: CMSampleBuffer, + of type: SCStreamOutputType + ) { + guard type == .screen, + sampleBuffer.isValid, + let attachmentsArray = CMSampleBufferGetSampleAttachmentsArray( + sampleBuffer, + createIfNecessary: false + ) as? [[SCStreamFrameInfo: Any]], + let attachments = attachmentsArray.first, + let statusRawValue = attachments[.status] as? Int, + SCFrameStatus(rawValue: statusRawValue) == .complete, + let pixelBuffer = sampleBuffer.imageBuffer else { + return + } + + let update = lock.withLock { + guard self.stream === stream else { + return ( + handler: Optional<(@Sendable (Bool) -> Void)>.none, + frameHandler: Optional<(@Sendable () -> Void)>.none, + generation: generation + ) + } + frameSequence &+= 1 + frame = CapturedBackdropFrame( + pixelBuffer: pixelBuffer, + captureHeightPoints: Float( + CVPixelBufferGetHeight(pixelBuffer) + ), + sequence: frameSequence + ) + let capturedFrameHandler = frameHandler + guard !hasDeliveredReadyFrame else { + return ( + handler: Optional<(@Sendable (Bool) -> Void)>.none, + frameHandler: capturedFrameHandler, + generation: generation + ) + } + hasDeliveredReadyFrame = true + return (readinessHandler, capturedFrameHandler, generation) + } + update.handler?(true) + update.frameHandler?() + } + + func stream( + _ stream: SCStream, + didStopWithError error: any Error + ) { + let handler = lock.withLock { + guard self.stream === stream else { + return Optional<(@Sendable (Bool) -> Void)>.none + } + generation &+= 1 + let handler = readinessHandler + self.stream = nil + frame = nil + hasDeliveredReadyFrame = false + readinessHandler = nil + frameHandler = nil + return handler + } + handler?(false) + } + + private func publishReadiness( + _ ready: Bool, + generation expectedGeneration: UInt + ) { + let handler = lock.withLock { + generation == expectedGeneration ? readinessHandler : nil + } + handler?(ready) + } + + private enum CaptureError: Error { + case displayUnavailable + case currentProcessUnavailable + } +} + +private extension NSLock { + func withLock(_ operation: () throws -> T) rethrows -> T { + lock() + defer { unlock() } + return try operation() + } +} +#endif diff --git a/KeyLight/Views/PhysicalRefractionShaders.metal b/KeyLight/Views/PhysicalRefractionShaders.metal new file mode 100644 index 0000000..da47258 --- /dev/null +++ b/KeyLight/Views/PhysicalRefractionShaders.metal @@ -0,0 +1,518 @@ +#include +using namespace metal; + +struct KeyLightRefractionUniforms { + float4 viewport; + float4 optics; + float4 tuning; + uint4 counts; +}; + +struct KeyLightRefractionSurface { + float4 frame; + float4 optical; +}; + +struct KeyLightRefractionVertexOut { + float4 position [[position]]; +}; + +vertex KeyLightRefractionVertexOut keyLightRefractionVertex( + uint vertexID [[vertex_id]] +) { + const float2 positions[3] = { + float2(-1.0, -1.0), + float2( 3.0, -1.0), + float2(-1.0, 3.0) + }; + KeyLightRefractionVertexOut output; + output.position = float4(positions[vertexID], 0.0, 1.0); + return output; +} + +static float keyLightRoof( + float normalizedX, + float smoothness +) { + const float x = abs(normalizedX); + if (x >= 1.0) { + return 0.0; + } + + // Current Wave: the same compact-to-wide plateau relationship used by + // LiquidGlassBellShape. Smoothstep is the continuous cubic shoulder. + const float shoulderShare = + 0.12 + 0.30 * pow(clamp(smoothness, 0.0, 1.0), 1.55); + const float plateauEdge = max(1.0 - 2.0 * shoulderShare, 0.02); + return 1.0 - smoothstep(plateauEdge, 1.0, x); +} + +static float keyLightSurfaceHeight( + float2 point, + KeyLightRefractionSurface surface +) { + const float visibility = clamp(surface.optical.x, 0.0, 1.0); + const float emergence = clamp(surface.optical.y, 0.0, 1.0); + const float smoothness = clamp(surface.optical.z, 0.0, 1.0); + const float flow = clamp(surface.optical.w, -1.0, 1.0); + if (visibility <= 0.0001 || emergence <= 0.0001) { + return 0.0; + } + + const float materialWidth = + surface.frame.z * (0.28 + 0.72 * emergence); + const float centerX = + surface.frame.x + surface.frame.z * 0.5 + + materialWidth * 0.045 * flow * emergence; + const float baselineY = surface.frame.y + surface.frame.w * 0.72; + const float rise = max(surface.frame.w * 0.72 * emergence, 0.5); + const float normalizedX = + (point.x - centerX) / max(materialWidth * 0.5, 0.5); + const float roof = keyLightRoof( + normalizedX, + smoothness + ); + if (roof <= 0.0) { + return 0.0; + } + + const float vertical = (baselineY - point.y) / rise; + const float throughLens = vertical / max(roof, 0.0001); + if (abs(throughLens) >= 1.0) { + return 0.0; + } + + // The visible display cuts through the center of a convex optical surface. + // Its lower half continues behind the bezel instead of terminating at the + // screen edge. Consequently the thickness and its vertical derivative are + // continuous at the bottom crop; only the open top and side silhouette can + // produce a refractive boundary. + const float verticalArc = sqrt( + max(1.0 - throughLens * throughLens, 0.0) + ); + return visibility + * pow(roof, 0.70) + * pow(verticalArc, 0.72); +} + +static float keyLightSurfaceDomain( + float2 point, + KeyLightRefractionSurface surface +) { + const float visibility = clamp(surface.optical.x, 0.0, 1.0); + const float emergence = clamp(surface.optical.y, 0.0, 1.0); + const float smoothness = clamp(surface.optical.z, 0.0, 1.0); + const float flow = clamp(surface.optical.w, -1.0, 1.0); + if (visibility <= 0.0001 || emergence <= 0.0001) { + return -100000.0; + } + + const float materialWidth = + surface.frame.z * (0.28 + 0.72 * emergence); + const float centerX = + surface.frame.x + surface.frame.z * 0.5 + + materialWidth * 0.045 * flow * emergence; + const float baselineY = surface.frame.y + surface.frame.w * 0.72; + const float rise = max(surface.frame.w * 0.72 * emergence, 0.5); + const float normalizedX = + (point.x - centerX) / max(materialWidth * 0.5, 0.5); + const float roof = keyLightRoof(normalizedX, smoothness); + const float vertical = (baselineY - point.y) / rise; + + // This is a signed coverage field for the open upper lens. There is no + // bottom term: the glass continues below the drawable and the view clips it. + const float horizontalDomain = 1.0 - abs(normalizedX); + const float topDomain = roof - vertical; + return min(horizontalDomain, topDomain); +} + +static float keyLightSmoothMaximum(float left, float right, float radius) { + if (left <= 0.0) { + return right; + } + if (right <= 0.0) { + return left; + } + const float h = clamp( + 0.5 + 0.5 * (left - right) / max(radius, 0.0001), + 0.0, + 1.0 + ); + return mix(right, left, h) + radius * h * (1.0 - h); +} + +static float keyLightSurfaceCenterX( + KeyLightRefractionSurface surface +) { + const float emergence = clamp(surface.optical.y, 0.0, 1.0); + const float flow = clamp(surface.optical.w, -1.0, 1.0); + const float materialWidth = + surface.frame.z * (0.28 + 0.72 * emergence); + return surface.frame.x + surface.frame.z * 0.5 + + materialWidth * 0.045 * flow * emergence; +} + +static float keyLightHeightField( + float2 point, + constant KeyLightRefractionUniforms &uniforms, + constant KeyLightRefractionSurface *surfaces +) { + float strongest = 0.0; + float second = 0.0; + float strongestCenterX = 0.0; + float secondCenterX = 0.0; + float strongestSmoothness = 0.0; + float secondSmoothness = 0.0; + const uint count = min(uniforms.counts.x, 32u); + for (uint index = 0u; index < count; ++index) { + const float candidate = keyLightSurfaceHeight( + point, + surfaces[index] + ); + const float candidateCenterX = + keyLightSurfaceCenterX(surfaces[index]); + const float candidateSmoothness = + clamp(surfaces[index].optical.z, 0.0, 1.0); + if (candidate > strongest) { + second = strongest; + secondCenterX = strongestCenterX; + secondSmoothness = strongestSmoothness; + strongest = candidate; + strongestCenterX = candidateCenterX; + strongestSmoothness = candidateSmoothness; + } else if (candidate > second) { + second = candidate; + secondCenterX = candidateCenterX; + secondSmoothness = candidateSmoothness; + } + } + + if (second <= 0.0) { + return strongest; + } + + const float leftCenterX = min(strongestCenterX, secondCenterX); + const float rightCenterX = max(strongestCenterX, secondCenterX); + const float centerDistance = rightCenterX - leftCenterX; + if (centerDistance <= 0.5 + || point.x <= leftCenterX + || point.x >= rightCenterX) { + return strongest; + } + + // Smooth-union only the saddle between two real key centers. The blend + // weight is exactly zero at both centers and everywhere outside them, so + // neither key's peak nor either exterior shoulder can move. + const float interiorT = + clamp((point.x - leftCenterX) / centerDistance, 0.0, 1.0); + const float interiorWeight = + 4.0 * interiorT * (1.0 - interiorT); + const float averageSmoothness = + 0.5 * (strongestSmoothness + secondSmoothness); + const float blendRadius = + (0.035 + 0.025 * averageSmoothness) * interiorWeight; + return keyLightSmoothMaximum( + strongest, + second, + blendRadius + ); +} + +static float keyLightDomainField( + float2 point, + constant KeyLightRefractionUniforms &uniforms, + constant KeyLightRefractionSurface *surfaces +) { + float domain = -100000.0; + const uint count = min(uniforms.counts.x, 32u); + for (uint index = 0u; index < count; ++index) { + domain = max( + domain, + keyLightSurfaceDomain(point, surfaces[index]) + ); + } + return domain; +} + +static float2 keyLightBoundedOffset( + float2 offset, + float maximumLength +) { + const float lengthSquared = dot(offset, offset); + const float maximumSquared = maximumLength * maximumLength; + if (lengthSquared <= maximumSquared || lengthSquared <= 0.000001) { + return offset; + } + return offset * (maximumLength * rsqrt(lengthSquared)); +} + +// The lens is attached to the physical bottom edge of the display. An ideal +// infinite plano-convex surface would bend its upper-face ray farther down, +// where this product has no screen content to sample. Constrain that component +// to the screen-facing hemisphere while preserving lateral dispersion. This +// models the opaque bezel boundary instead of stretching one nonexistent row. +static float2 keyLightScreenFacingOffset(float2 offset) { + return float2(offset.x, -abs(offset.y)); +} + +// A side key can still refract beyond the left or right display boundary. +// Mirror the nearest valid gradient once instead of repeating a single border +// texel. The bounded optical path is far smaller than one texture dimension, +// so one reflection is sufficient. +static float keyLightMirroredUnitCoordinate(float coordinate) { + if (coordinate < 0.0) { + return min(-coordinate, 1.0); + } + if (coordinate > 1.0) { + return max(2.0 - coordinate, 0.0); + } + return coordinate; +} + +static float2 keyLightValidBackdropUV( + float2 uv, + texture2d backdrop +) { + const float2 textureSize = max( + float2(backdrop.get_width(), backdrop.get_height()), + float2(1.0) + ); + const float2 halfTexel = 0.5 / textureSize; + const float2 mirrored = float2( + keyLightMirroredUnitCoordinate(uv.x), + keyLightMirroredUnitCoordinate(uv.y) + ); + return clamp(mirrored, halfTexel, 1.0 - halfTexel); +} + +static float3 keyLightBackdropColor( + texture2d backdrop, + sampler backdropSampler, + float2 uv +) { + const float4 sample = backdrop.sample( + backdropSampler, + keyLightValidBackdropUV(uv, backdrop) + ); + // Display captures are normally opaque. Multiplying by alpha makes clear + // ScreenCaptureKit padding resolve to transparent black instead of exposing + // undefined or white RGB payloads. + return sample.rgb * clamp(sample.a, 0.0, 1.0); +} + +fragment float4 keyLightRefractionFragment( + KeyLightRefractionVertexOut input [[stage_in]], + constant KeyLightRefractionUniforms &uniforms [[buffer(0)]], + constant KeyLightRefractionSurface *surfaces [[buffer(1)]], + texture2d capturedBackdrop [[texture(0)]] +) { + constexpr sampler backdropSampler( + min_filter::linear, + mag_filter::linear, + mip_filter::none, + address::clamp_to_edge, + coord::normalized + ); + + const float2 drawableSize = max( + uniforms.optics.zw, + float2(1.0) + ); + const float2 viewSize = max( + uniforms.viewport.xy, + float2(1.0) + ); + const float2 point = input.position.xy * viewSize / drawableSize; + const float domain = keyLightDomainField( + point, + uniforms, + surfaces + ); + const float domainAntialias = max(fwidth(domain) * 0.72, 0.00025); + const float coverage = smoothstep( + -domainAntialias, + domainAntialias, + domain + ); + if (coverage <= 0.0001) { + return float4(0.0); + } + + const float centerHeight = keyLightHeightField( + point, + uniforms, + surfaces + ); + if (centerHeight <= 0.0001) { + return float4(0.0); + } + + // One drawable pixel in point coordinates gives a stable normal at both + // 1x and Retina scale without quantizing the analytic contour. + const float epsilon = max( + max(viewSize.x / drawableSize.x, viewSize.y / drawableSize.y), + 0.35 + ); + const float2 gradient = float2( + keyLightHeightField( + point + float2(epsilon, 0.0), + uniforms, + surfaces + ) - keyLightHeightField( + point - float2(epsilon, 0.0), + uniforms, + surfaces + ), + keyLightHeightField( + point + float2(0.0, epsilon), + uniforms, + surfaces + ) - keyLightHeightField( + point - float2(0.0, epsilon), + uniforms, + surfaces + ) + ) / (2.0 * epsilon); + + // The height field is dimensionless. This converts it to the slope of the + // curved front interface; the rear interface is assumed flat and parallel + // to the display for a low-cost thin-lens approximation. + const float3 normal = normalize(float3(-gradient * 17.0, 1.0)); + const float3 incident = float3(0.0, 0.0, -1.0); + + // SCHOTT N-BK7 catalogue indices at the C, e, and F spectral lines. + // Red 1.51432, green 1.51872, blue 1.52238. + const float3 transmittedRed = refract(incident, normal, 1.0 / 1.51432); + const float3 transmittedGreen = refract(incident, normal, 1.0 / 1.51872); + const float3 transmittedBlue = refract(incident, normal, 1.0 / 1.52238); + const float pathLengthMultiplier = + clamp(uniforms.tuning.x, 0.5, 2.5); + const float transmissionOffsetLimit = + clamp(uniforms.tuning.y, 13.0, 65.0); + const float effectiveThickness = + (4.0 + 10.0 * centerHeight) + * uniforms.optics.x + * pathLengthMultiplier; + + const float2 redOffset = keyLightBoundedOffset( + keyLightScreenFacingOffset( + transmittedRed.xy / max(abs(transmittedRed.z), 0.16) + * effectiveThickness + ), + transmissionOffsetLimit + ); + const float2 greenOffset = keyLightBoundedOffset( + keyLightScreenFacingOffset( + transmittedGreen.xy / max(abs(transmittedGreen.z), 0.16) + * effectiveThickness + ), + transmissionOffsetLimit + ); + const float2 blueOffset = keyLightBoundedOffset( + keyLightScreenFacingOffset( + transmittedBlue.xy / max(abs(transmittedBlue.z), 0.16) + * effectiveThickness + ), + transmissionOffsetLimit + ); + + const float captureHeight = max(uniforms.viewport.z, viewSize.y); + const float captureTopInset = + max(captureHeight - viewSize.y, 0.0); + + const float2 redUV = float2( + (point.x + redOffset.x) / viewSize.x, + (captureTopInset + point.y + redOffset.y) / captureHeight + ); + const float2 greenUV = float2( + (point.x + greenOffset.x) / viewSize.x, + (captureTopInset + point.y + greenOffset.y) / captureHeight + ); + const float2 blueUV = float2( + (point.x + blueOffset.x) / viewSize.x, + (captureTopInset + point.y + blueOffset.y) / captureHeight + ); + + const float3 redSample = keyLightBackdropColor( + capturedBackdrop, + backdropSampler, + redUV + ); + const float3 greenSample = keyLightBackdropColor( + capturedBackdrop, + backdropSampler, + greenUV + ); + const float3 blueSample = keyLightBackdropColor( + capturedBackdrop, + backdropSampler, + blueUV + ); + const float3 transmittedColor = float3( + redSample.r, + greenSample.g, + blueSample.b + ); + + // Schlick Fresnel with the N-BK7 green-line index: F0 ≈ 0.04216. + const float f0 = 0.04216; + const float cosTheta = clamp(abs(normal.z), 0.0, 1.0); + const float fresnel = + f0 + (1.0 - f0) * pow(1.0 - cosTheta, 5.0); + + // A screen overlay cannot observe the room behind the viewer. Use one + // nearby backdrop sample as a bounded environment proxy instead of + // inventing a white or gray reflection. On a uniform backdrop this remains + // uniform, as real clear glass should. + const float3 reflectedRay = reflect(incident, normal); + const float reflectionDistance = + 3.0 + 7.0 * clamp(uniforms.optics.y, 0.0, 1.0); + const float2 reflectionOffset = keyLightBoundedOffset( + keyLightScreenFacingOffset( + reflectedRay.xy / max(abs(reflectedRay.z), 0.24) + * reflectionDistance + ), + 20.0 + ); + const float2 reflectedUV = float2( + (point.x + reflectionOffset.x) / viewSize.x, + (captureTopInset + point.y + reflectionOffset.y) / captureHeight + ); + const float3 reflectedColor = keyLightBackdropColor( + capturedBackdrop, + backdropSampler, + reflectedUV + ); + const float reflectedShare = clamp( + fresnel + * (0.36 + 0.54 * clamp(uniforms.optics.y, 0.0, 1.0)), + 0.0, + 0.82 + ); + const float3 finalColor = mix( + transmittedColor, + reflectedColor, + reflectedShare + ); + + // Only steep top and side normals replace the real backdrop with the + // refracted sample. The flat interior contributes zero color and zero + // opacity, so there is no synthetic body fill to turn gray or white. + const float edgeEnergy = smoothstep( + 0.055, + 0.72, + 1.0 - cosTheta + ); + const float presence = smoothstep(0.0005, 0.045, centerHeight); + const float userEdgeStrength = + clamp(uniforms.optics.y, 0.0, 1.0); + const float savedOpacity = + clamp(uniforms.viewport.w, 0.0, 1.0); + const float edgeOpacity = + 0.70 + 0.22 * userEdgeStrength + 0.08 * (1.0 - savedOpacity); + const float alpha = coverage + * presence + * edgeEnergy + * edgeOpacity; + return float4(finalColor * alpha, alpha); +} diff --git a/KeyLight/Views/SettingsAppearanceTab.swift b/KeyLight/Views/SettingsAppearanceTab.swift new file mode 100644 index 0000000..15b6aab --- /dev/null +++ b/KeyLight/Views/SettingsAppearanceTab.swift @@ -0,0 +1,850 @@ +import SwiftUI + +extension SettingsView { + @ViewBuilder + var appearanceTabContent: some View { + let liquidGlassUsesNeutralMaterial = + !model.effectStyle.usesClassicColorConfiguration + let solidBlackSelected = model.effectStyle == .solidBlack + let systemGlassSelected = model.effectStyle == .systemGlass + let physicalRefractionSelected = model.effectStyle == .physicalRefraction + let displayedHeight = + liquidGlassUsesNeutralMaterial + ? Double( + LiquidGlassTransitionMath.bezelHeight( + glowHeight: CGFloat(model.glowSize), + containerHeight: 120 + ) + ) + : model.glowSize + let heightRange: ClosedRange = + liquidGlassUsesNeutralMaterial + ? 4...53 + : 4...200 + let heightBinding = Binding( + get: { + liquidGlassUsesNeutralMaterial + ? Double( + LiquidGlassTransitionMath.bezelHeight( + glowHeight: CGFloat(model.glowSize), + containerHeight: 120 + ) + ) + : model.glowSize + }, + set: { newHeight in + if liquidGlassUsesNeutralMaterial { + model.glowSize = min(max((newHeight - 3) / 0.25, 4), 200) + } else { + model.glowSize = newHeight + } + } + ) + let chordStyleBinding = Binding( + get: { model.chordAppearance.style }, + set: { style in + model.chordAppearance = ChordAppearance( + style: style, + intensityMultiplier: model.chordAppearance.intensityMultiplier + ) + } + ) + let chordIntensityBinding = Binding( + get: { model.chordAppearance.intensityMultiplier }, + set: { intensity in + model.chordAppearance = ChordAppearance( + style: model.chordAppearance.style, + intensityMultiplier: intensity + ) + } + ) + + VStack(alignment: .leading, spacing: 8) { + Text("Effect Style") + .font(.headline) + LazyVGrid( + columns: [ + GridItem(.flexible(), spacing: 8), + GridItem(.flexible(), spacing: 8), + ], + spacing: 8 + ) { + ForEach(EffectStyle.allCases, id: \.self) { effect in + appearanceEffectCard(effect) + } + } + + if model.effectStyle.requiresMacOS26 && !liquidGlassRuntimeAvailable { + Text("Classic Glow fallback is active on this macOS version.") + .font(.caption) + .foregroundColor(.secondary) + } + + if systemGlassSelected && liquidGlassRuntimeAvailable { + Text( + "Capture-free comparison: Apple controls the clear-glass optics. KeyLight supplies only the key shape, grouping, and motion." + ) + .font(.caption) + .foregroundColor(.secondary) + } + + if physicalRefractionSelected { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 6) { + Image( + systemName: screenCaptureAccessGranted + ? "checkmark.circle.fill" + : "exclamationmark.triangle.fill" + ) + .foregroundStyle( + screenCaptureAccessGranted ? .green : .orange + ) + Text( + screenCaptureAccessGranted + ? "Screen Recording allowed" + : "Screen Recording permission required" + ) + .font(.callout) + } + + HStack { + if screenCaptureAccessGranted { + Button("Open Screen Recording Settings") { + ScreenCaptureAuthorization.openSettings() + } + } else { + Button("Allow Screen Recording") { + screenCaptureAccessGranted = + ScreenCaptureAuthorization.requestAccess() + model.refreshEffectRenderer() + } + Button("Check Again") { + screenCaptureAccessGranted = + ScreenCaptureAuthorization.isGranted + model.refreshEffectRenderer() + } + Button("Open Settings") { + ScreenCaptureAuthorization.openSettings() + } + } + } + .controlSize(.small) + + Text( + screenCaptureAccessGranted + ? "Only a 180–200 point strip at the bottom of the selected display is sampled. Frames stay in GPU-backed memory and are never saved." + : "Until access is allowed, the selected effect safely renders with capture-free System Glass. KeyLight never requests this permission automatically." + ) + .font(.caption) + .foregroundColor(.secondary) + } + .padding(8) + .background( + (screenCaptureAccessGranted ? Color.green : Color.orange) + .opacity(0.07), + in: RoundedRectangle(cornerRadius: 8, style: .continuous) + ) + } + } + + Divider() + + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Chord Appearance") + .font(.headline) + Spacer() + Button(chordPreviewActive ? "Testing…" : "Test Four Keys") { + startChordPreviewTest() + } + .controlSize(.small) + .disabled(chordPreviewActive || !model.isEnabled) + } + + Picker("Surface Style", selection: chordStyleBinding) { + ForEach(ChordSurfaceStyle.allCases, id: \.self) { style in + Text(style.displayName).tag(style) + } + } + .pickerStyle(.segmented) + + HStack { + Text("Chord Intensity") + Spacer() + Text("\(Int((model.chordAppearance.intensityMultiplier * 100).rounded()))%") + .foregroundColor(.secondary) + .monospacedDigit() + } + Slider( + value: chordIntensityBinding, + in: ChordAppearance.intensityRange, + step: 0.05 + ) + .accessibilityLabel("Chord intensity") + .accessibilityValue( + "\(Int((model.chordAppearance.intensityMultiplier * 100).rounded())) percent" + ) + + Text( + model.chordAppearance.style == .naturalMerge + ? "Adjacent held keys keep the current cohesive surface. Intensity applies only while two or more keys are held." + : "Each held key keeps its own material boundary. Intensity applies only while two or more keys are held." + ) + .font(.caption) + .foregroundColor(.secondary) + } + + Divider() + + if !liquidGlassUsesNeutralMaterial { + Group { + VStack(alignment: .leading, spacing: 8) { + Text("Color Mode") + .font(.headline) + Picker("", selection: $model.colorMode) { + Text("Solid").tag(ColorMode.solid) + Text("Position Gradient").tag(ColorMode.positionGradient) + Text("Random Per Key").tag(ColorMode.randomPerKey) + Text("Rainbow").tag(ColorMode.rainbow) + } + .pickerStyle(.segmented) + .labelsHidden() + .accessibilityLabel("Color mode") + .accessibilityValue(colorModeAccessibilityValue) + .accessibilityHint("Available with Classic Glow") + } + + if model.colorMode == .solid { + VStack(alignment: .leading, spacing: 8) { + Text("Color") + .font(.headline) + HStack(spacing: 12) { + ColorPicker("Glow Color", selection: $model.glowColor, supportsOpacity: false) + .labelsHidden() + .frame(width: 44, height: 28) + .accessibilityLabel("Glow color") + .accessibilityValue( + "Hex \(normalizedHex(model.glowColor.toHex(), fallback: "68B8FF"))") + + HStack(spacing: 4) { + Text("#") + .foregroundColor(.secondary) + TextField("Hex", text: $hexColor) + .textFieldStyle(.roundedBorder) + .frame(width: 70) + .onChange(of: hexColor) { _, newValue in + guard !isUpdatingColor else { return } + isUpdatingColor = true + defer { isUpdatingColor = false } + if let color = Color(hex: newValue) { + model.glowColor = color + } + } + } + + HStack(spacing: 6) { + ColorPresetButton( + color: Color(hex: "68B8FF") ?? .blue, model: model, hexColor: $hexColor) + ColorPresetButton( + color: Color(hex: "00E69A") ?? .green, model: model, hexColor: $hexColor) + ColorPresetButton( + color: Color(hex: "FF6B6B") ?? .red, model: model, hexColor: $hexColor) + ColorPresetButton( + color: Color(hex: "FFD93D") ?? .yellow, model: model, hexColor: $hexColor) + ColorPresetButton( + color: Color(hex: "C77DFF") ?? .purple, model: model, hexColor: $hexColor) + } + } + .onChange(of: model.glowColor) { _, newColor in + guard !isUpdatingColor else { return } + isUpdatingColor = true + defer { isUpdatingColor = false } + hexColor = newColor.toHex() ?? "68B8FF" + } + } + } + + if model.colorMode == .positionGradient { + VStack(alignment: .leading, spacing: 8) { + Text("Gradient Colors") + .font(.headline) + + HStack(spacing: 16) { + VStack(spacing: 4) { + Text("Start") + .font(.caption) + .foregroundColor(.secondary) + ColorPicker( + "Gradient Start", selection: $model.gradientStartColor, supportsOpacity: false + ) + .labelsHidden() + .frame(width: 44, height: 28) + .accessibilityLabel("Gradient start color") + .accessibilityValue( + "Hex \(normalizedHex(model.gradientStartColor.toHex(), fallback: "68B8FF"))") + } + + LinearGradient( + colors: [model.gradientStartColor, model.gradientEndColor], + startPoint: .leading, + endPoint: .trailing + ) + .frame(height: 12) + .cornerRadius(6) + .overlay( + RoundedRectangle(cornerRadius: 6) + .stroke(Color.primary.opacity(0.2), lineWidth: 1) + ) + + VStack(spacing: 4) { + Text("End") + .font(.caption) + .foregroundColor(.secondary) + ColorPicker( + "Gradient End", selection: $model.gradientEndColor, supportsOpacity: false + ) + .labelsHidden() + .frame(width: 44, height: 28) + .accessibilityLabel("Gradient end color") + .accessibilityValue( + "Hex \(normalizedHex(model.gradientEndColor.toHex(), fallback: "00E69A"))") + } + } + + HStack { + Text("Presets") + .font(.caption) + .foregroundColor(.secondary) + Spacer() + Button("Delete Selected") { + deleteSelectedGradientPreset() + } + .font(.caption) + .disabled(selectedGradientPresetID == nil || gradientPresets.count <= 1) + Button("Add Gradient Colors") { + saveCurrentGradientPreset() + } + .font(.caption) + } + + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 6) { + ForEach(gradientPresets) { preset in + GradientPresetButton( + startHex: preset.startHex, endHex: preset.endHex, model: model) + } + } + } + } + } + + if model.colorMode == .randomPerKey { + Text("Each key uses a deterministic random color derived from its key code.") + .font(.caption) + .foregroundColor(.secondary) + } + + if model.colorMode == .rainbow { + Text("Colors are distributed left-to-right by key position.") + .font(.caption) + .foregroundColor(.secondary) + } + } + } + + if !liquidGlassUsesNeutralMaterial { + Divider() + } + + VStack(alignment: .leading, spacing: 12) { + Text("Optics") + .font(.headline) + + if solidBlackSelected { + Text("Solid Black is always fully opaque inside the active silhouette.") + .font(.caption) + .foregroundColor(.secondary) + } else { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text("Opacity") + Spacer() + Text("\(Int(model.glowOpacity * 100))%") + .foregroundColor(.secondary) + .monospacedDigit() + } + Slider( + value: $model.glowOpacity, + in: 0.05...1.0, + onEditingChanged: settingsPreviewEditingChanged + ) + .accessibilityLabel("Opacity") + .accessibilityValue("\(Int(model.glowOpacity * 100)) percent") + if physicalRefractionSelected { + Text("Controls the visibility of the optical contour. The body remains clear.") + .font(.caption) + .foregroundColor(.secondary) + } else if systemGlassSelected { + Text( + "Controls the visibility of Apple's system glass; the system controls its lensing and refraction." + ) + .font(.caption) + .foregroundColor(.secondary) + } else if liquidGlassUsesNeutralMaterial { + Text( + "Lower values clear the body while preserving native lensing and strengthening chromatic top and side refraction." + ) + .font(.caption) + .foregroundColor(.secondary) + } + } + } + + if physicalRefractionSelected { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text("Refraction Strength") + Spacer() + Text( + "\(Int((model.physicalRefractionStrength * 100).rounded()))%" + ) + .foregroundColor(.secondary) + .monospacedDigit() + } + Slider( + value: $model.physicalRefractionStrength, + in: 0.5...2.5, + onEditingChanged: settingsPreviewEditingChanged + ) + .accessibilityLabel("Refraction strength") + .accessibilityValue( + "\(Int((model.physicalRefractionStrength * 100).rounded())) percent" + ) + .accessibilityHint( + "Adjusts backdrop displacement at the top and side edges only" + ) + Text( + "100% preserves the current tuned glass. Higher values increase the optical path length and color separation only at the top and side edges; the bottom stays transparent." + ) + .font(.caption) + .foregroundColor(.secondary) + } + } + + Text("Geometry") + .font(.headline) + + VStack(alignment: .leading, spacing: 4) { + HStack { + Text("Height") + Spacer() + Text("\(Int(displayedHeight.rounded()))") + .foregroundColor(.secondary) + .monospacedDigit() + } + Slider( + value: heightBinding, + in: heightRange, + onEditingChanged: settingsPreviewEditingChanged + ) + .accessibilityLabel("Height") + .accessibilityValue("\(Int(displayedHeight.rounded())) points") + } + + if !liquidGlassUsesNeutralMaterial { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text("Width") + Spacer() + Text("\(Int(model.glowWidth * 100))%") + .foregroundColor(.secondary) + .monospacedDigit() + } + Slider( + value: $model.glowWidth, + in: 0.3...3.0, + onEditingChanged: settingsPreviewEditingChanged + ) + .accessibilityLabel("Width") + .accessibilityValue("\(Int(model.glowWidth * 100)) percent") + } + + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(liquidGlassUsesNeutralMaterial ? "Smoothness" : "Roundness") + Spacer() + Text( + model.glowRoundness < 0.05 + ? (liquidGlassUsesNeutralMaterial ? "Compact" : "Sharp") + : model.glowRoundness > 0.95 + ? (liquidGlassUsesNeutralMaterial ? "Wide + Soft" : "Round") + : "\(Int(model.glowRoundness * 100))%" + ) + .foregroundColor(.secondary) + .monospacedDigit() + } + Slider( + value: $model.glowRoundness, + in: 0.0...1.0, + onEditingChanged: settingsPreviewEditingChanged + ) + .accessibilityLabel(liquidGlassUsesNeutralMaterial ? "Smoothness" : "Roundness") + .accessibilityValue( + liquidGlassUsesNeutralMaterial + ? liquidGlassSmoothnessAccessibilityValue + : roundnessAccessibilityValue + ) + .accessibilityHint( + liquidGlassUsesNeutralMaterial + ? "Move left for a compact wave or right for a wider, softer wave" + : "Controls the corner profile of Classic Glow" + ) + if liquidGlassUsesNeutralMaterial { + Text( + "The left side strongly compresses the wave; the right side spreads and softens it." + ) + .font(.caption) + .foregroundColor(.secondary) + } + } + + VStack(alignment: .leading, spacing: 4) { + HStack { + Text("Hardness") + Spacer() + Text("\(Int(model.glowFullness * 100))%") + .foregroundColor(.secondary) + .monospacedDigit() + } + Slider( + value: $model.glowFullness, + in: 0.0...1.0, + onEditingChanged: settingsPreviewEditingChanged + ) + .accessibilityLabel("Hardness") + .accessibilityValue("\(Int(model.glowFullness * 100)) percent") + .accessibilityHint("Available with Classic Glow") + Text("Controls glow boundary feather (0% soft, 100% crisp).") + .font(.caption) + .foregroundColor(.secondary) + } + } + + Text("Motion") + .font(.headline) + VStack(alignment: .leading, spacing: 4) { + HStack { + Text("Fade Duration") + Spacer() + Text("\(String(format: "%.2f", model.fadeDuration))s") + .foregroundColor(.secondary) + .monospacedDigit() + } + Slider( + value: $model.fadeDuration, + in: 0.05...2.0, + onEditingChanged: settingsPreviewEditingChanged + ) + .accessibilityLabel("Fade duration") + .accessibilityValue("\(String(format: "%.2f", model.fadeDuration)) seconds") + if liquidGlassUsesNeutralMaterial { + Text( + solidBlackSelected + ? "Sets the tempo for reveal, key-to-key flow, and geometric retraction." + : "Sets the tempo for reveal, key-to-key flow, and fade-out." + ) + .font(.caption) + .foregroundColor(.secondary) + } + } + } + + Divider() + + VStack(alignment: .leading, spacing: 8) { + Text("Themes") + .font(.headline) + + Text("Themes store glow style settings only (color, effect, and fade).") + .font(.caption) + .foregroundColor(.secondary) + + if savedThemes.isEmpty { + Text("No saved themes yet.") + .font(.subheadline) + .foregroundColor(.secondary) + } else { + ForEach(savedThemes) { theme in + let isActive = currentThemeID == theme.id + let pendingID = PendingDeletionID.theme(theme.id) + let pendingState = pendingDeletions[pendingID] + + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 8) { + if editingThemeID == theme.id { + TextField("Theme name", text: $themeRenameDraft) + .textFieldStyle(.roundedBorder) + .onChange(of: themeRenameDraft) { _, _ in + themeRenameError = nil + } + Spacer(minLength: 10) + + Button("Save") { + saveThemeRename(theme) + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + .disabled(themeRenameValidation(for: theme) != nil) + + Button("Cancel") { + cancelThemeRename() + } + .controlSize(.small) + } else { + Button { + selectTheme(theme, isActive: isActive) + } label: { + HStack(spacing: 7) { + Image(systemName: isActive ? "checkmark.circle.fill" : "circle") + .foregroundStyle(isActive ? Color.accentColor : Color.secondary) + .accessibilityHidden(true) + Text(themeDisplayName(theme, isActive: isActive)) + .font(.subheadline) + .lineLimit(1) + Spacer(minLength: 0) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(isActive) + .accessibilityLabel("Theme \(theme.name)") + .accessibilityValue( + themeSelectionAccessibilityValue( + isActive: isActive, isEdited: isActive && activeThemeIsEdited)) + + if let pendingState { + Button("Undo (\(pendingState.secondsRemaining)s)") { + cancelPendingDeletion(for: pendingID) + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + } else { + Menu { + Button("Rename…") { + startThemeRename(theme) + } + .disabled(theme.name == Theme.defaultTheme.name) + + Button("Delete", role: .destructive) { + queueThemeDeletion(theme) + } + .disabled(theme.name == Theme.defaultTheme.name) + } label: { + Image(systemName: "ellipsis.circle") + } + .menuStyle(.borderlessButton) + .menuIndicator(.hidden) + .fixedSize() + .help("Theme actions") + .accessibilityLabel("Actions for theme \(theme.name)") + } + } + } + .frame(minHeight: 30) + + if editingThemeID == theme.id, + let error = themeRenameError ?? themeRenameValidation(for: theme) + { + Text(error) + .font(.caption2) + .foregroundColor(.red) + .padding(.leading, 24) + } + } + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(isActive ? Color.accentColor.opacity(0.12) : Color.primary.opacity(0.03)) + ) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke( + isActive ? Color.accentColor.opacity(0.35) : Color.primary.opacity(0.2), + lineWidth: 1 + ) + ) + .padding(.vertical, 0.5) + } + } + + if showingThemeSaveField { + HStack { + TextField("Theme name", text: $newThemeName) + .textFieldStyle(.roundedBorder) + + Button("Save") { + let trimmed = trimmed(newThemeName) + guard !trimmed.isEmpty else { return } + if saveCurrentThemeAs(trimmed) { + showingThemeSaveField = false + newThemeName = "" + } + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + .disabled(trimmed(newThemeName).isEmpty) + + Button("Cancel") { + showingThemeSaveField = false + newThemeName = "" + } + .controlSize(.small) + } + } else { + HStack(spacing: 8) { + Button("Update Theme") { + updateActiveTheme() + } + .controlSize(.small) + .disabled(activeTheme == nil || !activeThemeIsEdited) + .accessibilityHint("Replaces the selected theme with the current appearance") + + Button("Save As…") { + showingThemeSaveField = true + } + .controlSize(.small) + + Button("Revert") { + revertActiveTheme() + } + .controlSize(.small) + .disabled(activeTheme == nil || !activeThemeIsEdited) + .accessibilityHint("Restores the selected theme's saved appearance") + + Spacer() + + Button("Share…") { + refreshThemeTransferStringFromActiveTheme() + themeTransferFeedback = nil + themeTransferMode = .share + } + .controlSize(.small) + .disabled(activeTheme == nil) + + Button("Import…") { + themeTransferString = "" + themeTransferFeedback = nil + themeTransferMode = .importTheme + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + } + } + + if let themeTransferFeedback { + InlineSettingsFeedback(feedback: themeTransferFeedback) + } + } + } + + private func appearanceEffectCard(_ effect: EffectStyle) -> some View { + let selected = model.effectStyle == effect + let available = effect.isAvailableOnCurrentSystem + return Button { + guard available else { return } + model.selectEffect(effect) + } label: { + HStack(alignment: .top, spacing: 8) { + Image( + systemName: selected + ? "checkmark.circle.fill" + : "circle" + ) + .foregroundStyle(selected ? Color.accentColor : .secondary) + .accessibilityHidden(true) + + VStack(alignment: .leading, spacing: 3) { + Text(effect.displayName) + .font(.callout.weight(.semibold)) + Text(effect.appearanceSummary) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(2) + HStack(spacing: 5) { + Text( + effect.usesScreenCapture + ? "Optional Screen Recording" + : "No Screen Capture") + if effect.requiresMacOS26 { + Text("macOS 26+") + } + } + .font(.caption2) + .foregroundStyle(effect.usesScreenCapture ? .orange : .secondary) + } + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity, minHeight: 68, alignment: .topLeading) + .padding(8) + .background( + selected + ? Color.accentColor.opacity(0.10) + : Color.secondary.opacity(0.05), + in: RoundedRectangle(cornerRadius: 8, style: .continuous) + ) + .overlay( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .stroke( + selected + ? Color.accentColor.opacity(0.55) + : Color.secondary.opacity(0.16), + lineWidth: 1 + ) + ) + .opacity(available ? 1 : 0.55) + } + .buttonStyle(.plain) + .disabled(!available) + .accessibilityLabel(effect.displayName) + .accessibilityValue( + selected + ? String(localized: "Selected") + : String(localized: "Not selected") + ) + .accessibilityHint(effect.appearanceAccessibilityHint) + } +} + +extension EffectStyle { + fileprivate var appearanceSummary: String { + switch self { + case .classicGlow: + String(localized: "Original single-target glow with unchanged timing.") + case .classicPlus: + String(localized: "Retired preview effect; migrated to Classic Glow.") + case .liquidGlass: + String(localized: "Retired preview effect; migrated to System Glass.") + case .systemGlass: + String(localized: "Capture-free optics controlled by macOS.") + case .physicalRefraction: + String(localized: "Backdrop refraction with System Glass fallback.") + case .solidBlack: + String(localized: "Opaque silhouette with geometric retraction.") + } + } + + fileprivate var appearanceAccessibilityHint: String { + if !isAvailableOnCurrentSystem { + return String( + localized: "Unavailable on this macOS version; Classic Glow remains the fallback.") + } + if usesScreenCapture { + return String( + localized: + "Uses optional Screen Recording only while a physical surface is active; System Glass is the capture-free fallback." + ) + } + return String(localized: "Selects this capture-free effect and updates the live preview.") + } +} diff --git a/KeyLight/Views/SettingsComponents.swift b/KeyLight/Views/SettingsComponents.swift new file mode 100644 index 0000000..681949f --- /dev/null +++ b/KeyLight/Views/SettingsComponents.swift @@ -0,0 +1,512 @@ +import AppKit +import SwiftUI + +enum ThemeTransferMode: String, Identifiable { + case share + case importTheme + + var id: String { rawValue } +} + +struct InlineSettingsFeedback: View { + let feedback: UserFeedback + + var body: some View { + HStack(alignment: .top, spacing: 6) { + Image(systemName: icon) + .foregroundStyle(color) + .accessibilityHidden(true) + VStack(alignment: .leading, spacing: 1) { + Text(feedback.title) + .font(.caption.weight(.semibold)) + if let detail = feedback.detail { + Text(detail) + .font(.caption2) + .foregroundStyle(.secondary) + } + } + } + .accessibilityElement(children: .combine) + } + + private var icon: String { + switch feedback.severity { + case .information: return "info.circle.fill" + case .success: return "checkmark.circle.fill" + case .warning: return "exclamationmark.triangle.fill" + case .error: return "xmark.circle.fill" + } + } + + private var color: Color { + switch feedback.severity { + case .information: return .blue + case .success: return .green + case .warning: return .orange + case .error: return .red + } + } +} + +struct SettingsFeedbackBanner: View { + let feedback: UserFeedback + let onRecovery: () -> Void + let onDismiss: () -> Void + + var body: some View { + HStack(alignment: .top, spacing: 10) { + Image(systemName: icon) + .foregroundStyle(color) + .accessibilityHidden(true) + + VStack(alignment: .leading, spacing: 3) { + Text(feedback.title) + .font(.callout.weight(.semibold)) + if let detail = feedback.detail { + Text(detail) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + + Spacer(minLength: 8) + + if let recoveryTitle { + Button(recoveryTitle) { + onRecovery() + } + .controlSize(.small) + } + + Button { + onDismiss() + } label: { + Image(systemName: "xmark") + } + .buttonStyle(.borderless) + .accessibilityLabel("Dismiss \(feedback.title)") + } + .padding(10) + .background(color.opacity(0.08), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .stroke(color.opacity(0.25), lineWidth: 1) + ) + .accessibilityElement(children: .contain) + } + + private var icon: String { + switch feedback.severity { + case .information: return "info.circle.fill" + case .success: return "checkmark.circle.fill" + case .warning: return "exclamationmark.triangle.fill" + case .error: return "xmark.circle.fill" + } + } + + private var color: Color { + switch feedback.severity { + case .information: return .blue + case .success: return .green + case .warning: return .orange + case .error: return .red + } + } + + private var recoveryTitle: String? { + switch feedback.recoveryAction { + case .checkAgain: return String(localized: "Check Again") + case .retry: return String(localized: "Retry") + case .openInputMonitoringSettings: return String(localized: "Open Settings") + case .undo, nil: return nil + } + } +} + +struct ThemeTransferSheet: View { + @Environment(\.dismiss) private var dismiss + + let mode: ThemeTransferMode + @Binding var transferString: String + @Binding var feedback: UserFeedback? + let onCopy: () -> Void + let onImport: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Text(mode == .share ? "Share Theme" : "Import Theme") + .font(.title2.bold()) + + Text( + mode == .share + ? "Copy this KeyLight theme string to share the current appearance." + : "Paste a KeyLight theme string. A valid theme is saved and applied immediately." + ) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + + if mode == .share { + ScrollView { + Text(transferString) + .font(.system(.caption, design: .monospaced)) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(8) + } + .frame(height: 100) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Color.primary.opacity(0.2), lineWidth: 1) + ) + .accessibilityLabel("Shareable theme string") + } else { + TextEditor(text: $transferString) + .font(.system(.caption, design: .monospaced)) + .frame(height: 120) + .padding(4) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Color.primary.opacity(0.2), lineWidth: 1) + ) + .accessibilityLabel("Theme string to import") + } + + if let feedback { + InlineSettingsFeedback(feedback: feedback) + } + + HStack { + Spacer() + Button("Cancel") { + dismiss() + } + + if mode == .share { + Button("Copy") { + onCopy() + } + .buttonStyle(.borderedProminent) + .disabled(transferString.isEmpty) + } else { + Button("Import and Apply") { + onImport() + } + .buttonStyle(.borderedProminent) + .disabled(transferString.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + } + } + .padding(20) + .frame(width: 500) + } +} + +struct InputMonitoringStatusBanner: View { + let model: KeyLightModel + @Environment(\.openWindow) private var openWindow + + var body: some View { + HStack(alignment: .top, spacing: 12) { + Image(systemName: statusIcon) + .font(.title3) + .foregroundStyle(statusColor) + .frame(width: 24) + .accessibilityHidden(true) + + VStack(alignment: .leading, spacing: 7) { + Text(statusTitle) + .font(.headline) + + Text(statusDetail) + .font(.callout) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + + DisclosureGroup("Technical Details") { + VStack(alignment: .leading, spacing: 6) { + VStack(alignment: .leading, spacing: 2) { + Text("Running app") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + Text(model.inputMonitoringAppPath) + .font(.caption.monospaced()) + .foregroundStyle(.primary) + .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + } + + if let installationIssue = model.inputMonitoringInstallationIssue { + Label(installationIssue, systemImage: "exclamationmark.triangle") + .font(.caption) + .foregroundStyle(.orange) + .fixedSize(horizontal: false, vertical: true) + + Text("Quit this copy, install it in Applications using the exact filename shown above, then launch that app. In Input Monitoring, remove any stale row, add the installed app again, turn it on, and retry in the relaunched app.") + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } else if model.inputMonitoringState == .permissionRequired { + Text("If this app is already listed but access remains unavailable, remove its stale row, add the installed app again, turn it on, and retry the monitor.") + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + if model.inputMonitoringState == .permissionRequired || + model.inputMonitoringState == .monitorUnavailable { + Text("Accessibility permission is not required.") + .font(.caption) + .foregroundStyle(.secondary) + } + + if Bundle.main.bundleIdentifier == + "com.keylight.app.motionpreview" { + Text(KeyLightApplicationIdentity.current.channel.localizedCaseInsensitiveContains("Signed") + ? "This Motion Preview uses a stable Developer ID identity, so normal preview upgrades can retain permission. It remains isolated from the production app." + : "Local Motion Preview builds use a new ad-hoc code identity when rebuilt. macOS may therefore require the preview row to be removed and added again. A Developer ID-signed preview can retain permission across normal upgrades.") + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + .padding(.top, 4) + } + .font(.caption) + + if showsRepairActions { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 8) { + if model.inputMonitoringInstallationIssue != nil { + Button("Review Installation…") { + KeyLightWindowActivation.present(.setup) { + openWindow(id: KeyLightSceneID.setup) + } + } + .buttonStyle(.borderedProminent) + .accessibilityHint("Explains the exact installation correction before Input Monitoring is requested") + } else if model.inputMonitoringState == .permissionRequired { + Button(model.hasSeenPermissionExplanation ? "Grant Access" : "Review Access…") { + if model.hasSeenPermissionExplanation { + model.requestInputMonitoringPermission() + } else { + model.requestPermissionSetupIfNeeded() + KeyLightWindowActivation.present(.setup) { + openWindow(id: KeyLightSceneID.setup) + } + } + } + .buttonStyle(.borderedProminent) + .accessibilityHint( + model.hasSeenPermissionExplanation + ? "Requests Input Monitoring access for KeyLight" + : "Explains why KeyLight needs Input Monitoring before requesting access" + ) + } + + if model.inputMonitoringInstallationIssue == nil { + Button("Retry Monitor") { + model.retryInputMonitoring() + } + .accessibilityHint("Retries the KeyLight keyboard monitor") + } + } + + if model.inputMonitoringInstallationIssue == nil { + Button("Open Input Monitoring Settings") { + model.openInputMonitoringSettings() + } + .accessibilityHint("Opens the Input Monitoring privacy settings") + } + } + .controlSize(.small) + } + } + + Spacer(minLength: 0) + } + .padding(12) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(Color.secondary.opacity(0.08)) + ) + .overlay( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .stroke(statusColor.opacity(0.35), lineWidth: 1) + ) + .accessibilityElement(children: .contain) + .accessibilityLabel("Input Monitoring status: \(statusTitle)") + } + + private var showsRepairActions: Bool { + model.inputMonitoringInstallationIssue != nil || + model.inputMonitoringState == .permissionRequired || + model.inputMonitoringState == .monitorUnavailable + } + + private var statusTitle: String { + switch model.inputMonitoringState { + case .checking: + return String(localized: "Checking Input Monitoring") + case .permissionRequired: + return String(localized: "Input Monitoring Required") + case .authorized: + return String(localized: "Input Monitoring Authorized") + case .starting: + return String(localized: "Input Monitoring Starting") + case .active: + return String(localized: "Input Monitoring Active") + case .monitorUnavailable: + return String(localized: "Input Monitoring Unavailable") + } + } + + private var statusDetail: String { + switch model.inputMonitoringState { + case .checking: + return String(localized: "KeyLight is checking macOS permission and keyboard monitor status.") + case .permissionRequired: + return String(localized: "KeyLight needs Input Monitoring to detect key presses.") + case .authorized: + return String(localized: "Input Monitoring is granted. Enable KeyLight to start the keyboard monitor.") + case .starting: + return String(localized: "KeyLight is starting the keyboard monitor.") + case .active: + return String(localized: "Input Monitoring is granted and key presses are being monitored.") + case .monitorUnavailable: + return String(localized: "Input Monitoring is granted, but KeyLight could not start the keyboard monitor.") + } + } + + private var statusIcon: String { + switch model.inputMonitoringState { + case .checking, .starting: + return "clock" + case .permissionRequired: + return "exclamationmark.triangle.fill" + case .authorized: + return "checkmark.shield" + case .active: + return "checkmark.circle.fill" + case .monitorUnavailable: + return "xmark.circle.fill" + } + } + + private var statusColor: Color { + switch model.inputMonitoringState { + case .checking, .starting: + return .secondary + case .permissionRequired: + return .orange + case .authorized, .active: + return .green + case .monitorUnavailable: + return .red + } + } +} + +struct SettingsScrollViewBridge: NSViewRepresentable { + let onResolve: (NSScrollView) -> Void + + func makeNSView(context: Context) -> NSView { + NSView() + } + + func updateNSView(_ nsView: NSView, context: Context) { + DispatchQueue.main.async { + var current: NSView? = nsView + while let view = current { + if let scrollView = view as? NSScrollView { + onResolve(scrollView) + return + } + current = view.superview + } + } + } +} + +struct ColorPresetButton: View { + let color: Color + let model: KeyLightModel + @Binding var hexColor: String + + private var isSelected: Bool { + model.glowColor.toHex()?.uppercased() == color.toHex()?.uppercased() + } + + var body: some View { + Button(action: { + model.glowColor = color + hexColor = color.toHex() ?? "" + }) { + RoundedRectangle(cornerRadius: 4) + .fill(color) + .frame(width: 20, height: 20) + .overlay( + RoundedRectangle(cornerRadius: 4) + .stroke( + isSelected ? Color.accentColor : Color.primary.opacity(0.2), + lineWidth: isSelected ? 2 : 1 + ) + ) + .overlay { + if isSelected { + Image(systemName: "checkmark.circle.fill") + .symbolRenderingMode(.palette) + .foregroundStyle(.white, Color.accentColor) + .font(.caption) + } + } + } + .buttonStyle(.plain) + .accessibilityLabel("Use color \(color.toHex() ?? "custom")") + .accessibilityValue(isSelected ? "Selected" : "Not selected") + .accessibilityAddTraits(isSelected ? .isSelected : []) + } +} + +struct GradientPresetButton: View { + let startHex: String + let endHex: String + let model: KeyLightModel + + private var isSelected: Bool { + let currentStart = model.gradientStartColor.toHex()?.uppercased() + let currentEnd = model.gradientEndColor.toHex()?.uppercased() + return currentStart == startHex.uppercased() && currentEnd == endHex.uppercased() + } + + var body: some View { + Button(action: { + model.gradientStartColor = Color(hex: startHex) ?? .blue + model.gradientEndColor = Color(hex: endHex) ?? .green + }) { + LinearGradient( + colors: [Color(hex: startHex) ?? .blue, Color(hex: endHex) ?? .green], + startPoint: .leading, + endPoint: .trailing + ) + .frame(width: 30, height: 20) + .cornerRadius(4) + .overlay( + RoundedRectangle(cornerRadius: 4) + .stroke(isSelected ? Color.accentColor : Color.primary.opacity(0.2), lineWidth: isSelected ? 2 : 1) + ) + .overlay { + if isSelected { + Image(systemName: "checkmark.circle.fill") + .symbolRenderingMode(.palette) + .foregroundStyle(.white, Color.accentColor) + .font(.caption) + } + } + } + .buttonStyle(.plain) + .accessibilityLabel("Use gradient from \(startHex) to \(endHex)") + .accessibilityValue(isSelected ? "Selected" : "Not selected") + .accessibilityAddTraits(isSelected ? .isSelected : []) + } +} diff --git a/KeyLight/Views/SettingsContentView.swift b/KeyLight/Views/SettingsContentView.swift index 528809b..8894ddd 100644 --- a/KeyLight/Views/SettingsContentView.swift +++ b/KeyLight/Views/SettingsContentView.swift @@ -1,6 +1,7 @@ import SwiftUI import AppKit import UniformTypeIdentifiers +import Darwin private enum SettingsImportValidationError: LocalizedError { case notRegularFile @@ -12,7 +13,7 @@ private enum SettingsImportValidationError: LocalizedError { case .notRegularFile: return "Selected item is not a regular file." case .fileTooLarge: - return "file too large (max 5MB)" + return "file too large (max 1MB)" case .fileSizeUnavailable: return "Could not determine file size." } @@ -20,800 +21,659 @@ private enum SettingsImportValidationError: LocalizedError { } private func loadValidatedSettingsImportData(from url: URL, maxFileSize: Int) throws -> Data { - let attributes = try FileManager.default.attributesOfItem(atPath: url.path) - guard settingsImportIsRegularFile(attributes: attributes) else { - throw SettingsImportValidationError.notRegularFile + let handle = try FileHandle(forReadingFrom: url) + defer { + try? handle.close() } - guard let sizeValue = attributes[.size], - let fileSize = settingsImportFileSizeInBytes(sizeValue), - fileSize >= 0 else { + var fileStatus = stat() + guard fstat(handle.fileDescriptor, &fileStatus) == 0 else { throw SettingsImportValidationError.fileSizeUnavailable } - - if fileSize > Int64(maxFileSize) { - throw SettingsImportValidationError.fileTooLarge + guard (fileStatus.st_mode & S_IFMT) == S_IFREG else { + throw SettingsImportValidationError.notRegularFile } - - return try Data(contentsOf: url) -} - -private func settingsImportIsRegularFile(attributes: [FileAttributeKey: Any]) -> Bool { - if let fileType = attributes[.type] as? FileAttributeType { - return fileType == .typeRegular + guard fileStatus.st_size >= 0 else { + throw SettingsImportValidationError.fileSizeUnavailable } - if let fileType = attributes[.type] as? String { - return fileType == FileAttributeType.typeRegular.rawValue + if fileStatus.st_size > Int64(maxFileSize) { + throw SettingsImportValidationError.fileTooLarge } - return false -} -private func settingsImportFileSizeInBytes(_ value: Any) -> Int64? { - switch value { - case let number as NSNumber: - return number.int64Value - case let intValue as Int: - return Int64(intValue) - case let int64Value as Int64: - return int64Value - case let uintValue as UInt: - return Int64(exactly: uintValue) - case let uint64Value as UInt64: - return uint64Value <= UInt64(Int64.max) ? Int64(uint64Value) : nil - case let stringValue as String: - return Int64(stringValue) - default: - return nil + let data = try handle.read(upToCount: maxFileSize + 1) ?? Data() + guard data.count <= maxFileSize else { + throw SettingsImportValidationError.fileTooLarge } + return data } struct SettingsView: View { - @EnvironmentObject var appState: AppState - - @State private var hexColor: String = "68B8FF" - @State private var hasPermission: Bool = false - @State private var isUpdatingColor = false - @State private var gradientPresets: [SettingsManager.GradientPreset] = [] - @State private var savedThemes: [SettingsManager.Theme] = [] - @State private var savedLayoutProfiles: [SettingsManager.KeyMappingProfile] = [] - @State private var currentThemeName: String = "current" - @State private var currentLayoutProfileName: String = "None" - @State private var showingThemeSaveField = false - @State private var newThemeName: String = "" - @State private var showingLayoutSaveField = false - @State private var newLayoutProfileName: String = "" - @State private var editingThemeID: UUID? - @State private var themeRenameDraft: String = "" - @State private var themeRenameError: String? - @State private var editingLayoutProfileID: UUID? - @State private var layoutRenameDraft: String = "" - @State private var layoutRenameError: String? - @State private var themeTransferStatus: String = "" - @State private var themeTransferString: String = "" - @State private var layoutTransferStatus: String = "" - @State private var hoveredThemeID: UUID? - @State private var hoveredLayoutProfileID: UUID? + @Bindable var model: KeyLightModel + @Environment(\.openWindow) var openWindow + let settings: SettingsManager + @ObservedObject var keyLayoutStore: KeyLayoutStore + let updateService: UpdateService + + @State var hexColor: String = "68B8FF" + @State var isUpdatingColor = false + @State var gradientPresets: [GradientPreset] = [] + @State private var selectedSettingsTab: SettingsTab = .appearance + @State var showingThemeSaveField = false + @State var newThemeName: String = "" + @State var showingLayoutSaveField = false + @State var newLayoutProfileName: String = "" + @State var editingThemeID: UUID? + @State var themeRenameDraft: String = "" + @State var themeRenameError: String? + @State var editingLayoutProfileID: UUID? + @State var layoutRenameDraft: String = "" + @State var layoutRenameError: String? + @State var themeTransferFeedback: UserFeedback? + @State var themeTransferString: String = "" + @State var themeTransferMode: ThemeTransferMode? + @State var layoutTransferFeedback: UserFeedback? + @State var screenCaptureAccessGranted = ScreenCaptureAuthorization.isGranted @State private var settingsScrollView: NSScrollView? - - @State private var pendingDeletions: [PendingDeletionID: PendingDeletionState] = [:] + @State private var settingsPreviewSession: SettingsGlowPreviewSession + @State var chordPreviewActive = false + @State var chordPreviewTask: Task? + @State var configurationSnapshots: [ConfigurationSnapshotDocument] = [] + @State var newConfigurationSnapshotName = "" + @State var editingConfigurationSnapshotID: UUID? + @State var configurationSnapshotRenameDraft = "" + @State var configurationSnapshotRenameError: String? + @State var snapshotConfirmation: SnapshotConfirmation? + @State var snapshotFilePanelHelper = + ConfigurationSnapshotFilePanelHelper() + + @State var pendingDeletions: [PendingDeletionID: PendingDeletionState] = [:] @State private var pendingDeletionTasks: [PendingDeletionID: Task] = [:] + @State private var deletionConfirmation: PendingDeletionConfirmation? + + private let inlineUndoSeconds = 10 + private let maxLayoutImportFileSize = PersistenceValidation.maximumLayoutImportSize + + init( + model: KeyLightModel, + settings: SettingsManager, + keyLayoutStore: KeyLayoutStore, + updateService: UpdateService + ) { + self.model = model + self.settings = settings + self.updateService = updateService + _keyLayoutStore = ObservedObject(wrappedValue: keyLayoutStore) + _settingsPreviewSession = State(initialValue: SettingsGlowPreviewSession( + show: { [weak model] in + model?.setPreview( + .preview( + .settings, + horizontalPosition: 0.5, + keyWidth: 1 + ), + source: .settings + ) + }, + hide: { [weak model] in + model?.clearPreview(.settings) + } + )) + } - private let inlineUndoSeconds = 5 - private let maxLayoutImportFileSize = 5_000_000 + private enum SettingsTab: Hashable { + case appearance + case keyboard + case snapshots + case general + } - private enum PendingDeletionID: Hashable { + enum PendingDeletionID: Hashable { case theme(UUID) case layout(UUID) + case configurationSnapshot(UUID) } - private enum PendingDeletionKind { - case theme(item: SettingsManager.Theme) - case layout(item: SettingsManager.KeyMappingProfile) + enum PendingDeletionKind { + case theme(item: Theme, wasActive: Bool) + case layout(item: KeyMappingProfile, wasActive: Bool) + case configurationSnapshot( + item: ConfigurationSnapshotDocument, + index: Int + ) } - private struct PendingDeletionState { + struct PendingDeletionState { let deletion: PendingDeletionKind var secondsRemaining: Int } - private var selectedGradientPresetID: UUID? { - let currentStart = appState.gradientStartColor.toHex()?.uppercased() - let currentEnd = appState.gradientEndColor.toHex()?.uppercased() - return gradientPresets.first(where: { preset in - preset.startHex.uppercased() == currentStart && preset.endHex.uppercased() == currentEnd - })?.id - } + struct PendingDeletionConfirmation: Identifiable { + let id = UUID() + let deletion: PendingDeletionKind - private var activeLayoutProfile: SettingsManager.KeyMappingProfile? { - savedLayoutProfiles.first(where: { $0.name == currentLayoutProfileName }) - } + var title: String { + switch deletion { + case .theme(let item, _): + return String(localized: "Delete Theme \"\(item.name)\"?") + case .layout(let item, _): + return String(localized: "Delete Layout \"\(item.name)\"?") + case .configurationSnapshot(let item, _): + return String( + localized: "Delete Snapshot \"\(item.name)\"?" + ) + } + } - var body: some View { - ScrollView { - VStack(alignment: .leading, spacing: 16) { - HStack { - Text("KeyLight") - .font(.title2) - .bold() - Spacer() - Text("⌘⇧K") - .font(.caption) - .foregroundColor(.secondary) - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background(RoundedRectangle(cornerRadius: 4).fill(Color.secondary.opacity(0.15))) - Toggle("", isOn: $appState.isEnabled) - .toggleStyle(.switch) - .labelsHidden() - } + var message: String { + String(localized: "This removes the saved item. You can undo for ten seconds.") + } + } - Divider() + enum SnapshotConfirmation: Identifiable { + case apply(ConfigurationSnapshotDocument) + case importConflict(ConfigurationSnapshotDocument) + case restorePrevious + + var id: String { + switch self { + case .apply(let document): + return "apply-\(document.id.uuidString)" + case .importConflict(let document): + return "import-\(document.id.uuidString)" + case .restorePrevious: + return "restore-previous" + } + } - VStack(alignment: .leading, spacing: 8) { - Text("Color Mode") - .font(.headline) - Picker("", selection: $appState.colorMode) { - Text("Solid").tag(SettingsManager.ColorMode.solid) - Text("Position Gradient").tag(SettingsManager.ColorMode.positionGradient) - Text("Random Per Key").tag(SettingsManager.ColorMode.randomPerKey) - Text("Rainbow").tag(SettingsManager.ColorMode.rainbow) - } - .pickerStyle(.segmented) - .labelsHidden() - } + var title: String { + switch self { + case .apply(let document): + return String(localized: "Apply \"\(document.name)\"?") + case .importConflict(let document): + return String( + localized: "A Snapshot Named \"\(document.name)\" Exists" + ) + case .restorePrevious: + return String(localized: "Restore Previous Setup?") + } + } - if appState.colorMode == .solid { - VStack(alignment: .leading, spacing: 8) { - Text("Color") - .font(.headline) - HStack(spacing: 12) { - ColorPicker("Glow Color", selection: $appState.glowColor, supportsOpacity: false) - .labelsHidden() - .frame(width: 44, height: 28) - - HStack(spacing: 4) { - Text("#") - .foregroundColor(.secondary) - TextField("Hex", text: $hexColor) - .textFieldStyle(.roundedBorder) - .frame(width: 70) - .onChange(of: hexColor) { _, newValue in - guard !isUpdatingColor else { return } - isUpdatingColor = true - defer { isUpdatingColor = false } - if let color = Color(hex: newValue) { - appState.glowColor = color - } - } - } + var message: String { + switch self { + case .apply: + return String( + localized: "KeyLight will replace its managed appearance, layout, display routing, and shortcut settings. Restore Previous Setup can reverse it." + ) + case .importConflict: + return String( + localized: "Replace the saved snapshot or keep both by saving a numbered copy. Importing does not apply it." + ) + case .restorePrevious: + return String( + localized: "KeyLight will swap the current managed setup with the hidden recovery setup. You can use this button again to swap back." + ) + } + } + } - HStack(spacing: 6) { - ColorPresetButton(color: Color(hex: "68B8FF") ?? .blue, appState: appState, hexColor: $hexColor) - ColorPresetButton(color: Color(hex: "00E69A") ?? .green, appState: appState, hexColor: $hexColor) - ColorPresetButton(color: Color(hex: "FF6B6B") ?? .red, appState: appState, hexColor: $hexColor) - ColorPresetButton(color: Color(hex: "FFD93D") ?? .yellow, appState: appState, hexColor: $hexColor) - ColorPresetButton(color: Color(hex: "C77DFF") ?? .purple, appState: appState, hexColor: $hexColor) - } - } - .onChange(of: appState.glowColor) { _, newColor in - guard !isUpdatingColor else { return } - isUpdatingColor = true - defer { isUpdatingColor = false } - hexColor = newColor.toHex() ?? "68B8FF" - } - } - } + private struct EffectPreviewConfiguration: Equatable { + let effectStyle: EffectStyle + let shapeProfile: SurfaceShapeProfile + let colorMode: ColorMode + let glowColorHex: String? + let gradientStartHex: String? + let gradientEndHex: String? + let opacity: Double + let height: Double + let width: Double + let roundness: Double + let fullness: Double + let fadeDuration: Double + } - if appState.colorMode == .positionGradient { - VStack(alignment: .leading, spacing: 8) { - Text("Gradient Colors") - .font(.headline) - - HStack(spacing: 16) { - VStack(spacing: 4) { - Text("Start") - .font(.caption) - .foregroundColor(.secondary) - ColorPicker("Gradient Start", selection: $appState.gradientStartColor, supportsOpacity: false) - .labelsHidden() - .frame(width: 44, height: 28) - } + var selectedGradientPresetID: UUID? { + let currentStart = model.gradientStartColor.toHex()?.uppercased() + let currentEnd = model.gradientEndColor.toHex()?.uppercased() + return gradientPresets.first(where: { preset in + preset.startHex.uppercased() == currentStart && preset.endHex.uppercased() == currentEnd + })?.id + } - LinearGradient( - colors: [appState.gradientStartColor, appState.gradientEndColor], - startPoint: .leading, - endPoint: .trailing - ) - .frame(height: 12) - .cornerRadius(6) - .overlay( - RoundedRectangle(cornerRadius: 6) - .stroke(Color.primary.opacity(0.2), lineWidth: 1) - ) + var activeLayoutProfile: KeyMappingProfile? { + keyLayoutStore.selectedProfile + } - VStack(spacing: 4) { - Text("End") - .font(.caption) - .foregroundColor(.secondary) - ColorPicker("Gradient End", selection: $appState.gradientEndColor, supportsOpacity: false) - .labelsHidden() - .frame(width: 44, height: 28) - } - } + var activeTheme: Theme? { + model.selectedTheme + } - HStack { - Text("Presets") - .font(.caption) - .foregroundColor(.secondary) - Spacer() - Button("Delete Selected") { - deleteSelectedGradientPreset() - } - .font(.caption) - .disabled(selectedGradientPresetID == nil || gradientPresets.count <= 1) - Button("Add Gradient Colors") { - saveCurrentGradientPreset() - } - .font(.caption) - } + var activeThemeIsEdited: Bool { + model.selectedThemeIsEdited + } - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 6) { - ForEach(gradientPresets) { preset in - GradientPresetButton(startHex: preset.startHex, endHex: preset.endHex, appState: appState) - } - } - } - } - } + var activeLayoutIsEdited: Bool { + keyLayoutStore.selectedProfileIsEdited + } - if appState.colorMode == .randomPerKey { - Text("Each key uses a deterministic random color derived from its key code.") - .font(.caption) - .foregroundColor(.secondary) - } + var savedThemes: [Theme] { model.savedThemes } + var savedLayoutProfiles: [KeyMappingProfile] { keyLayoutStore.savedProfiles } + var bundledLayoutPresets: [SettingsManager.BundledLayoutPreset] { + settings.bundledLayoutPresets() + } + var currentThemeID: UUID? { model.selectedThemeID } + var currentLayoutProfileID: UUID? { keyLayoutStore.selectedProfileID } - if appState.colorMode == .rainbow { - Text("Colors are distributed left-to-right by key position.") - .font(.caption) - .foregroundColor(.secondary) - } + var buildIdentity: KeyLightBuildIdentity { + KeyLightApplicationIdentity.current + } - Divider() + var appVersionDescription: String { + buildIdentity.versionDescription + } - VStack(alignment: .leading, spacing: 12) { - Text("Effect Settings") - .font(.headline) + var colorModeAccessibilityValue: String { + switch model.colorMode { + case .solid: return "Solid" + case .positionGradient: return "Position Gradient" + case .randomPerKey: return "Random Per Key" + case .rainbow: return "Rainbow" + } + } - VStack(alignment: .leading, spacing: 4) { - HStack { - Text("Opacity") - Spacer() - Text("\(Int(appState.glowOpacity * 100))%") - .foregroundColor(.secondary) - .monospacedDigit() - } - Slider(value: $appState.glowOpacity, in: 0.05...1.0) - } + var roundnessAccessibilityValue: String { + if model.glowRoundness < 0.05 { return "Sharp" } + if model.glowRoundness > 0.95 { return "Round" } + return "\(Int(model.glowRoundness * 100)) percent" + } - VStack(alignment: .leading, spacing: 4) { - HStack { - Text("Height") - Spacer() - Text("\(Int(appState.glowSize))") - .foregroundColor(.secondary) - .monospacedDigit() - } - Slider(value: $appState.glowSize, in: 30...200) - } + var liquidGlassSmoothnessAccessibilityValue: String { + if model.glowRoundness < 0.05 { return "Compact" } + if model.glowRoundness > 0.95 { return "Wide and soft" } + return "\(Int(model.glowRoundness * 100)) percent" + } - VStack(alignment: .leading, spacing: 4) { - HStack { - Text("Width") - Spacer() - Text("\(Int(appState.glowWidth * 100))%") - .foregroundColor(.secondary) - .monospacedDigit() - } - Slider(value: $appState.glowWidth, in: 0.3...3.0) - } + var hotKeyStatusTitle: String { + switch model.globalHotKeyStatus { + case .checking: return "Checking \(model.globalShortcut.displayName)" + case .registered: return "\(model.globalShortcut.displayName) Registered" + case .unavailable: return "\(model.globalShortcut.displayName) Unavailable" + } + } - VStack(alignment: .leading, spacing: 4) { - HStack { - Text("Roundness") - Spacer() - Text(appState.glowRoundness < 0.05 ? "Sharp" : appState.glowRoundness > 0.95 ? "Round" : "\(Int(appState.glowRoundness * 100))%") - .foregroundColor(.secondary) - .monospacedDigit() - } - Slider(value: $appState.glowRoundness, in: 0.0...1.0) - } + var hotKeyStatusIcon: String { + switch model.globalHotKeyStatus { + case .checking: return "clock" + case .registered: return "checkmark.circle.fill" + case .unavailable: return "exclamationmark.triangle.fill" + } + } - VStack(alignment: .leading, spacing: 4) { - HStack { - Text("Hardness") - Spacer() - Text("\(Int(appState.glowFullness * 100))%") - .foregroundColor(.secondary) - .monospacedDigit() - } - Slider(value: $appState.glowFullness, in: 0.0...1.0) - Text("Controls glow boundary feather (0% soft, 100% crisp).") - .font(.caption) - .foregroundColor(.secondary) - } + var hotKeyStatusColor: Color { + switch model.globalHotKeyStatus { + case .checking: return .secondary + case .registered: return .green + case .unavailable: return .orange + } + } - VStack(alignment: .leading, spacing: 4) { - HStack { - Text("Fade Duration") - Spacer() - Text("\(String(format: "%.2f", appState.fadeDuration))s") - .foregroundColor(.secondary) - .monospacedDigit() - } - Slider(value: $appState.fadeDuration, in: 0.05...2.0) + @ViewBuilder + private var permissionWarning: some View { + if model.inputMonitoringInstallationIssue != nil || + model.inputMonitoringState == .permissionRequired || + model.inputMonitoringState == .monitorUnavailable { + HStack(spacing: 10) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + .accessibilityHidden(true) + Text("Input Monitoring needs attention before KeyLight can detect keys reliably.") + .font(.callout) + Spacer(minLength: 8) + if selectedSettingsTab != .general { + Button("Review") { + selectedSettingsTab = .general } + .controlSize(.small) } + } + .padding(10) + .background(.orange.opacity(0.08), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + .accessibilityElement(children: .contain) + .accessibilityLabel("Input Monitoring needs attention") + } + } - Divider() - - VStack(alignment: .leading, spacing: 8) { - Text("Themes") - .font(.headline) - - Text("Themes store glow style settings only (color, effect, and fade).") - .font(.caption) - .foregroundColor(.secondary) - - if savedThemes.isEmpty { - Text("No saved themes yet.") - .font(.subheadline) - .foregroundColor(.secondary) - } else { - ForEach(savedThemes) { theme in - let isActive = currentThemeName == theme.name - let pendingID = PendingDeletionID.theme(theme.id) - let pendingState = pendingDeletions[pendingID] - let isHovered = hoveredThemeID == theme.id - - VStack(alignment: .leading, spacing: 2) { - HStack(spacing: 8) { - if editingThemeID == theme.id { - TextField("Theme name", text: $themeRenameDraft) - .textFieldStyle(.roundedBorder) - .onChange(of: themeRenameDraft) { _, _ in - themeRenameError = nil - } - Spacer(minLength: 10) - - Button("Save") { - saveThemeRename(theme) - } - .buttonStyle(.borderedProminent) - .controlSize(.small) - .disabled(themeRenameValidation(for: theme) != nil) - - Button("Cancel") { - cancelThemeRename() - } - .controlSize(.small) - } else { - Text(theme.name) - .font(.subheadline) - .lineLimit(1) - if isActive { - activeBadge() - } - - if theme.name != SettingsManager.Theme.defaultTheme.name && isHovered { - Button { - startThemeRename(theme) - } label: { - Image(systemName: "square.and.pencil") - .font(.system(size: 13, weight: .semibold)) - } - .buttonStyle(.borderless) - .help("Rename theme") - } - - Spacer(minLength: 0) - - if let pendingState { - Button("Undo (\(pendingState.secondsRemaining)s)") { - cancelPendingDeletion(for: pendingID) - } - .buttonStyle(.borderedProminent) - .controlSize(.small) - } else if theme.name != SettingsManager.Theme.defaultTheme.name && isHovered { - Button { - queueThemeDeletion(theme) - } label: { - Image(systemName: "trash") - .foregroundColor(.red) - } - .buttonStyle(.borderless) - } - } - } - .frame(minHeight: 30) - - if editingThemeID == theme.id, - let error = themeRenameError ?? themeRenameValidation(for: theme) { - Text(error) - .font(.caption2) - .foregroundColor(.red) - .padding(.leading, 24) - } - } - .padding(.horizontal, 8) - .padding(.vertical, 3) - .background( - RoundedRectangle(cornerRadius: 8) - .fill(isActive ? Color.accentColor.opacity(0.12) : Color.primary.opacity(0.03)) - ) - .overlay( - RoundedRectangle(cornerRadius: 8) - .stroke( - isActive ? Color.accentColor.opacity(0.35) : Color.primary.opacity(0.2), - lineWidth: 1 - ) - ) - .contentShape(Rectangle()) - .gesture( - TapGesture().onEnded { - guard editingThemeID == nil else { return } - selectTheme(theme, isActive: isActive) - }, - including: .gesture - ) - .onHover { hovering in - hoveredThemeID = hovering ? theme.id : (hoveredThemeID == theme.id ? nil : hoveredThemeID) - } - .padding(.vertical, 0.5) - } - } + var liquidGlassRuntimeAvailable: Bool { + EffectStyle.liquidGlass.isAvailableOnCurrentSystem + } - if showingThemeSaveField { - HStack { - TextField("Theme name", text: $newThemeName) - .textFieldStyle(.roundedBorder) - - Button("Save") { - let trimmed = trimmed(newThemeName) - guard !trimmed.isEmpty else { return } - var theme = appState.currentTheme() - theme.name = trimmed - SettingsManager.shared.saveTheme(theme) - SettingsManager.shared.currentThemeName = trimmed - reloadPersistedState() - showingThemeSaveField = false - newThemeName = "" - } - .buttonStyle(.borderedProminent) - .controlSize(.small) - .disabled(trimmed(newThemeName).isEmpty) + private var resolvedEffectStyle: EffectStyle { + model.effectStyle.resolvedForCurrentSystem + } - Button("Cancel") { - showingThemeSaveField = false - newThemeName = "" - } - .controlSize(.small) - } - } else { - HStack { - Button("Save Current...") { - showingThemeSaveField = true - } - .controlSize(.small) + private var effectPreviewConfiguration: EffectPreviewConfiguration { + EffectPreviewConfiguration( + effectStyle: model.effectStyle, + shapeProfile: model.surfaceShapeProfile, + colorMode: model.colorMode, + glowColorHex: model.glowColor.toHex(), + gradientStartHex: model.gradientStartColor.toHex(), + gradientEndHex: model.gradientEndColor.toHex(), + opacity: model.glowOpacity, + height: model.glowSize, + width: model.glowWidth, + roundness: model.glowRoundness, + fullness: model.glowFullness, + fadeDuration: model.fadeDuration + ) + } - Spacer() + var body: some View { + TabView(selection: $selectedSettingsTab) { + settingsPage(.appearance) + .tabItem { Label("Appearance", systemImage: "paintbrush") } + .tag(SettingsTab.appearance) + + settingsPage(.keyboard) + .tabItem { Label("Keyboard Layout", systemImage: "keyboard") } + .tag(SettingsTab.keyboard) + + settingsPage(.snapshots) + .tabItem { + Label("Snapshots", systemImage: "square.stack.3d.up") + } + .tag(SettingsTab.snapshots) - Button("Copy Theme String") { - copyThemeStringToClipboard() - } - .controlSize(.small) + settingsPage(.general) + .tabItem { Label("General", systemImage: "gearshape") } + .tag(SettingsTab.general) - Button("Import") { - importThemeString(themeTransferString) - } - .buttonStyle(.borderedProminent) - .controlSize(.small) - .disabled(trimmed(themeTransferString).isEmpty) - } + } + .frame(minWidth: 640, minHeight: 580) + .sheet(item: $themeTransferMode) { mode in + ThemeTransferSheet( + mode: mode, + transferString: $themeTransferString, + feedback: $themeTransferFeedback, + onCopy: copyThemeStringToClipboard, + onImport: { + if importThemeString(themeTransferString) { + themeTransferMode = nil } - - VStack(alignment: .leading, spacing: 6) { - Text("Theme String") - .font(.caption) - .foregroundColor(.secondary) - - TextEditor(text: $themeTransferString) - .font(.system(.caption, design: .monospaced)) - .frame(height: 78) - .padding(4) - .overlay( - RoundedRectangle(cornerRadius: 8) - .stroke(Color.primary.opacity(0.2), lineWidth: 1) - ) + } + ) + } + .alert(item: $deletionConfirmation) { confirmation in + Alert( + title: Text(confirmation.title), + message: Text(confirmation.message), + primaryButton: .destructive(Text("Delete")) { + confirmDeletion(confirmation.deletion) + }, + secondaryButton: .cancel() + ) + } + .confirmationDialog( + snapshotConfirmation?.title ?? "", + isPresented: Binding( + get: { snapshotConfirmation != nil }, + set: { isPresented in + if !isPresented { + snapshotConfirmation = nil } + } + ), + titleVisibility: .visible + ) { + switch snapshotConfirmation { + case .apply(let document): + Button("Apply Snapshot") { + applyConfigurationSnapshot(document) + snapshotConfirmation = nil + } + Button("Cancel", role: .cancel) { + snapshotConfirmation = nil + } + case .importConflict(let document): + Button("Replace") { + storeImportedConfigurationSnapshot( + document, + policy: .replace + ) + snapshotConfirmation = nil + } + Button("Save Copy") { + storeImportedConfigurationSnapshot( + document, + policy: .saveCopy + ) + snapshotConfirmation = nil + } + Button("Cancel", role: .cancel) { + snapshotConfirmation = nil + } + case .restorePrevious: + Button("Restore Previous Setup") { + restorePreviousConfigurationSnapshot() + snapshotConfirmation = nil + } + Button("Cancel", role: .cancel) { + snapshotConfirmation = nil + } + case nil: + EmptyView() + } + } message: { + Text(snapshotConfirmation?.message ?? "") + } + .onAppear { + hexColor = model.glowColor.toHex() ?? "68B8FF" + screenCaptureAccessGranted = ScreenCaptureAuthorization.isGranted + reloadPersistedState() + } + .onChange(of: effectPreviewConfiguration) { _, _ in + requestSettingsPreview() + } + .onChange(of: model.isEnabled) { _, isEnabled in + if !isEnabled { + stopSettingsPreview() + } + } + .onChange(of: selectedSettingsTab) { _, selectedTab in + if selectedTab != .keyboard { + stopChordPreviewTest() + } + } + .onChange(of: themeTransferFeedback) { _, feedback in + if let feedback { + model.announce(feedback) + } + } + .onChange(of: layoutTransferFeedback) { _, feedback in + if let feedback { + model.announce(feedback) + } + } + .onDisappear { + stopSettingsPreview() + stopChordPreviewTest() + } + } - if !themeTransferStatus.isEmpty { - Text(themeTransferStatus) - .font(.caption2) - .foregroundColor(isStatusError(themeTransferStatus) ? .red : .secondary) - } + @ViewBuilder + private func settingsPage(_ tab: SettingsTab) -> some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + permissionWarning + + if let feedback = model.feedback { + SettingsFeedbackBanner( + feedback: feedback, + onRecovery: { handleFeedbackRecovery(feedback.recoveryAction) }, + onDismiss: { model.feedback = nil } + ) } - Divider() - - VStack(alignment: .leading, spacing: 8) { - Text("Key Layout (Position + Width)") - .font(.headline) - - Text("Key layout profiles store keyboard geometry only: per-key offsets and per-key glow width overrides.") - .font(.caption) - .foregroundColor(.secondary) - - ForEach(savedLayoutProfiles) { profile in - let isActive = currentLayoutProfileName == profile.name - let pendingID = PendingDeletionID.layout(profile.id) - let pendingState = pendingDeletions[pendingID] - let isHovered = hoveredLayoutProfileID == profile.id - - VStack(alignment: .leading, spacing: 2) { - HStack(spacing: 8) { - if editingLayoutProfileID == profile.id { - TextField("Layout profile name", text: $layoutRenameDraft) - .textFieldStyle(.roundedBorder) - .onChange(of: layoutRenameDraft) { _, _ in - layoutRenameError = nil - } - Spacer(minLength: 10) - - Button("Save") { - saveLayoutProfileRename(profile) - } - .buttonStyle(.borderedProminent) - .controlSize(.small) - .disabled(layoutRenameValidation(for: profile) != nil) - - Button("Cancel") { - cancelLayoutProfileRename() - } - .controlSize(.small) - } else { - Text(profile.name) - .font(.subheadline) - .lineLimit(1) - if isActive { - activeBadge() - } - - if isHovered { - Button { - startLayoutProfileRename(profile) - } label: { - Image(systemName: "square.and.pencil") - .font(.system(size: 13, weight: .semibold)) - } - .buttonStyle(.borderless) - .help("Rename layout profile") - } - - Spacer(minLength: 0) - - if let pendingState { - Button("Undo (\(pendingState.secondsRemaining)s)") { - cancelPendingDeletion(for: pendingID) - } - .buttonStyle(.borderedProminent) - .controlSize(.small) - } else if isHovered { - Button { - queueLayoutDeletion(profile) - } label: { - Image(systemName: "trash") - .foregroundColor(.red) - } - .buttonStyle(.borderless) - } - } - } - .frame(minHeight: 30) - - if editingLayoutProfileID == profile.id, - let error = layoutRenameError ?? layoutRenameValidation(for: profile) { - Text(error) - .font(.caption2) - .foregroundColor(.red) - .padding(.leading, 24) + ForEach(Array(pendingDeletions.keys), id: \.self) { id in + if let pending = pendingDeletions[id] { + HStack(spacing: 10) { + Image(systemName: "arrow.uturn.backward.circle.fill") + .foregroundStyle(.orange) + .accessibilityHidden(true) + Text(deletedItemDescription(pending.deletion)) + .font(.callout) + Spacer(minLength: 8) + Button("Undo (\(pending.secondsRemaining)s)") { + cancelPendingDeletion(for: id) } + .controlSize(.small) } - .padding(.horizontal, 8) - .padding(.vertical, 3) + .padding(10) .background( - RoundedRectangle(cornerRadius: 8) - .fill(isActive ? Color.accentColor.opacity(0.12) : Color.primary.opacity(0.03)) - ) - .overlay( - RoundedRectangle(cornerRadius: 8) - .stroke( - isActive ? Color.accentColor.opacity(0.35) : Color.primary.opacity(0.2), - lineWidth: 1 - ) + .orange.opacity(0.08), + in: RoundedRectangle(cornerRadius: 8, style: .continuous) ) - .contentShape(Rectangle()) - .gesture( - TapGesture().onEnded { - guard editingLayoutProfileID == nil else { return } - selectLayoutProfile(profile, isActive: isActive) - }, - including: .gesture - ) - .onHover { hovering in - hoveredLayoutProfileID = hovering ? profile.id : (hoveredLayoutProfileID == profile.id ? nil : hoveredLayoutProfileID) - } - .padding(.vertical, 0.5) - } - - if showingLayoutSaveField { - HStack { - TextField("Layout profile name", text: $newLayoutProfileName) - .textFieldStyle(.roundedBorder) - - Button("Save") { - let trimmed = trimmed(newLayoutProfileName) - guard !trimmed.isEmpty else { return } - let profile = SettingsManager.KeyMappingProfile( - name: trimmed, - keyOffsets: KeyPositionManager.shared.keyOffsets, - keyWidthOverrides: KeyWidthManager.shared.keyWidthOverrides - ) - SettingsManager.shared.saveKeyMappingProfile(profile) - reloadPersistedState() - showingLayoutSaveField = false - newLayoutProfileName = "" - } - .buttonStyle(.borderedProminent) - .controlSize(.small) - .disabled(trimmed(newLayoutProfileName).isEmpty) - - Button("Cancel") { - showingLayoutSaveField = false - newLayoutProfileName = "" - } - .controlSize(.small) - } - } else { - HStack { - Button("Save Current...") { - showingLayoutSaveField = true - } - .controlSize(.small) - - Spacer() - - Button("Export Active") { - exportActiveLayoutProfile() - } - .controlSize(.small) - .disabled(activeLayoutProfile == nil) - - Button("Import") { - importLayoutProfile() - } - .buttonStyle(.borderedProminent) - .controlSize(.small) - } - } - - HStack { - Button("Adjust Key Positions...") { - NotificationCenter.default.post(name: .openKeyPositionEditor, object: nil) - } - .buttonStyle(.link) - } - - if !layoutTransferStatus.isEmpty { - Text(layoutTransferStatus) - .font(.caption2) - .foregroundColor(isStatusError(layoutTransferStatus) ? .red : .secondary) + .accessibilityElement(children: .contain) } } - Divider() - - Toggle("Launch at Login", isOn: $appState.launchAtLogin) - - HStack { - Circle() - .fill(hasPermission ? Color.green : Color.red) - .frame(width: 8, height: 8) - .accessibilityLabel("Permission: \(hasPermission ? "Granted" : "Required")") - Text(hasPermission ? "Input Monitoring enabled" : "Input Monitoring required") - .font(.caption) - .foregroundColor(.secondary) - Spacer() - Button("Open Settings") { - PermissionManager.shared.openInputMonitoringSettings() - } - .buttonStyle(.link) - .font(.caption) + + switch tab { + case .appearance: + appearanceTabContent + case .keyboard: + keyboardTabContent + case .snapshots: + snapshotsTabContent + case .general: + generalTabContent } } .padding(20) .background( SettingsScrollViewBridge { scrollView in - if settingsScrollView !== scrollView { + if selectedSettingsTab == tab, settingsScrollView !== scrollView { settingsScrollView = scrollView } } ) } - .frame(minWidth: 460, minHeight: 520) - .onAppear { - hexColor = appState.glowColor.toHex() ?? "68B8FF" - hasPermission = PermissionManager.shared.hasInputMonitoringPermission() - reloadPersistedState() - } - .onReceive(NotificationCenter.default.publisher(for: .permissionStatusChanged)) { _ in - hasPermission = PermissionManager.shared.hasInputMonitoringPermission() - } - .onReceive(NotificationCenter.default.publisher(for: .settingsStorageChanged)) { _ in - reloadPersistedState() - } - .onDisappear { - clearPendingDeletionState() + } + + func settingsPreviewEditingChanged(_ isEditing: Bool) { + settingsPreviewSession.editingChanged( + isEditing, + isEnabled: model.isEnabled + ) + } + + private func handleFeedbackRecovery(_ action: UserFeedback.RecoveryAction?) { + guard let action else { return } + switch action { + case .checkAgain, .retry: + model.retryInputMonitoring() + case .openInputMonitoringSettings: + model.openInputMonitoringSettings() + case .undo: + // Undo feedback is owned by the operation that created it. Settings + // does not invent a generic undo target. + break } + model.feedback = nil + } + + private func requestSettingsPreview() { + settingsPreviewSession.configurationChanged(isEnabled: model.isEnabled) + } + + func stopSettingsPreview() { + settingsPreviewSession.stop() } - private func saveCurrentGradientPreset() { - let startHex = appState.gradientStartColor.toHex() ?? "68B8FF" - let endHex = appState.gradientEndColor.toHex() ?? "00E69A" - SettingsManager.shared.saveGradientPreset(startHex: startHex, endHex: endHex) - gradientPresets = SettingsManager.shared.savedGradientPresets + func saveCurrentGradientPreset() { + let startHex = model.gradientStartColor.toHex() ?? "68B8FF" + let endHex = model.gradientEndColor.toHex() ?? "00E69A" + settings.saveGradientPreset(startHex: startHex, endHex: endHex) + gradientPresets = settings.savedGradientPresets } private func deleteGradientPreset(_ id: UUID) { - SettingsManager.shared.deleteGradientPreset(id: id) - gradientPresets = SettingsManager.shared.savedGradientPresets + settings.deleteGradientPreset(id: id) + gradientPresets = settings.savedGradientPresets } - private func deleteSelectedGradientPreset() { + func deleteSelectedGradientPreset() { guard let selectedID = selectedGradientPresetID, gradientPresets.count > 1 else { return } deleteGradientPreset(selectedID) } - private func activeBadge() -> some View { - Text("Active") - .font(.caption2.weight(.semibold)) - .padding(.horizontal, 8) - .padding(.vertical, 2) - .foregroundColor(.accentColor) - .background( - Capsule() - .fill(Color.accentColor.opacity(0.16)) - ) + func queueThemeDeletion(_ theme: Theme) { + guard theme.name != Theme.defaultTheme.name else { return } + deletionConfirmation = PendingDeletionConfirmation( + deletion: .theme(item: theme, wasActive: currentThemeID == theme.id) + ) } - private func queueThemeDeletion(_ theme: SettingsManager.Theme) { - guard theme.name != SettingsManager.Theme.defaultTheme.name else { return } - if editingThemeID == theme.id { - cancelThemeRename() - } - queuePendingDeletion(id: .theme(theme.id), deletion: .theme(item: theme)) + func queueLayoutDeletion(_ profile: KeyMappingProfile) { + deletionConfirmation = PendingDeletionConfirmation( + deletion: .layout(item: profile, wasActive: currentLayoutProfileID == profile.id) + ) } - private func queueLayoutDeletion(_ profile: SettingsManager.KeyMappingProfile) { - if editingLayoutProfileID == profile.id { - cancelLayoutProfileRename() - } - queuePendingDeletion(id: .layout(profile.id), deletion: .layout(item: profile)) + func queueConfigurationSnapshotDeletion( + _ document: ConfigurationSnapshotDocument + ) { + guard let index = configurationSnapshots.firstIndex(where: { + $0.id == document.id + }) else { return } + deletionConfirmation = PendingDeletionConfirmation( + deletion: .configurationSnapshot( + item: document, + index: index + ) + ) } - private func queuePendingDeletion(id: PendingDeletionID, deletion: PendingDeletionKind) { + private func confirmDeletion(_ deletion: PendingDeletionKind) { + let id: PendingDeletionID + switch deletion { + case .theme(let item, _): + id = .theme(item.id) + if editingThemeID == item.id { + cancelThemeRename() + } + settings.deleteTheme(named: item.name) + case .layout(let item, _): + id = .layout(item.id) + if editingLayoutProfileID == item.id { + cancelLayoutProfileRename() + } + settings.deleteKeyMappingProfile(named: item.name) + case .configurationSnapshot(let item, _): + id = .configurationSnapshot(item.id) + if editingConfigurationSnapshotID == item.id { + cancelConfigurationSnapshotRename() + } + do { + _ = try settings.deleteConfigurationSnapshot(id: item.id) + } catch { + model.feedback = UserFeedback( + severity: .error, + title: String(localized: "Snapshot Couldn’t Be Deleted"), + detail: error.localizedDescription + ) + return + } + } + pendingDeletionTasks[id]?.cancel() pendingDeletions[id] = PendingDeletionState(deletion: deletion, secondsRemaining: inlineUndoSeconds) + reloadPersistedState() + model.feedback = UserFeedback( + severity: .success, + title: String(localized: "Saved Item Deleted"), + detail: String(localized: "Choose Undo within ten seconds to restore it."), + recoveryAction: .undo + ) pendingDeletionTasks[id] = Task { var remaining = inlineUndoSeconds while remaining > 0 { @@ -824,7 +684,8 @@ struct SettingsView: View { await MainActor.run { guard var pending = pendingDeletions[id] else { return } if remaining == 0 { - applyPendingDeletion(id: id, pending: pending) + pendingDeletions[id] = nil + pendingDeletionTasks[id] = nil } else { pending.secondsRemaining = remaining pendingDeletions[id] = pending @@ -834,39 +695,55 @@ struct SettingsView: View { } } - private func applyPendingDeletion(id: PendingDeletionID, pending: PendingDeletionState) { - pendingDeletions[id] = nil + func cancelPendingDeletion(for id: PendingDeletionID) { + guard let pending = pendingDeletions[id] else { return } pendingDeletionTasks[id]?.cancel() pendingDeletionTasks[id] = nil + pendingDeletions[id] = nil switch pending.deletion { - case .theme(let item): - SettingsManager.shared.deleteTheme(named: item.name) - if editingThemeID == item.id { - cancelThemeRename() + case .theme(let item, let wasActive): + settings.saveTheme(item) + if wasActive { + settings.activeThemeID = item.id } - case .layout(let item): - SettingsManager.shared.deleteKeyMappingProfile(named: item.name) - if editingLayoutProfileID == item.id { - cancelLayoutProfileRename() + case .layout(let item, let wasActive): + _ = settings.saveKeyMappingProfile(item) + if wasActive { + settings.activeLayoutID = item.id + } + case .configurationSnapshot(let item, let index): + do { + try settings.restoreDeletedConfigurationSnapshot( + item, + at: index + ) + } catch { + model.feedback = UserFeedback( + severity: .error, + title: String(localized: "Snapshot Couldn’t Be Restored"), + detail: error.localizedDescription + ) + return } } - reloadPersistedState() + model.feedback = UserFeedback( + severity: .success, + title: String(localized: "Deletion Undone"), + detail: String(localized: "The saved item was restored.") + ) } - private func cancelPendingDeletion(for id: PendingDeletionID) { - pendingDeletionTasks[id]?.cancel() - pendingDeletionTasks[id] = nil - pendingDeletions[id] = nil - } - - private func clearPendingDeletionState() { - for task in pendingDeletionTasks.values { - task.cancel() + private func deletedItemDescription(_ deletion: PendingDeletionKind) -> String { + switch deletion { + case .theme(let item, _): + return String(localized: "Deleted theme \"\(item.name)\".") + case .layout(let item, _): + return String(localized: "Deleted layout \"\(item.name)\".") + case .configurationSnapshot(let item, _): + return String(localized: "Deleted snapshot \"\(item.name)\".") } - pendingDeletionTasks.removeAll() - pendingDeletions.removeAll() } private func copyThemeStringToClipboard() { @@ -874,49 +751,122 @@ struct SettingsView: View { refreshThemeTransferStringFromActiveTheme() } guard !themeTransferString.isEmpty else { - themeTransferStatus = "Export failed: could not encode theme." + themeTransferFeedback = UserFeedback( + severity: .error, + title: String(localized: "Theme Couldn’t Be Shared"), + detail: String(localized: "KeyLight could not encode the current theme.") + ) return } let pasteboard = NSPasteboard.general pasteboard.clearContents() if pasteboard.setString(themeTransferString, forType: .string) { - themeTransferStatus = "Theme string copied." + themeTransferFeedback = UserFeedback( + severity: .success, + title: String(localized: "Theme String Copied") + ) } else { - themeTransferStatus = "Export failed: could not copy to clipboard." + themeTransferFeedback = UserFeedback( + severity: .error, + title: String(localized: "Theme Couldn’t Be Copied"), + detail: String(localized: "The system pasteboard did not accept the theme string.") + ) } } - private func importThemeString(_ value: String) { + @discardableResult + private func importThemeString(_ value: String) -> Bool { do { - let theme = try SettingsManager.shared.importThemeString(value) - SettingsManager.shared.saveTheme(theme) + let theme = try settings.importThemeString(value) + settings.saveTheme(theme) + let persistedTheme = settings.savedThemes.first(where: { $0.name == theme.name }) ?? theme preserveScrollOffset { - appState.applyTheme(theme) + model.applyTheme(persistedTheme) + settings.activeThemeID = persistedTheme.id reloadPersistedState() } - themeTransferStatus = "Imported and applied theme \"\(theme.name)\"." + requestSettingsPreview() + themeTransferFeedback = UserFeedback( + severity: .success, + title: String(localized: "Theme Imported"), + detail: String(localized: "Applied \"\(persistedTheme.name)\".") + ) + return true } catch { - themeTransferStatus = "Import failed: \(error.localizedDescription)" + themeTransferFeedback = UserFeedback( + severity: .error, + title: String(localized: "Theme Import Failed"), + detail: error.localizedDescription + ) + return false } } - private func selectTheme(_ theme: SettingsManager.Theme, isActive: Bool) { + func selectTheme(_ theme: Theme, isActive: Bool) { guard !isActive else { return } preserveScrollOffset { - appState.applyTheme(theme) + model.applyTheme(theme) + settings.activeThemeID = theme.id reloadPersistedState() } + requestSettingsPreview() } - private func selectLayoutProfile(_ profile: SettingsManager.KeyMappingProfile, isActive: Bool) { + func selectLayoutProfile(_ profile: KeyMappingProfile, isActive: Bool) { guard !isActive else { return } preserveScrollOffset { - KeyPositionManager.shared.loadProfile(profile) + applyLayoutProfile(profile) reloadPersistedState() } } + func applyBundledLayoutPreset(_ preset: SettingsManager.BundledLayoutPreset) { + do { + let profile: KeyMappingProfile + if let existing = savedLayoutProfiles.first(where: { + $0.name.caseInsensitiveCompare(preset.displayName) == .orderedSame + }) { + profile = existing + } else { + let imported = try settings.importBundledLayoutPreset(preset) + guard let persisted = settings.saveKeyMappingProfile(imported) else { + layoutTransferFeedback = UserFeedback( + severity: .error, + title: String(localized: "Layout Preset Couldn’t Be Applied"), + detail: String(localized: "The preset could not be saved as a keyboard layout.") + ) + return + } + profile = persisted + } + applyLayoutProfile(profile) + reloadPersistedState() + layoutTransferFeedback = UserFeedback( + severity: .success, + title: String(localized: "Layout Preset Applied"), + detail: String(localized: "Applied \"\(profile.name)\". Use Calibrate Keyboard for device-specific fine tuning.") + ) + } catch { + layoutTransferFeedback = UserFeedback( + severity: .error, + title: String(localized: "Layout Preset Couldn’t Be Applied"), + detail: error.localizedDescription + ) + } + } + + private func applyLayoutProfile(_ profile: KeyMappingProfile) { + keyLayoutStore.apply( + KeyLayout( + offsets: profile.keyOffsets, + widthMultipliers: profile.keyWidthOverrides + ), + asBaseline: true + ) + settings.activeLayoutID = profile.id + } + private func preserveScrollOffset(_ action: () -> Void) { let currentOffset = settingsScrollView?.contentView.bounds.origin.y ?? 0 var transaction = Transaction() @@ -939,33 +889,48 @@ struct SettingsView: View { scrollView.reflectScrolledClipView(clipView) } - private func exportActiveLayoutProfile() { - guard let activeProfile = activeLayoutProfile else { - layoutTransferStatus = "Export failed: no active layout profile." - return - } - guard let data = SettingsManager.shared.exportLayoutProfileData(activeProfile) else { - layoutTransferStatus = "Export failed: could not encode layout profile." + func exportActiveLayoutProfile() { + var liveProfile = activeLayoutProfile ?? KeyMappingProfile( + name: "Current Layout", + keyOffsets: [:], + keyWidthOverrides: [:] + ) + liveProfile.keyOffsets = keyLayoutStore.layout.offsets + liveProfile.keyWidthOverrides = keyLayoutStore.layout.widthMultipliers + guard let data = settings.exportLayoutProfileData(liveProfile) else { + layoutTransferFeedback = UserFeedback( + severity: .error, + title: String(localized: "Layout Couldn’t Be Exported"), + detail: String(localized: "KeyLight could not encode the current layout.") + ) return } let panel = NSSavePanel() panel.allowedContentTypes = [.json] - panel.nameFieldStringValue = "KeyLight-Layout-\(safeFilename(activeProfile.name)).json" + panel.nameFieldStringValue = "KeyLight-Layout-\(safeFilename(liveProfile.name)).json" if panel.runModal() == .OK, let url = panel.url { do { - try data.write(to: url) - layoutTransferStatus = "Exported \"\(activeProfile.name)\"." + try data.write(to: url, options: .atomic) + layoutTransferFeedback = UserFeedback( + severity: .success, + title: String(localized: "Layout Exported"), + detail: String(localized: "Saved the current layout as \"\(liveProfile.name)\".") + ) } catch { - layoutTransferStatus = "Export failed: \(error.localizedDescription)" + layoutTransferFeedback = UserFeedback( + severity: .error, + title: String(localized: "Layout Export Failed"), + detail: error.localizedDescription + ) } } else { - layoutTransferStatus = "" + layoutTransferFeedback = nil } } - private func importLayoutProfile() { + func importLayoutProfile() { let panel = NSOpenPanel() panel.allowedContentTypes = [.json] panel.allowsMultipleSelection = false @@ -983,15 +948,37 @@ struct SettingsView: View { try loadValidatedSettingsImportData(from: url, maxFileSize: maxFileSize) }.value - let importedProfile = try SettingsManager.shared.importLayoutProfileData(data) - SettingsManager.shared.saveKeyMappingProfile(importedProfile) - KeyPositionManager.shared.loadProfile(importedProfile) + let importedProfile = try settings.importLayoutProfileData(data) + guard let persistedProfile = settings.saveKeyMappingProfile(importedProfile) else { + throw NSError( + domain: "KeyLight", + code: 26, + userInfo: [ + NSLocalizedDescriptionKey: String( + localized: "A layout with that name already exists." + ) + ] + ) + } + applyLayoutProfile(persistedProfile) reloadPersistedState() - layoutTransferStatus = "Imported and applied layout profile \"\(importedProfile.name)\"." + layoutTransferFeedback = UserFeedback( + severity: .success, + title: String(localized: "Layout Imported"), + detail: String(localized: "Applied \"\(persistedProfile.name)\".") + ) } catch SettingsImportValidationError.fileTooLarge { - layoutTransferStatus = "Import failed: file too large (max 5MB)" + layoutTransferFeedback = UserFeedback( + severity: .error, + title: String(localized: "Layout Import Failed"), + detail: String(localized: "The file is too large (maximum 1 MB).") + ) } catch { - layoutTransferStatus = "Import failed: \(error.localizedDescription)" + layoutTransferFeedback = UserFeedback( + severity: .error, + title: String(localized: "Layout Import Failed"), + detail: error.localizedDescription + ) } } } @@ -1009,180 +996,275 @@ struct SettingsView: View { return sanitized.isEmpty ? "Layout-Profile" : sanitized } - private func startThemeRename(_ theme: SettingsManager.Theme) { + func startThemeRename(_ theme: Theme) { editingThemeID = theme.id themeRenameDraft = theme.name themeRenameError = nil } - private func cancelThemeRename() { + func cancelThemeRename() { editingThemeID = nil themeRenameDraft = "" themeRenameError = nil } - private func themeRenameValidation(for theme: SettingsManager.Theme) -> String? { - let trimmed = trimmed(themeRenameDraft) - if trimmed.isEmpty { - return "Name cannot be empty." + func themeRenameValidation(for theme: Theme) -> String? { + guard let normalizedName = PersistenceValidation.normalizedName(themeRenameDraft) else { + return String(localized: "Name cannot be empty.") } - if trimmed.caseInsensitiveCompare(theme.name) == .orderedSame { + if normalizedName.caseInsensitiveCompare(theme.name) == .orderedSame { return nil } let exists = savedThemes.contains { existing in - existing.id != theme.id && existing.name.caseInsensitiveCompare(trimmed) == .orderedSame + existing.id != theme.id && + existing.name.caseInsensitiveCompare(normalizedName) == .orderedSame } - return exists ? "Theme name already exists." : nil + return exists ? String(localized: "Theme name already exists.") : nil } - private func saveThemeRename(_ theme: SettingsManager.Theme) { + func saveThemeRename(_ theme: Theme) { if let error = themeRenameValidation(for: theme) { themeRenameError = error return } - SettingsManager.shared.renameTheme(from: theme.name, to: themeRenameDraft) + guard let newName = PersistenceValidation.normalizedName(themeRenameDraft), + settings.renameTheme(from: theme.name, to: newName) else { + themeRenameError = String(localized: "Choose a unique name up to 100 characters.") + return + } reloadPersistedState() cancelThemeRename() + themeTransferFeedback = UserFeedback( + severity: .success, + title: String(localized: "Theme Renamed"), + detail: String(localized: "Renamed \"\(theme.name)\" to \"\(newName)\".") + ) } - private func startLayoutProfileRename(_ profile: SettingsManager.KeyMappingProfile) { + func startLayoutProfileRename(_ profile: KeyMappingProfile) { editingLayoutProfileID = profile.id layoutRenameDraft = profile.name layoutRenameError = nil } - private func cancelLayoutProfileRename() { + func cancelLayoutProfileRename() { editingLayoutProfileID = nil layoutRenameDraft = "" layoutRenameError = nil } - private func layoutRenameValidation(for profile: SettingsManager.KeyMappingProfile) -> String? { - let trimmed = trimmed(layoutRenameDraft) - if trimmed.isEmpty { - return "Name cannot be empty." + func layoutRenameValidation(for profile: KeyMappingProfile) -> String? { + guard let normalizedName = PersistenceValidation.normalizedName(layoutRenameDraft) else { + return String(localized: "Name cannot be empty.") } - if trimmed.caseInsensitiveCompare(profile.name) == .orderedSame { + if normalizedName.caseInsensitiveCompare(profile.name) == .orderedSame { return nil } let exists = savedLayoutProfiles.contains { existing in - existing.id != profile.id && existing.name.caseInsensitiveCompare(trimmed) == .orderedSame + existing.id != profile.id && + existing.name.caseInsensitiveCompare(normalizedName) == .orderedSame } - return exists ? "Layout profile name already exists." : nil + return exists ? String(localized: "Layout profile name already exists.") : nil } - private func saveLayoutProfileRename(_ profile: SettingsManager.KeyMappingProfile) { + func saveLayoutProfileRename(_ profile: KeyMappingProfile) { if let error = layoutRenameValidation(for: profile) { layoutRenameError = error return } - SettingsManager.shared.renameKeyMappingProfile(from: profile.name, to: layoutRenameDraft) + guard let newName = PersistenceValidation.normalizedName(layoutRenameDraft), + settings.renameKeyMappingProfile(from: profile.name, to: newName) else { + layoutRenameError = String(localized: "Choose a unique name up to 100 characters.") + return + } reloadPersistedState() cancelLayoutProfileRename() + layoutTransferFeedback = UserFeedback( + severity: .success, + title: String(localized: "Layout Renamed"), + detail: String(localized: "Renamed \"\(profile.name)\" to \"\(newName)\".") + ) } - private func isStatusError(_ status: String) -> Bool { - let lowered = status.lowercased() - return lowered.contains("failed") || lowered.contains("invalid") || lowered.contains("unsupported") + func normalizedHex(_ value: String?, fallback: String = "") -> String { + (value ?? fallback).uppercased() } - private func reloadPersistedState() { - gradientPresets = SettingsManager.shared.savedGradientPresets - savedThemes = SettingsManager.shared.savedThemes - savedLayoutProfiles = SettingsManager.shared.savedKeyMappingProfiles - currentThemeName = SettingsManager.shared.currentThemeName - currentLayoutProfileName = SettingsManager.shared.currentKeyMappingProfileName - refreshThemeTransferStringFromActiveTheme() + private func liveThemeSnapshot(id: UUID, name: String) -> Theme { + Theme( + id: id, + name: name, + colorHex: normalizedHex(model.glowColor.toHex(), fallback: "68B8FF"), + opacity: model.glowOpacity, + size: model.glowSize, + width: model.glowWidth, + glowRoundness: model.glowRoundness, + glowFullness: model.glowFullness, + fadeDuration: model.fadeDuration, + colorMode: model.colorMode, + effectStyle: model.effectStyle, + shapeProfile: model.surfaceShapeProfile, + gradientStartHex: normalizedHex(model.gradientStartColor.toHex(), fallback: "68B8FF"), + gradientEndHex: normalizedHex(model.gradientEndColor.toHex(), fallback: "00E69A") + ) + } - if let editingThemeID, !savedThemes.contains(where: { $0.id == editingThemeID }) { - cancelThemeRename() + @discardableResult + func saveCurrentThemeAs(_ requestedName: String) -> Bool { + guard let name = PersistenceValidation.normalizedName(requestedName) else { return false } + guard !savedThemes.contains(where: { + $0.name.caseInsensitiveCompare(name) == .orderedSame + }) else { + themeTransferFeedback = UserFeedback( + severity: .error, + title: String(localized: "Theme Couldn’t Be Saved"), + detail: String(localized: "A theme named \"\(name)\" already exists.") + ) + return false } - if let editingLayoutProfileID, !savedLayoutProfiles.contains(where: { $0.id == editingLayoutProfileID }) { - cancelLayoutProfileRename() + let theme = liveThemeSnapshot(id: UUID(), name: name) + settings.saveTheme(theme) + if let saved = settings.savedThemes.first(where: { $0.name == name }) { + settings.activeThemeID = saved.id } + reloadPersistedState() + themeTransferFeedback = UserFeedback( + severity: .success, + title: String(localized: "Theme Saved"), + detail: String(localized: "Saved \"\(name)\".") + ) + return true } - private func refreshThemeTransferStringFromActiveTheme() { - let activeTheme = savedThemes.first(where: { $0.name == currentThemeName }) ?? appState.currentTheme() - themeTransferString = SettingsManager.shared.exportThemeString(activeTheme) ?? "" + func updateActiveTheme() { + guard let activeTheme else { return } + settings.saveTheme( + liveThemeSnapshot(id: activeTheme.id, name: activeTheme.name) + ) + settings.activeThemeID = activeTheme.id + reloadPersistedState() + themeTransferFeedback = UserFeedback( + severity: .success, + title: String(localized: "Theme Updated"), + detail: String(localized: "Saved the current appearance to \"\(activeTheme.name)\".") + ) } - private func trimmed(_ value: String) -> String { - value.trimmingCharacters(in: .whitespacesAndNewlines) + func revertActiveTheme() { + guard let activeTheme else { return } + model.applyTheme(activeTheme) + settings.activeThemeID = activeTheme.id + reloadPersistedState() + requestSettingsPreview() } -} -private struct SettingsScrollViewBridge: NSViewRepresentable { - let onResolve: (NSScrollView) -> Void + func updateActiveLayoutProfile() { + guard var profile = activeLayoutProfile else { return } + profile.keyOffsets = keyLayoutStore.layout.offsets + profile.keyWidthOverrides = keyLayoutStore.layout.widthMultipliers + settings.saveKeyMappingProfile(profile) + keyLayoutStore.markCurrentAsBaseline() + reloadPersistedState() + layoutTransferFeedback = UserFeedback( + severity: .success, + title: String(localized: "Layout Updated"), + detail: String(localized: "Saved the current calibration to \"\(profile.name)\".") + ) + } - func makeNSView(context: Context) -> NSView { - NSView() + func revertActiveLayoutProfile() { + guard let activeLayoutProfile else { return } + applyLayoutProfile(activeLayoutProfile) + reloadPersistedState() } - func updateNSView(_ nsView: NSView, context: Context) { - DispatchQueue.main.async { - var current: NSView? = nsView - while let view = current { - if let scrollView = view as? NSScrollView { - onResolve(scrollView) - return - } - current = view.superview - } - } + func themeDisplayName(_ theme: Theme, isActive: Bool) -> String { + isActive && activeThemeIsEdited + ? String(localized: "\(theme.name) · Edited") + : theme.name } -} -struct ColorPresetButton: View { - let color: Color - @ObservedObject var appState: AppState - @Binding var hexColor: String + func layoutDisplayName(_ profile: KeyMappingProfile, isActive: Bool) -> String { + isActive && activeLayoutIsEdited + ? String(localized: "\(profile.name) · Edited") + : profile.name + } - var body: some View { - Button(action: { - appState.glowColor = color - hexColor = color.toHex() ?? "" - }) { - RoundedRectangle(cornerRadius: 4) - .fill(color) - .frame(width: 20, height: 20) - .overlay( - RoundedRectangle(cornerRadius: 4) - .stroke(Color.primary.opacity(0.2), lineWidth: 1) - ) + func themeSelectionAccessibilityValue(isActive: Bool, isEdited: Bool) -> String { + guard isActive else { return String(localized: "Not selected") } + return isEdited + ? String(localized: "Selected, edited") + : String(localized: "Selected") + } + + func reloadPersistedState() { + gradientPresets = settings.savedGradientPresets + configurationSnapshots = settings.configurationSnapshots + model.reloadSavedThemes() + keyLayoutStore.reloadSavedProfiles(from: settings) + + if let editingThemeID, !savedThemes.contains(where: { $0.id == editingThemeID }) { + cancelThemeRename() + } + if let editingLayoutProfileID, !savedLayoutProfiles.contains(where: { $0.id == editingLayoutProfileID }) { + cancelLayoutProfileRename() + } + if let editingConfigurationSnapshotID, + !configurationSnapshots.contains(where: { + $0.id == editingConfigurationSnapshotID + }) { + cancelConfigurationSnapshotRename() } - .buttonStyle(.plain) } -} -struct GradientPresetButton: View { - let startHex: String - let endHex: String - @ObservedObject var appState: AppState + func refreshThemeTransferStringFromActiveTheme() { + let liveTheme = liveThemeSnapshot( + id: activeTheme?.id ?? UUID(), + name: activeTheme?.name ?? settings.currentThemeName + ) + themeTransferString = settings.exportThemeString(liveTheme) ?? "" + } - private var isSelected: Bool { - let currentStart = appState.gradientStartColor.toHex()?.uppercased() - let currentEnd = appState.gradientEndColor.toHex()?.uppercased() - return currentStart == startHex.uppercased() && currentEnd == endHex.uppercased() + func trimmed(_ value: String) -> String { + value.trimmingCharacters(in: .whitespacesAndNewlines) } - var body: some View { - Button(action: { - appState.gradientStartColor = Color(hex: startHex) ?? .blue - appState.gradientEndColor = Color(hex: endHex) ?? .green - }) { - LinearGradient( - colors: [Color(hex: startHex) ?? .blue, Color(hex: endHex) ?? .green], - startPoint: .leading, - endPoint: .trailing - ) - .frame(width: 30, height: 20) - .cornerRadius(4) - .overlay( - RoundedRectangle(cornerRadius: 4) - .stroke(isSelected ? Color.accentColor : Color.primary.opacity(0.2), lineWidth: isSelected ? 2 : 1) + func startChordPreviewTest() { + chordPreviewTask?.cancel() + let keyCodes: [UInt16] = [0, 1, 2, 3] // A, S, D, F: adjacent and visually diagnostic. + let targets = zip(PreviewSource.chordTestSources, keyCodes).map { source, keyCode in + let keyInfo = KeyMapping.keyInfo(for: keyCode) + return GlowTarget.preview( + source, + colorReferenceKeyCode: keyCode, + horizontalPosition: Double(keyLayoutStore.adjustedPosition( + for: keyCode, + originalPosition: keyInfo.position + )), + keyWidth: Double(keyLayoutStore.effectiveWidth( + for: keyCode, + defaultWidth: keyInfo.width + )) ) } - .buttonStyle(.plain) + model.setChordPreview(targets) + chordPreviewActive = true + chordPreviewTask = Task { @MainActor in + do { + try await Task.sleep(nanoseconds: 2_000_000_000) + } catch { + return + } + guard !Task.isCancelled else { return } + stopChordPreviewTest() + } + } + + func stopChordPreviewTest() { + chordPreviewTask?.cancel() + chordPreviewTask = nil + guard chordPreviewActive else { return } + chordPreviewActive = false + model.clearChordPreview() } } diff --git a/KeyLight/Views/SettingsGeneralTab.swift b/KeyLight/Views/SettingsGeneralTab.swift new file mode 100644 index 0000000..bfc0c7b --- /dev/null +++ b/KeyLight/Views/SettingsGeneralTab.swift @@ -0,0 +1,311 @@ +import AppKit +import Carbon.HIToolbox +import SwiftUI + +extension SettingsView { + @ViewBuilder + var generalTabContent: some View { + HStack { + Text(KeyLightApplicationIdentity.displayName) + .font(.title2) + .bold() + Spacer() + Text(model.globalShortcut.displayName) + .font(.caption) + .foregroundColor(.secondary) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(RoundedRectangle(cornerRadius: 4).fill(Color.secondary.opacity(0.15))) + Toggle("", isOn: $model.isEnabled) + .toggleStyle(.switch) + .labelsHidden() + .accessibilityLabel("Enable \(KeyLightApplicationIdentity.displayName)") + } + + Divider() + + Toggle("Launch at Login", isOn: $model.launchAtLogin) + .accessibilityHint("Starts KeyLight automatically after you sign in") + + Divider() + + Text("Power Saving") + .font(.headline) + + Picker("Mode", selection: $model.powerSavingMode) { + ForEach(PowerSavingMode.allCases, id: \.self) { mode in + Text(mode.displayName).tag(mode) + } + } + .pickerStyle(.segmented) + + LabeledContent("Current Power State") { + Text(powerEnvironmentDescription) + .foregroundStyle(.secondary) + } + + if model.powerSavingMode == .automatic { + if model.powerEnvironmentState.requiresFallback { + VStack(alignment: .leading, spacing: 4) { + Label( + model.effectStyle == .physicalRefraction + ? "Temporarily using \(model.effectRuntimeStatus.resolvedEffect.displayName)" + : "Automatic power saving is active", + systemImage: "leaf.fill" + ) + .foregroundStyle(.orange) + + Text( + model.powerEnvironmentState.fallbackReason + ?? "macOS requested reduced power use." + ) + .font(.caption) + .foregroundStyle(.secondary) + + if model.effectStyle == .physicalRefraction { + Text("Your Physical Refraction selection is preserved and returns automatically when the condition clears.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } else { + Text("Physical Refraction remains active until Low Power Mode or serious thermal pressure begins.") + .font(.caption) + .foregroundStyle(.secondary) + } + } else { + Text("Automatic renderer fallback is disabled.") + .font(.caption) + .foregroundStyle(.secondary) + } + + Divider() + + LabeledContent("Global Shortcut") { + HStack(spacing: 8) { + GlobalShortcutRecorder(shortcut: $model.globalShortcut) + .fixedSize() + + Button("Reset") { + model.globalShortcut = .default + } + .controlSize(.small) + .disabled(model.globalShortcut == .default) + } + } + + LabeledContent("Shortcut Status") { + Label(hotKeyStatusTitle, systemImage: hotKeyStatusIcon) + .foregroundStyle(hotKeyStatusColor) + .accessibilityLabel("\(model.globalShortcut.displayName): \(hotKeyStatusTitle)") + } + + LabeledContent("Version") { + Text(appVersionDescription) + .foregroundStyle(.secondary) + } + + LabeledContent("Build Channel") { + Text(buildIdentity.channel) + .foregroundStyle(.secondary) + } + + LabeledContent("Bundle ID") { + Text(buildIdentity.bundleIdentifier) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } + + Button("Copy Build Information") { + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(buildIdentity.supportSummary, forType: .string) + } + .accessibilityHint("Copies the version, build channel, bundle identifier, and app location") + + Divider() + + Toggle( + "Automatically check for updates", + isOn: Binding( + get: { updateService.automaticallyChecksForUpdates }, + set: { updateService.automaticallyChecksForUpdates = $0 } + ) + ) + .disabled(!updateService.isConfigured) + .accessibilityHint("Contacts only the signed KeyLight update feed") + + LabeledContent("Updates") { + Text(updateService.status.displayName) + .foregroundStyle(.secondary) + } + + Button("Check for Updates…") { + updateService.checkForUpdates() + } + .disabled(!updateService.canCheckForUpdates) + + Divider() + + Text("Permissions") + .font(.headline) + + InputMonitoringStatusBanner(model: model) + + VStack(alignment: .leading, spacing: 10) { + HStack { + Label( + screenCaptureAccessGranted + ? "Screen Recording Allowed" + : "Screen Recording Not Allowed", + systemImage: screenCaptureAccessGranted + ? "checkmark.shield.fill" + : "rectangle.on.rectangle.slash" + ) + .foregroundStyle( + screenCaptureAccessGranted ? .green : .secondary + ) + Spacer() + Button("Check Again") { + screenCaptureAccessGranted = + ScreenCaptureAuthorization.isGranted + model.refreshEffectRenderer() + } + .controlSize(.small) + } + + Text("Optional. Physical Refraction uses it only while a key surface is visible; no captured image is saved or sent.") + .font(.caption) + .foregroundStyle(.secondary) + + HStack { + if !screenCaptureAccessGranted { + Button("Allow Screen Recording…") { + screenCaptureAccessGranted = + ScreenCaptureAuthorization.requestAccess() + model.refreshEffectRenderer() + } + .buttonStyle(.borderedProminent) + } + Button("Open Screen Recording Settings") { + ScreenCaptureAuthorization.openSettings() + } + } + .controlSize(.small) + } + + HStack(spacing: 14) { + Link("Privacy", destination: URL(string: "https://github.com/keylight-macos/keylight/blob/main/PRIVACY.md")!) + Link("Troubleshooting", destination: URL(string: "https://github.com/keylight-macos/keylight/blob/main/docs/TROUBLESHOOTING.md")!) + Link("Releases", destination: URL(string: "https://github.com/keylight-macos/keylight/releases")!) + } + .accessibilityElement(children: .contain) + } + + private var powerEnvironmentDescription: String { + let lowPower = model.powerEnvironmentState.isLowPowerModeEnabled + ? "Low Power Mode on" + : "Low Power Mode off" + return "\(lowPower), thermal \(model.powerEnvironmentState.thermalState.displayName.lowercased())" + } +} + +/// A deliberately local recorder: capture begins only after the user presses +/// the button, consumes one key-down event, and stores key-code metadata only. +private struct GlobalShortcutRecorder: NSViewRepresentable { + @Binding var shortcut: GlobalShortcut + + func makeCoordinator() -> Coordinator { + Coordinator(shortcut: $shortcut) + } + + func makeNSView(context: Context) -> NSButton { + let button = NSButton(title: shortcut.displayName, target: nil, action: nil) + button.bezelStyle = .rounded + button.setButtonType(.momentaryPushIn) + button.target = context.coordinator + button.action = #selector(Coordinator.beginRecording) + button.toolTip = "Press, then type a shortcut with Command, Option, Control, or Shift. Escape cancels." + button.setAccessibilityLabel("Record global shortcut") + context.coordinator.button = button + return button + } + + func updateNSView(_ button: NSButton, context: Context) { + context.coordinator.shortcut = $shortcut + if !context.coordinator.isRecording { + button.title = shortcut.displayName + button.setAccessibilityValue(shortcut.displayName) + } + } + + static func dismantleNSView(_ button: NSButton, coordinator: Coordinator) { + coordinator.stopRecording(restoreTitle: true) + } + + @MainActor + final class Coordinator: NSObject { + var shortcut: Binding + weak var button: NSButton? + private var localMonitor: Any? + + var isRecording: Bool { localMonitor != nil } + + init(shortcut: Binding) { + self.shortcut = shortcut + } + + @objc func beginRecording() { + guard localMonitor == nil else { + stopRecording(restoreTitle: true) + return + } + + button?.title = "Press Shortcut…" + button?.setAccessibilityValue("Waiting for shortcut") + localMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in + guard let self else { return event } + return self.capture(event) + } + } + + func stopRecording(restoreTitle: Bool) { + if let localMonitor { + NSEvent.removeMonitor(localMonitor) + self.localMonitor = nil + } + if restoreTitle { + button?.title = shortcut.wrappedValue.displayName + button?.setAccessibilityValue(shortcut.wrappedValue.displayName) + } + } + + private func capture(_ event: NSEvent) -> NSEvent? { + let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + if event.keyCode == UInt16(kVK_Escape), + flags.intersection([.command, .option, .control, .shift]).isEmpty { + stopRecording(restoreTitle: true) + return nil + } + + var carbonModifiers: UInt32 = 0 + if flags.contains(.command) { carbonModifiers |= UInt32(cmdKey) } + if flags.contains(.option) { carbonModifiers |= UInt32(optionKey) } + if flags.contains(.control) { carbonModifiers |= UInt32(controlKey) } + if flags.contains(.shift) { carbonModifiers |= UInt32(shiftKey) } + + guard let recorded = GlobalShortcut( + keyCode: UInt32(event.keyCode), + modifiers: carbonModifiers + ) else { + NSSound.beep() + return nil + } + + shortcut.wrappedValue = recorded + stopRecording(restoreTitle: true) + return nil + } + } +} diff --git a/KeyLight/Views/SettingsGlowPreviewSession.swift b/KeyLight/Views/SettingsGlowPreviewSession.swift new file mode 100644 index 0000000..22d7283 --- /dev/null +++ b/KeyLight/Views/SettingsGlowPreviewSession.swift @@ -0,0 +1,67 @@ +import Foundation + +/// Owns Settings preview visibility so delayed hides cannot outlive an +/// interaction or window. Rendering priority remains in OverlayController. +@MainActor +final class SettingsGlowPreviewSession { + typealias ShowHandler = @MainActor () -> Void + typealias HideHandler = @MainActor () -> Void + + private let hideDelay: TimeInterval + private let showHandler: ShowHandler + private let hideHandler: HideHandler + private var hideTask: Task? + private var isEditing = false + + init( + hideDelay: TimeInterval = 0.5, + show: @escaping ShowHandler, + hide: @escaping HideHandler + ) { + self.hideDelay = hideDelay.isFinite ? max(hideDelay, 0) : 0.5 + showHandler = show + hideHandler = hide + } + + func configurationChanged(isEnabled: Bool) { + cancelPendingHide() + guard isEnabled else { + hideHandler() + return + } + + showHandler() + guard !isEditing else { return } + scheduleHide() + } + + func editingChanged(_ editing: Bool, isEnabled: Bool) { + isEditing = editing + configurationChanged(isEnabled: isEnabled) + } + + func stop() { + cancelPendingHide() + isEditing = false + hideHandler() + } + + private func scheduleHide() { + let delay = hideDelay + hideTask = Task { @MainActor [weak self] in + do { + try await Task.sleep(for: .seconds(delay)) + } catch { + return + } + guard !Task.isCancelled, let self else { return } + hideTask = nil + hideHandler() + } + } + + private func cancelPendingHide() { + hideTask?.cancel() + hideTask = nil + } +} diff --git a/KeyLight/Views/SettingsKeyboardTab.swift b/KeyLight/Views/SettingsKeyboardTab.swift new file mode 100644 index 0000000..a604737 --- /dev/null +++ b/KeyLight/Views/SettingsKeyboardTab.swift @@ -0,0 +1,380 @@ +import SwiftUI + +extension SettingsView { + @ViewBuilder + var keyboardTabContent: some View { + Divider() + + VStack(alignment: .leading, spacing: 8) { + HStack { + VStack(alignment: .leading, spacing: 3) { + Text("Multi-Key Chord Test") + .font(.headline) + Text("Temporarily lights A–S–D–F together using the active layout. The test is not saved and records no typing.") + .font(.caption) + .foregroundColor(.secondary) + } + Spacer(minLength: 12) + Button(chordPreviewActive ? "Testing…" : "Test Four Keys") { + startChordPreviewTest() + } + .buttonStyle(.borderedProminent) + .disabled(chordPreviewActive || !model.isEnabled) + } + } + + Divider() + + VStack(alignment: .leading, spacing: 8) { + Text("Display Routing") + .font(.headline) + + Text("Choose where KeyLight appears. Automatic keeps the original built-in-first behavior and safely falls back when a display disconnects.") + .font(.caption) + .foregroundColor(.secondary) + + Picker("Glow Display", selection: Binding( + get: { model.overlayDisplaySelection }, + set: { model.overlayDisplaySelection = $0 } + )) { + Text("Automatic (Built-in First)").tag(OverlayDisplaySelection.automatic) + Text("Built-in Display").tag(OverlayDisplaySelection.builtIn) + Text("Main Display").tag(OverlayDisplaySelection.main) + + ForEach(model.availableDisplays) { display in + Text(displaySelectionLabel(display)) + .tag(OverlayDisplaySelection.specific(display.id)) + } + } + + VStack(alignment: .leading, spacing: 6) { + Text("Mirror to Additional Displays") + .font(.subheadline.weight(.semibold)) + + let additionalDisplays = model.availableDisplays.filter { + $0.id != model.activeDisplayPersistentID + } + if additionalDisplays.isEmpty + && unavailableMirroredDisplayIDs.isEmpty { + Text("No additional displays are connected.") + .font(.caption) + .foregroundColor(.secondary) + } else { + ForEach(additionalDisplays) { display in + Toggle( + displaySelectionLabel(display), + isOn: mirroredDisplayBinding(for: display.id) + ) + } + ForEach(unavailableMirroredDisplayIDs, id: \.self) { displayID in + Toggle( + "Unavailable display (\(displayID))", + isOn: mirroredDisplayBinding(for: displayID) + ) + .foregroundStyle(.secondary) + } + } + + Text("Every selected display uses the currently active keyboard layout and the same held-key state.") + .font(.caption2) + .foregroundColor(.secondary) + } + + if let activeDisplay = activeOverlayDisplay { + Text("Active: \(displaySelectionLabel(activeDisplay))") + .font(.caption) + .foregroundColor(.secondary) + + Picker("Layout on This Display", selection: Binding( + get: { model.boundLayoutProfileID(forDisplay: activeDisplay.id) }, + set: { model.setLayoutProfileBinding($0, forDisplay: activeDisplay.id) } + )) { + Text("Keep Current Layout").tag(Optional.none) + ForEach(savedLayoutProfiles) { profile in + Text(profile.name).tag(Optional(profile.id)) + } + } + .disabled(savedLayoutProfiles.isEmpty) + + Text("A bound profile is applied when this display becomes active. Unsaved calibration edits are never discarded.") + .font(.caption2) + .foregroundColor(.secondary) + } else { + Text("No active display is available.") + .font(.caption) + .foregroundColor(.secondary) + } + + if model.activeDisplayPersistentIDs.count > 1 { + Text("Rendering on \(model.activeDisplayPersistentIDs.count) displays") + .font(.caption) + .foregroundColor(.secondary) + } + } + + Divider() + + VStack(alignment: .leading, spacing: 8) { + Text("Key Layout (Position + Width)") + .font(.headline) + + Text("Key layout profiles store keyboard geometry only: per-key offsets and per-key glow width overrides.") + .font(.caption) + .foregroundColor(.secondary) + + ForEach(savedLayoutProfiles) { profile in + let isActive = currentLayoutProfileID == profile.id + let pendingID = PendingDeletionID.layout(profile.id) + let pendingState = pendingDeletions[pendingID] + + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 8) { + if editingLayoutProfileID == profile.id { + TextField("Layout profile name", text: $layoutRenameDraft) + .textFieldStyle(.roundedBorder) + .onChange(of: layoutRenameDraft) { _, _ in + layoutRenameError = nil + } + Spacer(minLength: 10) + + Button("Save") { + saveLayoutProfileRename(profile) + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + .disabled(layoutRenameValidation(for: profile) != nil) + + Button("Cancel") { + cancelLayoutProfileRename() + } + .controlSize(.small) + } else { + Button { + selectLayoutProfile(profile, isActive: isActive) + } label: { + HStack(spacing: 7) { + Image(systemName: isActive ? "checkmark.circle.fill" : "circle") + .foregroundStyle(isActive ? Color.accentColor : Color.secondary) + .accessibilityHidden(true) + Text(layoutDisplayName(profile, isActive: isActive)) + .font(.subheadline) + .lineLimit(1) + Spacer(minLength: 0) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(isActive) + .accessibilityLabel("Keyboard layout \(profile.name)") + .accessibilityValue(themeSelectionAccessibilityValue(isActive: isActive, isEdited: isActive && activeLayoutIsEdited)) + + if let pendingState { + Button("Undo (\(pendingState.secondsRemaining)s)") { + cancelPendingDeletion(for: pendingID) + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + } else { + Menu { + Button("Rename…") { + startLayoutProfileRename(profile) + } + + Button("Delete", role: .destructive) { + queueLayoutDeletion(profile) + } + } label: { + Image(systemName: "ellipsis.circle") + } + .menuStyle(.borderlessButton) + .menuIndicator(.hidden) + .fixedSize() + .help("Layout profile actions") + .accessibilityLabel("Actions for keyboard layout \(profile.name)") + } + } + } + .frame(minHeight: 30) + + if editingLayoutProfileID == profile.id, + let error = layoutRenameError ?? layoutRenameValidation(for: profile) { + Text(error) + .font(.caption2) + .foregroundColor(.red) + .padding(.leading, 24) + } + } + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(isActive ? Color.accentColor.opacity(0.12) : Color.primary.opacity(0.03)) + ) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke( + isActive ? Color.accentColor.opacity(0.35) : Color.primary.opacity(0.2), + lineWidth: 1 + ) + ) + .padding(.vertical, 0.5) + } + + if showingLayoutSaveField { + HStack { + TextField("Layout profile name", text: $newLayoutProfileName) + .textFieldStyle(.roundedBorder) + + Button("Save") { + guard let normalizedName = PersistenceValidation.normalizedName( + newLayoutProfileName + ) else { + return + } + guard !savedLayoutProfiles.contains(where: { + $0.name.caseInsensitiveCompare(normalizedName) == .orderedSame + }) else { + layoutTransferFeedback = UserFeedback( + severity: .error, + title: String(localized: "Layout Couldn’t Be Saved"), + detail: String(localized: "A layout named \"\(normalizedName)\" already exists.") + ) + return + } + let profile = KeyMappingProfile( + name: normalizedName, + keyOffsets: keyLayoutStore.layout.offsets, + keyWidthOverrides: keyLayoutStore.layout.widthMultipliers + ) + if let persisted = settings.saveKeyMappingProfile(profile) { + keyLayoutStore.markCurrentAsBaseline() + reloadPersistedState() + showingLayoutSaveField = false + newLayoutProfileName = "" + layoutTransferFeedback = UserFeedback( + severity: .success, + title: String(localized: "Layout Saved"), + detail: String(localized: "Saved \"\(persisted.name)\".") + ) + } else { + layoutTransferFeedback = UserFeedback( + severity: .error, + title: String(localized: "Layout Couldn’t Be Saved"), + detail: String(localized: "Choose a unique name up to 100 characters.") + ) + } + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + .disabled(trimmed(newLayoutProfileName).isEmpty) + + Button("Cancel") { + showingLayoutSaveField = false + newLayoutProfileName = "" + } + .controlSize(.small) + } + } else { + HStack(spacing: 8) { + Button("Update Layout") { + updateActiveLayoutProfile() + } + .controlSize(.small) + .disabled(activeLayoutProfile == nil || !activeLayoutIsEdited) + .accessibilityHint("Replaces the selected layout with the current calibration") + + Button("Save As…") { + showingLayoutSaveField = true + } + .controlSize(.small) + + Menu("Add Preset…") { + ForEach(bundledLayoutPresets) { preset in + Button(preset.displayName) { + applyBundledLayoutPreset(preset) + } + } + } + .controlSize(.small) + .disabled(bundledLayoutPresets.isEmpty) + + Button("Revert") { + revertActiveLayoutProfile() + } + .controlSize(.small) + .disabled(activeLayoutProfile == nil || !activeLayoutIsEdited) + .accessibilityHint("Restores the selected layout's saved calibration") + + Spacer() + + Button("Export Current…") { + exportActiveLayoutProfile() + } + .controlSize(.small) + + Button("Import…") { + importLayoutProfile() + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + } + } + + HStack(spacing: 12) { + Button("Guided Calibration…") { + KeyLightWindowActivation.present(.guidedCalibration) { + openWindow(id: KeyLightSceneID.guidedCalibration) + } + } + .buttonStyle(.borderedProminent) + + Button("Fine-Tune Manually…") { + KeyLightWindowActivation.present(.keyEditor) { + openWindow(id: KeyLightSceneID.keyEditor) + } + } + .buttonStyle(.link) + } + + if let layoutTransferFeedback { + InlineSettingsFeedback(feedback: layoutTransferFeedback) + } + } + } + + private var activeOverlayDisplay: OverlayDisplayDescriptor? { + guard let activeID = model.activeDisplayPersistentID else { return nil } + return model.availableDisplays.first(where: { $0.id == activeID }) + } + + private func displaySelectionLabel(_ display: OverlayDisplayDescriptor) -> String { + var details: [String] = [] + if display.isBuiltIn { details.append(String(localized: "Built-in")) } + if display.isMain { details.append(String(localized: "Main")) } + return details.isEmpty + ? display.name + : "\(display.name) (\(details.joined(separator: ", ")))" + } + + private var unavailableMirroredDisplayIDs: [String] { + let connected = Set(model.availableDisplays.map(\.id)) + return model.mirroredDisplayIDs + .subtracting(connected) + .sorted() + } + + private func mirroredDisplayBinding(for persistentID: String) -> Binding { + Binding( + get: { model.mirroredDisplayIDs.contains(persistentID) }, + set: { isSelected in + var selected = model.mirroredDisplayIDs + if isSelected { + selected.insert(persistentID) + } else { + selected.remove(persistentID) + } + model.mirroredDisplayIDs = selected + } + ) + } +} diff --git a/KeyLight/Views/SettingsSnapshotsTab.swift b/KeyLight/Views/SettingsSnapshotsTab.swift new file mode 100644 index 0000000..1f8a409 --- /dev/null +++ b/KeyLight/Views/SettingsSnapshotsTab.swift @@ -0,0 +1,355 @@ +import SwiftUI + +extension SettingsView { + @ViewBuilder + var snapshotsTabContent: some View { + VStack(alignment: .leading, spacing: 6) { + Text("Configuration Snapshots") + .font(.title2.bold()) + Text("Save or transfer the complete KeyLight-managed appearance, keyboard, display-routing, and shortcut setup. Permissions, enabled state, and Launch at Login are never included.") + .font(.callout) + .foregroundStyle(.secondary) + } + + Divider() + + VStack(alignment: .leading, spacing: 10) { + Text("Save Current") + .font(.headline) + HStack { + TextField( + "Snapshot name", + text: $newConfigurationSnapshotName + ) + .textFieldStyle(.roundedBorder) + .onSubmit { saveCurrentConfigurationSnapshot() } + + Button("Save Current") { + saveCurrentConfigurationSnapshot() + } + .buttonStyle(.borderedProminent) + .disabled( + PersistenceValidation.normalizedName( + newConfigurationSnapshotName + ) == nil + ) + } + + HStack { + Button("Import…") { + importConfigurationSnapshot() + } + Button("Restore Previous Setup") { + snapshotConfirmation = .restorePrevious + } + .disabled(!settings.hasPreviousConfigurationSnapshot) + Spacer() + Text("Up to 1 MB per import · 500 KB saved data") + .font(.caption2) + .foregroundStyle(.secondary) + } + } + + Divider() + + if configurationSnapshots.isEmpty { + VStack(spacing: 8) { + Image(systemName: "square.stack.3d.up.slash") + .font(.title2) + .foregroundStyle(.secondary) + Text("No Saved Snapshots") + .font(.headline) + Text("Save the current setup or import a .keylight-snapshot.json file.") + .font(.caption) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 28) + } else { + VStack(alignment: .leading, spacing: 10) { + Text("Saved Snapshots") + .font(.headline) + + ForEach(configurationSnapshots) { document in + configurationSnapshotRow(document) + if document.id != configurationSnapshots.last?.id { + Divider() + } + } + } + } + } + + @ViewBuilder + private func configurationSnapshotRow( + _ document: ConfigurationSnapshotDocument + ) -> some View { + VStack(alignment: .leading, spacing: 7) { + HStack(alignment: .firstTextBaseline) { + if editingConfigurationSnapshotID == document.id { + TextField( + "Snapshot name", + text: $configurationSnapshotRenameDraft + ) + .textFieldStyle(.roundedBorder) + .onSubmit { + saveConfigurationSnapshotRename(document) + } + Button("Save") { + saveConfigurationSnapshotRename(document) + } + Button("Cancel") { + cancelConfigurationSnapshotRename() + } + } else { + Text(document.name) + .font(.body.weight(.semibold)) + Spacer() + Text( + document.createdAt.formatted( + date: .abbreviated, + time: .shortened + ) + ) + .font(.caption) + .foregroundStyle(.secondary) + } + } + + if editingConfigurationSnapshotID == document.id, + let configurationSnapshotRenameError { + Text(configurationSnapshotRenameError) + .font(.caption) + .foregroundStyle(.red) + } + + Text(configurationSnapshotSummary(document)) + .font(.caption) + .foregroundStyle(.secondary) + + HStack { + Button("Apply") { + snapshotConfirmation = .apply(document) + } + .buttonStyle(.borderedProminent) + Button("Rename") { + startConfigurationSnapshotRename(document) + } + .disabled(editingConfigurationSnapshotID == document.id) + Button("Export…") { + exportConfigurationSnapshot(document) + } + Button("Delete", role: .destructive) { + queueConfigurationSnapshotDeletion(document) + } + Spacer() + } + .controlSize(.small) + } + .padding(.vertical, 3) + } + + func saveCurrentConfigurationSnapshot() { + model.flushPendingPersist() + keyLayoutStore.flush() + do { + let document = try settings.saveCurrentConfigurationSnapshot( + named: newConfigurationSnapshotName + ) + newConfigurationSnapshotName = "" + reloadPersistedState() + model.feedback = UserFeedback( + severity: .success, + title: String(localized: "Configuration Snapshot Saved"), + detail: String(localized: "Saved \"\(document.name)\".") + ) + } catch { + model.feedback = UserFeedback( + severity: .error, + title: String(localized: "Snapshot Couldn’t Be Saved"), + detail: error.localizedDescription + ) + } + } + + func startConfigurationSnapshotRename( + _ document: ConfigurationSnapshotDocument + ) { + editingConfigurationSnapshotID = document.id + configurationSnapshotRenameDraft = document.name + configurationSnapshotRenameError = nil + } + + func cancelConfigurationSnapshotRename() { + editingConfigurationSnapshotID = nil + configurationSnapshotRenameDraft = "" + configurationSnapshotRenameError = nil + } + + func saveConfigurationSnapshotRename( + _ document: ConfigurationSnapshotDocument + ) { + do { + let renamed = try settings.renameConfigurationSnapshot( + id: document.id, + to: configurationSnapshotRenameDraft + ) + cancelConfigurationSnapshotRename() + reloadPersistedState() + model.feedback = UserFeedback( + severity: .success, + title: String(localized: "Configuration Snapshot Renamed"), + detail: String(localized: "Renamed to \"\(renamed.name)\".") + ) + } catch { + configurationSnapshotRenameError = error.localizedDescription + } + } + + func applyConfigurationSnapshot( + _ document: ConfigurationSnapshotDocument + ) { + prepareCurrentConfigurationForSnapshotTransaction() + do { + try settings.applyConfigurationSnapshot(document) + reloadAfterConfigurationSnapshotTransaction() + model.feedback = UserFeedback( + severity: .success, + title: String(localized: "Configuration Snapshot Applied"), + detail: String( + localized: "Applied \"\(document.name)\". Restore Previous Setup can reverse this change." + ) + ) + } catch { + reloadAfterConfigurationSnapshotTransaction() + model.feedback = UserFeedback( + severity: .error, + title: String(localized: "Snapshot Couldn’t Be Applied"), + detail: error.localizedDescription + ) + } + } + + func restorePreviousConfigurationSnapshot() { + prepareCurrentConfigurationForSnapshotTransaction() + do { + try settings.restorePreviousConfigurationSnapshot() + reloadAfterConfigurationSnapshotTransaction() + model.feedback = UserFeedback( + severity: .success, + title: String(localized: "Previous Setup Restored"), + detail: String( + localized: "The setup was swapped successfully. Choose Restore Previous Setup again to swap back." + ) + ) + } catch { + reloadAfterConfigurationSnapshotTransaction() + model.feedback = UserFeedback( + severity: .error, + title: String(localized: "Previous Setup Couldn’t Be Restored"), + detail: error.localizedDescription + ) + } + } + + func importConfigurationSnapshot() { + do { + guard let data = try snapshotFilePanelHelper.chooseImportData() + else { return } + let document = try settings.decodeConfigurationSnapshotDocument( + data + ) + if settings.configurationSnapshotNameConflicts(with: document) { + snapshotConfirmation = .importConflict(document) + } else { + storeImportedConfigurationSnapshot( + document, + policy: .rejectConflict + ) + } + } catch { + model.feedback = UserFeedback( + severity: .error, + title: String(localized: "Snapshot Import Failed"), + detail: error.localizedDescription + ) + } + } + + func storeImportedConfigurationSnapshot( + _ document: ConfigurationSnapshotDocument, + policy: ConfigurationSnapshotImportPolicy + ) { + do { + let imported = try settings.importConfigurationSnapshot( + document, + policy: policy + ) + reloadPersistedState() + model.feedback = UserFeedback( + severity: .success, + title: String(localized: "Configuration Snapshot Imported"), + detail: String( + localized: "Stored \"\(imported.name)\" without applying it." + ) + ) + } catch { + model.feedback = UserFeedback( + severity: .error, + title: String(localized: "Snapshot Import Failed"), + detail: error.localizedDescription + ) + } + } + + func exportConfigurationSnapshot( + _ document: ConfigurationSnapshotDocument + ) { + do { + let data = try settings.exportConfigurationSnapshotData( + id: document.id + ) + guard let url = try snapshotFilePanelHelper.export( + data, + suggestedName: document.name + ) else { return } + model.feedback = UserFeedback( + severity: .success, + title: String(localized: "Configuration Snapshot Exported"), + detail: String(localized: "Saved \"\(url.lastPathComponent)\".") + ) + } catch { + model.feedback = UserFeedback( + severity: .error, + title: String(localized: "Snapshot Export Failed"), + detail: error.localizedDescription + ) + } + } + + private func prepareCurrentConfigurationForSnapshotTransaction() { + stopSettingsPreview() + stopChordPreviewTest() + model.flushPendingPersist() + keyLayoutStore.flush() + keyLayoutStore.cancelPendingWork() + } + + private func reloadAfterConfigurationSnapshotTransaction() { + keyLayoutStore.reloadFromPersistence() + keyLayoutStore.reloadSavedProfiles(from: settings) + model.reloadManagedConfiguration() + hexColor = model.glowColor.toHex() ?? "68B8FF" + reloadPersistedState() + } + + private func configurationSnapshotSummary( + _ document: ConfigurationSnapshotDocument + ) -> String { + let configuration = document.configuration + let displayCount = 1 + configuration.mirroredDisplayIDs.count + return String( + localized: "\(configuration.themes.count) themes · \(configuration.layoutProfiles.count) layouts · \(displayCount) display routes · \(configuration.currentEffect.style.displayName)" + ) + } +} diff --git a/KeyLight/Views/SettingsWindow.swift b/KeyLight/Views/SettingsWindow.swift deleted file mode 100644 index 3e7440f..0000000 --- a/KeyLight/Views/SettingsWindow.swift +++ /dev/null @@ -1,50 +0,0 @@ -import AppKit -import SwiftUI - -@MainActor -final class SettingsWindowController: NSObject, NSWindowDelegate { - static let shared = SettingsWindowController() - - private weak var window: NSWindow? - private var retainedWindow: NSWindow? - - func showWindow(appState: AppState) { - if let window = window { - window.makeKeyAndOrderFront(nil) - NSApp.activate(ignoringOtherApps: true) - return - } - - let settingsView = SettingsView() - .environmentObject(appState) - - let hostingView = NSHostingView(rootView: settingsView) - - let newWindow = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 520, height: 760), - styleMask: [.titled, .closable, .resizable, .miniaturizable], - backing: .buffered, - defer: false - ) - newWindow.title = "KeyLight Settings" - newWindow.contentView = hostingView - newWindow.center() - newWindow.isReleasedWhenClosed = false - newWindow.minSize = NSSize(width: 460, height: 520) - newWindow.delegate = self - newWindow.makeKeyAndOrderFront(nil) - NSApp.activate(ignoringOtherApps: true) - - window = newWindow - retainedWindow = newWindow - } - - func windowWillClose(_ notification: Notification) { - retainedWindow = nil - } - - func closeWindow() { - retainedWindow = nil - window?.close() - } -} diff --git a/KeyLight/Views/SurfaceMotionEngine.swift b/KeyLight/Views/SurfaceMotionEngine.swift new file mode 100644 index 0000000..67fb808 --- /dev/null +++ b/KeyLight/Views/SurfaceMotionEngine.swift @@ -0,0 +1,257 @@ +import CoreGraphics +import Foundation + +struct SurfaceMotionState: Identifiable, Equatable, Sendable { + let id: GlowID + var frame: CGRect = .zero + var visibility: Double = 0 + var emergence: CGFloat = 0 + var smoothness: CGFloat = 0.7069 + var isVisible = false +} + +struct SurfaceMotionVelocity: Equatable, Sendable { + var frame = CGRect.zero + var visibility: Double = 0 + var emergence: CGFloat = 0 + var smoothness: CGFloat = 0 + + static let zero = SurfaceMotionVelocity() +} + +struct SurfaceMotionSample: Equatable, Sendable { + var state: SurfaceMotionState + var velocity: SurfaceMotionVelocity +} + +struct SurfaceMotionTransition: Equatable, Sendable { + let start: SurfaceMotionState + let destination: SurfaceMotionState + let initialVelocity: SurfaceMotionVelocity + let startTime: TimeInterval + let duration: TimeInterval + + var endTime: TimeInterval { startTime + duration } + + func isComplete(at time: TimeInterval) -> Bool { + !duration.isFinite || duration <= 0 || time >= endTime + } + + func sample(at time: TimeInterval) -> SurfaceMotionSample { + guard duration.isFinite, duration > 0 else { + return SurfaceMotionSample(state: destination, velocity: .zero) + } + + let progress = min(max((time - startTime) / duration, 0), 1) + if progress >= 1 { + return SurfaceMotionSample(state: destination, velocity: .zero) + } + + let x = Self.hermite( + start.frame.origin.x, + destination.frame.origin.x, + initialVelocity.frame.origin.x, + duration, + progress + ) + let y = Self.hermite( + start.frame.origin.y, + destination.frame.origin.y, + initialVelocity.frame.origin.y, + duration, + progress + ) + let width = Self.hermite( + start.frame.width, + destination.frame.width, + initialVelocity.frame.width, + duration, + progress + ) + let height = Self.hermite( + start.frame.height, + destination.frame.height, + initialVelocity.frame.height, + duration, + progress + ) + let visibility = Self.hermite( + start.visibility, + destination.visibility, + initialVelocity.visibility, + duration, + progress + ) + let emergence = Self.hermite( + start.emergence, + destination.emergence, + initialVelocity.emergence, + duration, + progress + ) + let smoothness = Self.hermite( + start.smoothness, + destination.smoothness, + initialVelocity.smoothness, + duration, + progress + ) + + return SurfaceMotionSample( + state: SurfaceMotionState( + id: start.id, + frame: CGRect( + x: x.value, + y: y.value, + width: max(finite(width.value, fallback: destination.frame.width), 1), + height: max(finite(height.value, fallback: destination.frame.height), 1) + ), + visibility: Self.unitValue( + visibility.value, + fallback: destination.visibility + ), + emergence: Self.unitValue( + emergence.value, + fallback: destination.emergence + ), + smoothness: Self.unitValue( + smoothness.value, + fallback: destination.smoothness + ), + isVisible: start.isVisible || destination.isVisible + ), + velocity: SurfaceMotionVelocity( + frame: CGRect( + x: x.velocity, + y: y.velocity, + width: width.velocity, + height: height.velocity + ), + visibility: visibility.velocity, + emergence: emergence.velocity, + smoothness: smoothness.velocity + ) + ) + } + + private static func hermite( + _ start: CGFloat, + _ destination: CGFloat, + _ initialVelocity: CGFloat, + _ duration: TimeInterval, + _ progress: Double + ) -> (value: CGFloat, velocity: CGFloat) { + let result = hermite( + Double(start), + Double(destination), + Double(initialVelocity), + duration, + progress + ) + return (CGFloat(result.value), CGFloat(result.velocity)) + } + + private static func hermite( + _ start: Double, + _ destination: Double, + _ initialVelocity: Double, + _ duration: TimeInterval, + _ progress: Double + ) -> (value: Double, velocity: Double) { + let safeStart = start.isFinite ? start : 0 + let safeDestination = destination.isFinite ? destination : safeStart + let safeVelocity = initialVelocity.isFinite ? initialVelocity : 0 + let safeDuration = duration.isFinite ? max(duration, 0.000_001) : 0.000_001 + let t = min(max(progress, 0), 1) + let t2 = t * t + let t3 = t2 * t + + let h00 = 2 * t3 - 3 * t2 + 1 + let h10 = t3 - 2 * t2 + t + let h01 = -2 * t3 + 3 * t2 + let value = h00 * safeStart + + h10 * safeDuration * safeVelocity + + h01 * safeDestination + + let dh00 = 6 * t2 - 6 * t + let dh10 = 3 * t2 - 4 * t + 1 + let dh01 = -6 * t2 + 6 * t + let velocity = ( + dh00 * safeStart + + dh10 * safeDuration * safeVelocity + + dh01 * safeDestination + ) / safeDuration + + return (value, velocity) + } + + private static func unitValue( + _ value: Double, + fallback: Double + ) -> Double { + guard value.isFinite else { return min(max(fallback, 0), 1) } + return min(max(value, 0), 1) + } + + private static func unitValue( + _ value: CGFloat, + fallback: CGFloat + ) -> CGFloat { + guard value.isFinite else { return min(max(fallback, 0), 1) } + return min(max(value, 0), 1) + } + + private func finite(_ value: CGFloat, fallback: CGFloat) -> CGFloat { + value.isFinite ? value : fallback + } +} + +struct SurfaceMotionTrack: Identifiable, Equatable, Sendable { + let id: GlowID + var state: SurfaceMotionState + var transition: SurfaceMotionTransition? + + init(id: GlowID) { + self.id = id + state = SurfaceMotionState(id: id) + } + + init( + state: SurfaceMotionState, + transition: SurfaceMotionTransition? = nil + ) { + id = state.id + self.state = state + self.transition = transition + } + + func sample(at time: TimeInterval) -> SurfaceMotionSample { + transition?.sample(at: time) + ?? SurfaceMotionSample(state: state, velocity: .zero) + } +} + +/// Renderer-independent owner for persistent surface tracks and monotonic time. +/// Material-specific presenters remain free to choose how those tracks draw. +struct SurfaceMotionEngine: Sendable { + private let clock: any SurfaceMotionClock + private(set) var tracks: [SurfaceMotionTrack] = [] + + init(clock: any SurfaceMotionClock = SystemSurfaceMotionClock()) { + self.clock = clock + } + + var currentTime: TimeInterval { clock.now() } + var hasActiveTransitions: Bool { + tracks.contains { $0.transition != nil } + } + + mutating func setTracks(_ tracks: [SurfaceMotionTrack]) { + var seen: Set = [] + self.tracks = tracks.filter { seen.insert($0.id).inserted } + } + + func samples(at time: TimeInterval) -> [SurfaceMotionSample] { + tracks.map { $0.sample(at: time) } + } +} diff --git a/KeyLightTests/AppCoordinatorTests.swift b/KeyLightTests/AppCoordinatorTests.swift new file mode 100644 index 0000000..9d6a0a2 --- /dev/null +++ b/KeyLightTests/AppCoordinatorTests.swift @@ -0,0 +1,787 @@ +import AppKit +import Carbon.HIToolbox +import XCTest +@testable import KeyLight + +final class AppCoordinatorTests: XCTestCase { + @MainActor + func testStartAndShutdownAreIdempotent() { + let harness = CoordinatorHarness() + + harness.coordinator.start() + harness.coordinator.start() + + XCTAssertEqual(harness.input.startValues, [true]) + XCTAssertEqual(harness.overlay.startCount, 1) + XCTAssertEqual(harness.overlay.enabledValues, [true]) + XCTAssertEqual(harness.overlay.appliedConfigurations.count, 1) + XCTAssertEqual(harness.hotKey.startCount, 1) + XCTAssertEqual(harness.hotKey.shortcutValues, [.default]) + XCTAssertEqual(harness.accessibility.readCount, 1) + + harness.coordinator.shutdown() + harness.coordinator.shutdown() + + XCTAssertEqual(harness.input.stopCount, 1) + XCTAssertEqual(harness.overlay.shutdownCount, 1) + XCTAssertEqual(harness.hotKey.stopCount, 1) + } + + @MainActor + func testModelActionsReachInjectedServicesAndInputStatusUpdatesModel() { + let harness = CoordinatorHarness(accessibilityOptions: AccessibilityDisplayOptions( + reduceMotion: true, + reduceTransparency: false, + increaseContrast: true + )) + harness.coordinator.start() + let initialApplyCount = harness.overlay.appliedConfigurations.count + + harness.model.isEnabled = false + + XCTAssertEqual(harness.overlay.enabledValues.last, false) + XCTAssertEqual(harness.input.enabledValues.last, false) + XCTAssertEqual(harness.overlay.appliedConfigurations.count, initialApplyCount + 1) + + harness.model.glowOpacity = 0.42 + harness.model.physicalRefractionStrength = 2.1 + + let latestConfiguration = harness.overlay.appliedConfigurations.last?.configuration + XCTAssertEqual( + latestConfiguration?.maximumOpacity ?? -1, + Float(0.42), + accuracy: 0.0001 + ) + XCTAssertEqual( + latestConfiguration?.refractionStrength ?? -1, + CGFloat(2.1), + accuracy: 0.0001 + ) + XCTAssertEqual(latestConfiguration?.reduceMotion, true) + XCTAssertEqual(latestConfiguration?.reduceTransparency, false) + XCTAssertEqual(latestConfiguration?.increaseContrast, true) + + harness.model.requestInputMonitoringPermission() + harness.model.retryInputMonitoring() + harness.model.openInputMonitoringSettings() + + XCTAssertEqual(harness.input.permissionRequestCount, 1) + XCTAssertEqual(harness.input.retryCount, 1) + XCTAssertEqual(harness.input.openSettingsCount, 1) + + let customShortcut = GlobalShortcut( + keyCode: UInt32(kVK_ANSI_L), + modifiers: UInt32(controlKey | optionKey) + )! + harness.model.globalShortcut = customShortcut + XCTAssertEqual(harness.hotKey.shortcutValues.last, customShortcut) + + harness.model.mirroredDisplayIDs = ["studio-display"] + XCTAssertEqual( + harness.overlay.mirroredDisplayValues.last, + Set(["studio-display"]) + ) + + harness.input.emitStatus(InputControllerStatus( + state: .monitorUnavailable, + runningApplicationPath: "/Applications/KeyLight.app", + installationIssue: "Install the signed app", + lastKnownAuthorization: true, + monitorRunning: false, + recheckInterval: 5 + )) + + XCTAssertEqual(harness.model.inputMonitoringState, .monitorUnavailable) + XCTAssertEqual(harness.model.inputMonitoringAppPath, "/Applications/KeyLight.app") + XCTAssertEqual(harness.model.inputMonitoringInstallationIssue, "Install the signed app") + } + + @MainActor + func testKeyboardTargetsResolveThroughInjectedLiveLayoutStore() { + let harness = CoordinatorHarness() + let keyCode: UInt16 = 0 + let base = KeyMapping.keyInfo(for: keyCode) + harness.layoutStore.setOffset(0.1, for: keyCode) + harness.layoutStore.setWidthMultiplier(1.5, for: keyCode) + harness.coordinator.start() + + harness.input.emitKeyboardEvent(.keyDown( + keyCode, + source: .eventTap, + timestamp: 1 + )) + + let target = harness.overlay.events.last?.target + XCTAssertEqual(target?.id, .physicalKey(keyCode)) + XCTAssertEqual( + target?.horizontalPosition ?? -1, + Double(min(max(base.position + 0.1, 0), 1)), + accuracy: 0.0001 + ) + XCTAssertEqual( + target?.keyWidth ?? -1, + Double(base.width * 1.5), + accuracy: 0.0001 + ) + } + + @MainActor + func testPlatformObserversAreRemovedAndStaleCallbacksAreHarmlessAfterShutdown() { + let harness = CoordinatorHarness() + harness.coordinator.start() + + harness.notificationCenter.post( + name: NSApplication.didChangeScreenParametersNotification, + object: nil + ) + harness.notificationCenter.post( + name: NSApplication.didBecomeActiveNotification, + object: nil + ) + harness.model.setPreview( + .preview( + .settings, + horizontalPosition: 0.4, + keyWidth: 1.2 + ), + source: .settings + ) + harness.model.clearPreview(.settings) + let chord = [GlowTarget.preview( + .chordTest1, + colorReferenceKeyCode: 0, + horizontalPosition: 0.3, + keyWidth: 1 + )] + harness.model.setChordPreview(chord) + harness.model.clearChordPreview() + harness.workspaceNotificationCenter.post( + name: NSWorkspace.willSleepNotification, + object: nil + ) + harness.workspaceNotificationCenter.post( + name: NSWorkspace.didWakeNotification, + object: nil + ) + harness.workspaceNotificationCenter.post( + name: NSWorkspace.screensDidSleepNotification, + object: nil + ) + harness.workspaceNotificationCenter.post( + name: NSWorkspace.screensDidWakeNotification, + object: nil + ) + + harness.accessibility.options = AccessibilityDisplayOptions( + reduceMotion: true, + reduceTransparency: true, + increaseContrast: false + ) + harness.workspaceNotificationCenter.post( + name: NSWorkspace.accessibilityDisplayOptionsDidChangeNotification, + object: nil + ) + + XCTAssertEqual(harness.overlay.topologyUpdateCount, 3) + XCTAssertEqual(harness.input.activationCount, 1) + XCTAssertEqual(harness.input.sleepCount, 2) + XCTAssertEqual(harness.input.wakeCount, 2) + XCTAssertEqual( + Array(harness.overlay.enabledValues.suffix(4)), + [false, true, false, true], + "Sleep must stop renderer-owned capture immediately and wake restores the saved enabled intent" + ) + XCTAssertEqual(harness.overlay.previews.map(\.source), [.settings]) + XCTAssertEqual(harness.overlay.clearedPreviews, [.settings]) + XCTAssertEqual(harness.overlay.chordPreviews, [chord]) + XCTAssertEqual(harness.overlay.chordPreviewClearCount, 1) + XCTAssertEqual(harness.accessibility.readCount, 2) + XCTAssertEqual( + harness.overlay.appliedConfigurations.last?.configuration.reduceTransparency, + true + ) + + harness.coordinator.shutdown() + + let serviceSnapshot = harness.snapshot + let enabledBeforeStaleCallbacks = harness.model.isEnabled + let hotKeyStatusBeforeStaleCallbacks = harness.model.globalHotKeyStatus + let inputStateBeforeStaleCallbacks = harness.model.inputMonitoringState + let physicalActivityBeforeStaleCallbacks = harness.model.physicalKeyActivity + + harness.notificationCenter.post( + name: NSApplication.didChangeScreenParametersNotification, + object: nil + ) + harness.notificationCenter.post( + name: NSApplication.didBecomeActiveNotification, + object: nil + ) + harness.workspaceNotificationCenter.post( + name: NSWorkspace.willSleepNotification, + object: nil + ) + harness.workspaceNotificationCenter.post( + name: NSWorkspace.didWakeNotification, + object: nil + ) + harness.workspaceNotificationCenter.post( + name: NSWorkspace.screensDidSleepNotification, + object: nil + ) + harness.workspaceNotificationCenter.post( + name: NSWorkspace.screensDidWakeNotification, + object: nil + ) + harness.workspaceNotificationCenter.post( + name: NSWorkspace.accessibilityDisplayOptionsDidChangeNotification, + object: nil + ) + harness.input.emitKeyboardEvent(.keyDown( + 0, + source: .eventTap, + timestamp: 1 + )) + harness.input.emitStatus(InputControllerStatus( + state: .active, + runningApplicationPath: "/stale", + installationIssue: nil, + lastKnownAuthorization: true, + monitorRunning: true, + recheckInterval: 300 + )) + harness.hotKey.emitStatus(.registered) + harness.hotKey.press() + harness.overlay.emitPhysicalEvent(.keyDown( + 1, + source: .eventTap, + timestamp: 2 + )) + + harness.model.isEnabled.toggle() + harness.model.glowOpacity = 0.33 + harness.model.requestInputMonitoringPermission() + harness.model.retryInputMonitoring() + harness.model.openInputMonitoringSettings() + harness.model.setPreview( + .preview(.settings, horizontalPosition: 0.5, keyWidth: 1), + source: .settings + ) + harness.model.clearPreview(.settings) + + XCTAssertEqual(harness.snapshot, serviceSnapshot) + XCTAssertNotEqual(harness.model.isEnabled, enabledBeforeStaleCallbacks) + XCTAssertEqual(harness.model.globalHotKeyStatus, hotKeyStatusBeforeStaleCallbacks) + XCTAssertEqual(harness.model.inputMonitoringState, inputStateBeforeStaleCallbacks) + XCTAssertEqual(harness.model.physicalKeyActivity, physicalActivityBeforeStaleCallbacks) + } + + @MainActor + func testHotKeyCallbacksMapStatusAndPressToTheModel() { + let harness = CoordinatorHarness() + harness.coordinator.start() + + harness.hotKey.emitStatus(.registering) + XCTAssertEqual(harness.model.globalHotKeyStatus, .checking) + + harness.hotKey.emitStatus(.registered) + XCTAssertEqual(harness.model.globalHotKeyStatus, .registered) + + harness.hotKey.emitStatus(.unavailable(.hotKeyRegistrationFailed(status: -9876))) + XCTAssertEqual(harness.model.globalHotKeyStatus, .unavailable) + XCTAssertEqual(harness.model.feedback?.title, "Keyboard Shortcut Unavailable") + + let enabledBeforePress = harness.model.isEnabled + harness.hotKey.press() + XCTAssertEqual(harness.model.isEnabled, !enabledBeforePress) + XCTAssertEqual(harness.overlay.enabledValues.last, !enabledBeforePress) + XCTAssertEqual(harness.input.enabledValues.last, !enabledBeforePress) + } + + @MainActor + func testFirstRunRequestsSetupWithoutRequestingPermission() { + let firstRun = CoordinatorHarness() + + firstRun.coordinator.start() + + XCTAssertTrue(firstRun.model.permissionSetupPresentationRequested) + XCTAssertEqual(firstRun.input.permissionRequestCount, 0) + + let returningUser = CoordinatorHarness(hasSeenPermissionExplanation: true) + returningUser.coordinator.start() + + XCTAssertFalse(returningUser.model.permissionSetupPresentationRequested) + XCTAssertEqual(returningUser.input.permissionRequestCount, 0) + + let existingWithoutPriorPermissionExplanation = CoordinatorHarness( + hasSeenPermissionExplanation: false + ) + existingWithoutPriorPermissionExplanation.coordinator.start() + + XCTAssertFalse( + existingWithoutPriorPermissionExplanation.model.permissionSetupPresentationRequested + ) + XCTAssertEqual( + existingWithoutPriorPermissionExplanation.input.permissionRequestCount, + 0 + ) + } + + @MainActor + func testAutomaticPowerSavingTracksLowPowerAndEveryThermalState() { + let harness = CoordinatorHarness(powerEnvironmentState: .normal) + harness.coordinator.start() + harness.model.effectStyle = .physicalRefraction + + let cases: [(PowerThermalState, Bool)] = [ + (.nominal, false), + (.fair, false), + (.serious, true), + (.critical, true) + ] + for (thermalState, expectedActive) in cases { + harness.powerEnvironment.state = PowerEnvironmentState( + isLowPowerModeEnabled: false, + thermalState: thermalState + ) + harness.notificationCenter.post( + name: ProcessInfo.thermalStateDidChangeNotification, + object: nil + ) + let latest = harness.overlay.appliedConfigurations.last + XCTAssertEqual( + latest?.configuration.automaticPowerSavingIsActive, + expectedActive, + "Unexpected policy for \(thermalState)" + ) + XCTAssertEqual(latest?.effectStyle, .physicalRefraction) + XCTAssertEqual(harness.model.effectStyle, .physicalRefraction) + } + + harness.powerEnvironment.state = PowerEnvironmentState( + isLowPowerModeEnabled: true, + thermalState: .nominal + ) + harness.notificationCenter.post( + name: Notification.Name.NSProcessInfoPowerStateDidChange, + object: nil + ) + XCTAssertTrue(try! XCTUnwrap( + harness.overlay.appliedConfigurations.last + ).configuration.automaticPowerSavingIsActive) + XCTAssertEqual(harness.model.effectStyle, .physicalRefraction) + } + + @MainActor + func testPowerSavingRestoresOnceAndOffModeSuppressesFallback() { + let harness = CoordinatorHarness(powerEnvironmentState: PowerEnvironmentState( + isLowPowerModeEnabled: false, + thermalState: .serious + )) + harness.coordinator.start() + harness.model.effectStyle = .physicalRefraction + XCTAssertTrue(try! XCTUnwrap( + harness.overlay.appliedConfigurations.last + ).configuration.automaticPowerSavingIsActive) + + harness.powerEnvironment.state = .normal + let beforeRestore = harness.overlay.appliedConfigurations.count + harness.notificationCenter.post( + name: ProcessInfo.thermalStateDidChangeNotification, + object: nil + ) + XCTAssertEqual( + harness.overlay.appliedConfigurations.count, + beforeRestore + 1 + ) + XCTAssertFalse(try! XCTUnwrap( + harness.overlay.appliedConfigurations.last + ).configuration.automaticPowerSavingIsActive) + + harness.notificationCenter.post( + name: ProcessInfo.thermalStateDidChangeNotification, + object: nil + ) + XCTAssertEqual( + harness.overlay.appliedConfigurations.count, + beforeRestore + 1, + "An unchanged power state must not restore the renderer twice" + ) + + harness.powerEnvironment.state = PowerEnvironmentState( + isLowPowerModeEnabled: true, + thermalState: .critical + ) + harness.notificationCenter.post( + name: Notification.Name.NSProcessInfoPowerStateDidChange, + object: nil + ) + harness.model.powerSavingMode = .off + let latest = try! XCTUnwrap(harness.overlay.appliedConfigurations.last) + XCTAssertFalse(latest.configuration.automaticPowerSavingIsActive) + XCTAssertEqual(harness.model.effectStyle, .physicalRefraction) + } +} + +@MainActor +private final class CoordinatorHarness { + struct ServiceSnapshot: Equatable { + let inputActivationCount: Int + let inputSleepCount: Int + let inputWakeCount: Int + let inputPermissionRequestCount: Int + let inputRetryCount: Int + let inputOpenSettingsCount: Int + let inputEnabledValues: [Bool] + let overlayTopologyUpdateCount: Int + let overlayPreviewCount: Int + let overlayClearPreviewCount: Int + let overlayEventCount: Int + let overlayEnabledValues: [Bool] + let overlayApplyCount: Int + let accessibilityReadCount: Int + } + + let model: KeyLightModel + let coordinator: AppCoordinator + let input: CoordinatorInputSpy + let overlay: CoordinatorOverlaySpy + let hotKey: CoordinatorHotKeySpy + let accessibility: AccessibilityOptionsBox + let powerEnvironment: PowerEnvironmentBox + let layoutStore: KeyLayoutStore + let notificationCenter: NotificationCenter + let workspaceNotificationCenter: NotificationCenter + private let isolatedDefaults: CoordinatorIsolatedDefaults + + var snapshot: ServiceSnapshot { + ServiceSnapshot( + inputActivationCount: input.activationCount, + inputSleepCount: input.sleepCount, + inputWakeCount: input.wakeCount, + inputPermissionRequestCount: input.permissionRequestCount, + inputRetryCount: input.retryCount, + inputOpenSettingsCount: input.openSettingsCount, + inputEnabledValues: input.enabledValues, + overlayTopologyUpdateCount: overlay.topologyUpdateCount, + overlayPreviewCount: overlay.previews.count, + overlayClearPreviewCount: overlay.clearedPreviews.count, + overlayEventCount: overlay.events.count, + overlayEnabledValues: overlay.enabledValues, + overlayApplyCount: overlay.appliedConfigurations.count, + accessibilityReadCount: accessibility.readCount + ) + } + + init( + hasSeenPermissionExplanation: Bool? = nil, + accessibilityOptions: AccessibilityDisplayOptions = AccessibilityDisplayOptions( + reduceMotion: false, + reduceTransparency: false, + increaseContrast: false + ), + powerEnvironmentState: PowerEnvironmentState = .normal + ) { + let defaults = CoordinatorIsolatedDefaults() + if let hasSeenPermissionExplanation { + defaults.defaults.set( + hasSeenPermissionExplanation, + forKey: "hasSeenPermissionExplanation" + ) + } + let settings = SettingsManager( + preferencesStore: PreferencesStore(userDefaults: defaults.defaults) + ) + let model = KeyLightModel(settings: settings, feedbackAnnouncer: { _ in }) + let input = CoordinatorInputSpy() + let overlay = CoordinatorOverlaySpy() + let hotKey = CoordinatorHotKeySpy() + let accessibility = AccessibilityOptionsBox(options: accessibilityOptions) + let powerEnvironment = PowerEnvironmentBox(state: powerEnvironmentState) + let notificationCenter = NotificationCenter() + let workspaceNotificationCenter = NotificationCenter() + let keyLayoutStore = KeyLayoutStore( + defaults: defaults.defaults, + debounceInterval: 0 + ) + + self.model = model + self.input = input + self.overlay = overlay + self.hotKey = hotKey + self.accessibility = accessibility + self.powerEnvironment = powerEnvironment + self.layoutStore = keyLayoutStore + self.notificationCenter = notificationCenter + self.workspaceNotificationCenter = workspaceNotificationCenter + isolatedDefaults = defaults + coordinator = AppCoordinator( + model: model, + notificationCenter: notificationCenter, + workspaceNotificationCenter: workspaceNotificationCenter, + keyLayoutStore: keyLayoutStore, + inputControllerFactory: { onEvent, onStatus in + input.install(onKeyboardEvent: onEvent, onStatusChange: onStatus) + return input + }, + overlayControllerFactory: { onPhysicalEvent in + overlay.install(onPhysicalEvent: onPhysicalEvent) + return overlay + }, + hotKeyServiceFactory: { onPress, onStatus in + hotKey.install(onPress: onPress, onStatusChange: onStatus) + return hotKey + }, + accessibilityOptionsProvider: { + accessibility.read() + }, + powerEnvironmentProvider: { + powerEnvironment.read() + } + ) + } +} + +@MainActor +private final class CoordinatorInputSpy: AppCoordinatorInputControlling { + private var onKeyboardEvent: (@MainActor (KeyboardEvent) -> Void)? + private var onStatusChange: (@MainActor (InputControllerStatus) -> Void)? + + private(set) var startValues: [Bool] = [] + private(set) var stopCount = 0 + private(set) var enabledValues: [Bool] = [] + private(set) var activationCount = 0 + private(set) var sleepCount = 0 + private(set) var wakeCount = 0 + private(set) var permissionRequestCount = 0 + private(set) var retryCount = 0 + private(set) var openSettingsCount = 0 + + func install( + onKeyboardEvent: @escaping @MainActor (KeyboardEvent) -> Void, + onStatusChange: @escaping @MainActor (InputControllerStatus) -> Void + ) { + self.onKeyboardEvent = onKeyboardEvent + self.onStatusChange = onStatusChange + } + + func start(isEnabled: Bool) { + startValues.append(isEnabled) + } + + func stop() { + stopCount += 1 + } + + func setEnabled(_ enabled: Bool) { + enabledValues.append(enabled) + } + + func applicationDidBecomeActive() { + activationCount += 1 + } + + func handleSleep() { + sleepCount += 1 + } + + func handleWake() { + wakeCount += 1 + } + + func requestPermission() { + permissionRequestCount += 1 + } + + func retry() { + retryCount += 1 + } + + func openInputMonitoringSettings() { + openSettingsCount += 1 + } + + func emitKeyboardEvent(_ event: KeyboardEvent) { + onKeyboardEvent?(event) + } + + func emitStatus(_ status: InputControllerStatus) { + onStatusChange?(status) + } +} + +@MainActor +private final class CoordinatorOverlaySpy: AppCoordinatorOverlayControlling { + struct AppliedConfiguration { + let effectStyle: EffectStyle + let configuration: RendererConfiguration + } + + struct PreviewCall { + let target: GlowTarget + let source: PreviewSource + } + + private var onPhysicalEvent: (@MainActor (KeyboardEvent) -> Void)? + + private(set) var startCount = 0 + private(set) var shutdownCount = 0 + private(set) var enabledValues: [Bool] = [] + private(set) var appliedConfigurations: [AppliedConfiguration] = [] + private(set) var events: [(event: KeyboardEvent, target: GlowTarget?)] = [] + private(set) var topologyUpdateCount = 0 + private(set) var previews: [PreviewCall] = [] + private(set) var clearedPreviews: [PreviewSource] = [] + private(set) var chordPreviews: [[GlowTarget]] = [] + private(set) var chordPreviewClearCount = 0 + private(set) var mirroredDisplayValues: [Set] = [] + + func install(onPhysicalEvent: @escaping @MainActor (KeyboardEvent) -> Void) { + self.onPhysicalEvent = onPhysicalEvent + } + + func start() { + startCount += 1 + } + + func shutdown() { + shutdownCount += 1 + } + + func setEnabled(_ enabled: Bool) { + enabledValues.append(enabled) + } + + func apply(effectStyle: EffectStyle, configuration: RendererConfiguration) { + appliedConfigurations.append(AppliedConfiguration( + effectStyle: effectStyle, + configuration: configuration + )) + } + + func handle(_ event: KeyboardEvent, target: GlowTarget?) { + events.append((event, target)) + } + + func updateDisplayTopology() { + topologyUpdateCount += 1 + } + + func setPreview(_ target: GlowTarget, source: PreviewSource) { + previews.append(PreviewCall(target: target, source: source)) + } + + func clearPreview(_ source: PreviewSource) { + clearedPreviews.append(source) + } + + func setChordPreview(_ targets: [GlowTarget]) { + chordPreviews.append(targets) + } + + func clearChordPreview() { + chordPreviewClearCount += 1 + } + + func setMirroredDisplayIDs(_ persistentIDs: Set) { + mirroredDisplayValues.append(persistentIDs) + } + + func emitPhysicalEvent(_ event: KeyboardEvent) { + onPhysicalEvent?(event) + } +} + +@MainActor +private final class CoordinatorHotKeySpy: AppCoordinatorHotKeyServicing { + private var onPress: (@MainActor @Sendable () -> Void)? + private var onStatusChange: (@MainActor @Sendable (HotKeyServiceStatus) -> Void)? + + private(set) var startCount = 0 + private(set) var stopCount = 0 + private(set) var statusEmissionCount = 0 + private(set) var shortcutValues: [GlobalShortcut] = [] + private(set) var pressCount = 0 + + func install( + onPress: @escaping @MainActor @Sendable () -> Void, + onStatusChange: @escaping @MainActor @Sendable (HotKeyServiceStatus) -> Void + ) { + self.onPress = onPress + self.onStatusChange = onStatusChange + } + + func start() { + startCount += 1 + } + + func stop() { + stopCount += 1 + } + + func setShortcut(_ shortcut: GlobalShortcut) { + shortcutValues.append(shortcut) + } + + func emitStatus(_ status: HotKeyServiceStatus) { + statusEmissionCount += 1 + onStatusChange?(status) + } + + func press() { + pressCount += 1 + onPress?() + } +} + +@MainActor +private final class AccessibilityOptionsBox { + var options: AccessibilityDisplayOptions + private(set) var readCount = 0 + + init(options: AccessibilityDisplayOptions) { + self.options = options + } + + func read() -> AccessibilityDisplayOptions { + readCount += 1 + return options + } +} + +@MainActor +private final class PowerEnvironmentBox { + var state: PowerEnvironmentState + private(set) var readCount = 0 + + init(state: PowerEnvironmentState) { + self.state = state + } + + func read() -> PowerEnvironmentState { + readCount += 1 + return state + } +} + +private final class CoordinatorIsolatedDefaults { + let suiteName = "KeyLight.AppCoordinatorTests.\(UUID().uuidString)" + let defaults: UserDefaults + + init() { + guard let defaults = UserDefaults(suiteName: suiteName) else { + fatalError("Could not create isolated defaults suite") + } + self.defaults = defaults + defaults.removePersistentDomain(forName: suiteName) + defaults.set(true, forKey: "fadeDurationDefaultMigratedV2") + defaults.set(1, forKey: "defaultExperienceSeedVersion") + defaults.set(1, forKey: "defaultLayoutMigrationVersion") + defaults.set(1, forKey: "bundledLayoutProfilesSeedVersion") + defaults.set(1, forKey: "stableSelectionMigrationVersion") + } + + deinit { + defaults.removePersistentDomain(forName: suiteName) + } +} diff --git a/KeyLightTests/InputControllerTests.swift b/KeyLightTests/InputControllerTests.swift new file mode 100644 index 0000000..be9ee49 --- /dev/null +++ b/KeyLightTests/InputControllerTests.swift @@ -0,0 +1,786 @@ +import XCTest +@testable import KeyLight + +final class InputControllerTests: XCTestCase { + func testKeyboardMonitorReportsRecoveredTapInterruption() { + var resetCount = 0 + var unavailableCount = 0 + let monitor = KeyboardMonitor( + onStreamReset: { _ in resetCount += 1 }, + onBecameUnavailable: { _ in unavailableCount += 1 }, + callback: { _ in } + ) + + XCTAssertEqual( + monitor._testResolveModifierFlagsChanged(keyCode: 55, flags: [.maskCommand]), + true + ) + monitor._testReportEventTapRecoveryOutcome(reenabled: true) + + XCTAssertEqual(resetCount, 1) + XCTAssertEqual(unavailableCount, 0) + XCTAssertEqual( + monitor._testResolveModifierFlagsChanged(keyCode: 55, flags: [.maskCommand]), + true, + "Recovered streams must not retain stale modifier state" + ) + } + + @MainActor + func testAuthorizedStartIsActiveSlowPollingAndIdempotent() { + let permission = FakeInputPermission(authorized: true) + let monitors = FakeInputMonitorFactory() + let scheduler = FakeInputRecheckScheduler() + let recorder = InputControllerRecorder() + let controller = makeController( + permission: permission, + monitors: monitors, + scheduler: scheduler, + recorder: recorder + ) + + controller.start(isEnabled: true) + + XCTAssertEqual(controller.state, .active) + XCTAssertEqual(monitors.sessions.count, 1) + XCTAssertEqual(monitors.sessions[0].startCount, 1) + XCTAssertEqual(controller.currentRecheckInterval, 300) + XCTAssertEqual(scheduler.activeToken?.interval, 300) + XCTAssertEqual(recorder.statuses.last?.runningApplicationPath, "/Applications/KeyLight.app") + XCTAssertTrue(recorder.statuses.last?.monitorRunning == true) + + controller.start(isEnabled: true) + controller.applicationDidBecomeActive() + + XCTAssertEqual(monitors.sessions.count, 1) + XCTAssertEqual(monitors.sessions[0].startCount, 1) + XCTAssertEqual(scheduler.createdTokens.count, 1) + } + + @MainActor + func testPermissionRequestRequiresExplicitActionAndValidInstallation() { + let permission = FakeInputPermission(authorized: false, requestResult: false) + let monitors = FakeInputMonitorFactory() + let scheduler = FakeInputRecheckScheduler() + let recorder = InputControllerRecorder() + let controller = makeController( + permission: permission, + monitors: monitors, + scheduler: scheduler, + recorder: recorder + ) + + controller.start(isEnabled: true) + XCTAssertEqual(controller.state, .permissionRequired) + XCTAssertEqual(permission.requestCount, 0) + XCTAssertEqual(controller.currentRecheckInterval, 5) + + controller.requestPermission() + XCTAssertEqual(permission.requestCount, 1) + XCTAssertEqual(controller.state, .permissionRequired) + + permission.installationIssue = "Running from a disk image" + controller.requestPermission() + XCTAssertEqual(permission.requestCount, 1) + XCTAssertEqual(recorder.statuses.last?.installationIssue, "Running from a disk image") + } + + @MainActor + func testOpeningInputMonitoringSettingsUsesInjectedPermissionProvider() { + let permission = FakeInputPermission(authorized: false) + let controller = makeController( + permission: permission, + monitors: FakeInputMonitorFactory(), + scheduler: FakeInputRecheckScheduler(), + recorder: InputControllerRecorder() + ) + + controller.openInputMonitoringSettings() + + XCTAssertEqual(permission.openSettingsCount, 1) + } + + @MainActor + func testSuccessfulPermissionRequestStartsMonitor() { + let permission = FakeInputPermission(authorized: false, requestResult: true) + let monitors = FakeInputMonitorFactory() + let scheduler = FakeInputRecheckScheduler() + let recorder = InputControllerRecorder() + let controller = makeController( + permission: permission, + monitors: monitors, + scheduler: scheduler, + recorder: recorder + ) + + controller.start(isEnabled: true) + controller.requestPermission() + + XCTAssertEqual(permission.requestCount, 1) + XCTAssertTrue(permission.authorized) + XCTAssertEqual(controller.state, .active) + XCTAssertEqual(monitors.sessions.count, 1) + XCTAssertEqual(controller.currentRecheckInterval, 300) + } + + @MainActor + func testExplicitPermissionRequestWorksWhileEffectIsDisabledWithoutStartingMonitor() { + let permission = FakeInputPermission(authorized: false, requestResult: true) + let monitors = FakeInputMonitorFactory() + let scheduler = FakeInputRecheckScheduler() + let recorder = InputControllerRecorder() + let controller = makeController( + permission: permission, + monitors: monitors, + scheduler: scheduler, + recorder: recorder + ) + + controller.start(isEnabled: false) + controller.requestPermission() + + XCTAssertEqual(permission.requestCount, 1) + XCTAssertTrue(permission.authorized) + XCTAssertEqual(controller.state, .authorized) + XCTAssertTrue(monitors.sessions.isEmpty) + XCTAssertEqual(controller.currentRecheckInterval, 300) + } + + @MainActor + func testDisableStopsOnceEmitsResetAndRetainsAuthorizedState() { + let harness = makeAuthorizedHarness() + harness.controller.start(isEnabled: true) + harness.recorder.events.removeAll() + + harness.controller.setEnabled(false) + + XCTAssertEqual(harness.controller.state, .authorized) + XCTAssertEqual(harness.monitors.sessions[0].stopCount, 1) + XCTAssertEqual(harness.recorder.events, [ + .streamReset(source: .lifecycle, timestamp: 10) + ]) + XCTAssertEqual(harness.controller.currentRecheckInterval, 300) + + harness.controller.setEnabled(false) + XCTAssertEqual(harness.monitors.sessions[0].stopCount, 1) + XCTAssertEqual(harness.recorder.events.count, 1) + } + + @MainActor + func testSleepWakeAreIdempotentAndRestartFreshSession() { + let harness = makeAuthorizedHarness() + harness.controller.start(isEnabled: true) + harness.recorder.events.removeAll() + + harness.controller.handleSleep() + harness.controller.handleSleep() + + XCTAssertTrue(harness.controller.isSleeping) + XCTAssertEqual(harness.monitors.sessions[0].stopCount, 1) + XCTAssertNil(harness.scheduler.activeToken) + XCTAssertEqual(harness.recorder.events, [ + .streamReset(source: .lifecycle, timestamp: 10) + ]) + + harness.controller.handleWake() + harness.controller.handleWake() + + XCTAssertFalse(harness.controller.isSleeping) + XCTAssertEqual(harness.controller.state, .active) + XCTAssertEqual(harness.monitors.sessions.count, 2) + XCTAssertEqual(harness.recorder.events, [ + .streamReset(source: .lifecycle, timestamp: 10), + .streamReset(source: .lifecycle, timestamp: 10) + ]) + } + + @MainActor + func testUnavailableMonitorResetsAndRestartsWhileStaleCallbackIsIgnored() { + let harness = makeAuthorizedHarness() + harness.controller.start(isEnabled: true) + harness.recorder.events.removeAll() + let first = harness.monitors.sessions[0] + + first.becomeUnavailable() + + XCTAssertEqual(first.stopCount, 1) + XCTAssertEqual(harness.monitors.sessions.count, 2) + XCTAssertEqual(harness.controller.state, .active) + XCTAssertEqual(harness.recorder.events, [ + .streamReset(source: .eventTap, timestamp: 10) + ]) + + first.becomeUnavailable() + XCTAssertEqual(harness.monitors.sessions.count, 2) + XCTAssertEqual(harness.recorder.events.count, 1) + } + + @MainActor + func testRecoveredMonitorStreamResetsHeldKeysWithoutRestartingSession() { + let harness = makeAuthorizedHarness() + harness.controller.start(isEnabled: true) + harness.recorder.events.removeAll() + let monitor = harness.monitors.sessions[0] + + monitor.emit(InputMonitorEvent(keyCode: 0, isKeyDown: true)) + monitor.reportRecoveredStreamInterruption() + + XCTAssertEqual(harness.controller.state, .active) + XCTAssertEqual(harness.monitors.sessions.count, 1) + XCTAssertEqual(monitor.startCount, 1) + XCTAssertEqual(monitor.stopCount, 0) + XCTAssertEqual(harness.recorder.events, [ + .keyDown(0, source: .eventTap, timestamp: 10), + .streamReset(source: .eventTap, timestamp: 10) + ]) + } + + @MainActor + func testFailedMonitorStartUsesFastRetryThenRecovers() { + let permission = FakeInputPermission(authorized: true) + let monitors = FakeInputMonitorFactory(startResults: [false, true]) + let scheduler = FakeInputRecheckScheduler() + let recorder = InputControllerRecorder() + let controller = makeController( + permission: permission, + monitors: monitors, + scheduler: scheduler, + recorder: recorder + ) + + controller.start(isEnabled: true) + + XCTAssertEqual(controller.state, .monitorUnavailable) + XCTAssertEqual(controller.currentRecheckInterval, 5) + XCTAssertEqual(monitors.sessions[0].stopCount, 1) + + scheduler.fireActive() + + XCTAssertEqual(controller.state, .active) + XCTAssertEqual(monitors.sessions.count, 2) + XCTAssertEqual(controller.currentRecheckInterval, 300) + XCTAssertTrue(scheduler.createdTokens[0].isCancelled) + } + + @MainActor + func testMonitorEventsAreCanonicalPrivacySafeKeyboardEvents() { + let permission = FakeInputPermission(authorized: true) + let monitors = FakeInputMonitorFactory() + let scheduler = FakeInputRecheckScheduler() + let recorder = InputControllerRecorder() + var timestamp: TimeInterval = 20 + let controller = InputController( + permissionProvider: permission, + monitorFactory: monitors.makeMonitor, + recheckScheduler: scheduler.schedule, + clock: { + defer { timestamp += 1 } + return timestamp + }, + isTestEnvironment: false, + onKeyboardEvent: recorder.record(event:), + onStatusChange: recorder.record(status:) + ) + controller.start(isEnabled: true) + let monitor = monitors.sessions[0] + + monitor.emit(InputMonitorEvent( + keyCode: 500, + isKeyDown: true, + isRepeat: true, + source: .consumerHID + )) + monitor.emit(InputMonitorEvent( + keyCode: 500, + isKeyDown: false, + isRepeat: true, + source: .consumerHID + )) + + XCTAssertEqual(recorder.events, [ + .keyDown(122, isRepeat: true, source: .consumerHID, timestamp: 20), + .keyUp(122, source: .consumerHID, timestamp: 21) + ]) + } + + @MainActor + func testFastTimerReconcilesPermissionAndSwitchesToSlowPolling() { + let permission = FakeInputPermission(authorized: false) + let monitors = FakeInputMonitorFactory() + let scheduler = FakeInputRecheckScheduler() + let recorder = InputControllerRecorder() + let controller = makeController( + permission: permission, + monitors: monitors, + scheduler: scheduler, + recorder: recorder + ) + controller.start(isEnabled: true) + XCTAssertEqual(scheduler.activeToken?.interval, 5) + + permission.authorized = true + scheduler.fireActive() + + XCTAssertEqual(controller.state, .active) + XCTAssertEqual(monitors.sessions.count, 1) + XCTAssertEqual(scheduler.activeToken?.interval, 300) + } + + @MainActor + func testStopIsIdempotentAndCancelsControllerWork() { + let harness = makeAuthorizedHarness() + harness.controller.start(isEnabled: true) + harness.recorder.events.removeAll() + + harness.controller.stop() + harness.controller.stop() + + XCTAssertFalse(harness.controller.isStarted) + XCTAssertEqual(harness.monitors.sessions[0].stopCount, 1) + XCTAssertNil(harness.scheduler.activeToken) + XCTAssertEqual(harness.recorder.events, [ + .streamReset(source: .lifecycle, timestamp: 10) + ]) + } + + @MainActor + func testAppHostedTestModeNeverTouchesPermissionOrMonitor() { + let permission = FakeInputPermission(authorized: true) + let monitors = FakeInputMonitorFactory() + let scheduler = FakeInputRecheckScheduler() + let recorder = InputControllerRecorder() + let controller = InputController( + permissionProvider: permission, + monitorFactory: monitors.makeMonitor, + recheckScheduler: scheduler.schedule, + isTestEnvironment: true, + onKeyboardEvent: recorder.record(event:), + onStatusChange: recorder.record(status:) + ) + + controller.start(isEnabled: true, allowPermissionRequest: true) + controller.applicationDidBecomeActive() + + XCTAssertEqual(controller.state, .checking) + XCTAssertEqual(permission.preflightCount, 0) + XCTAssertEqual(permission.requestCount, 0) + XCTAssertTrue(monitors.sessions.isEmpty) + XCTAssertNil(scheduler.activeToken) + XCTAssertTrue(recorder.events.isEmpty) + } + + // MARK: - Harness + + @MainActor + private func makeController( + permission: FakeInputPermission, + monitors: FakeInputMonitorFactory, + scheduler: FakeInputRecheckScheduler, + recorder: InputControllerRecorder + ) -> InputController { + InputController( + permissionProvider: permission, + monitorFactory: monitors.makeMonitor, + recheckScheduler: scheduler.schedule, + clock: { 10 }, + isTestEnvironment: false, + onKeyboardEvent: recorder.record(event:), + onStatusChange: recorder.record(status:) + ) + } + + @MainActor + private func makeAuthorizedHarness() -> AuthorizedInputHarness { + let permission = FakeInputPermission(authorized: true) + let monitors = FakeInputMonitorFactory() + let scheduler = FakeInputRecheckScheduler() + let recorder = InputControllerRecorder() + return AuthorizedInputHarness( + controller: makeController( + permission: permission, + monitors: monitors, + scheduler: scheduler, + recorder: recorder + ), + monitors: monitors, + scheduler: scheduler, + recorder: recorder + ) + } +} + +final class KeyboardEventDecoderTests: XCTestCase { + func testModifierFixtureHandlesSharedFlagsAndUnknownKeys() { + var decoder = KeyboardEventDecoder() + + XCTAssertEqual(decoder.resolveModifierFlagsChanged(keyCode: 55, flagIsSet: true), true) + XCTAssertEqual(decoder.resolveModifierFlagsChanged(keyCode: 54, flagIsSet: true), true) + XCTAssertEqual(decoder.resolveModifierFlagsChanged(keyCode: 54, flagIsSet: true), false) + XCTAssertEqual(decoder.resolveModifierFlagsChanged(keyCode: 55, flagIsSet: false), false) + XCTAssertNil(decoder.resolveModifierFlagsChanged(keyCode: 12, flagIsSet: true)) + } + + func testCapsLockFixtureProducesMomentaryPulse() { + var decoder = KeyboardEventDecoder() + + XCTAssertEqual(decoder.resolveModifierFlagsChanged(keyCode: 57, flagIsSet: true), true) + XCTAssertEqual(decoder.capsLockEmitSequence(isKeyDown: true), [true, false]) + XCTAssertEqual(decoder.resolveModifierFlagsChanged(keyCode: 57, flagIsSet: false), false) + XCTAssertEqual(decoder.capsLockEmitSequence(isKeyDown: false), [false]) + } + + func testMediaFixtureParsesTransitionsAndPrefersHIDDuplicates() throws { + var decoder = KeyboardEventDecoder() + let keyDownData = (UInt32(16) << 16) | (UInt32(0x0A) << 8) + let keyUpData = (UInt32(16) << 16) | (UInt32(0x0B) << 8) + + let down = try XCTUnwrap(decoder.decodeSystemDefinedMediaEvent( + subtypeRawValue: 8, + data1: keyDownData, + now: 100 + )) + let up = try XCTUnwrap(decoder.decodeSystemDefinedMediaEvent( + subtypeRawValue: 8, + data1: keyUpData, + now: 100.01 + )) + XCTAssertEqual(down, .init(keyCode: 516, isKeyDown: true)) + XCTAssertEqual(up, .init(keyCode: 516, isKeyDown: false)) + XCTAssertNil(decoder.decodeSystemDefinedMediaEvent( + subtypeRawValue: 7, + data1: keyDownData, + now: 100 + )) + + XCTAssertFalse(decoder.shouldDedupeMediaEvent( + canonicalKeyCode: 517, + isKeyDown: true, + source: .hid, + now: 101 + )) + XCTAssertTrue(decoder.shouldDedupeMediaEvent( + canonicalKeyCode: 517, + isKeyDown: true, + source: .systemDefined, + now: 101.01 + )) + XCTAssertFalse(decoder.shouldDedupeMediaEvent( + canonicalKeyCode: 517, + isKeyDown: true, + source: .systemDefined, + now: 101.05 + )) + } + + func testSystemMediaFixtureMapsRewindAndFastForwardToF7AndF9() throws { + let fixtures: [(nxCode: UInt32, alias: UInt16, functionKey: UInt16)] = [ + (20, 506, 98), + (19, 517, 101), + ] + + for fixture in fixtures { + let decoder = KeyboardEventDecoder() + let keyDownData = (fixture.nxCode << 16) | (UInt32(0x0A) << 8) + let keyUpData = (fixture.nxCode << 16) | (UInt32(0x0B) << 8) + let down = try XCTUnwrap(decoder.decodeSystemDefinedMediaEvent( + subtypeRawValue: 8, + data1: keyDownData, + now: 300 + )) + let up = try XCTUnwrap(decoder.decodeSystemDefinedMediaEvent( + subtypeRawValue: 8, + data1: keyUpData, + now: 300.01 + )) + + XCTAssertEqual(down, .init(keyCode: fixture.alias, isKeyDown: true)) + XCTAssertEqual(up, .init(keyCode: fixture.alias, isKeyDown: false)) + XCTAssertEqual( + KeyboardLayoutInfo.canonicalKeyCode(for: fixture.alias), + fixture.functionKey + ) + } + } + + func testNewerAppleDoNotDisturbRawCodeMapsToF6() throws { + let decoder = KeyboardEventDecoder() + let event = try XCTUnwrap(decoder.decodeKeyboardEvent( + rawKeyCode: 178, + isKeyDown: true, + isRepeat: false, + charactersIgnoringModifiers: nil, + specialKeyRawValue: nil, + isMappedKeyCode: false + )) + + XCTAssertEqual(event.keyCode, 97) + XCTAssertTrue(event.isKeyDown) + XCTAssertEqual(event.source, .eventTap) + } + + func testRepeatFixtureRetainsRepeatOnlyForKeyDown() throws { + let decoder = KeyboardEventDecoder() + let down = try XCTUnwrap(decoder.decodeKeyboardEvent( + rawKeyCode: 0, + isKeyDown: true, + isRepeat: true, + charactersIgnoringModifiers: nil, + specialKeyRawValue: nil, + isMappedKeyCode: true + )) + let up = try XCTUnwrap(decoder.decodeKeyboardEvent( + rawKeyCode: 0, + isKeyDown: false, + isRepeat: true, + charactersIgnoringModifiers: nil, + specialKeyRawValue: nil, + isMappedKeyCode: true + )) + + XCTAssertTrue(down.isRepeat) + XCTAssertFalse(up.isRepeat) + } + + func testUnknownRawFixtureRequiresTrustedMetadata() throws { + let decoder = KeyboardEventDecoder() + + XCTAssertNil(decoder.decodeKeyboardEvent( + rawKeyCode: 163, + isKeyDown: true, + isRepeat: false, + charactersIgnoringModifiers: nil, + specialKeyRawValue: nil, + isMappedKeyCode: false + )) + + let trusted = try XCTUnwrap(decoder.decodeKeyboardEvent( + rawKeyCode: 163, + isKeyDown: true, + isRepeat: false, + charactersIgnoringModifiers: String(UnicodeScalar(0xF706)!), + specialKeyRawValue: nil, + isMappedKeyCode: false + )) + XCTAssertEqual(trusted.keyCode, 99) + } + + func testResetClearsModifierTopRowAndMediaDedupeState() { + var decoder = KeyboardEventDecoder() + XCTAssertEqual(decoder.resolveModifierFlagsChanged(keyCode: 55, flagIsSet: true), true) + decoder.recordTrustedKeyboardTopRowEvent( + canonicalKeyCode: 122, + isKeyDown: true, + now: 200 + ) + XCTAssertTrue(decoder.shouldDedupeMediaEvent( + canonicalKeyCode: 122, + isKeyDown: true, + source: .systemDefined, + now: 200.01 + )) + XCTAssertFalse(decoder.shouldDedupeMediaEvent( + canonicalKeyCode: 516, + isKeyDown: true, + source: .systemDefined, + now: 201 + )) + XCTAssertTrue(decoder.shouldDedupeMediaEvent( + canonicalKeyCode: 516, + isKeyDown: true, + source: .systemDefined, + now: 201.01 + )) + + decoder.reset() + + XCTAssertEqual(decoder.resolveModifierFlagsChanged(keyCode: 55, flagIsSet: true), true) + XCTAssertFalse(decoder.shouldDedupeMediaEvent( + canonicalKeyCode: 122, + isKeyDown: true, + source: .systemDefined, + now: 200.01 + )) + XCTAssertFalse(decoder.shouldDedupeMediaEvent( + canonicalKeyCode: 516, + isKeyDown: true, + source: .systemDefined, + now: 201.01 + )) + } +} + +@MainActor +private final class FakeInputPermission: InputPermissionProviding { + var runningApplicationPath = "/Applications/KeyLight.app" + var installationIssue: String? + var authorized: Bool + var requestResult: Bool + private(set) var preflightCount = 0 + private(set) var requestCount = 0 + private(set) var openSettingsCount = 0 + + init(authorized: Bool, requestResult: Bool? = nil) { + self.authorized = authorized + self.requestResult = requestResult ?? authorized + } + + func hasInputMonitoringPermission() -> Bool { + preflightCount += 1 + return authorized + } + + func requestInputMonitoringPermission() -> Bool { + requestCount += 1 + authorized = requestResult + return requestResult + } + + func openInputMonitoringSettings() { + openSettingsCount += 1 + } +} + +@MainActor +private final class FakeInputMonitor: InputMonitoringSession { + private let startResult: Bool + private let onEvent: @MainActor (InputMonitorEvent) -> Void + private let onStreamReset: @MainActor () -> Void + private let onUnavailable: @MainActor () -> Void + + private(set) var isRunning = false + private(set) var startCount = 0 + private(set) var stopCount = 0 + + init( + startResult: Bool, + onEvent: @escaping @MainActor (InputMonitorEvent) -> Void, + onStreamReset: @escaping @MainActor () -> Void, + onUnavailable: @escaping @MainActor () -> Void + ) { + self.startResult = startResult + self.onEvent = onEvent + self.onStreamReset = onStreamReset + self.onUnavailable = onUnavailable + } + + func start() -> Bool { + startCount += 1 + isRunning = startResult + return startResult + } + + func stop() { + stopCount += 1 + isRunning = false + } + + func emit(_ event: InputMonitorEvent) { + onEvent(event) + } + + func becomeUnavailable() { + isRunning = false + onUnavailable() + } + + func reportRecoveredStreamInterruption() { + onStreamReset() + } +} + +@MainActor +private final class FakeInputMonitorFactory { + private var startResults: [Bool] + private(set) var sessions: [FakeInputMonitor] = [] + + init(startResults: [Bool] = []) { + self.startResults = startResults + } + + func makeMonitor( + onEvent: @escaping @MainActor (InputMonitorEvent) -> Void, + onStreamReset: @escaping @MainActor () -> Void, + onUnavailable: @escaping @MainActor () -> Void + ) -> any InputMonitoringSession { + let startResult = startResults.isEmpty ? true : startResults.removeFirst() + let monitor = FakeInputMonitor( + startResult: startResult, + onEvent: onEvent, + onStreamReset: onStreamReset, + onUnavailable: onUnavailable + ) + sessions.append(monitor) + return monitor + } +} + +@MainActor +private final class FakeInputRecheckToken: InputControllerRecheckToken { + let interval: TimeInterval + let tolerance: TimeInterval + let action: @MainActor () -> Void + private(set) var isCancelled = false + + init( + interval: TimeInterval, + tolerance: TimeInterval, + action: @escaping @MainActor () -> Void + ) { + self.interval = interval + self.tolerance = tolerance + self.action = action + } + + func cancel() { + isCancelled = true + } +} + +@MainActor +private final class FakeInputRecheckScheduler { + private(set) var createdTokens: [FakeInputRecheckToken] = [] + + var activeToken: FakeInputRecheckToken? { + createdTokens.last(where: { !$0.isCancelled }) + } + + func schedule( + interval: TimeInterval, + tolerance: TimeInterval, + action: @escaping @MainActor () -> Void + ) -> any InputControllerRecheckToken { + let token = FakeInputRecheckToken( + interval: interval, + tolerance: tolerance, + action: action + ) + createdTokens.append(token) + return token + } + + func fireActive() { + activeToken?.action() + } +} + +@MainActor +private final class InputControllerRecorder { + var events: [KeyboardEvent] = [] + var statuses: [InputControllerStatus] = [] + + func record(event: KeyboardEvent) { + events.append(event) + } + + func record(status: InputControllerStatus) { + statuses.append(status) + } +} + +@MainActor +private struct AuthorizedInputHarness { + let controller: InputController + let monitors: FakeInputMonitorFactory + let scheduler: FakeInputRecheckScheduler + let recorder: InputControllerRecorder +} diff --git a/KeyLightTests/KeyLayoutStoreTests.swift b/KeyLightTests/KeyLayoutStoreTests.swift new file mode 100644 index 0000000..52da339 --- /dev/null +++ b/KeyLightTests/KeyLayoutStoreTests.swift @@ -0,0 +1,475 @@ +import CoreGraphics +import Foundation +import XCTest +@testable import KeyLight + +final class KeyLayoutStoreTests: XCTestCase { + @MainActor + func testGuidedCalibrationUsesNineStableReferenceAnchors() { + XCTAssertEqual(GuidedCalibrationDraft.anchors.map(\.keyCode), [ + 18, 22, 24, + 0, 4, 36, + 55, 49, 124 + ]) + XCTAssertEqual(Set(GuidedCalibrationDraft.anchors.map(\.row)), Set([1, 3, 5])) + } + + @MainActor + func testGuidedCalibrationPreservesBaselineWhenAnchorsDoNotMove() { + let baseline = KeyLayout( + offsets: [18: 0.01, 4: -0.02, 49: 0.015], + widthMultipliers: [18: 1.1, 49: 0.95] + ) + let draft = GuidedCalibrationDraft(baseline: baseline) + let fitted = draft.fittedLayout + + for key in KeyboardLayoutInfo.allKeys { + XCTAssertEqual( + key.position + (fitted.offsets[key.id] ?? 0), + key.position + (baseline.offsets[key.id] ?? 0), + accuracy: 0.000_001, + "Position changed for \(key.label)" + ) + } + } + + @MainActor + func testGuidedCalibrationInterpolatesAndNormalizesFittedLayout() { + var draft = GuidedCalibrationDraft(baseline: .empty) + for anchor in GuidedCalibrationDraft.anchors { + draft.setAlignedPosition( + draft.alignedPosition(for: anchor.keyCode) + 0.04, + for: anchor.keyCode + ) + } + + let fitted = draft.fittedLayout + XCTAssertEqual(fitted.offsets[0] ?? .nan, 0.04, accuracy: 0.000_001) + XCTAssertEqual(fitted.offsets[49] ?? .nan, 0.04, accuracy: 0.000_001) + XCTAssertTrue(fitted.offsets.values.allSatisfy { + $0 >= KeyLayoutStore.minimumOffset && $0 <= KeyLayoutStore.maximumOffset + }) + XCTAssertTrue(fitted.widthMultipliers.values.allSatisfy { + $0 >= KeyLayoutStore.minimumWidthMultiplier + && $0 <= KeyLayoutStore.maximumWidthMultiplier + }) + } + + @MainActor + func testGuidedCalibrationCanCreateAndActivateNewProfileWithoutReplacingExisting() throws { + let (defaults, suiteName) = try makeIsolatedDefaults() + defer { defaults.removePersistentDomain(forName: suiteName) } + let preferences = PreferencesStore(userDefaults: defaults) + let settings = SettingsManager(preferencesStore: preferences) + let existing = KeyMappingProfile(name: "Existing", keyOffsets: [49: 0.1]) + _ = settings.saveKeyMappingProfile(existing) + let profilesBefore = settings.savedKeyMappingProfiles + let store = KeyLayoutStore( + preferencesStore: preferences, + settingsManager: settings, + debounceInterval: 60 + ) + defer { store.cancelPendingWork() } + + var draft = GuidedCalibrationDraft(baseline: store.layout) + draft.setAlignedPosition(0.55, for: 49) + let fitted = draft.fittedLayout + let requested = KeyMappingProfile( + name: "Guided Calibration", + keyOffsets: fitted.offsets, + keyWidthOverrides: fitted.widthMultipliers + ) + let saved = try XCTUnwrap(settings.saveKeyMappingProfile(requested)) + store.reloadSavedProfiles(from: settings) + XCTAssertTrue(store.selectSavedProfile(id: saved.id)) + + let profilesAfter = settings.savedKeyMappingProfiles + XCTAssertEqual(profilesAfter.count, profilesBefore.count + 1) + for profile in profilesBefore { + XCTAssertTrue(profilesAfter.contains(profile)) + } + XCTAssertEqual(store.selectedProfileID, saved.id) + XCTAssertEqual(store.layout, fitted) + } + + @MainActor + func testGuidedCalibrationDraftDoesNotMutateLayoutUntilCommitted() throws { + let (defaults, suiteName) = try makeIsolatedDefaults() + defer { defaults.removePersistentDomain(forName: suiteName) } + let preferences = PreferencesStore(userDefaults: defaults) + let settings = SettingsManager(preferencesStore: preferences) + let store = KeyLayoutStore( + preferencesStore: preferences, + settingsManager: settings, + debounceInterval: 60 + ) + defer { store.cancelPendingWork() } + let baseline = store.layout + let profilesBefore = settings.savedKeyMappingProfiles + let offsetsBefore = defaults.dictionary(forKey: KeyLayoutStore.offsetsKey) + let widthsBefore = defaults.dictionary(forKey: KeyLayoutStore.widthMultipliersKey) + + var abandonedDraft = GuidedCalibrationDraft(baseline: baseline) + abandonedDraft.setAlignedPosition(0.61, for: 49) + _ = abandonedDraft.fittedLayout + + XCTAssertEqual(store.layout, baseline) + XCTAssertEqual(settings.savedKeyMappingProfiles, profilesBefore) + XCTAssertEqual( + defaults.dictionary(forKey: KeyLayoutStore.offsetsKey) as NSDictionary?, + offsetsBefore as NSDictionary? + ) + XCTAssertEqual( + defaults.dictionary(forKey: KeyLayoutStore.widthMultipliersKey) as NSDictionary?, + widthsBefore as NSDictionary? + ) + } + + @MainActor + func testSavedProfileIdentityAndEditedStateLiveInLayoutStore() throws { + let (defaults, suiteName) = try makeIsolatedDefaults() + defer { defaults.removePersistentDomain(forName: suiteName) } + let preferences = PreferencesStore(userDefaults: defaults) + let settings = SettingsManager(preferencesStore: preferences) + var profile = KeyMappingProfile( + name: "Baseline Layout", + keyOffsets: [49: 0.1], + keyWidthOverrides: [49: 1.25] + ) + profile.id = UUID() + settings.savedKeyMappingProfiles = [profile] + settings.activeLayoutID = profile.id + defaults.set(["49": 0.1], forKey: KeyLayoutStore.offsetsKey) + defaults.set(["49": 1.25], forKey: KeyLayoutStore.widthMultipliersKey) + + let store = KeyLayoutStore( + preferencesStore: preferences, + settingsManager: settings, + debounceInterval: 60 + ) + defer { store.cancelPendingWork() } + + XCTAssertEqual(store.selectedProfile?.id, profile.id) + XCTAssertFalse(store.selectedProfileIsEdited) + + store.setOffset(0.2, for: 49) + XCTAssertTrue(store.selectedProfileIsEdited) + + store.revert() + XCTAssertFalse(store.selectedProfileIsEdited) + + settings.renameKeyMappingProfile(from: profile.name, to: "Renamed Layout") + store.reloadSavedProfiles() + XCTAssertEqual(store.selectedProfile?.id, profile.id) + XCTAssertEqual(store.selectedProfile?.name, "Renamed Layout") + XCTAssertFalse(store.selectedProfileIsEdited) + } + + @MainActor + func testLoadsLegacyKeysAndNormalizesBothCalibrationDimensions() throws { + let (defaults, suiteName) = try makeIsolatedDefaults() + defer { defaults.removePersistentDomain(forName: suiteName) } + + defaults.set([ + "500": 0.4, + "122": 0.1, + "125": -0.8, + "9999": 0.2 + ], forKey: "KeyPositionOffsets") + defaults.set([ + "500": 2.5, + "122": 0.2, + "126": 9.0, + "9999": 1.5 + ], forKey: "KeyWidthOverrides") + + let store = KeyLayoutStore(defaults: defaults) + defer { store.cancelPendingWork() } + + XCTAssertEqual(KeyLayoutStore.offsetsKey, "KeyPositionOffsets") + XCTAssertEqual(KeyLayoutStore.widthMultipliersKey, "KeyWidthOverrides") + XCTAssertEqual(store.layout.offsets[122], 0.1) + XCTAssertEqual(store.layout.offsets[125], -0.5) + XCTAssertNil(store.layout.offsets[500]) + XCTAssertNil(store.layout.offsets[9999]) + XCTAssertEqual(store.layout.widthMultipliers[122], 0.2) + XCTAssertEqual(store.layout.widthMultipliers[126], 5.0) + XCTAssertNil(store.layout.widthMultipliers[500]) + XCTAssertNil(store.layout.widthMultipliers[9999]) + XCTAssertEqual(store.layout, store.baseline) + XCTAssertFalse(store.isEdited) + } + + @MainActor + func testNormalizationRejectsInvalidValuesAndPrefersCanonicalKeys() { + let normalized = KeyLayoutStore.normalized(KeyLayout( + offsets: [500: 0.3, 122: 0.2, 125: -.infinity, 9_999: 0.1], + widthMultipliers: [500: 2.4, 122: 1.4, 126: .nan, 9_999: 2] + )) + + XCTAssertEqual(normalized.offsets, [122: 0.2]) + XCTAssertEqual(normalized.widthMultipliers, [122: 1.4]) + XCTAssertLessThanOrEqual(normalized.offsets.count, KeyLayoutStore.maximumEntryCount) + XCTAssertLessThanOrEqual(normalized.widthMultipliers.count, KeyLayoutStore.maximumEntryCount) + } + + @MainActor + func testMixedGestureIsOneUndoAndRedoTransaction() throws { + let (defaults, suiteName) = try makeIsolatedDefaults() + let store = KeyLayoutStore(defaults: defaults, debounceInterval: 60) + defer { + store.cancelPendingWork() + defaults.removePersistentDomain(forName: suiteName) + } + + store.beginGestureTransaction() + store.setOffset(0.1, for: 500) + store.setOffset(0.25, for: 500) + store.setWidthMultiplier(1.8, for: 500) + store.endGestureTransaction() + + XCTAssertEqual(store.layout.offsets[122], 0.25) + XCTAssertEqual(store.layout.widthMultipliers[122], 1.8) + XCTAssertTrue(store.canUndo) + XCTAssertFalse(store.canRedo) + + store.undo() + XCTAssertEqual(store.layout, .empty) + XCTAssertFalse(store.canUndo) + XCTAssertTrue(store.canRedo) + + store.redo() + XCTAssertEqual(store.layout.offsets[122], 0.25) + XCTAssertEqual(store.layout.widthMultipliers[122], 1.8) + XCTAssertTrue(store.canUndo) + XCTAssertFalse(store.canRedo) + } + + @MainActor + func testEmptyGestureDoesNotCreateHistory() throws { + let (defaults, suiteName) = try makeIsolatedDefaults() + let store = KeyLayoutStore(defaults: defaults) + defer { + store.cancelPendingWork() + defaults.removePersistentDomain(forName: suiteName) + } + + store.beginGestureTransaction() + store.endGestureTransaction() + + XCTAssertFalse(store.canUndo) + XCTAssertFalse(store.canRedo) + } + + @MainActor + func testApplyResetKeyResetAllAndRevertAreAtomic() throws { + let (defaults, suiteName) = try makeIsolatedDefaults() + defaults.set(["10": 0.05], forKey: "KeyPositionOffsets") + defaults.set(["10": 1.2], forKey: "KeyWidthOverrides") + + let store = KeyLayoutStore(defaults: defaults, debounceInterval: 60) + defer { + store.cancelPendingWork() + defaults.removePersistentDomain(forName: suiteName) + } + + let replacement = KeyLayout( + offsets: [10: 0.2, 49: -0.1], + widthMultipliers: [10: 1.5, 49: 1.1] + ) + store.apply(replacement) + XCTAssertEqual(store.layout, replacement) + XCTAssertTrue(store.isEdited) + + store.resetKey(10) + XCTAssertNil(store.layout.offsets[10]) + XCTAssertNil(store.layout.widthMultipliers[10]) + store.undo() + XCTAssertEqual(store.layout, replacement) + + store.resetAll() + XCTAssertEqual(store.layout, .empty) + store.undo() + XCTAssertEqual(store.layout, replacement) + + store.revert() + XCTAssertEqual(store.layout.offsets, [10: 0.05]) + XCTAssertEqual(store.layout.widthMultipliers, [10: 1.2]) + XCTAssertFalse(store.isEdited) + } + + @MainActor + func testApplyAsBaselineAndMarkCurrentAsBaselineTrackEditedState() throws { + let (defaults, suiteName) = try makeIsolatedDefaults() + let store = KeyLayoutStore(defaults: defaults, debounceInterval: 60) + defer { + store.cancelPendingWork() + defaults.removePersistentDomain(forName: suiteName) + } + + let profile = KeyLayout(offsets: [49: 0.1], widthMultipliers: [49: 1.3]) + store.apply(profile, asBaseline: true) + XCTAssertEqual(store.layout, profile) + XCTAssertEqual(store.baseline, profile) + XCTAssertFalse(store.isEdited) + + store.setOffset(0.2, for: 49) + XCTAssertTrue(store.isEdited) + store.markCurrentAsBaseline() + XCTAssertFalse(store.isEdited) + } + + @MainActor + func testDimensionSpecificCompatibilityOperationsPreserveOtherCalibration() throws { + let (defaults, suiteName) = try makeIsolatedDefaults() + let initial = KeyLayout( + offsets: [49: 0.1], + widthMultipliers: [49: 1.4] + ) + let store = KeyLayoutStore(defaults: defaults, debounceInterval: 60) + defer { + store.cancelPendingWork() + defaults.removePersistentDomain(forName: suiteName) + } + + store.apply(initial, asBaseline: true) + store.resetAllOffsets() + XCTAssertTrue(store.layout.offsets.isEmpty) + XCTAssertEqual(store.layout.widthMultipliers, initial.widthMultipliers) + + store.replaceAllOffsets([49: -0.2]) + store.resetWidthMultiplier(for: 49) + XCTAssertEqual(store.layout.offsets, [49: -0.2]) + XCTAssertTrue(store.layout.widthMultipliers.isEmpty) + + store.replaceAllWidthMultipliers([49: 2.2]) + store.resetOffset(for: 49) + XCTAssertTrue(store.layout.offsets.isEmpty) + XCTAssertEqual(store.layout.widthMultipliers, [49: 2.2]) + } + + @MainActor + func testReloadingOwnPersistedSnapshotDoesNotDestroyUndoHistory() throws { + let (defaults, suiteName) = try makeIsolatedDefaults() + let store = KeyLayoutStore(defaults: defaults, debounceInterval: 60) + defer { + store.cancelPendingWork() + defaults.removePersistentDomain(forName: suiteName) + } + + store.setOffset(0.2, for: 49) + store.flush() + XCTAssertTrue(store.canUndo) + + // An identical persisted snapshot must be a no-op so a lifecycle + // reconciliation cannot destroy editor history. + store.reloadFromPersistence() + XCTAssertTrue(store.canUndo) + store.undo() + XCTAssertEqual(store.layout, .empty) + } + + @MainActor + func testFlushWritesLegacyDictionariesWithoutAnInternalEventBus() throws { + let (defaults, suiteName) = try makeIsolatedDefaults() + let store = KeyLayoutStore( + defaults: defaults, + debounceInterval: 60 + ) + defer { + store.cancelPendingWork() + defaults.removePersistentDomain(forName: suiteName) + } + + store.beginGestureTransaction() + store.setOffset(0.2, for: 500) + store.setWidthMultiplier(1.75, for: 500) + store.endGestureTransaction() + + XCTAssertNil(defaults.object(forKey: "KeyPositionOffsets")) + XCTAssertNil(defaults.object(forKey: "KeyWidthOverrides")) + + store.flush() + + XCTAssertEqual(persistedNumber(defaults, key: "KeyPositionOffsets", entry: "122"), 0.2) + XCTAssertEqual(persistedNumber(defaults, key: "KeyWidthOverrides", entry: "122"), 1.75) + } + + @MainActor + func testTrailingPersistenceUsesLatestSnapshot() async throws { + let (defaults, suiteName) = try makeIsolatedDefaults() + let store = KeyLayoutStore(defaults: defaults, debounceInterval: 0.02) + defer { + store.cancelPendingWork() + defaults.removePersistentDomain(forName: suiteName) + } + + store.setOffset(0.1, for: 49) + try await Task.sleep(nanoseconds: 5_000_000) + store.setOffset(0.3, for: 49) + try await Task.sleep(nanoseconds: 40_000_000) + + XCTAssertEqual(persistedNumber(defaults, key: "KeyPositionOffsets", entry: "49"), 0.3) + } + + @MainActor + func testCancelPendingWorkPreventsDeferredPersistence() async throws { + let (defaults, suiteName) = try makeIsolatedDefaults() + let store = KeyLayoutStore(defaults: defaults, debounceInterval: 0.01) + defer { + store.cancelPendingWork() + defaults.removePersistentDomain(forName: suiteName) + } + + store.setOffset(0.2, for: 49) + store.cancelPendingWork() + try await Task.sleep(nanoseconds: 30_000_000) + + XCTAssertNil(defaults.object(forKey: "KeyPositionOffsets")) + XCTAssertNil(defaults.object(forKey: "KeyWidthOverrides")) + } + + @MainActor + func testRuntimeEditsCanonicalizeClampAndFilter() throws { + let (defaults, suiteName) = try makeIsolatedDefaults() + let store = KeyLayoutStore(defaults: defaults, debounceInterval: 60) + defer { + store.cancelPendingWork() + defaults.removePersistentDomain(forName: suiteName) + } + + store.beginGestureTransaction() + store.setOffset(9, for: 500) + store.setWidthMultiplier(-2, for: 500) + store.setOffset(.nan, for: 49) + store.setWidthMultiplier(.infinity, for: 49) + store.setOffset(0.2, for: 9_999) + store.setWidthMultiplier(2, for: 9_999) + store.endGestureTransaction() + + XCTAssertEqual(store.layout.offsets[122], 0.5) + XCTAssertEqual(store.layout.widthMultipliers[122], 0.1) + XCTAssertEqual(store.layout.offsets[49], 0) + XCTAssertEqual(store.layout.widthMultipliers[49], 1) + XCTAssertNil(store.layout.offsets[9_999]) + XCTAssertNil(store.layout.widthMultipliers[9_999]) + XCTAssertEqual(store.adjustedPosition(for: 500, originalPosition: 0.8), 1) + XCTAssertEqual(store.effectiveWidth(for: 500, defaultWidth: 0.8), 0.08, accuracy: 0.0001) + } + + private func makeIsolatedDefaults() throws -> (UserDefaults, String) { + let suiteName = "KeyLayoutStoreTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defaults.removePersistentDomain(forName: suiteName) + return (defaults, suiteName) + } + + private func persistedNumber( + _ defaults: UserDefaults, + key: String, + entry: String + ) -> CGFloat? { + guard let number = defaults.dictionary(forKey: key)?[entry] as? NSNumber else { return nil } + return CGFloat(truncating: number) + } +} diff --git a/KeyLightTests/KeyLightTests.swift b/KeyLightTests/KeyLightTests.swift index 272d366..f257cb8 100644 --- a/KeyLightTests/KeyLightTests.swift +++ b/KeyLightTests/KeyLightTests.swift @@ -8,6 +8,7 @@ private enum TestDefaultsKeys { "isEnabled", "glowColorHex", "glowOpacity", + "physicalRefractionStrength", "glowSize", "glowWidth", "glowRoundness", @@ -16,6 +17,12 @@ private enum TestDefaultsKeys { "fadeDurationDefaultMigratedV2", "launchAtLogin", "colorMode", + "effectStyle", + "chordSurfaceStyle", + "chordIntensityMultiplier", + "powerSavingMode", + "effectConfigurationsByStyleV1", + "surfaceShapeProfile", "savedThemes", "currentThemeName", "keyMappingProfiles", @@ -26,6 +33,18 @@ private enum TestDefaultsKeys { "defaultExperienceSeedVersion", "defaultLayoutMigrationVersion", "bundledLayoutProfilesSeedVersion", + "hasSeenPermissionExplanation", + "activeThemeID", + "activeLayoutID", + "overlayDisplaySelection", + "mirroredDisplayIDs", + "displayLayoutProfileBindings", + "globalShortcut", + "stableSelectionMigrationVersion", + "onboardingCompletedVersion", + "onboardingDeferredVersion", + "configurationSnapshotsV1", + "configurationSnapshotRecoveryV1", "KeyPositionOffsets", "KeyWidthOverrides" ] @@ -65,12 +84,6 @@ private final class DefaultsSnapshot { } } -private func repositoryRootURL(from filePath: StaticString = #filePath) -> URL { - URL(fileURLWithPath: "\(filePath)") - .deletingLastPathComponent() // KeyLightTests/ - .deletingLastPathComponent() // repo root -} - final class SettingsManagerContractTests: XCTestCase { private var snapshot: DefaultsSnapshot! @@ -92,6 +105,7 @@ final class SettingsManagerContractTests: XCTestCase { "isEnabled", "glowColorHex", "glowOpacity", + "physicalRefractionStrength", "glowSize", "glowWidth", "glowRoundness", @@ -100,6 +114,12 @@ final class SettingsManagerContractTests: XCTestCase { "fadeDurationDefaultMigratedV2", "launchAtLogin", "colorMode", + "effectStyle", + "chordSurfaceStyle", + "chordIntensityMultiplier", + "powerSavingMode", + "effectConfigurationsByStyleV1", + "surfaceShapeProfile", "savedThemes", "currentThemeName", "keyMappingProfiles", @@ -110,45 +130,812 @@ final class SettingsManagerContractTests: XCTestCase { "defaultExperienceSeedVersion", "defaultLayoutMigrationVersion", "bundledLayoutProfilesSeedVersion", + "hasSeenPermissionExplanation", + "activeThemeID", + "activeLayoutID", + "overlayDisplaySelection", + "mirroredDisplayIDs", + "displayLayoutProfileBindings", + "globalShortcut", + "stableSelectionMigrationVersion", + "onboardingCompletedVersion", + "onboardingDeferredVersion", + "configurationSnapshotsV1", + "configurationSnapshotRecoveryV1", "KeyPositionOffsets", "KeyWidthOverrides" ] XCTAssertEqual(Set(TestDefaultsKeys.all), expected) + #if DEBUG + XCTAssertEqual(Set(SettingsManager._testUserDefaultsKeyContract), expected) + #endif - let settings = SettingsManager.shared + let settings = SettingsManager() + let defaults = UserDefaults.standard settings.isEnabled = false settings.glowColorHex = "ABCDEF" settings.glowOpacity = 0.42 + settings.physicalRefractionStrength = 2.25 settings.glowSize = 88 settings.glowWidth = 1.25 settings.glowRoundness = 0.75 settings.glowFullness = 0.33 settings.fadeDuration = 0.9 settings.colorMode = .rainbow + settings.effectStyle = .physicalRefraction + settings.chordAppearance = ChordAppearance( + style: .independent, + intensityMultiplier: 1.35 + ) + settings.powerSavingMode = .off + settings.mirroredDisplayIDs = ["display-b", "display-a"] + settings.surfaceShapeProfile = .currentWave settings.gradientStartHex = "112233" settings.gradientEndHex = "445566" - settings.launchAtLogin = false + // The launch-at-login setter talks to SMAppService and is intentionally + // covered outside this storage-only contract. Exercise its persisted key + // through the getter so unsigned test hosts remain deterministic. + defaults.set(true, forKey: "launchAtLogin") - let defaults = UserDefaults.standard XCTAssertEqual(defaults.object(forKey: "isEnabled") as? Bool, false) XCTAssertEqual(defaults.string(forKey: "glowColorHex"), "ABCDEF") XCTAssertEqual(defaults.object(forKey: "glowOpacity") as? Double, 0.42) + XCTAssertEqual( + defaults.object( + forKey: "physicalRefractionStrength" + ) as? Double, + 2.25 + ) XCTAssertEqual(defaults.object(forKey: "glowSize") as? Double, 88) XCTAssertEqual(defaults.object(forKey: "glowWidth") as? Double, 1.25) XCTAssertEqual(defaults.object(forKey: "glowRoundness") as? Double, 0.75) XCTAssertEqual(defaults.object(forKey: "glowFullness") as? Double, 0.33) XCTAssertEqual(defaults.object(forKey: "fadeDuration") as? Double, 0.9) XCTAssertEqual(defaults.string(forKey: "colorMode"), "rainbow") + XCTAssertEqual( + defaults.string(forKey: "effectStyle"), + "physicalRefraction" + ) + XCTAssertEqual(defaults.string(forKey: "chordSurfaceStyle"), "independent") + XCTAssertEqual( + defaults.object(forKey: "chordIntensityMultiplier") as? Double, + 1.35 + ) + XCTAssertEqual(defaults.string(forKey: "powerSavingMode"), "off") + XCTAssertEqual( + defaults.object(forKey: "mirroredDisplayIDs") as? [String], + ["display-a", "display-b"] + ) + XCTAssertEqual( + defaults.string(forKey: "surfaceShapeProfile"), + "currentWave" + ) XCTAssertEqual(defaults.string(forKey: "gradientStartHex"), "112233") XCTAssertEqual(defaults.string(forKey: "gradientEndHex"), "445566") - XCTAssertEqual(defaults.object(forKey: "launchAtLogin") as? Bool, false) + XCTAssertTrue(defaults.bool(forKey: "launchAtLogin")) } @MainActor func testLegacyGradientColorModeFallback() { let defaults = UserDefaults.standard defaults.set("gradient", forKey: "colorMode") - XCTAssertEqual(SettingsManager.shared.colorMode, .positionGradient) + XCTAssertEqual(SettingsManager().colorMode, .positionGradient) + } + + @MainActor + func testTinyHeightEndpointPersistsWithoutBeingRaisedToLegacyMinimum() { + let settings = SettingsManager() + settings.glowSize = 4 + + XCTAssertEqual(settings.glowSize, 4) + XCTAssertEqual(UserDefaults.standard.object(forKey: "glowSize") as? Double, 4) + } + + @MainActor + func testDefaultEffectStyleIsClassicGlow() { + XCTAssertEqual(SettingsManager().effectStyle, .classicGlow) + } + + @MainActor + func testChordAppearanceDefaultsAndNormalizesPersistedValues() { + let defaults = UserDefaults.standard + let settings = SettingsManager() + XCTAssertEqual(settings.chordAppearance, .default) + + defaults.set("futureStyle", forKey: "chordSurfaceStyle") + defaults.set(9.0, forKey: "chordIntensityMultiplier") + XCTAssertEqual(settings.chordAppearance.style, .naturalMerge) + XCTAssertEqual(settings.chordAppearance.intensityMultiplier, 1.5) + + settings.chordAppearance = ChordAppearance( + style: .independent, + intensityMultiplier: -4 + ) + XCTAssertEqual(settings.chordAppearance.style, .independent) + XCTAssertEqual(settings.chordAppearance.intensityMultiplier, 0.5) + } + + @MainActor + func testPowerSavingDefaultsToAutomaticAndRejectsUnknownValues() { + let defaults = UserDefaults.standard + let settings = SettingsManager() + XCTAssertEqual(settings.powerSavingMode, .automatic) + + settings.powerSavingMode = .off + XCTAssertEqual(settings.powerSavingMode, .off) + + defaults.set("futureMode", forKey: "powerSavingMode") + XCTAssertEqual(settings.powerSavingMode, .automatic) + } + + @MainActor + func testMirroredDisplayIDsPersistUnavailableSelectionsAndNormalizeInput() { + let defaults = UserDefaults.standard + let settings = SettingsManager() + defaults.set( + ["connected", " disconnected ", "", String(repeating: "x", count: 201)], + forKey: "mirroredDisplayIDs" + ) + + XCTAssertEqual( + settings.mirroredDisplayIDs, + Set(["connected", "disconnected"]) + ) + + settings.mirroredDisplayIDs = ["offline", "connected"] + XCTAssertEqual( + defaults.object(forKey: "mirroredDisplayIDs") as? [String], + ["connected", "offline"] + ) + } + + @MainActor + func testInvalidPersistedEffectStyleFallsBackToClassicGlow() { + UserDefaults.standard.set("futureEffect", forKey: "effectStyle") + XCTAssertEqual(SettingsManager().effectStyle, .classicGlow) + } + + @MainActor + func testSystemGlassPersistsAsADistinctEffectRoute() { + let settings = SettingsManager() + settings.effectStyle = .systemGlass + + XCTAssertEqual( + UserDefaults.standard.string(forKey: "effectStyle"), + "systemGlass" + ) + XCTAssertEqual(SettingsManager().effectStyle, .systemGlass) + } + + @MainActor + func testRetiredEffectPreferencesMigrateToSupportedRoutes() { + let settings = SettingsManager() + settings.effectStyle = .classicPlus + + XCTAssertEqual( + UserDefaults.standard.string(forKey: "effectStyle"), + "classicGlow" + ) + XCTAssertEqual(SettingsManager().effectStyle, .classicGlow) + XCTAssertEqual( + EffectStyle.classicPlus.resolved(liquidGlassAvailable: false), + .classicGlow + ) + XCTAssertTrue(EffectStyle.classicPlus.usesClassicColorConfiguration) + XCTAssertFalse(EffectStyle.classicPlus.usesScreenCapture) + XCTAssertFalse(EffectStyle.classicPlus.requiresMacOS26) + + settings.effectStyle = .liquidGlass + XCTAssertEqual( + UserDefaults.standard.string(forKey: "effectStyle"), + "systemGlass" + ) + XCTAssertEqual(SettingsManager().effectStyle, .systemGlass) + } + + @MainActor + func testEffectStyleAvailabilityResolvesSupportedGlassRoutes() { + let settings = SettingsManager() + settings.effectStyle = .systemGlass + + XCTAssertTrue(EffectStyle.classicGlow.isAvailableOnCurrentSystem) + XCTAssertEqual(EffectStyle.classicGlow.resolvedForCurrentSystem, .classicGlow) + + #if compiler(>=6.2) + if #available(macOS 26.0, *) { + XCTAssertTrue(EffectStyle.systemGlass.isAvailableOnCurrentSystem) + XCTAssertEqual(EffectStyle.systemGlass.resolvedForCurrentSystem, .systemGlass) + } else { + XCTAssertFalse(EffectStyle.systemGlass.isAvailableOnCurrentSystem) + XCTAssertEqual(EffectStyle.systemGlass.resolvedForCurrentSystem, .classicGlow) + } + #else + XCTAssertFalse(EffectStyle.systemGlass.isAvailableOnCurrentSystem) + XCTAssertEqual(EffectStyle.systemGlass.resolvedForCurrentSystem, .classicGlow) + #endif + + XCTAssertEqual(settings.effectStyle, .systemGlass) + } + + @MainActor + func testEffectStyleResolverCoversSupportedAndUnsupportedSystems() { + XCTAssertEqual( + EffectStyle.liquidGlass.resolved(liquidGlassAvailable: true), + .systemGlass + ) + XCTAssertEqual( + EffectStyle.liquidGlass.resolved(liquidGlassAvailable: false), + .classicGlow + ) + XCTAssertEqual( + EffectStyle.systemGlass.resolved(liquidGlassAvailable: true), + .systemGlass + ) + XCTAssertEqual( + EffectStyle.systemGlass.resolved(liquidGlassAvailable: false), + .classicGlow + ) + XCTAssertEqual( + EffectStyle.classicGlow.resolved(liquidGlassAvailable: false), + .classicGlow + ) + } + + @MainActor + func testEachSupportedEffectKeepsIndependentSettingsAndDefaults() { + let settings = SettingsManager() + + var classic = settings.effectConfiguration(for: .classicGlow) + classic.opacity = 0.31 + classic.height = 47 + settings.setEffectConfiguration(classic, for: .classicGlow) + + var physical = settings.effectConfiguration(for: .physicalRefraction) + physical.opacity = 0.84 + physical.refractionStrength = 2.2 + physical.height = 91 + settings.setEffectConfiguration(physical, for: .physicalRefraction) + + let reloaded = SettingsManager() + XCTAssertEqual( + reloaded.effectConfiguration(for: .classicGlow).opacity, + 0.31, + accuracy: 0.000_001 + ) + XCTAssertEqual( + reloaded.effectConfiguration(for: .classicGlow).height, + 47, + accuracy: 0.000_001 + ) + XCTAssertEqual( + reloaded.effectConfiguration(for: .physicalRefraction).opacity, + 0.84, + accuracy: 0.000_001 + ) + XCTAssertEqual( + reloaded.effectConfiguration(for: .physicalRefraction) + .refractionStrength, + 2.2, + accuracy: 0.000_001 + ) + XCTAssertEqual( + reloaded.effectConfiguration(for: .physicalRefraction).height, + 91, + accuracy: 0.000_001 + ) + XCTAssertEqual( + reloaded.effectConfiguration(for: .solidBlack).opacity, + 1, + accuracy: 0.000_001 + ) + } + + @MainActor + func testSelectingEffectsRestoresTheirOwnLiveSliderValues() { + let settings = SettingsManager() + let model = KeyLightModel( + settings: settings, + feedbackAnnouncer: { _ in } + ) + + model.glowOpacity = 0.29 + model.glowSize = 44 + model.flushPendingPersist() + + model.selectEffect(.physicalRefraction) + XCTAssertEqual(model.glowOpacity, 0.80, accuracy: 0.000_001) + XCTAssertEqual( + model.physicalRefractionStrength, + 1, + accuracy: 0.000_001 + ) + + model.glowOpacity = 0.87 + model.glowSize = 93 + model.physicalRefractionStrength = 2.3 + model.flushPendingPersist() + + model.selectEffect(.classicGlow) + XCTAssertEqual(model.glowOpacity, 0.29, accuracy: 0.000_001) + XCTAssertEqual(model.glowSize, 44, accuracy: 0.000_001) + + model.selectEffect(.physicalRefraction) + XCTAssertEqual(model.glowOpacity, 0.87, accuracy: 0.000_001) + XCTAssertEqual(model.glowSize, 93, accuracy: 0.000_001) + XCTAssertEqual( + model.physicalRefractionStrength, + 2.3, + accuracy: 0.000_001 + ) + } +} + +final class ConfigurationSnapshotTests: XCTestCase { + private var suiteName: String! + private var defaults: UserDefaults! + + override func setUp() { + super.setUp() + suiteName = "KeyLight.ConfigurationSnapshotTests.\(UUID().uuidString)" + defaults = UserDefaults(suiteName: suiteName) + defaults.removePersistentDomain(forName: suiteName) + } + + override func tearDown() { + defaults.removePersistentDomain(forName: suiteName) + defaults = nil + suiteName = nil + super.tearDown() + } + + @MainActor + func testRoundTripCapturesAllowlistedSetupAndPreservesExclusions() throws { + let settings = makeSettings() + let theme = Theme( + name: "Snapshot Theme", + colorHex: "123456", + opacity: 0.61, + refractionStrength: 1.7, + size: 91, + width: 1.4, + glowRoundness: 0.4, + glowFullness: 0.3, + fadeDuration: 1.8, + colorMode: .rainbow, + effectStyle: .physicalRefraction, + gradientStartHex: "112233", + gradientEndHex: "AABBCC" + ) + let layout = KeyMappingProfile( + name: "Snapshot Layout", + keyOffsets: [0: 0.2, 1: -0.1], + keyWidthOverrides: [0: 1.25] + ) + settings.savedThemes = [theme] + settings.activeThemeID = theme.id + settings.savedKeyMappingProfiles = [layout] + settings.activeLayoutID = layout.id + settings.displayLayoutProfileBindings = ["display-main": layout.id] + settings.overlayDisplaySelection = .specific("display-main") + settings.mirroredDisplayIDs = ["display-left", "display-right"] + settings.chordAppearance = ChordAppearance( + style: .independent, + intensityMultiplier: 1.35 + ) + settings.powerSavingMode = .off + settings.savedGradientPresets = [GradientPreset( + startHex: "010203", + endHex: "A0B0C0", + name: "Snapshot Gradient" + )] + settings.globalShortcut = GlobalShortcut( + keyCode: 7, + modifiers: 512 + )! + var effect = EffectConfiguration.defaultConfiguration( + for: .physicalRefraction + ) + effect.color.solidHex = "123456" + effect.opacity = 0.61 + effect.refractionStrength = 1.7 + effect.height = 91 + settings.effectConfiguration = effect + var classic = EffectConfiguration.defaultConfiguration( + for: .classicGlow + ) + classic.height = 42 + settings.setEffectConfiguration(classic, for: .classicGlow) + defaults.set(["0": 0.2, "1": -0.1], forKey: "KeyPositionOffsets") + defaults.set(["0": 1.25], forKey: "KeyWidthOverrides") + + // Explicit exclusions are changed after capture and must survive apply. + settings.isEnabled = false + defaults.set(true, forKey: "launchAtLogin") + settings.hasSeenPermissionExplanation = false + defaults.set(11, forKey: "onboardingCompletedVersion") + + let document = try settings.saveCurrentConfigurationSnapshot( + named: "Studio" + ) + let exported = try settings.exportConfigurationSnapshotData( + id: document.id + ) + let object = try XCTUnwrap( + JSONSerialization.jsonObject(with: exported) as? [String: Any] + ) + XCTAssertEqual( + object["kind"] as? String, + ConfigurationSnapshotDocument.documentKind + ) + XCTAssertEqual(object["version"] as? Int, 1) + let configuration = try XCTUnwrap( + object["configuration"] as? [String: Any] + ) + #if DEBUG + XCTAssertEqual( + Set(configuration.keys), + SettingsManager._testConfigurationSnapshotPayloadKeyRegistry + ) + let storageKeys = Set( + SettingsManager._testConfigurationSnapshotStorageKeyRegistry + ) + XCTAssertFalse(storageKeys.contains("isEnabled")) + XCTAssertFalse(storageKeys.contains("launchAtLogin")) + XCTAssertFalse(storageKeys.contains("hasSeenPermissionExplanation")) + XCTAssertFalse(storageKeys.contains("onboardingCompletedVersion")) + XCTAssertFalse(storageKeys.contains("configurationSnapshotsV1")) + XCTAssertFalse(storageKeys.contains("configurationSnapshotRecoveryV1")) + #endif + XCTAssertNil(configuration["isEnabled"]) + XCTAssertNil(configuration["launchAtLogin"]) + XCTAssertNil(configuration["permissions"]) + + settings.glowSize = 17 + settings.chordAppearance = .default + settings.powerSavingMode = .automatic + settings.overlayDisplaySelection = .automatic + settings.mirroredDisplayIDs = [] + settings.globalShortcut = .default + defaults.set(["0": -0.4], forKey: "KeyPositionOffsets") + settings.isEnabled = true + defaults.set(false, forKey: "launchAtLogin") + settings.hasSeenPermissionExplanation = true + defaults.set(99, forKey: "onboardingCompletedVersion") + + try settings.applyConfigurationSnapshot(id: document.id) + + XCTAssertEqual(settings.effectStyle, .physicalRefraction) + XCTAssertEqual(settings.glowSize, 91, accuracy: 0.000_001) + XCTAssertEqual( + settings.effectConfiguration(for: .classicGlow).height, + 42, + accuracy: 0.000_001 + ) + XCTAssertEqual(settings.chordAppearance.style, .independent) + XCTAssertEqual( + settings.chordAppearance.intensityMultiplier, + 1.35, + accuracy: 0.000_001 + ) + XCTAssertEqual(settings.powerSavingMode, .off) + XCTAssertEqual( + settings.overlayDisplaySelection, + .specific("display-main") + ) + XCTAssertEqual( + settings.mirroredDisplayIDs, + ["display-left", "display-right"] + ) + XCTAssertEqual( + settings.displayLayoutProfileBindings, + ["display-main": layout.id] + ) + XCTAssertEqual(settings.globalShortcut.keyCode, 7) + let restoredOffset = try XCTUnwrap( + defaults.dictionary(forKey: "KeyPositionOffsets")?["0"] + as? Double + ) + XCTAssertEqual(restoredOffset, 0.2, accuracy: 0.000_001) + XCTAssertTrue(settings.isEnabled) + XCTAssertFalse(defaults.bool(forKey: "launchAtLogin")) + XCTAssertTrue(settings.hasSeenPermissionExplanation) + XCTAssertEqual(defaults.integer(forKey: "onboardingCompletedVersion"), 99) + XCTAssertEqual(settings.configurationSnapshots.count, 1) + XCTAssertTrue(settings.hasPreviousConfigurationSnapshot) + } + + @MainActor + func testMalformedFutureUnknownAndOldFieldDocuments() throws { + let settings = makeSettings() + XCTAssertThrowsError( + try settings.decodeConfigurationSnapshotDocument(Data("not json".utf8)) + ) { error in + XCTAssertEqual(error as? ConfigurationSnapshotError, .invalidDocument) + } + + let future = ConfigurationSnapshotDocument( + version: 99, + name: "Future", + configuration: .default + ) + XCTAssertThrowsError( + try settings.decodeConfigurationSnapshotDocument( + JSONEncoder().encode(future) + ) + ) { error in + XCTAssertEqual( + error as? ConfigurationSnapshotError, + .unsupportedVersion(99) + ) + } + + let oldFieldJSON = """ + { + "kind": "keylightConfigurationSnapshot", + "version": 1, + "name": "Older", + "configuration": { + "unknownFutureField": {"ignored": true} + }, + "unknownTopLevelField": true + } + """ + let older = try settings.decodeConfigurationSnapshotDocument( + Data(oldFieldJSON.utf8) + ) + XCTAssertEqual(older.configuration.currentEffect, .default) + XCTAssertEqual(older.configuration.chordAppearance, .default) + XCTAssertEqual(older.configuration.powerSavingMode, .automatic) + XCTAssertEqual(older.configuration.primaryDisplaySelection, "automatic") + XCTAssertEqual(older.configuration.mirroredDisplayIDs, []) + + XCTAssertThrowsError( + try settings.decodeConfigurationSnapshotDocument( + Data( + count: SettingsManager + .maximumConfigurationSnapshotImportSize + 1 + ) + ) + ) { error in + XCTAssertEqual(error as? ConfigurationSnapshotError, .importTooLarge) + } + } + + @MainActor + func testImportConflictsReplaceOrSaveCopyWithoutApplying() throws { + let settings = makeSettings() + settings.glowColorHex = "111111" + let original = try settings.saveCurrentConfigurationSnapshot( + named: "Portable" + ) + let imported = try settings.decodeConfigurationSnapshotDocument( + settings.exportConfigurationSnapshotData(id: original.id) + ) + + settings.glowColorHex = "ABCDEF" + XCTAssertThrowsError( + try settings.importConfigurationSnapshot( + imported, + policy: .rejectConflict + ) + ) { error in + XCTAssertEqual( + error as? ConfigurationSnapshotError, + .nameConflict("Portable") + ) + } + _ = try settings.importConfigurationSnapshot( + imported, + policy: .replace + ) + let copy = try settings.importConfigurationSnapshot( + imported, + policy: .saveCopy + ) + + XCTAssertEqual(settings.glowColorHex, "ABCDEF") + XCTAssertEqual(settings.configurationSnapshots.count, 2) + XCTAssertEqual(copy.name, "Portable Copy") + + let renamed = try settings.renameConfigurationSnapshot( + id: copy.id, + to: "Travel" + ) + XCTAssertEqual(renamed.name, "Travel") + let deleted = try settings.deleteConfigurationSnapshot(id: copy.id) + XCTAssertEqual(settings.configurationSnapshots.count, 1) + try settings.restoreDeletedConfigurationSnapshot( + deleted.document, + at: deleted.index + ) + XCTAssertEqual(settings.configurationSnapshots.map(\.name), [ + "Portable", + "Travel" + ]) + } + + @MainActor + func testAtomicFailureRollsBackAndDoesNotReplaceRecovery() throws { + let settings = makeSettings(snapshotCommitVerifier: { _ in false }) + settings.glowOpacity = 0.2 + settings.mirroredDisplayIDs = ["before"] + let document = try settings.saveCurrentConfigurationSnapshot( + named: "Rollback" + ) + + settings.glowOpacity = 0.8 + settings.mirroredDisplayIDs = ["current"] + XCTAssertThrowsError( + try settings.applyConfigurationSnapshot(id: document.id) + ) { error in + XCTAssertEqual( + error as? ConfigurationSnapshotError, + .transactionFailed + ) + } + + XCTAssertEqual(settings.glowOpacity, 0.8, accuracy: 0.000_001) + XCTAssertEqual(settings.mirroredDisplayIDs, ["current"]) + XCTAssertFalse(settings.hasPreviousConfigurationSnapshot) + XCTAssertEqual(settings.configurationSnapshots.count, 1) + } + + @MainActor + func testRestorePreviousSetupSwapsForOneLevelUndoRedo() throws { + let settings = makeSettings() + settings.glowOpacity = 0.2 + let document = try settings.saveCurrentConfigurationSnapshot( + named: "Low Opacity" + ) + + settings.glowOpacity = 0.8 + settings.mirroredDisplayIDs = ["second"] + try settings.applyConfigurationSnapshot(id: document.id) + XCTAssertEqual(settings.glowOpacity, 0.2, accuracy: 0.000_001) + XCTAssertEqual(settings.mirroredDisplayIDs, []) + + try settings.restorePreviousConfigurationSnapshot() + XCTAssertEqual(settings.glowOpacity, 0.8, accuracy: 0.000_001) + XCTAssertEqual(settings.mirroredDisplayIDs, ["second"]) + + try settings.restorePreviousConfigurationSnapshot() + XCTAssertEqual(settings.glowOpacity, 0.2, accuracy: 0.000_001) + XCTAssertEqual(settings.mirroredDisplayIDs, []) + } + + @MainActor + func testModelReloadBroadcastsCompleteAppliedStateAndKeepsConflictingShortcut() throws { + let settings = makeSettings() + let capturedShortcut = GlobalShortcut( + keyCode: 7, + modifiers: 512 + )! + settings.globalShortcut = capturedShortcut + settings.overlayDisplaySelection = .specific("captured-primary") + settings.mirroredDisplayIDs = ["captured-mirror"] + settings.glowOpacity = 0.24 + let document = try settings.saveCurrentConfigurationSnapshot( + named: "Runtime Reload" + ) + + settings.globalShortcut = .default + settings.overlayDisplaySelection = .automatic + settings.mirroredDisplayIDs = [] + settings.glowOpacity = 0.91 + let model = KeyLightModel( + settings: settings, + feedbackAnnouncer: { _ in } + ) + var configurationReloadCount = 0 + var displaySelections: [OverlayDisplaySelection] = [] + var mirrorSelections: [Set] = [] + var shortcutSelections: [GlobalShortcut] = [] + model.connectRuntime( + onEnabledChange: { _ in }, + onConfigurationChange: { configurationReloadCount += 1 }, + onPermissionRequest: {}, + onPermissionRetry: {}, + onDisplaySelectionChange: { displaySelections.append($0) }, + onMirroredDisplaysChange: { mirrorSelections.append($0) }, + onShortcutChange: { shortcutSelections.append($0) } + ) + + try settings.applyConfigurationSnapshot(id: document.id) + model.reloadManagedConfiguration() + + XCTAssertEqual(configurationReloadCount, 1) + XCTAssertEqual(displaySelections, [.specific("captured-primary")]) + XCTAssertEqual(mirrorSelections, [["captured-mirror"]]) + XCTAssertEqual(shortcutSelections, [capturedShortcut]) + XCTAssertEqual(model.glowOpacity, 0.24, accuracy: 0.000_001) + XCTAssertEqual(model.globalShortcut, capturedShortcut) + + // Carbon may report a structurally valid shortcut as occupied. The + // applied setup remains intact and surfaces the conflict for editing. + model.updateGlobalHotKeyStatus(.unavailable) + XCTAssertEqual(model.globalHotKeyStatus, .unavailable) + XCTAssertEqual(model.globalShortcut, capturedShortcut) + XCTAssertEqual(settings.globalShortcut, capturedShortcut) + } + + @MainActor + func testInvalidShortcutAndPersistentLibraryLimitAreRejected() throws { + let settings = makeSettings() + var object = try XCTUnwrap( + JSONSerialization.jsonObject( + with: JSONEncoder().encode(ConfigurationSnapshotDocument( + name: "Invalid Shortcut", + configuration: .default + )) + ) as? [String: Any] + ) + var configuration = try XCTUnwrap( + object["configuration"] as? [String: Any] + ) + configuration["globalShortcut"] = [ + "keyCode": 9_999, + "modifiers": 0 + ] + object["configuration"] = configuration + let invalidShortcut = try JSONSerialization.data(withJSONObject: object) + XCTAssertThrowsError( + try settings.decodeConfigurationSnapshotDocument(invalidShortcut) + ) { error in + guard let snapshotError = error as? ConfigurationSnapshotError, + case .invalidConfiguration = snapshotError else { + return XCTFail("Expected invalid configuration, got \(error)") + } + } + + let keyCodes = KeyboardLayoutInfo.allKeys.map(\.id) + let layouts = (0..<128).map { index in + KeyMappingProfile( + name: "Large Layout \(index)", + keyOffsets: Dictionary(uniqueKeysWithValues: keyCodes.map { + ($0, CGFloat(index % 5) / 20) + }), + keyWidthOverrides: Dictionary( + uniqueKeysWithValues: keyCodes.map { + ($0, 1 + CGFloat(index % 3) / 10) + } + ) + ) + } + var large = ConfigurationSnapshotPayload.default + large.layoutProfiles = layouts + let largeDocument = ConfigurationSnapshotDocument( + name: "Large 0", + configuration: large + ) + var reachedLimit = false + for index in 0..<6 { + var copy = largeDocument + copy.id = UUID() + copy.name = "Large \(index)" + do { + _ = try settings.importConfigurationSnapshot( + copy, + policy: .rejectConflict + ) + } catch ConfigurationSnapshotError.persistentDataTooLarge { + reachedLimit = true + break + } + } + XCTAssertTrue(reachedLimit, "The 500 KB persistent-data limit must be enforced") + } + + @MainActor + private func makeSettings( + snapshotCommitVerifier: @escaping SettingsManager.SnapshotCommitVerifier = { _ in true } + ) -> SettingsManager { + SettingsManager( + preferencesStore: PreferencesStore( + userDefaults: defaults, + usesSystemPreferences: false + ), + snapshotCommitVerifier: snapshotCommitVerifier + ) } } @@ -169,9 +956,9 @@ final class ThemeAndLayoutProfileContractTests: XCTestCase { @MainActor func testThemeRenameRejectsCaseInsensitiveCollisions() { - let settings = SettingsManager.shared + let settings = SettingsManager() - let one = SettingsManager.Theme( + let one = Theme( name: "Alpha", colorHex: "111111", opacity: 0.7, @@ -184,7 +971,7 @@ final class ThemeAndLayoutProfileContractTests: XCTestCase { gradientStartHex: "3399FF", gradientEndHex: "00FF88" ) - let two = SettingsManager.Theme( + let two = Theme( name: "Beta", colorHex: "222222", opacity: 0.7, @@ -199,7 +986,7 @@ final class ThemeAndLayoutProfileContractTests: XCTestCase { ) settings.savedThemes = [one, two] - settings.renameTheme(from: "Alpha", to: "bEtA") + XCTAssertFalse(settings.renameTheme(from: "Alpha", to: "bEtA")) let names = settings.savedThemes.map(\.name) XCTAssertTrue(names.contains("Alpha")) @@ -208,18 +995,65 @@ final class ThemeAndLayoutProfileContractTests: XCTestCase { @MainActor func testLayoutProfileRenameRejectsCaseInsensitiveCollisions() { - let settings = SettingsManager.shared + let settings = SettingsManager() - let one = SettingsManager.KeyMappingProfile(name: "Desk", keyOffsets: [122: 0.1]) - let two = SettingsManager.KeyMappingProfile(name: "Laptop", keyOffsets: [120: -0.1]) + let one = KeyMappingProfile(name: "Desk", keyOffsets: [122: 0.1]) + let two = KeyMappingProfile(name: "Laptop", keyOffsets: [120: -0.1]) settings.savedKeyMappingProfiles = [one, two] - settings.renameKeyMappingProfile(from: "Desk", to: "lApToP") + XCTAssertFalse(settings.renameKeyMappingProfile(from: "Desk", to: "lApToP")) let names = settings.savedKeyMappingProfiles.map(\.name) XCTAssertTrue(names.contains("Desk")) XCTAssertTrue(names.contains("Laptop")) } + + @MainActor + func testRenameCollisionIsCheckedAfterNameLengthNormalization() { + let settings = SettingsManager() + let maximumName = String(repeating: "A", count: PersistenceValidation.maximumNameLength) + let existingTheme = Theme( + name: maximumName, + colorHex: "111111", + opacity: 0.7, + size: 60, + width: 1, + fadeDuration: 1, + colorMode: .solid, + gradientStartHex: nil, + gradientEndHex: nil + ) + let renamedTheme = Theme( + name: "Rename Me", + colorHex: "222222", + opacity: 0.7, + size: 60, + width: 1, + fadeDuration: 1, + colorMode: .solid, + gradientStartHex: nil, + gradientEndHex: nil + ) + settings.savedThemes = [existingTheme, renamedTheme] + + XCTAssertFalse( + settings.renameTheme( + from: renamedTheme.name, + to: maximumName + " suffix" + ) + ) + + let existingLayout = KeyMappingProfile(name: maximumName, keyOffsets: [:]) + let renamedLayout = KeyMappingProfile(name: "Rename Layout", keyOffsets: [:]) + settings.savedKeyMappingProfiles = [existingLayout, renamedLayout] + + XCTAssertFalse( + settings.renameKeyMappingProfile( + from: renamedLayout.name, + to: maximumName + " suffix" + ) + ) + } } final class KeyGeometryContractTests: XCTestCase { @@ -239,62 +1073,62 @@ final class KeyGeometryContractTests: XCTestCase { @MainActor func testKeyPositionNormalizationCanonicalizationAndClamp() { - let manager = KeyPositionManager.shared - manager.resetAllKeys() + let store = KeyLayoutStore(defaults: .standard, debounceInterval: 60) + store.resetAll() // Media aliases normalize to canonical function keys, with canonical values winning conflicts. - manager.replaceAllOffsets([ + store.replaceAllOffsets([ 500: 0.4, 122: 0.1, 126: 1.0, 9999: 0.2 ]) - let exported = manager.exportOffsets() + let exported = store.exportOffsets() XCTAssertEqual(exported["122"], 0.1) XCTAssertNil(exported["500"]) XCTAssertEqual(exported["126"], 0.5) // clamped from 1.0 XCTAssertNil(exported["9999"]) - manager.replaceAllOffsets([500: 0.4]) - let aliasOnlyExport = manager.exportOffsets() + store.replaceAllOffsets([500: 0.4]) + let aliasOnlyExport = store.exportOffsets() XCTAssertEqual(aliasOnlyExport["122"], 0.4) XCTAssertNil(aliasOnlyExport["500"]) } @MainActor func testKeyWidthNormalizationCanonicalizationClampUndoRedo() { - let manager = KeyWidthManager.shared - manager.resetAllKeys() + let store = KeyLayoutStore(defaults: .standard, debounceInterval: 60) + store.resetAll() // Media aliases normalize to canonical function keys, with canonical values winning conflicts. - manager.replaceAllOverrides([ + store.replaceAllWidthMultipliers([ 500: 2.5, 122: 0.2, 126: 8.0, 9999: 1.5 ]) - var exported = manager.exportOverrides() + var exported = store.exportWidthMultipliers() XCTAssertEqual(exported["122"], 0.2) XCTAssertNil(exported["500"]) XCTAssertEqual(exported["126"], 5.0) // clamped max XCTAssertNil(exported["9999"]) - manager.setWidthMultiplier(1.8, for: 122) - exported = manager.exportOverrides() + store.setWidthMultiplier(1.8, for: 122) + exported = store.exportWidthMultipliers() XCTAssertEqual(exported["122"], 1.8) - manager.undo() - exported = manager.exportOverrides() + store.undo() + exported = store.exportWidthMultipliers() XCTAssertEqual(exported["122"], 0.2) - manager.redo() - exported = manager.exportOverrides() + store.redo() + exported = store.exportWidthMultipliers() XCTAssertEqual(exported["122"], 1.8) - manager.replaceAllOverrides([500: 2.5]) - let aliasOnlyExport = manager.exportOverrides() + store.replaceAllWidthMultipliers([500: 2.5]) + let aliasOnlyExport = store.exportWidthMultipliers() XCTAssertEqual(aliasOnlyExport["122"], 2.5) XCTAssertNil(aliasOnlyExport["500"]) } @@ -308,7 +1142,7 @@ final class KeyGeometryContractTests: XCTestCase { @MainActor func testImportedStringKeyOffsetsNormalizationContract() { - let normalized = KeyPositionManager.normalizedImportedOffsets(from: [ + let normalized = KeyLayoutStore.normalizedImportedOffsets(from: [ "500": 0.4, "122": 0.1, "125": -0.8, // lower clamp @@ -328,32 +1162,30 @@ final class KeyGeometryContractTests: XCTestCase { @MainActor func testMediaKeyFallbackUsesFunctionOverridesWhenMediaOverridesMissing() { - let positionManager = KeyPositionManager.shared - let widthManager = KeyWidthManager.shared - positionManager.resetAllKeys() - widthManager.resetAllKeys() + let store = KeyLayoutStore(defaults: .standard, debounceInterval: 60) + store.resetAll() - positionManager.setOffset(0.08, for: 122) // F1 - widthManager.setWidthMultiplier(1.6, for: 122) + store.setOffset(0.08, for: 122) // F1 + store.setWidthMultiplier(1.6, for: 122) - let fallbackPosition = positionManager.adjustedPosition(for: 500, originalPosition: 0.195) + let fallbackPosition = store.adjustedPosition(for: 500, originalPosition: 0.195) XCTAssertEqual(fallbackPosition, 0.275, accuracy: 0.0001) - let fallbackWidth = widthManager.effectiveWidth(for: 500, defaultWidth: 0.8) + let fallbackWidth = store.effectiveWidth(for: 500, defaultWidth: 0.8) XCTAssertEqual(fallbackWidth, 1.28, accuracy: 0.0001) // Setting media aliases writes canonical function-key overrides. - positionManager.setOffset(-0.03, for: 500) - widthManager.setWidthMultiplier(1.1, for: 500) + store.setOffset(-0.03, for: 500) + store.setWidthMultiplier(1.1, for: 500) - let directPosition = positionManager.adjustedPosition(for: 500, originalPosition: 0.195) + let directPosition = store.adjustedPosition(for: 500, originalPosition: 0.195) XCTAssertEqual(directPosition, 0.165, accuracy: 0.0001) - let directWidth = widthManager.effectiveWidth(for: 500, defaultWidth: 0.8) + let directWidth = store.effectiveWidth(for: 500, defaultWidth: 0.8) XCTAssertEqual(directWidth, 0.88, accuracy: 0.0001) - let exportedOffsets = positionManager.exportOffsets() - let exportedWidths = widthManager.exportOverrides() + let exportedOffsets = store.exportOffsets() + let exportedWidths = store.exportWidthMultipliers() XCTAssertEqual(exportedOffsets["122"], -0.03) XCTAssertEqual(exportedWidths["122"], 1.1) XCTAssertNil(exportedOffsets["500"]) @@ -378,45 +1210,241 @@ final class ThemeAndLayoutTransferTests: XCTestCase { @MainActor func testThemeStringRoundTripAndSanitization() throws { - let settings = SettingsManager.shared - let raw = SettingsManager.Theme( + let settings = SettingsManager() + let raw = Theme( name: " Imported Theme ", colorHex: "GGGGGG", opacity: 3.0, + refractionStrength: 3.0, size: 500.0, width: 0.05, glowRoundness: -1.0, glowFullness: 3.0, fadeDuration: .infinity, colorMode: .rainbow, + effectStyle: .physicalRefraction, + shapeProfile: .currentWave, gradientStartHex: "12", gradientEndHex: "!" ) let serialized = try XCTUnwrap(settings.exportThemeString(raw)) - XCTAssertTrue(serialized.hasPrefix("keylight-theme-v1;")) + XCTAssertTrue(serialized.hasPrefix("keylight-theme-v5;")) XCTAssertTrue(serialized.contains("name=Imported%20Theme")) XCTAssertTrue(serialized.contains("mode=rainbow")) + XCTAssertTrue(serialized.contains("effect=physicalRefraction")) + XCTAssertTrue(serialized.contains("shape=currentWave")) + XCTAssertTrue(serialized.contains("refraction=2.5000")) let imported = try settings.importThemeString(serialized) XCTAssertEqual(imported.name, "Imported Theme") XCTAssertEqual(imported.colorHex, "68B8FF") XCTAssertEqual(imported.opacity, 1.0) + XCTAssertEqual(imported.refractionStrength, 2.5) XCTAssertEqual(imported.size, 200.0) XCTAssertEqual(imported.width, 0.1) XCTAssertEqual(imported.glowRoundness, 0.0) XCTAssertEqual(imported.glowFullness, 1.0) XCTAssertEqual(imported.fadeDuration, 1.0004, accuracy: 0.0001) + XCTAssertEqual(imported.effectStyle, .physicalRefraction) + XCTAssertEqual(imported.shapeProfile, .currentWave) XCTAssertEqual(imported.gradientStartHex, "120000") XCTAssertEqual(imported.gradientEndHex, "68B8FF") } + @MainActor + func testThemeStringRoundTripPreservesTinySystemGlassHeight() throws { + let settings = SettingsManager() + let theme = Theme( + name: "Tiny Glass", + colorHex: "68B8FF", + opacity: 0.8, + size: 4, + width: 1, + glowRoundness: 0.7, + glowFullness: 0.6, + fadeDuration: 1, + colorMode: .solid, + effectStyle: .systemGlass, + gradientStartHex: nil, + gradientEndHex: nil + ) + + let encoded = try XCTUnwrap(settings.exportThemeString(theme)) + XCTAssertEqual(try settings.importThemeString(encoded).size, 4) + } + + @MainActor + func testLegacySavedThemeJSONDefaultsToClassicGlow() throws { + let legacyJSON = """ + [ + { + "name": "Legacy JSON Theme", + "colorHex": "68B8FF", + "opacity": 0.8, + "size": 80.0, + "width": 1.0, + "glowRoundness": 0.7, + "glowFullness": 0.6, + "fadeDuration": 1.0, + "colorMode": "positionGradient", + "gradientStartHex": "68B8FF", + "gradientEndHex": "00E69A" + } + ] + """ + UserDefaults.standard.set(Data(legacyJSON.utf8), forKey: "savedThemes") + + let imported = try XCTUnwrap(SettingsManager().savedThemes.first) + XCTAssertEqual(imported.name, "Legacy JSON Theme") + XCTAssertEqual(imported.effectStyle, .classicGlow) + XCTAssertEqual(imported.shapeProfile, .currentWave) + XCTAssertEqual(imported.refractionStrength, 1.0) + } + + @MainActor + func testSavedThemeJSONMigratesRetiredLiquidGlassToSystemGlass() throws { + let settings = SettingsManager() + let theme = Theme( + name: "Native Glass", + colorHex: "68B8FF", + opacity: 0.8, + size: 80, + width: 1, + glowRoundness: 0.7, + glowFullness: 0.6, + fadeDuration: 1, + colorMode: .positionGradient, + effectStyle: .liquidGlass, + gradientStartHex: "68B8FF", + gradientEndHex: "00E69A" + ) + + settings.savedThemes = [theme] + + let reloaded = try XCTUnwrap(settings.savedThemes.first) + XCTAssertEqual(reloaded.name, "Native Glass") + XCTAssertEqual(reloaded.effectStyle, .systemGlass) + } + + @MainActor + func testThemeStringRoundTripPreservesSystemGlassRoute() throws { + let settings = SettingsManager() + let theme = Theme( + name: "System Optics", + colorHex: "68B8FF", + opacity: 0.8, + size: 80, + width: 1, + glowRoundness: 0.7, + glowFullness: 0.6, + fadeDuration: 1, + colorMode: .positionGradient, + effectStyle: .systemGlass, + gradientStartHex: "68B8FF", + gradientEndHex: "00E69A" + ) + + let encoded = try XCTUnwrap(settings.exportThemeString(theme)) + XCTAssertTrue(encoded.contains("effect=systemGlass")) + XCTAssertEqual( + try settings.importThemeString(encoded).effectStyle, + .systemGlass + ) + } + + @MainActor + func testThemeStringV5MigratesClassicPlusAndV4StillRejectsIt() throws { + let settings = SettingsManager() + let theme = Theme( + name: "Classic Plus", + colorHex: "68B8FF", + opacity: 0.8, + size: 80, + width: 1, + glowRoundness: 0.7, + glowFullness: 0.6, + fadeDuration: 1, + colorMode: .positionGradient, + effectStyle: .classicPlus, + gradientStartHex: "68B8FF", + gradientEndHex: "00E69A" + ) + + let encoded = try XCTUnwrap(settings.exportThemeString(theme)) + XCTAssertTrue(encoded.hasPrefix("keylight-theme-v5;")) + XCTAssertTrue(encoded.contains("effect=classicGlow")) + XCTAssertEqual( + try settings.importThemeString(encoded).effectStyle, + .classicGlow + ) + + let previewEraV5 = encoded.replacingOccurrences( + of: "effect=classicGlow", + with: "effect=classicPlus" + ) + XCTAssertEqual( + try settings.importThemeString(previewEraV5).effectStyle, + .classicGlow + ) + + let legacySmuggle = previewEraV5.replacingOccurrences( + of: "keylight-theme-v5;", + with: "keylight-theme-v4;" + ) + XCTAssertThrowsError(try settings.importThemeString(legacySmuggle)) + } + + @MainActor + func testThemeStringV1ImportDefaultsToClassicGlow() throws { + let v1 = "keylight-theme-v1;name=Legacy;mode=solid;color=68B8FF;opacity=0.5000;size=60.0000;width=1.0000;round=0.7000;hard=0.6000;fade=1.0000;gstart=68B8FF;gend=00E69A" + let imported = try SettingsManager().importThemeString(v1) + XCTAssertEqual(imported.name, "Legacy") + XCTAssertEqual(imported.effectStyle, .classicGlow) + XCTAssertEqual(imported.shapeProfile, .currentWave) + XCTAssertEqual(imported.refractionStrength, 1.0) + } + + @MainActor + func testThemeStringV2MigratesRetiredEffectAndDefaultsToCurrentWave() throws { + let v2 = "keylight-theme-v2;name=V2%20Glass;mode=solid;effect=liquidGlass;color=68B8FF;opacity=0.5000;size=60.0000;width=1.0000;round=0.7000;hard=0.6000;fade=1.0000;gstart=68B8FF;gend=00E69A" + let imported = try SettingsManager().importThemeString(v2) + XCTAssertEqual(imported.name, "V2 Glass") + XCTAssertEqual(imported.effectStyle, .systemGlass) + XCTAssertEqual(imported.shapeProfile, .currentWave) + XCTAssertEqual(imported.refractionStrength, 1.0) + } + + @MainActor + func testPreviewEraShapeValuesNormalizeToCurrentWave() throws { + let settings = SettingsManager() + let legacyV3 = "keylight-theme-v3;name=Old%20Shape;mode=solid;effect=liquidGlass;shape=opticalDome;color=68B8FF;opacity=0.5;size=60;width=1;round=0.7;hard=0.6;fade=1;gstart=68B8FF;gend=00E69A" + + XCTAssertEqual( + try settings.importThemeString(legacyV3).shapeProfile, + .currentWave + ) + XCTAssertEqual( + try settings.importThemeString(legacyV3).refractionStrength, + 1.0 + ) + + UserDefaults.standard.set( + "softPillow", + forKey: "surfaceShapeProfile" + ) + XCTAssertEqual(settings.surfaceShapeProfile, .currentWave) + } + @MainActor func testThemeStringRejectsMalformedAndOversizedInput() { - let settings = SettingsManager.shared + let settings = SettingsManager() XCTAssertThrowsError(try settings.importThemeString("not-a-keylight-theme")) XCTAssertThrowsError(try settings.importThemeString("keylight-theme-v1;name=test;name=dup")) XCTAssertThrowsError(try settings.importThemeString("keylight-theme-v1;name=test;mode=solid")) + XCTAssertThrowsError(try settings.importThemeString("keylight-theme-v2;name=test;mode=solid;effect=unknown;color=68B8FF;opacity=0.5;size=60;width=1;round=0.7;hard=0.6;fade=1;gstart=68B8FF;gend=00E69A")) + XCTAssertThrowsError(try settings.importThemeString("keylight-theme-v3;name=test;mode=solid;effect=liquidGlass;shape=unknown;color=68B8FF;opacity=0.5;size=60;width=1;round=0.7;hard=0.6;fade=1;gstart=68B8FF;gend=00E69A")) + XCTAssertThrowsError(try settings.importThemeString("keylight-theme-v4;name=test;mode=solid;effect=physicalRefraction;shape=currentWave;color=68B8FF;opacity=0.5;size=60;width=1;round=0.7;hard=0.6;fade=1;gstart=68B8FF;gend=00E69A")) let oversized = "keylight-theme-v1;" + String(repeating: "a", count: 20_000) XCTAssertThrowsError(try settings.importThemeString(oversized)) @@ -443,7 +1471,7 @@ final class ThemeAndLayoutTransferTests: XCTestCase { ] let data = try JSONSerialization.data(withJSONObject: payload) - let imported = try SettingsManager.shared.importLayoutProfileData(data) + let imported = try SettingsManager().importLayoutProfileData(data) XCTAssertEqual(imported.name, "Imported Layout") XCTAssertEqual(imported.keyOffsets[122], 0.1) XCTAssertEqual(imported.keyOffsets[125], -0.5) @@ -459,27 +1487,12 @@ final class ThemeAndLayoutTransferTests: XCTestCase { @MainActor func testLayoutProfileImportRejectsInvalidSchema() { let invalid = Data("{\"version\":1}".utf8) - XCTAssertThrowsError(try SettingsManager.shared.importLayoutProfileData(invalid)) - } - - @MainActor - func testVariantLayoutTemplateImportsOffsetsAndWidths() throws { - let profileURL = repositoryRootURL() - .appendingPathComponent("docs/variants/macbook-air-13-m4/keylight-layout-profile-template.json") - let data = try Data(contentsOf: profileURL) - let imported = try SettingsManager.shared.importLayoutProfileData(data) - - XCTAssertEqual(try XCTUnwrap(imported.keyOffsets[10]), 0.012, accuracy: 0.0001) - XCTAssertEqual(try XCTUnwrap(imported.keyOffsets[44]), -0.008, accuracy: 0.0001) - XCTAssertEqual(try XCTUnwrap(imported.keyOffsets[123]), 0.006, accuracy: 0.0001) - XCTAssertEqual(try XCTUnwrap(imported.keyWidthOverrides[10]), 1.12, accuracy: 0.0001) - XCTAssertEqual(try XCTUnwrap(imported.keyWidthOverrides[49]), 1.03, accuracy: 0.0001) - XCTAssertEqual(try XCTUnwrap(imported.keyWidthOverrides[123]), 0.95, accuracy: 0.0001) + XCTAssertThrowsError(try SettingsManager().importLayoutProfileData(invalid)) } @MainActor func testFreshInstallSeedAppliesCurrentThemeAndDefaultAirLayout() throws { - let settings = SettingsManager.shared + let settings = SettingsManager() settings._testApplyDefaultExperienceSeedIfNeeded() XCTAssertEqual(settings.currentThemeName, "current") @@ -492,6 +1505,7 @@ final class ThemeAndLayoutTransferTests: XCTestCase { XCTAssertEqual(activeTheme.glowRoundness, 0.7069, accuracy: 0.0001) XCTAssertEqual(activeTheme.glowFullness, 0.6046, accuracy: 0.0001) XCTAssertEqual(activeTheme.fadeDuration, 1.0004, accuracy: 0.0001) + XCTAssertEqual(activeTheme.effectStyle, .classicGlow) XCTAssertEqual(activeTheme.gradientStartHex, "68B8FF") XCTAssertEqual(activeTheme.gradientEndHex, "00E69A") @@ -508,8 +1522,8 @@ final class ThemeAndLayoutTransferTests: XCTestCase { @MainActor func testFreshInstallSeedDoesNotOverrideExistingUserData() { - let settings = SettingsManager.shared - let existingTheme = SettingsManager.Theme( + let settings = SettingsManager() + let existingTheme = Theme( name: "Existing", colorHex: "FFFFFF", opacity: 0.5, @@ -525,7 +1539,7 @@ final class ThemeAndLayoutTransferTests: XCTestCase { settings.savedThemes = [existingTheme] settings.currentThemeName = existingTheme.name - let existingLayout = SettingsManager.KeyMappingProfile( + let existingLayout = KeyMappingProfile( name: "Existing Layout", keyOffsets: [122: 0.02], keyWidthOverrides: [122: 1.2] @@ -547,7 +1561,7 @@ final class ThemeAndLayoutTransferTests: XCTestCase { // Simulate existing non-layout state so strict fresh-install seeding is skipped. defaults.set("ABCDEF", forKey: "glowColorHex") - let settings = SettingsManager.shared + let settings = SettingsManager() settings.savedKeyMappingProfiles = [] settings.currentKeyMappingProfileName = "None" defaults.removeObject(forKey: "KeyPositionOffsets") @@ -566,9 +1580,9 @@ final class ThemeAndLayoutTransferTests: XCTestCase { @MainActor func testLayoutMigrationDoesNotOverrideExistingCustomLayout() { let defaults = UserDefaults.standard - let settings = SettingsManager.shared + let settings = SettingsManager() - let existingLayout = SettingsManager.KeyMappingProfile( + let existingLayout = KeyMappingProfile( name: "Existing Layout", keyOffsets: [122: 0.02], keyWidthOverrides: [122: 1.2] @@ -589,8 +1603,9 @@ final class ThemeAndLayoutTransferTests: XCTestCase { @MainActor func testBundledLayoutPresetListAndImport() throws { - let settings = SettingsManager.shared + let settings = SettingsManager() let presets = settings.bundledLayoutPresets() + XCTAssertEqual(presets.count, 5) let airPreset = try XCTUnwrap(presets.first(where: { $0.id == "macbook-air-13-m4-default" })) let imported = try settings.importBundledLayoutPreset(airPreset) @@ -602,12 +1617,26 @@ final class ThemeAndLayoutTransferTests: XCTestCase { let importedPro = try settings.importBundledLayoutPreset(proPreset) XCTAssertEqual(importedPro.name, "MacBook Pro 14 M4") XCTAssertFalse(importedPro.keyOffsets.isEmpty) + + let ansiPreset = try XCTUnwrap(presets.first(where: { $0.id == "macbook-ansi-baseline" })) + let importedANSI = try settings.importBundledLayoutPreset(ansiPreset) + XCTAssertEqual(importedANSI.name, "MacBook ANSI Baseline") + XCTAssertTrue(importedANSI.keyOffsets.isEmpty) + XCTAssertTrue(importedANSI.keyWidthOverrides.isEmpty) + + let isoPreset = try XCTUnwrap(presets.first(where: { $0.id == "macbook-iso-baseline" })) + XCTAssertTrue(try settings.importBundledLayoutPreset(isoPreset).keyOffsets.isEmpty) + + let compactPreset = try XCTUnwrap(presets.first(where: { + $0.id == "magic-keyboard-compact-baseline" + })) + XCTAssertTrue(try settings.importBundledLayoutPreset(compactPreset).keyOffsets.isEmpty) } @MainActor func testBundledLayoutProfileSeedAddsMissingMBPWithoutChangingActiveAir() throws { let defaults = UserDefaults.standard - let settings = SettingsManager.shared + let settings = SettingsManager() let presets = settings.bundledLayoutPresets() let airPreset = try XCTUnwrap(presets.first(where: { $0.id == "macbook-air-13-m4-default" })) let airProfile = try settings.importBundledLayoutPreset(airPreset, forcedName: "MacBook Air 13 M4 Default") @@ -628,7 +1657,7 @@ final class ThemeAndLayoutTransferTests: XCTestCase { @MainActor func testBundledLayoutProfileSeedDoesNotRecreateAfterUserDeletion() throws { let defaults = UserDefaults.standard - let settings = SettingsManager.shared + let settings = SettingsManager() let presets = settings.bundledLayoutPresets() let airPreset = try XCTUnwrap(presets.first(where: { $0.id == "macbook-air-13-m4-default" })) let airProfile = try settings.importBundledLayoutPreset(airPreset, forcedName: "MacBook Air 13 M4 Default") @@ -644,345 +1673,207 @@ final class ThemeAndLayoutTransferTests: XCTestCase { } } -final class KeyboardMonitorContractTests: XCTestCase { - func testMediaVirtualKeyResolutionAndDedupeWindow() { - let monitor = KeyboardMonitor { _ in } - - XCTAssertEqual(monitor._testResolveVirtualKeyCode(nxCode: 0), 520) // sound up - XCTAssertEqual(monitor._testResolveVirtualKeyCode(nxCode: 3), 500) // brightness down - XCTAssertEqual(monitor._testResolveVirtualKeyCode(nxCode: 7), 518) // mute - XCTAssertNil(monitor._testResolveVirtualKeyCode(nxCode: 4)) - XCTAssertNil(monitor._testResolveVirtualKeyCode(nxCode: 5)) - XCTAssertNil(monitor._testResolveVirtualKeyCode(nxCode: 6)) - XCTAssertNil(monitor._testResolveVirtualKeyCode(nxCode: 19)) - XCTAssertNil(monitor._testResolveVirtualKeyCode(nxCode: 20)) - XCTAssertNil(monitor._testResolveVirtualKeyCode(nxCode: 999)) - - let t0: CFAbsoluteTime = 1000 - XCTAssertFalse(monitor._testShouldDedupeMediaEvent(keyCode: 516, isKeyDown: true, now: t0)) - XCTAssertTrue(monitor._testShouldDedupeMediaEvent(keyCode: 516, isKeyDown: true, now: t0 + 0.01)) - XCTAssertFalse(monitor._testShouldDedupeMediaEvent(keyCode: 516, isKeyDown: true, now: t0 + 0.05)) - - // HID wins when both sources report the same physical press in a short window. - XCTAssertFalse( - monitor._testShouldDedupeMediaEventWithSource( - keyCode: 517, - isKeyDown: true, - source: "hid", - now: t0 + 1.0 - ) - ) - XCTAssertTrue( - monitor._testShouldDedupeMediaEventWithSource( - keyCode: 517, - isKeyDown: true, - source: "system", - now: t0 + 1.01 - ) - ) - } - - func testDeterministicTopRowMappingAndUnknownRawBehavior() { - let monitor = KeyboardMonitor { _ in } - +final class InputMonitoringReconciliationTests: XCTestCase { + func testDeniedPermissionRequestsOnlyWhenExplicitlyAllowed() { XCTAssertEqual( - monitor._testResolveKeyboardEventKeyCode( - rawKeyCode: 160, - charactersIgnoringModifiers: String(UnicodeScalar(0xF706)!) + InputMonitoringReconciliationResolver.resolve( + installationIssue: nil, + authorized: false, + allowRequest: true, + isEnabled: true, + monitorExists: false, + monitorRunning: false ), - 99 // F3 - ) - XCTAssertEqual( - monitor._testResolveKeyboardEventConfidence( - rawKeyCode: 160, - charactersIgnoringModifiers: String(UnicodeScalar(0xF706)!) - ), - "high" - ) - XCTAssertEqual( - monitor._testResolveKeyboardEventKeyCode( - rawKeyCode: 161, - charactersIgnoringModifiers: String(UnicodeScalar(0xF707)!) - ), - 118 // F4 - ) - XCTAssertEqual( - monitor._testResolveKeyboardEventKeyCode( - rawKeyCode: 162, - charactersIgnoringModifiers: String(UnicodeScalar(0xF708)!) - ), - 96 // F5 + .requestPermission ) XCTAssertEqual( - monitor._testResolveKeyboardEventKeyCode( - rawKeyCode: 163, - charactersIgnoringModifiers: "A" + InputMonitoringReconciliationResolver.resolve( + installationIssue: nil, + authorized: false, + allowRequest: false, + isEnabled: true, + monitorExists: true, + monitorRunning: true ), - 163 + .settle(state: .permissionRequired, stopMonitor: true) ) XCTAssertEqual( - monitor._testResolveKeyboardEventKeyCode( - rawKeyCode: 122, - charactersIgnoringModifiers: String(UnicodeScalar(0xF706)!) + InputMonitoringReconciliationResolver.resolve( + installationIssue: nil, + authorized: false, + allowRequest: true, + isEnabled: false, + monitorExists: false, + monitorRunning: false ), - 122 - ) - - let trustedRawMappings: [(UInt16, UInt16)] = [ - (145, 122), - (144, 120), - (160, 99), - (131, 118), - (177, 96), - (176, 97), - (173, 98), - (174, 100), - (175, 101), - (74, 109), - (73, 103), - (72, 111) - ] - - for (raw, expected) in trustedRawMappings { - XCTAssertEqual( - monitor._testResolveKeyboardEventKeyCode( - rawKeyCode: raw, - charactersIgnoringModifiers: nil - ), - expected - ) - XCTAssertEqual( - monitor._testResolveKeyboardEventConfidence( - rawKeyCode: raw, - charactersIgnoringModifiers: nil - ), - "high" - ) - } - - // Unknown raw key remains unresolved. - XCTAssertEqual( - monitor._testResolveKeyboardEventConfidence( - rawKeyCode: 163, - charactersIgnoringModifiers: nil - ), - "unknown" + .requestPermission ) + } - // NSEvent specialKey provides deterministic top-row mapping for hardware-specific raw codes. + func testInvalidInstallationBlocksPermissionRequest() { XCTAssertEqual( - monitor._testResolveKeyboardEventKeyCodeWithSpecialKey( - rawKeyCode: 160, - specialKeyRawValue: NSEvent.SpecialKey.f4.rawValue + InputMonitoringReconciliationResolver.resolve( + installationIssue: "Running from a disk image", + authorized: false, + allowRequest: true, + isEnabled: true, + monitorExists: false, + monitorRunning: false ), - 118 + .settle(state: .permissionRequired, stopMonitor: false) ) + } + + func testAuthorizedDisabledEffectStopsMonitorAndRemainsAuthorized() { XCTAssertEqual( - monitor._testResolveKeyboardEventKeyCodeWithSpecialKey( - rawKeyCode: 177, - specialKeyRawValue: NSEvent.SpecialKey.f5.rawValue + InputMonitoringReconciliationResolver.resolve( + installationIssue: nil, + authorized: true, + allowRequest: false, + isEnabled: false, + monitorExists: true, + monitorRunning: true ), - 96 + .settle(state: .authorized, stopMonitor: true) ) + } + + func testAuthorizedRunningMonitorRemainsActive() { XCTAssertEqual( - monitor._testResolveKeyboardEventKeyCodeWithSpecialKey( - rawKeyCode: 173, - specialKeyRawValue: NSEvent.SpecialKey.f7.rawValue + InputMonitoringReconciliationResolver.resolve( + installationIssue: nil, + authorized: true, + allowRequest: false, + isEnabled: true, + monitorExists: true, + monitorRunning: true ), - 98 + .settle(state: .active, stopMonitor: false) ) + } + + func testAuthorizedMissingOrDeadMonitorStartsTruthfully() { XCTAssertEqual( - monitor._testResolveKeyboardEventKeyCodeWithSpecialKey( - rawKeyCode: 175, - specialKeyRawValue: NSEvent.SpecialKey.f9.rawValue + InputMonitoringReconciliationResolver.resolve( + installationIssue: nil, + authorized: true, + allowRequest: false, + isEnabled: true, + monitorExists: false, + monitorRunning: false ), - 101 + .startMonitor(stopExisting: false) ) XCTAssertEqual( - monitor._testResolveKeyboardEventConfidenceWithSpecialKey( - rawKeyCode: 173, - specialKeyRawValue: NSEvent.SpecialKey.f7.rawValue + InputMonitoringReconciliationResolver.resolve( + installationIssue: nil, + authorized: true, + allowRequest: false, + isEnabled: true, + monitorExists: true, + monitorRunning: false ), - "high" + .startMonitor(stopExisting: true) ) + XCTAssertEqual(InputMonitoringReconciliationResolver.stateAfterMonitorStart(succeeded: true), .active) + XCTAssertEqual(InputMonitoringReconciliationResolver.stateAfterMonitorStart(succeeded: false), .monitorUnavailable) } - func testUnresolvedKeyboardRawDoesNotSuppressMediaSource() { - let monitor = KeyboardMonitor { _ in } - let t0: CFAbsoluteTime = 2100 - - XCTAssertEqual( - monitor._testResolveKeyboardEventConfidence( - rawKeyCode: 163, - charactersIgnoringModifiers: nil - ), - "unknown" + @MainActor + func testInstallationGuardRecognizesDiskImagesAndFinderRenames() { + XCTAssertNotNil( + PermissionManager.installationIssue( + for: URL(fileURLWithPath: "/Volumes/KeyLight 2.0.0/KeyLight.app") + ) ) - - // No keyboard-first suppression: first media event for this key/state should pass. - XCTAssertFalse( - monitor._testShouldDedupeMediaEventWithSource( - keyCode: 506, - isKeyDown: true, - source: "system", - now: t0 + 0.01 + XCTAssertNotNil( + PermissionManager.installationIssue( + for: URL(fileURLWithPath: "/Applications/KeyLight 2.app") ) ) - // Duplicate key/state in-window still dedupes normally. - XCTAssertTrue( - monitor._testShouldDedupeMediaEventWithSource( - keyCode: 506, - isKeyDown: true, - source: "system", - now: t0 + 0.02 + XCTAssertNotNil( + PermissionManager.installationIssue( + for: URL(fileURLWithPath: "/private/tmp/Downloads/KeyLight.app") ) ) - } - - func testTrustedRawTopRowMediaCodesMapToFunctionKeys() { - let monitor = KeyboardMonitor { _ in } - - let rawMediaCodesToExpected: [(UInt16, UInt16)] = [ - (145, 122), - (144, 120), - (160, 99), - (131, 118), - (177, 96), - (176, 97), - (173, 98), - (174, 100), - (175, 101), - (74, 109), - (73, 103), - (72, 111) - ] - for (code, expected) in rawMediaCodesToExpected { - XCTAssertEqual( - monitor._testResolveKeyboardEventKeyCode( - rawKeyCode: code, - charactersIgnoringModifiers: nil - ), - expected, - "Raw top-row code \(code) should resolve to F-key \(expected)" + XCTAssertNotNil( + PermissionManager.installationIssue( + for: URL(fileURLWithPath: "/private/var/folders/example/AppTranslocation/KeyLight.app") ) - XCTAssertEqual( - monitor._testResolveKeyboardEventConfidence( - rawKeyCode: code, - charactersIgnoringModifiers: nil - ), - "high", - "Raw top-row code \(code) should be trusted via explicit map" + ) + XCTAssertNil( + PermissionManager.installationIssue( + for: URL(fileURLWithPath: "/Applications/KeyLight.app") ) - } - } - - func testTrustedMetadataStillMapsF4F5F7F9() { - let monitor = KeyboardMonitor { _ in } - - XCTAssertEqual( - monitor._testResolveKeyboardEventConfidenceWithSpecialKey( - rawKeyCode: 160, - specialKeyRawValue: NSEvent.SpecialKey.f4.rawValue - ), - "high" ) - XCTAssertEqual( - monitor._testResolveKeyboardEventKeyCodeWithSpecialKey( - rawKeyCode: 160, - specialKeyRawValue: NSEvent.SpecialKey.f4.rawValue - ), - 118 + XCTAssertNil( + PermissionManager.installationIssue( + for: URL(fileURLWithPath: "/tmp/DerivedData/Build/Products/Debug/KeyLight.app"), + bundleIdentifier: "com.keylight.app.debug" + ) ) - XCTAssertEqual( - monitor._testResolveKeyboardEventKeyCodeWithSpecialKey( - rawKeyCode: 177, - specialKeyRawValue: NSEvent.SpecialKey.f5.rawValue - ), - 96 + XCTAssertNotNil( + PermissionManager.installationIssue( + for: URL(fileURLWithPath: "/Volumes/Debug/KeyLight.app"), + bundleIdentifier: "com.keylight.app.debug" + ) ) - XCTAssertEqual( - monitor._testResolveKeyboardEventKeyCodeWithSpecialKey( - rawKeyCode: 173, - specialKeyRawValue: NSEvent.SpecialKey.f7.rawValue - ), - 98 + + XCTAssertNil( + PermissionManager.installationIssue( + for: URL(fileURLWithPath: "/Applications/KeyLight 2.0.app"), + bundleIdentifier: "com.keylight.app.v2", + expectedBundleName: "KeyLight 2.0.app" + ) ) - XCTAssertEqual( - monitor._testResolveKeyboardEventKeyCodeWithSpecialKey( - rawKeyCode: 175, - specialKeyRawValue: NSEvent.SpecialKey.f9.rawValue - ), - 101 + XCTAssertNotNil( + PermissionManager.installationIssue( + for: URL(fileURLWithPath: "/Applications/KeyLight.app"), + bundleIdentifier: "com.keylight.app.v2", + expectedBundleName: "KeyLight 2.0.app" + ) + ) + XCTAssertNotNil( + PermissionManager.installationIssue( + for: URL(fileURLWithPath: "/Applications/KeyLight 2.0 2.app"), + bundleIdentifier: "com.keylight.app.v2", + expectedBundleName: "KeyLight 2.0.app" + ) + ) + XCTAssertNotNil( + PermissionManager.installationIssue( + for: URL(fileURLWithPath: "/Volumes/KeyLight 2.0/KeyLight 2.0.app"), + bundleIdentifier: "com.keylight.app.v2", + expectedBundleName: "KeyLight 2.0.app" + ) ) } - func testLegacyNXTopRowOverridesAreDisabled() { - let monitor = KeyboardMonitor { _ in } + @MainActor + func testEveryEnabledStateMutationCallsTheLifecycleReconcilerDirectly() { + let snapshot = DefaultsSnapshot(keys: TestDefaultsKeys.all) + snapshot.clear() + defer { snapshot.restore() } + + let appState = KeyLightModel(settings: SettingsManager()) + var receivedEnabledValues: [Bool] = [] + appState.connectRuntime( + onEnabledChange: { receivedEnabledValues.append($0) }, + onConfigurationChange: {}, + onPermissionRequest: {}, + onPermissionRetry: {} + ) + appState.isEnabled.toggle() - XCTAssertNil(monitor._testResolveVirtualKeyCode(nxCode: 4)) - XCTAssertNil(monitor._testResolveVirtualKeyCode(nxCode: 5)) - XCTAssertNil(monitor._testResolveVirtualKeyCode(nxCode: 6)) - XCTAssertNil(monitor._testResolveVirtualKeyCode(nxCode: 19)) - XCTAssertNil(monitor._testResolveVirtualKeyCode(nxCode: 20)) + XCTAssertEqual(receivedEnabledValues, [appState.isEnabled]) } - func testModifierFlagsChangedResolutionForCommandOptionControlAndFn() { - let monitor = KeyboardMonitor { _ in } - - // Left and right Command - XCTAssertEqual(monitor._testResolveModifierFlagsChanged(keyCode: 55, flags: [.maskCommand]), true) - XCTAssertEqual(monitor._testResolveModifierFlagsChanged(keyCode: 54, flags: [.maskCommand]), true) - XCTAssertEqual(monitor._testResolveModifierFlagsChanged(keyCode: 54, flags: [.maskCommand]), false) - XCTAssertEqual(monitor._testResolveModifierFlagsChanged(keyCode: 55, flags: []), false) - - // Left and right Option - XCTAssertEqual(monitor._testResolveModifierFlagsChanged(keyCode: 58, flags: [.maskAlternate]), true) - XCTAssertEqual(monitor._testResolveModifierFlagsChanged(keyCode: 61, flags: [.maskAlternate]), true) - XCTAssertEqual(monitor._testResolveModifierFlagsChanged(keyCode: 61, flags: [.maskAlternate]), false) - XCTAssertEqual(monitor._testResolveModifierFlagsChanged(keyCode: 58, flags: []), false) - - // Left and right Control - XCTAssertEqual(monitor._testResolveModifierFlagsChanged(keyCode: 59, flags: [.maskControl]), true) - XCTAssertEqual(monitor._testResolveModifierFlagsChanged(keyCode: 62, flags: [.maskControl]), true) - XCTAssertEqual(monitor._testResolveModifierFlagsChanged(keyCode: 62, flags: [.maskControl]), false) - XCTAssertEqual(monitor._testResolveModifierFlagsChanged(keyCode: 59, flags: []), false) - - // Fn - XCTAssertEqual(monitor._testResolveModifierFlagsChanged(keyCode: 63, flags: [.maskSecondaryFn]), true) - XCTAssertEqual(monitor._testResolveModifierFlagsChanged(keyCode: 63, flags: []), false) - - // Caps Lock stays isolated to key 57 and never maps to top-row aliases. - XCTAssertEqual(monitor._testResolveModifierFlagsChanged(keyCode: 57, flags: [.maskAlphaShift]), true) - XCTAssertEqual(monitor._testResolveModifierFlagsChanged(keyCode: 57, flags: []), false) + func testPrivacyBoundaryUsesListenOnlyTapAtRuntime() { XCTAssertEqual( - monitor._testResolveKeyboardEventKeyCode( - rawKeyCode: 57, - charactersIgnoringModifiers: nil - ), - 57 + KeyboardMonitor.eventTapOptions.rawValue, + CGEventTapOptions.listenOnly.rawValue ) - XCTAssertEqual(monitor._testCapsLockEmitSequence(isKeyDown: true), [true, false]) - XCTAssertEqual(monitor._testCapsLockEmitSequence(isKeyDown: false), [false]) - - // Unknown/non-modifier key should be ignored - XCTAssertNil(monitor._testResolveModifierFlagsChanged(keyCode: 12, flags: [.maskCommand])) - } -} - -final class NotificationContractTests: XCTestCase { - func testNotificationNameRawValuesRemainStable() { - XCTAssertEqual(Notification.Name.glowSettingsChanged.rawValue, "glowSettingsChanged") - XCTAssertEqual(Notification.Name.settingsStorageChanged.rawValue, "settingsStorageChanged") - XCTAssertEqual(Notification.Name.openKeyPositionEditor.rawValue, "openKeyPositionEditor") - XCTAssertEqual(Notification.Name.openSettingsWindow.rawValue, "openSettingsWindow") - XCTAssertEqual(Notification.Name.permissionStatusChanged.rawValue, "permissionStatusChanged") - XCTAssertEqual(Notification.Name.keyPositionsChanged.rawValue, "keyPositionsChanged") - XCTAssertEqual(Notification.Name.keyWidthsChanged.rawValue, "keyWidthsChanged") - XCTAssertEqual(Notification.Name.showGlowPreview.rawValue, "showGlowPreview") - XCTAssertEqual(Notification.Name.hideGlowPreview.rawValue, "hideGlowPreview") - XCTAssertEqual(Notification.Name.physicalKeyDown.rawValue, "physicalKeyDown") - XCTAssertEqual(Notification.Name.physicalKeyUp.rawValue, "physicalKeyUp") } } diff --git a/KeyLightTests/KeyboardMonitorContractTests.swift b/KeyLightTests/KeyboardMonitorContractTests.swift new file mode 100644 index 0000000..a0b9470 --- /dev/null +++ b/KeyLightTests/KeyboardMonitorContractTests.swift @@ -0,0 +1,404 @@ +import XCTest +import CoreGraphics +import AppKit +import IOKit.hid +@testable import KeyLight + +final class KeyboardMonitorContractTests: XCTestCase { + func testMediaVirtualKeyResolutionAndDedupeWindow() { + let monitor = KeyboardMonitor { _ in } + + XCTAssertEqual(monitor._testResolveVirtualKeyCode(nxCode: 0), 520) // sound up + XCTAssertEqual(monitor._testResolveVirtualKeyCode(nxCode: 3), 500) // brightness down + XCTAssertEqual(monitor._testResolveVirtualKeyCode(nxCode: 7), 518) // mute + XCTAssertEqual(monitor._testResolveVirtualKeyCode(nxCode: 19), 517) // fast-forward/F9 + XCTAssertEqual(monitor._testResolveVirtualKeyCode(nxCode: 20), 506) // rewind/F7 + XCTAssertNil(monitor._testResolveVirtualKeyCode(nxCode: 4)) + XCTAssertNil(monitor._testResolveVirtualKeyCode(nxCode: 5)) + XCTAssertNil(monitor._testResolveVirtualKeyCode(nxCode: 6)) + XCTAssertNil(monitor._testResolveVirtualKeyCode(nxCode: 999)) + + let t0: CFAbsoluteTime = 1000 + XCTAssertFalse(monitor._testShouldDedupeMediaEvent(keyCode: 516, isKeyDown: true, now: t0)) + XCTAssertTrue(monitor._testShouldDedupeMediaEvent(keyCode: 516, isKeyDown: true, now: t0 + 0.01)) + XCTAssertFalse(monitor._testShouldDedupeMediaEvent(keyCode: 516, isKeyDown: true, now: t0 + 0.05)) + + // HID wins when both sources report the same physical press in a short window. + XCTAssertFalse( + monitor._testShouldDedupeMediaEventWithSource( + keyCode: 517, + isKeyDown: true, + source: "hid", + now: t0 + 1.0 + ) + ) + XCTAssertTrue( + monitor._testShouldDedupeMediaEventWithSource( + keyCode: 517, + isKeyDown: true, + source: "system", + now: t0 + 1.01 + ) + ) + + XCTAssertEqual( + monitor._testResolveHIDConsumerUsage( + UInt32(kHIDUsage_Csmr_VolumeIncrement) + ), + 520 + ) + XCTAssertNil(monitor._testResolveHIDConsumerUsage(0xFFFF)) + } + + func testAppleMediaRowHIDUsagesResolveToF6F7AndF9AliasesOnlyOnAllowedPages() { + let monitor = KeyboardMonitor { _ in } + let hardwareMappings: [(UInt32, UInt32, UInt16, UInt16)] = [ + (UInt32(kHIDPage_GenericDesktop), UInt32(kHIDUsage_GD_DoNotDisturb), 505, 97), + (UInt32(kHIDPage_KeyboardOrKeypad), UInt32(kHIDUsage_KeyboardF6), 505, 97), + (UInt32(kHIDPage_KeyboardOrKeypad), UInt32(kHIDUsage_KeyboardF7), 506, 98), + (UInt32(kHIDPage_KeyboardOrKeypad), UInt32(kHIDUsage_KeyboardF9), 517, 101), + (UInt32(kHIDPage_Consumer), UInt32(kHIDUsage_Csmr_Rewind), 506, 98), + (UInt32(kHIDPage_Consumer), UInt32(kHIDUsage_Csmr_ScanPreviousTrack), 506, 98), + (UInt32(kHIDPage_Consumer), UInt32(kHIDUsage_Csmr_FastForward), 517, 101), + (UInt32(kHIDPage_Consumer), UInt32(kHIDUsage_Csmr_ScanNextTrack), 517, 101), + ] + + for (page, usage, alias, functionKey) in hardwareMappings { + XCTAssertEqual(monitor._testResolveHIDUsage(page: page, usage: usage), alias) + XCTAssertEqual(KeyboardLayoutInfo.canonicalKeyCode(for: alias), functionKey) + } + + XCTAssertNil( + monitor._testResolveHIDUsage( + page: UInt32(kHIDPage_Consumer), + usage: UInt32(kHIDUsage_GD_DoNotDisturb) + ) + ) + XCTAssertNil( + monitor._testResolveHIDUsage( + page: UInt32(kHIDPage_GenericDesktop), + usage: UInt32(kHIDUsage_Csmr_Rewind) + ) + ) + XCTAssertNil( + monitor._testResolveHIDUsage( + page: UInt32(kHIDPage_KeyboardOrKeypad), + usage: UInt32(kHIDUsage_KeyboardF8) + ) + ) + } + + func testDeterministicTopRowMappingAndUnknownRawBehavior() { + let monitor = KeyboardMonitor { _ in } + + XCTAssertEqual( + monitor._testResolveKeyboardEventKeyCode( + rawKeyCode: 160, + charactersIgnoringModifiers: String(UnicodeScalar(0xF706)!) + ), + 99 // F3 + ) + XCTAssertEqual( + monitor._testResolveKeyboardEventConfidence( + rawKeyCode: 160, + charactersIgnoringModifiers: String(UnicodeScalar(0xF706)!) + ), + "high" + ) + XCTAssertEqual( + monitor._testResolveKeyboardEventKeyCode( + rawKeyCode: 161, + charactersIgnoringModifiers: String(UnicodeScalar(0xF707)!) + ), + 118 // F4 + ) + XCTAssertEqual( + monitor._testResolveKeyboardEventKeyCode( + rawKeyCode: 162, + charactersIgnoringModifiers: String(UnicodeScalar(0xF708)!) + ), + 96 // F5 + ) + + XCTAssertEqual( + monitor._testResolveKeyboardEventKeyCode( + rawKeyCode: 163, + charactersIgnoringModifiers: "A" + ), + 163 + ) + + XCTAssertEqual( + monitor._testResolveKeyboardEventKeyCode( + rawKeyCode: 122, + charactersIgnoringModifiers: String(UnicodeScalar(0xF706)!) + ), + 122 + ) + + let trustedRawMappings: [(UInt16, UInt16)] = [ + (145, 122), + (144, 120), + (160, 99), + (131, 118), + (177, 96), + (176, 97), + (178, 97), + (173, 98), + (174, 100), + (175, 101), + (74, 109), + (73, 103), + (72, 111) + ] + + for (raw, expected) in trustedRawMappings { + XCTAssertEqual( + monitor._testResolveKeyboardEventKeyCode( + rawKeyCode: raw, + charactersIgnoringModifiers: nil + ), + expected + ) + XCTAssertEqual( + monitor._testResolveKeyboardEventConfidence( + rawKeyCode: raw, + charactersIgnoringModifiers: nil + ), + "high" + ) + } + + // Unknown raw key remains unresolved. + XCTAssertEqual( + monitor._testResolveKeyboardEventConfidence( + rawKeyCode: 163, + charactersIgnoringModifiers: nil + ), + "unknown" + ) + + // NSEvent specialKey provides deterministic top-row mapping for hardware-specific raw codes. + XCTAssertEqual( + monitor._testResolveKeyboardEventKeyCodeWithSpecialKey( + rawKeyCode: 160, + specialKeyRawValue: NSEvent.SpecialKey.f4.rawValue + ), + 118 + ) + XCTAssertEqual( + monitor._testResolveKeyboardEventKeyCodeWithSpecialKey( + rawKeyCode: 177, + specialKeyRawValue: NSEvent.SpecialKey.f5.rawValue + ), + 96 + ) + XCTAssertEqual( + monitor._testResolveKeyboardEventKeyCodeWithSpecialKey( + rawKeyCode: 173, + specialKeyRawValue: NSEvent.SpecialKey.f7.rawValue + ), + 98 + ) + XCTAssertEqual( + monitor._testResolveKeyboardEventKeyCodeWithSpecialKey( + rawKeyCode: 175, + specialKeyRawValue: NSEvent.SpecialKey.f9.rawValue + ), + 101 + ) + XCTAssertEqual( + monitor._testResolveKeyboardEventConfidenceWithSpecialKey( + rawKeyCode: 173, + specialKeyRawValue: NSEvent.SpecialKey.f7.rawValue + ), + "high" + ) + } + + func testEventLoopKeyboardDecodingIsSafeOffMainQueueWithoutAppKitMetadata() { + let monitor = KeyboardMonitor { _ in } + let result = DispatchQueue( + label: "KeyLightTests.KeyboardEventLoop" + ).sync { + ( + monitor._testDecodeEventLoopKeyboardEvent( + rawKeyCode: 49, + isKeyDown: true + ), + monitor._testDecodeEventLoopKeyboardEvent( + rawKeyCode: 160, + isKeyDown: true + ), + monitor._testDecodeEventLoopKeyboardEvent( + rawKeyCode: 163, + isKeyDown: true + ) + ) + } + + XCTAssertEqual(result.0?.keyCode, 49) + XCTAssertEqual(result.1?.keyCode, 99) + XCTAssertNil(result.2) + } + + func testUnresolvedKeyboardRawDoesNotSuppressMediaSource() { + let monitor = KeyboardMonitor { _ in } + let t0: CFAbsoluteTime = 2100 + + XCTAssertEqual( + monitor._testResolveKeyboardEventConfidence( + rawKeyCode: 163, + charactersIgnoringModifiers: nil + ), + "unknown" + ) + + // No keyboard-first suppression: first media event for this key/state should pass. + XCTAssertFalse( + monitor._testShouldDedupeMediaEventWithSource( + keyCode: 506, + isKeyDown: true, + source: "system", + now: t0 + 0.01 + ) + ) + // Duplicate key/state in-window still dedupes normally. + XCTAssertTrue( + monitor._testShouldDedupeMediaEventWithSource( + keyCode: 506, + isKeyDown: true, + source: "system", + now: t0 + 0.02 + ) + ) + } + + func testTrustedRawTopRowMediaCodesMapToFunctionKeys() { + let monitor = KeyboardMonitor { _ in } + + let rawMediaCodesToExpected: [(UInt16, UInt16)] = [ + (145, 122), + (144, 120), + (160, 99), + (131, 118), + (177, 96), + (176, 97), + (178, 97), + (173, 98), + (174, 100), + (175, 101), + (74, 109), + (73, 103), + (72, 111) + ] + for (code, expected) in rawMediaCodesToExpected { + XCTAssertEqual( + monitor._testResolveKeyboardEventKeyCode( + rawKeyCode: code, + charactersIgnoringModifiers: nil + ), + expected, + "Raw top-row code \(code) should resolve to F-key \(expected)" + ) + XCTAssertEqual( + monitor._testResolveKeyboardEventConfidence( + rawKeyCode: code, + charactersIgnoringModifiers: nil + ), + "high", + "Raw top-row code \(code) should be trusted via explicit map" + ) + } + } + + func testTrustedMetadataStillMapsF4F5F7F9() { + let monitor = KeyboardMonitor { _ in } + + XCTAssertEqual( + monitor._testResolveKeyboardEventConfidenceWithSpecialKey( + rawKeyCode: 160, + specialKeyRawValue: NSEvent.SpecialKey.f4.rawValue + ), + "high" + ) + XCTAssertEqual( + monitor._testResolveKeyboardEventKeyCodeWithSpecialKey( + rawKeyCode: 160, + specialKeyRawValue: NSEvent.SpecialKey.f4.rawValue + ), + 118 + ) + XCTAssertEqual( + monitor._testResolveKeyboardEventKeyCodeWithSpecialKey( + rawKeyCode: 177, + specialKeyRawValue: NSEvent.SpecialKey.f5.rawValue + ), + 96 + ) + XCTAssertEqual( + monitor._testResolveKeyboardEventKeyCodeWithSpecialKey( + rawKeyCode: 173, + specialKeyRawValue: NSEvent.SpecialKey.f7.rawValue + ), + 98 + ) + XCTAssertEqual( + monitor._testResolveKeyboardEventKeyCodeWithSpecialKey( + rawKeyCode: 175, + specialKeyRawValue: NSEvent.SpecialKey.f9.rawValue + ), + 101 + ) + } + + func testUnsupportedNXTopRowOverridesRemainDisabled() { + let monitor = KeyboardMonitor { _ in } + + XCTAssertNil(monitor._testResolveVirtualKeyCode(nxCode: 4)) + XCTAssertNil(monitor._testResolveVirtualKeyCode(nxCode: 5)) + XCTAssertNil(monitor._testResolveVirtualKeyCode(nxCode: 6)) + } + + func testModifierFlagsChangedResolutionForCommandOptionControlAndFn() { + let monitor = KeyboardMonitor { _ in } + + // Left and right Command + XCTAssertEqual(monitor._testResolveModifierFlagsChanged(keyCode: 55, flags: [.maskCommand]), true) + XCTAssertEqual(monitor._testResolveModifierFlagsChanged(keyCode: 54, flags: [.maskCommand]), true) + XCTAssertEqual(monitor._testResolveModifierFlagsChanged(keyCode: 54, flags: [.maskCommand]), false) + XCTAssertEqual(monitor._testResolveModifierFlagsChanged(keyCode: 55, flags: []), false) + + // Left and right Option + XCTAssertEqual(monitor._testResolveModifierFlagsChanged(keyCode: 58, flags: [.maskAlternate]), true) + XCTAssertEqual(monitor._testResolveModifierFlagsChanged(keyCode: 61, flags: [.maskAlternate]), true) + XCTAssertEqual(monitor._testResolveModifierFlagsChanged(keyCode: 61, flags: [.maskAlternate]), false) + XCTAssertEqual(monitor._testResolveModifierFlagsChanged(keyCode: 58, flags: []), false) + + // Left and right Control + XCTAssertEqual(monitor._testResolveModifierFlagsChanged(keyCode: 59, flags: [.maskControl]), true) + XCTAssertEqual(monitor._testResolveModifierFlagsChanged(keyCode: 62, flags: [.maskControl]), true) + XCTAssertEqual(monitor._testResolveModifierFlagsChanged(keyCode: 62, flags: [.maskControl]), false) + XCTAssertEqual(monitor._testResolveModifierFlagsChanged(keyCode: 59, flags: []), false) + + // Fn + XCTAssertEqual(monitor._testResolveModifierFlagsChanged(keyCode: 63, flags: [.maskSecondaryFn]), true) + XCTAssertEqual(monitor._testResolveModifierFlagsChanged(keyCode: 63, flags: []), false) + + // Caps Lock stays isolated to key 57 and never maps to top-row aliases. + XCTAssertEqual(monitor._testResolveModifierFlagsChanged(keyCode: 57, flags: [.maskAlphaShift]), true) + XCTAssertEqual(monitor._testResolveModifierFlagsChanged(keyCode: 57, flags: []), false) + XCTAssertEqual( + monitor._testResolveKeyboardEventKeyCode( + rawKeyCode: 57, + charactersIgnoringModifiers: nil + ), + 57 + ) + XCTAssertEqual(monitor._testCapsLockEmitSequence(isKeyDown: true), [true, false]) + XCTAssertEqual(monitor._testCapsLockEmitSequence(isKeyDown: false), [false]) + + // Unknown/non-modifier key should be ignored + XCTAssertNil(monitor._testResolveModifierFlagsChanged(keyCode: 12, flags: [.maskCommand])) + } +} diff --git a/KeyLightTests/OverlayControllerTests.swift b/KeyLightTests/OverlayControllerTests.swift new file mode 100644 index 0000000..cddb4da --- /dev/null +++ b/KeyLightTests/OverlayControllerTests.swift @@ -0,0 +1,794 @@ +import AppKit +import XCTest + +#if canImport(KeyLight) +@testable import KeyLight +#endif + +final class OverlayControllerTests: XCTestCase { + func testBuiltInDisplayWinsOverMainAndExternalDisplays() { + let candidates = [ + OverlayDisplayCandidate(id: 10, isBuiltIn: false, isMain: true), + OverlayDisplayCandidate(id: 20, isBuiltIn: true, isMain: false), + OverlayDisplayCandidate(id: 30, isBuiltIn: false, isMain: false) + ] + + XCTAssertEqual(OverlayDisplayResolver.targetID(in: candidates), 20) + } + + func testMainDisplayIsFallbackWhenNoBuiltInDisplayExists() { + let candidates = [ + OverlayDisplayCandidate(id: 10, isBuiltIn: false, isMain: false), + OverlayDisplayCandidate(id: 20, isBuiltIn: false, isMain: true) + ] + + XCTAssertEqual(OverlayDisplayResolver.targetID(in: candidates), 20) + } + + func testFirstDisplayIsDeterministicLastFallback() { + let candidates = [ + OverlayDisplayCandidate(id: 10, isBuiltIn: false, isMain: false), + OverlayDisplayCandidate(id: 20, isBuiltIn: false, isMain: false) + ] + + XCTAssertEqual(OverlayDisplayResolver.targetID(in: candidates), 10) + XCTAssertNil(OverlayDisplayResolver.targetID(in: [])) + } + + func testExplicitDisplaySelectionsUseStableIdentityAndSafeFallbacks() { + let candidates = [ + OverlayDisplayCandidate( + id: 10, + persistentID: "external", + name: "Studio Display", + isBuiltIn: false, + isMain: true + ), + OverlayDisplayCandidate( + id: 20, + persistentID: "internal", + name: "Built-in Display", + isBuiltIn: true, + isMain: false + ) + ] + + XCTAssertEqual( + OverlayDisplayResolver.targetID(in: candidates, selection: .specific("external")), + 10 + ) + XCTAssertEqual( + OverlayDisplayResolver.targetID(in: candidates, selection: .main), + 10 + ) + XCTAssertEqual( + OverlayDisplayResolver.targetID(in: candidates, selection: .builtIn), + 20 + ) + XCTAssertEqual( + OverlayDisplayResolver.targetID(in: candidates, selection: .specific("disconnected")), + 20, + "A disconnected explicit display must retain the original automatic fallback" + ) + } + + func testDisplaySelectionPersistenceRejectsMalformedSpecificValues() { + XCTAssertEqual(OverlayDisplaySelection(persistedValue: nil), .automatic) + XCTAssertEqual(OverlayDisplaySelection(persistedValue: "unknown"), .automatic) + XCTAssertEqual(OverlayDisplaySelection(persistedValue: "display:"), .automatic) + XCTAssertEqual( + OverlayDisplaySelection(persistedValue: "display:stable-id"), + .specific("stable-id") + ) + XCTAssertEqual(OverlayDisplaySelection.main.persistedValue, "main") + } + + @MainActor + func testControllerRetargetsWhenDisplaySelectionChanges() { + let source = MutableOverlayDisplaySource(displays: [ + OverlayDisplayCandidate( + id: 10, + persistentID: "external", + name: "Studio Display", + isBuiltIn: false, + isMain: true, + frame: CGRect(x: 0, y: 0, width: 1_920, height: 1_080) + ), + OverlayDisplayCandidate( + id: 20, + persistentID: "internal", + name: "Built-in Display", + isBuiltIn: true, + isMain: false, + frame: CGRect(x: 1_920, y: 0, width: 1_440, height: 900) + ) + ]) + var panels: [FakeOverlayPanel] = [] + let controller = OverlayController( + displayProvider: { source.displays }, + windowFactory: { frame in + let panel = FakeOverlayPanel(frame: frame) + panels.append(panel) + return panel + } + ) + + controller.start() + XCTAssertEqual(controller.activeDisplayPersistentID, "internal") + + controller.setDisplaySelection(.specific("external")) + + XCTAssertEqual(controller.activeDisplayPersistentID, "external") + XCTAssertEqual(controller.availableDisplays.map(\.id), ["external", "internal"]) + XCTAssertEqual(panels.count, 2) + XCTAssertTrue(panels[0].isClosed) + } + + @MainActor + func testControllerKeepsExactlyOnePanelAndRetargetsTopology() { + let displaySource = MutableOverlayDisplaySource(displays: [ + OverlayDisplayCandidate( + id: 10, + isBuiltIn: true, + isMain: true, + frame: CGRect(x: 20, y: 30, width: 1_440, height: 900) + ) + ]) + var panels: [FakeOverlayPanel] = [] + let controller = OverlayController( + displayProvider: { displaySource.displays }, + windowFactory: { frame in + let panel = FakeOverlayPanel(frame: frame) + panels.append(panel) + return panel + } + ) + + controller.start() + controller.updateDisplayTopology() + + XCTAssertEqual(panels.count, 1) + XCTAssertEqual(panels[0].frame, CGRect(x: 20, y: 30, width: 1_440, height: 120)) + XCTAssertEqual(panels[0].orderFrontCount, 1) + XCTAssertFalse(panels[0].isClosed) + + displaySource.displays = [ + OverlayDisplayCandidate( + id: 20, + isBuiltIn: false, + isMain: true, + frame: CGRect(x: -1_920, y: 0, width: 1_920, height: 1_080) + ) + ] + controller.updateDisplayTopology() + + XCTAssertEqual(panels.count, 2) + XCTAssertTrue(panels[0].isClosed) + XCTAssertEqual(panels[1].frame, CGRect(x: -1_920, y: 0, width: 1_920, height: 120)) + XCTAssertEqual(controller.activeDisplayID, 20) + } + + @MainActor + func testSameDisplayResizePreservesAndRedrawsHeldPhysicalTarget() { + let displaySource = MutableOverlayDisplaySource(displays: [ + OverlayDisplayCandidate( + id: 10, + isBuiltIn: true, + isMain: true, + frame: CGRect(x: 0, y: 0, width: 1_000, height: 800) + ) + ]) + let panel = FakeOverlayPanel(frame: .zero) + var physicalEvents: [KeyboardEvent] = [] + let controller = OverlayController( + displayProvider: { displaySource.displays }, + windowFactory: { frame in + panel.setFrame(frame, display: false) + return panel + }, + onPhysicalEvent: { physicalEvents.append($0) } + ) + let preview = GlowTarget.preview( + .settings, + horizontalPosition: 0.5, + keyWidth: 1 + ) + + controller.start() + controller.setPreview(preview, source: .settings) + controller.handle( + .keyDown(12, source: .eventTap, timestamp: 1), + target: .physicalKey(12, horizontalPosition: 0.2, keyWidth: 1) + ) + displaySource.displays = [ + OverlayDisplayCandidate( + id: 10, + isBuiltIn: true, + isMain: true, + frame: CGRect(x: 0, y: 0, width: 1_200, height: 800) + ) + ] + + controller.updateDisplayTopology() + + XCTAssertEqual(panel.frame, CGRect(x: 0, y: 0, width: 1_200, height: 120)) + XCTAssertEqual( + controller.resolvedTarget, + .physicalKey(12, horizontalPosition: 0.2, keyWidth: 1) + ) + XCTAssertEqual(physicalEvents.map(\.action), [.down]) + XCTAssertEqual(panel.renderer.shown, [ + preview, + .physicalKey(12, horizontalPosition: 0.2, keyWidth: 1), + .physicalKey(12, horizontalPosition: 0.2, keyWidth: 1) + ]) + XCTAssertEqual(panel.renderer.clearCount, 1) + } + + @MainActor + func testAtomicConfigurationAndInteractionPriorityReachRenderer() { + let panel = FakeOverlayPanel(frame: .zero) + var physicalEvents: [KeyboardEvent] = [] + let controller = OverlayController( + displayProvider: { + [OverlayDisplayCandidate( + id: 1, + isBuiltIn: true, + isMain: true, + frame: CGRect(x: 0, y: 0, width: 1_000, height: 800) + )] + }, + windowFactory: { _ in panel }, + onPhysicalEvent: { physicalEvents.append($0) } + ) + let configuration = RendererConfiguration( + colorMode: .rainbow, + glowHeight: 77, + maximumOpacity: 0.42, + reduceMotion: true + ) + let settingsPreview = GlowTarget.preview( + .settings, + horizontalPosition: 0.5, + keyWidth: 1 + ) + let physicalTarget = GlowTarget.physicalKey( + 12, + horizontalPosition: 0.2, + keyWidth: 1.4 + ) + + controller.start() + controller.apply(effectStyle: .classicGlow, configuration: configuration) + controller.setPreview(settingsPreview, source: .settings) + controller.handle( + .keyDown(12, source: .eventTap, timestamp: 1), + target: physicalTarget + ) + controller.handle(.keyUp(12, source: .eventTap, timestamp: 2)) + + XCTAssertEqual(panel.renderer.applied.last, configuration) + XCTAssertEqual(panel.renderer.shown, [settingsPreview, physicalTarget, settingsPreview]) + XCTAssertEqual(panel.renderer.hidden, [.preview(.settings), .physicalKey(12)]) + XCTAssertEqual(physicalEvents.count, 2) + XCTAssertEqual(controller.resolvedTarget, settingsPreview) + } + + @MainActor + func testStreamResetIsForwardedToCalibrationActivity() { + let panel = FakeOverlayPanel(frame: .zero) + var physicalEvents: [KeyboardEvent] = [] + let controller = OverlayController( + displayProvider: { + [OverlayDisplayCandidate( + id: 1, + isBuiltIn: true, + isMain: true, + frame: CGRect(x: 0, y: 0, width: 1_000, height: 800) + )] + }, + windowFactory: { _ in panel }, + onPhysicalEvent: { physicalEvents.append($0) } + ) + + controller.start() + controller.handle( + .keyDown(12, source: .eventTap, timestamp: 1), + target: .physicalKey(12, horizontalPosition: 0.5, keyWidth: 1) + ) + controller.handle(.streamReset(source: .lifecycle, timestamp: 2)) + + XCTAssertEqual(physicalEvents.map(\.action), [.down, .streamReset]) + } + + @MainActor + func testControllerRestoresNewestRemainingChordKey() { + let panel = FakeOverlayPanel(frame: .zero) + let controller = OverlayController( + displayProvider: { + [OverlayDisplayCandidate( + id: 1, + isBuiltIn: true, + isMain: true, + frame: CGRect(x: 0, y: 0, width: 1_000, height: 800) + )] + }, + windowFactory: { _ in panel } + ) + let first = GlowTarget.physicalKey(10, horizontalPosition: 0.2, keyWidth: 1) + let second = GlowTarget.physicalKey(11, horizontalPosition: 0.8, keyWidth: 1) + + controller.start() + controller.handle( + .keyDown(10, source: .eventTap, timestamp: 1), + target: first + ) + controller.handle( + .keyDown(11, source: .eventTap, timestamp: 2), + target: second + ) + controller.handle(.keyUp(11, source: .eventTap, timestamp: 3)) + + XCTAssertEqual(panel.renderer.shown, [first, second, first]) + XCTAssertEqual(panel.renderer.hidden, [.physicalKey(11)]) + XCTAssertEqual(controller.resolvedTarget, first) + } + + @MainActor + func testConcurrentRendererKeepsEveryHeldKeyAndReleasesOnlyItsOwnIdentity() { + let panel = FakeOverlayPanel( + frame: .zero, + supportsConcurrentPhysicalTargets: true + ) + let controller = OverlayController( + displayProvider: { + [OverlayDisplayCandidate( + id: 1, + isBuiltIn: true, + isMain: true, + frame: CGRect(x: 0, y: 0, width: 1_000, height: 800) + )] + }, + windowFactory: { _ in panel } + ) + let preview = GlowTarget.preview( + .settings, + horizontalPosition: 0.5, + keyWidth: 1 + ) + let first = GlowTarget.physicalKey( + 10, + horizontalPosition: 0.20, + keyWidth: 1 + ) + let second = GlowTarget.physicalKey( + 11, + horizontalPosition: 0.26, + keyWidth: 1 + ) + + controller.start() + controller.setPreview(preview, source: .settings) + controller.handle( + .keyDown(10, source: .eventTap, timestamp: 1), + target: first + ) + controller.handle( + .keyDown(11, source: .eventTap, timestamp: 2), + target: second + ) + controller.handle(.keyUp(10, source: .eventTap, timestamp: 3)) + + XCTAssertEqual(panel.renderer.shown, [preview, first, second]) + XCTAssertEqual( + panel.renderer.hidden, + [.preview(.settings), .physicalKey(10)] + ) + XCTAssertEqual(controller.resolvedTarget, second) + + controller.handle(.keyUp(11, source: .eventTap, timestamp: 4)) + + XCTAssertEqual(panel.renderer.shown, [preview, first, second, preview]) + XCTAssertEqual( + panel.renderer.hidden, + [.preview(.settings), .physicalKey(10), .physicalKey(11)] + ) + XCTAssertEqual(controller.resolvedTarget, preview) + } + + @MainActor + func testEphemeralChordPreviewShowsTogetherAndPhysicalInputTemporarilyWins() { + let panel = FakeOverlayPanel( + frame: .zero, + supportsConcurrentPhysicalTargets: true + ) + let controller = OverlayController( + displayProvider: { + [OverlayDisplayCandidate( + id: 1, + isBuiltIn: true, + isMain: true, + frame: CGRect(x: 0, y: 0, width: 1_000, height: 800) + )] + }, + windowFactory: { _ in panel } + ) + let settings = GlowTarget.preview(.settings, horizontalPosition: 0.5, keyWidth: 1) + let chord = PreviewSource.chordTestSources.enumerated().map { index, source in + GlowTarget.preview( + source, + colorReferenceKeyCode: UInt16(index), + horizontalPosition: 0.3 + Double(index) * 0.1, + keyWidth: 1 + ) + } + let physical = GlowTarget.physicalKey(18, horizontalPosition: 0.2, keyWidth: 1) + + controller.start() + controller.setPreview(settings, source: .settings) + controller.setChordPreview(chord) + + XCTAssertEqual(Array(panel.renderer.shown.suffix(4)), chord) + XCTAssertEqual(panel.renderer.hidden.last, settings.id) + + controller.handle(.keyDown(18, source: .eventTap, timestamp: 1), target: physical) + XCTAssertEqual(Array(panel.renderer.hidden.suffix(4)), chord.map(\.id)) + XCTAssertEqual(panel.renderer.shown.last, physical) + + controller.handle(.keyUp(18, source: .eventTap, timestamp: 2)) + XCTAssertEqual(Array(panel.renderer.shown.suffix(4)), chord) + + controller.clearChordPreview() + XCTAssertEqual(panel.renderer.shown.last, settings) + XCTAssertTrue(controller.activePreviewSources.contains(.settings)) + XCTAssertFalse(controller.activePreviewSources.contains(where: { $0.isChordTest })) + } + + @MainActor + func testDisablingClearsRuntimeAndNeverResurrectsPreview() { + let panel = FakeOverlayPanel(frame: .zero) + let controller = OverlayController( + displayProvider: { + [OverlayDisplayCandidate( + id: 1, + isBuiltIn: true, + isMain: true, + frame: CGRect(x: 0, y: 0, width: 1_000, height: 800) + )] + }, + windowFactory: { _ in panel } + ) + controller.start() + controller.setPreview( + .preview(.settings, horizontalPosition: 0.5, keyWidth: 1), + source: .settings + ) + + controller.setEnabled(false) + controller.setPreview( + .preview(.settings, horizontalPosition: 0.6, keyWidth: 1), + source: .settings + ) + controller.setEnabled(true) + + XCTAssertNil(controller.resolvedTarget) + XCTAssertEqual(panel.renderer.clearCount, 1) + XCTAssertEqual(panel.renderer.shown.count, 1) + } + + @MainActor + func testMirroringBroadcastsConfigurationAndHeldKeysToThreePanels() { + let source = MutableOverlayDisplaySource(displays: [ + display(id: 1, persistentID: "primary", builtIn: true, main: true), + display(id: 2, persistentID: "left", x: -1_000), + display(id: 3, persistentID: "right", x: 1_000) + ]) + var panels: [FakeOverlayPanel] = [] + let controller = OverlayController( + displayProvider: { source.displays }, + windowFactory: { frame in + let panel = FakeOverlayPanel( + frame: frame, + supportsConcurrentPhysicalTargets: true + ) + panels.append(panel) + return panel + } + ) + + controller.setMirroredDisplayIDs(["left", "right"]) + controller.start() + XCTAssertEqual(panels.count, 3) + XCTAssertEqual( + controller.activeDisplayPersistentIDs, + ["primary", "left", "right"] + ) + + let configuration = RendererConfiguration( + colorMode: .rainbow, + maximumOpacity: 0.44 + ) + controller.apply(effectStyle: .systemGlass, configuration: configuration) + XCTAssertTrue(panels.allSatisfy { $0.renderer.applied.last == configuration }) + XCTAssertTrue(panels.allSatisfy { $0.effectStyles.last == .systemGlass }) + + let first = GlowTarget.physicalKey(10, horizontalPosition: 0.25, keyWidth: 1) + let second = GlowTarget.physicalKey(11, horizontalPosition: 0.75, keyWidth: 1.2) + controller.handle( + .keyDown(10, source: .eventTap, timestamp: 1), + target: first + ) + controller.handle( + .keyDown(11, source: .eventTap, timestamp: 2), + target: second + ) + XCTAssertTrue(panels.allSatisfy { $0.renderer.shown.suffix(2) == [first, second] }) + + controller.handle(.keyUp(10, source: .eventTap, timestamp: 3)) + XCTAssertTrue(panels.allSatisfy { $0.renderer.hidden.last == first.id }) + XCTAssertEqual(controller.resolvedTarget, second) + } + + @MainActor + func testPrimaryMirrorDeduplicationClosesTheFormerPrimaryPanel() { + let source = MutableOverlayDisplaySource(displays: [ + display(id: 1, persistentID: "internal", builtIn: true), + display(id: 2, persistentID: "external", main: true) + ]) + var panels: [FakeOverlayPanel] = [] + let controller = OverlayController( + displayProvider: { source.displays }, + windowFactory: { frame in + let panel = FakeOverlayPanel(frame: frame) + panels.append(panel) + return panel + } + ) + + controller.setMirroredDisplayIDs(["external"]) + controller.start() + XCTAssertEqual(Set(controller.activeDisplayPersistentIDs), ["internal", "external"]) + + controller.setDisplaySelection(.specific("external")) + + XCTAssertEqual(controller.activeDisplayPersistentIDs, ["external"]) + XCTAssertEqual(panels.filter { !$0.isClosed }.count, 1) + XCTAssertTrue(panels[0].isClosed) + } + + @MainActor + func testDisconnectedMirrorReturnsWithItsStableIDAndNewDisplayID() { + let source = MutableOverlayDisplaySource(displays: [ + display(id: 1, persistentID: "primary", builtIn: true, main: true), + display(id: 2, persistentID: "mirror") + ]) + var panels: [FakeOverlayPanel] = [] + let controller = OverlayController( + displayProvider: { source.displays }, + windowFactory: { frame in + let panel = FakeOverlayPanel(frame: frame) + panels.append(panel) + return panel + } + ) + controller.setMirroredDisplayIDs(["mirror"]) + controller.start() + XCTAssertEqual(controller.activeDisplayPersistentIDs.count, 2) + + source.displays.removeAll { $0.persistentID == "mirror" } + controller.updateDisplayTopology() + XCTAssertEqual(controller.activeDisplayPersistentIDs, ["primary"]) + XCTAssertTrue(panels[1].isClosed) + + source.displays.append(display( + id: 42, + persistentID: "mirror", + x: 1_000 + )) + controller.updateDisplayTopology() + XCTAssertEqual( + controller.activeDisplayPersistentIDs, + ["primary", "mirror"] + ) + XCTAssertEqual(panels.filter { !$0.isClosed }.count, 2) + XCTAssertEqual(panels.count, 3) + } + + @MainActor + func testMirrorResizeReplaysHeldStateOnlyOnTheAffectedPanel() { + let source = MutableOverlayDisplaySource(displays: [ + display(id: 1, persistentID: "primary", builtIn: true, main: true), + display(id: 2, persistentID: "mirror", x: 1_000) + ]) + var panels: [FakeOverlayPanel] = [] + let controller = OverlayController( + displayProvider: { source.displays }, + windowFactory: { frame in + let panel = FakeOverlayPanel( + frame: frame, + supportsConcurrentPhysicalTargets: true + ) + panels.append(panel) + return panel + } + ) + controller.setMirroredDisplayIDs(["mirror"]) + controller.start() + let held = GlowTarget.physicalKey(7, horizontalPosition: 0.4, keyWidth: 1) + controller.handle( + .keyDown(7, source: .eventTap, timestamp: 1), + target: held + ) + let primaryClearCount = panels[0].renderer.clearCount + let mirrorClearCount = panels[1].renderer.clearCount + + source.displays[1] = display( + id: 2, + persistentID: "mirror", + x: 1_000, + width: 1_400 + ) + controller.updateDisplayTopology() + + XCTAssertEqual(panels[0].renderer.clearCount, primaryClearCount) + XCTAssertEqual(panels[1].renderer.clearCount, mirrorClearCount + 1) + XCTAssertEqual(panels[1].renderer.shown.last, held) + XCTAssertEqual(controller.resolvedTarget, held) + } + + @MainActor + func testRuntimeStatusAggregatesWorstPanelAndPublishesStableIDs() { + let source = MutableOverlayDisplaySource(displays: [ + display(id: 1, persistentID: "primary", builtIn: true, main: true), + display(id: 2, persistentID: "mirror", x: 1_000) + ]) + var panels: [FakeOverlayPanel] = [] + let controller = OverlayController( + displayProvider: { source.displays }, + windowFactory: { frame in + let panel = FakeOverlayPanel(frame: frame) + panels.append(panel) + return panel + } + ) + var statuses: [EffectRuntimeStatus] = [] + controller.setRuntimeStatusHandler { statuses.append($0) } + controller.setMirroredDisplayIDs(["mirror"]) + controller.start() + + panels[1].renderer.emitRuntimeState(GlowRendererRuntimeState( + readiness: .fallback, + captureState: .permissionRequired, + fallbackReason: "Mirror fallback" + )) + XCTAssertEqual(statuses.last?.rendererReadiness, .fallback) + XCTAssertEqual(statuses.last?.fallbackReason, "Mirror fallback") + XCTAssertEqual( + statuses.last?.activeDisplayPersistentIDs, + ["primary", "mirror"] + ) + + panels[0].renderer.emitRuntimeState(GlowRendererRuntimeState( + readiness: .failed, + captureState: .failed, + fallbackReason: "Primary failed" + )) + XCTAssertEqual(statuses.last?.rendererReadiness, .failed) + XCTAssertEqual(statuses.last?.fallbackReason, "Primary failed") + + controller.shutdown() + XCTAssertTrue(panels.allSatisfy(\.isClosed)) + XCTAssertTrue(controller.activeDisplayPersistentIDs.isEmpty) + } + + private func display( + id: CGDirectDisplayID, + persistentID: String, + builtIn: Bool = false, + main: Bool = false, + x: CGFloat = 0, + width: CGFloat = 1_000 + ) -> OverlayDisplayCandidate { + OverlayDisplayCandidate( + id: id, + persistentID: persistentID, + name: persistentID.capitalized, + isBuiltIn: builtIn, + isMain: main, + frame: CGRect(x: x, y: 0, width: width, height: 800) + ) + } +} + +@MainActor +private final class MutableOverlayDisplaySource { + var displays: [OverlayDisplayCandidate] + + init(displays: [OverlayDisplayCandidate]) { + self.displays = displays + } +} + +@MainActor +private final class FakeOverlayPanel: OverlayPanel { + let renderer: FakeOverlayRenderer + private(set) var frame: NSRect + private(set) var effectStyles: [EffectStyle] = [] + private(set) var orderFrontCount = 0 + private(set) var isClosed = false + + var glowRenderer: (any GlowRenderer)? { renderer } + + init( + frame: NSRect, + supportsConcurrentPhysicalTargets: Bool = false + ) { + self.frame = frame + renderer = FakeOverlayRenderer( + supportsConcurrentPhysicalTargets: supportsConcurrentPhysicalTargets + ) + } + + func setEffectStyle(_ requestedStyle: EffectStyle) { + effectStyles.append(requestedStyle) + } + + func setFrame(_ frameRect: NSRect, display flag: Bool) { + frame = frameRect + } + + func orderFrontRegardless() { + orderFrontCount += 1 + } + + func close() { + isClosed = true + } +} + +@MainActor +private final class FakeOverlayRenderer: GlowRenderer { + let view = NSView(frame: .zero) + let supportsConcurrentPhysicalTargets: Bool + private(set) var applied: [RendererConfiguration] = [] + private(set) var shown: [GlowTarget] = [] + private(set) var hidden: [GlowID] = [] + private(set) var clearCount = 0 + private var runtimeStatusHandler: + (@MainActor (GlowRendererRuntimeState) -> Void)? + + init(supportsConcurrentPhysicalTargets: Bool = false) { + self.supportsConcurrentPhysicalTargets = supportsConcurrentPhysicalTargets + } + + func apply(_ configuration: RendererConfiguration) { + applied.append(configuration) + } + + func show(_ target: GlowTarget) { + shown.append(target) + } + + func refresh(_ id: GlowID) -> Bool { + false + } + + func hide(_ id: GlowID) { + hidden.append(id) + } + + func clear() { + clearCount += 1 + } + + func setRuntimeStatusHandler( + _ handler: (@MainActor (GlowRendererRuntimeState) -> Void)? + ) { + runtimeStatusHandler = handler + handler?(.ready) + } + + func emitRuntimeState(_ state: GlowRendererRuntimeState) { + runtimeStatusHandler?(state) + } +} diff --git a/KeyLightTests/PerformanceContractTests.swift b/KeyLightTests/PerformanceContractTests.swift new file mode 100644 index 0000000..2a727a1 --- /dev/null +++ b/KeyLightTests/PerformanceContractTests.swift @@ -0,0 +1,59 @@ +import XCTest + +#if canImport(KeyLight) +@testable import KeyLight +#endif + +final class PerformanceContractTests: XCTestCase { + func testOneHundredThousandSyntheticTransitionsKeepRuntimeStateBounded() { + var state = GlowInteractionState() + state.setPreview( + .preview(.settings, horizontalPosition: 0.5, keyWidth: 1), + for: .settings + ) + + for index in 0..<100_000 { + let keyCode = UInt16(index % 128) + let target = GlowTarget.physicalKey( + keyCode, + horizontalPosition: Double(index % 100) / 100, + keyWidth: 1 + ) + state.handle( + .keyDown( + keyCode, + source: .eventTap, + timestamp: TimeInterval(index) + ), + target: target + ) + state.handle( + .keyUp( + keyCode, + source: .eventTap, + timestamp: TimeInterval(index) + 0.5 + ) + ) + } + + XCTAssertTrue(state.heldPhysicalKeyCodes.isEmpty) + XCTAssertEqual(state.activePreviewSourcesInPriorityOrder, [.settings]) + XCTAssertEqual(state.resolvedTarget?.id, .preview(.settings)) + } + + func testNormalizedKeyboardEventHasNoCharacterOrTextSurface() { + let event = KeyboardEvent.keyDown( + 42, + source: .eventTap, + timestamp: 1 + ) + let storedLabels = Set(Mirror(reflecting: event).children.compactMap(\.label)) + + XCTAssertEqual( + storedLabels, + ["action", "canonicalKeyCode", "isRepeat", "sequence", "source", "timestamp"] + ) + XCTAssertFalse(storedLabels.contains("character")) + XCTAssertFalse(storedLabels.contains("text")) + } +} diff --git a/KeyLightTests/PreferencesCompatibilityTests.swift b/KeyLightTests/PreferencesCompatibilityTests.swift new file mode 100644 index 0000000..5df8d8e --- /dev/null +++ b/KeyLightTests/PreferencesCompatibilityTests.swift @@ -0,0 +1,598 @@ +import XCTest +import Carbon.HIToolbox +@testable import KeyLight + +private final class IsolatedDefaults { + let name = "KeyLightTests.PreferencesCompatibility.\(UUID().uuidString)" + let defaults: UserDefaults + + init() { + defaults = UserDefaults(suiteName: name)! + defaults.removePersistentDomain(forName: name) + } + + deinit { + defaults.removePersistentDomain(forName: name) + } + + func bypassUnrelatedStartupMigrations(includeStableSelection: Bool = true) { + defaults.set(true, forKey: "fadeDurationDefaultMigratedV2") + defaults.set(1, forKey: "defaultExperienceSeedVersion") + defaults.set(1, forKey: "defaultLayoutMigrationVersion") + defaults.set(1, forKey: "bundledLayoutProfilesSeedVersion") + if includeStableSelection { + defaults.set(1, forKey: "stableSelectionMigrationVersion") + } + } +} + +final class ConfigurationValueTests: XCTestCase { + func testDefaultConfigurationPreservesEstablishedDefaults() { + XCTAssertEqual(AppPreferences.default.isEnabled, true) + XCTAssertEqual(AppPreferences.default.launchAtLogin, false) + XCTAssertEqual(AppPreferences.default.effect.style, .classicGlow) + XCTAssertEqual(AppPreferences.default.effect.color.mode, .positionGradient) + XCTAssertEqual(AppPreferences.default.effect.color.solidHex, "68B8FF") + XCTAssertEqual(AppPreferences.default.effect.color.gradientStartHex, "68B8FF") + XCTAssertEqual(AppPreferences.default.effect.color.gradientEndHex, "00E69A") + XCTAssertEqual(AppPreferences.default.effect.opacity, 0.8013) + XCTAssertEqual(AppPreferences.default.effect.refractionStrength, 1.0) + XCTAssertEqual(AppPreferences.default.effect.height, 80.5536) + XCTAssertEqual(AppPreferences.default.effect.width, 1.0) + XCTAssertEqual(AppPreferences.default.effect.roundness, 0.7069) + XCTAssertEqual(AppPreferences.default.effect.hardness, 0.6046) + XCTAssertEqual(AppPreferences.default.effect.fadeDuration, 1.0004) + } + + func testFeedbackIsTypedAndEquatable() { + let id = UUID() + let first = UserFeedback( + id: id, + severity: .warning, + title: "Input Monitoring unavailable", + detail: "KeyLight cannot observe keys.", + recoveryAction: .openInputMonitoringSettings + ) + let second = UserFeedback( + id: id, + severity: .warning, + title: "Input Monitoring unavailable", + detail: "KeyLight cannot observe keys.", + recoveryAction: .openInputMonitoringSettings + ) + + XCTAssertEqual(first, second) + } +} + +final class SavedDomainValueCompatibilityTests: XCTestCase { + func testStandaloneThemeKeepsPersistedJSONShape() throws { + let theme = Theme( + id: try XCTUnwrap(UUID(uuidString: "AAAAAAAA-BBBB-4CCC-8DDD-EEEEEEEEEEEE")), + name: "Fixture", + colorHex: "112233", + opacity: 0.5, + size: 80, + width: 1.2, + glowRoundness: 0.7, + glowFullness: 0.6, + fadeDuration: 1, + colorMode: .positionGradient, + effectStyle: .liquidGlass, + gradientStartHex: "445566", + gradientEndHex: "778899" + ) + + let object = try XCTUnwrap( + JSONSerialization.jsonObject(with: JSONEncoder().encode(theme)) as? [String: Any] + ) + + XCTAssertEqual(Set(object.keys), [ + "id", "name", "colorHex", "opacity", "size", "width", + "glowRoundness", "glowFullness", "fadeDuration", "colorMode", + "effectStyle", "shapeProfile", "refractionStrength", + "gradientStartHex", "gradientEndHex" + ]) + XCTAssertEqual(object["colorMode"] as? String, "positionGradient") + XCTAssertEqual(object["effectStyle"] as? String, "liquidGlass") + XCTAssertEqual(object["shapeProfile"] as? String, "currentWave") + } + + func testStandaloneLayoutAndGradientKeepPersistedJSONShapes() throws { + var layout = KeyMappingProfile( + name: "Fixture Layout", + keyOffsets: [122: 0.1], + keyWidthOverrides: [122: 1.25] + ) + layout.id = try XCTUnwrap(UUID(uuidString: "BBBBBBBB-CCCC-4DDD-8EEE-FFFFFFFFFFFF")) + let layoutObject = try XCTUnwrap( + JSONSerialization.jsonObject(with: JSONEncoder().encode(layout)) as? [String: Any] + ) + + XCTAssertEqual(Set(layoutObject.keys), ["id", "name", "keyOffsets", "keyWidthOverrides"]) + let offsets = try XCTUnwrap(layoutObject["keyOffsets"] as? [String: Any]) + let widths = try XCTUnwrap(layoutObject["keyWidthOverrides"] as? [String: Any]) + XCTAssertEqual((offsets["122"] as? NSNumber)?.doubleValue, 0.1) + XCTAssertEqual((widths["122"] as? NSNumber)?.doubleValue, 1.25) + + let gradient = GradientPreset( + id: try XCTUnwrap(UUID(uuidString: "CCCCCCCC-DDDD-4EEE-8FFF-AAAAAAAAAAAA")), + startHex: "112233", + endHex: "445566", + name: "Fixture Gradient" + ) + let gradientObject = try XCTUnwrap( + JSONSerialization.jsonObject(with: JSONEncoder().encode(gradient)) as? [String: Any] + ) + XCTAssertEqual(Set(gradientObject.keys), ["id", "startHex", "endHex", "name"]) + } +} + +final class PreferencesStoreAdapterTests: XCTestCase { + func testAdapterUsesOnlyItsInjectedSuite() { + let isolated = IsolatedDefaults() + let store = PreferencesStore(userDefaults: isolated.defaults) + + let sentinelKey = "KeyLightTests.injected-store-sentinel" + store.set(true, forKey: sentinelKey) + store.set("value", forKey: "string") + store.set(Data([1, 2, 3]), forKey: "data") + store.set(["one": 1], forKey: "dictionary") + + XCTAssertTrue(store.bool(forKey: sentinelKey)) + XCTAssertEqual(store.string(forKey: "string"), "value") + XCTAssertEqual(store.data(forKey: "data"), Data([1, 2, 3])) + XCTAssertEqual(store.dictionary(forKey: "dictionary")?["one"] as? Int, 1) + XCTAssertNil(UserDefaults.standard.object(forKey: "KeyLightTests.injected-store-sentinel")) + + store.removeObject(forKey: sentinelKey) + XCTAssertNil(store.object(forKey: sentinelKey)) + } +} + +final class StableSelectionPersistenceTests: XCTestCase { + @MainActor + func testLegacyRecordsAreRepairedAfterMigrationAndKeepDeterministicIDs() throws { + let isolated = IsolatedDefaults() + isolated.bypassUnrelatedStartupMigrations(includeStableSelection: true) + let store = PreferencesStore(userDefaults: isolated.defaults) + + let legacyThemes = Data(""" + [ + { + "name": "First", + "colorHex": "112233", + "opacity": 0.4, + "size": 70, + "width": 1, + "fadeDuration": 0.8, + "colorMode": "solid" + }, + { + "name": "Selected", + "colorHex": "68B8FF", + "opacity": 0.75, + "size": 90, + "width": 1.2, + "fadeDuration": 1, + "colorMode": "positionGradient" + } + ] + """.utf8) + let legacyLayouts = Data(""" + [ + { + "name": "First Layout", + "keyOffsets": { "122": 0.1 } + }, + { + "name": "Selected Layout", + "keyOffsets": { "120": -0.2 }, + "keyWidthOverrides": { "120": 1.25 } + } + ] + """.utf8) + + let previouslySelectedThemeID = UUID() + let previouslySelectedLayoutID = UUID() + store.set(legacyThemes, forKey: "savedThemes") + store.set("Selected", forKey: "currentThemeName") + store.set(previouslySelectedThemeID.uuidString, forKey: "activeThemeID") + store.set(legacyLayouts, forKey: "keyMappingProfiles") + store.set("Selected Layout", forKey: "currentKeyMappingProfileName") + store.set(previouslySelectedLayoutID.uuidString, forKey: "activeLayoutID") + + let firstLoad = SettingsManager(preferencesStore: store) + let firstThemeIDs = firstLoad.savedThemes.map(\.id) + let firstLayoutIDs = firstLoad.savedKeyMappingProfiles.map(\.id) + + XCTAssertEqual(firstThemeIDs.count, 2) + XCTAssertEqual(firstLayoutIDs.count, 2) + XCTAssertEqual(firstThemeIDs[1], previouslySelectedThemeID) + XCTAssertEqual(firstLayoutIDs[1], previouslySelectedLayoutID) + XCTAssertEqual(firstLoad.activeThemeID, firstThemeIDs[1]) + XCTAssertEqual(firstLoad.activeLayoutID, firstLayoutIDs[1]) + XCTAssertEqual(firstLoad.savedThemes[1].effectStyle, .classicGlow) + XCTAssertEqual(firstLoad.savedKeyMappingProfiles[1].keyWidthOverrides[120], 1.25) + try assertEveryRecordHasUUID(in: XCTUnwrap(store.data(forKey: "savedThemes"))) + try assertEveryRecordHasUUID(in: XCTUnwrap(store.data(forKey: "keyMappingProfiles"))) + + // Simulate an older KeyLight build rewriting the arrays without the + // fields it does not know, after migration version 1 is already set. + store.set(legacyThemes, forKey: "savedThemes") + store.set(legacyLayouts, forKey: "keyMappingProfiles") + + let secondLoad = SettingsManager(preferencesStore: store) + + XCTAssertEqual(secondLoad.savedThemes.map(\.id), firstThemeIDs) + XCTAssertEqual(secondLoad.savedKeyMappingProfiles.map(\.id), firstLayoutIDs) + XCTAssertEqual(secondLoad.activeThemeID, firstThemeIDs[1]) + XCTAssertEqual(secondLoad.activeLayoutID, firstLayoutIDs[1]) + XCTAssertEqual(store.integer(forKey: "stableSelectionMigrationVersion"), 1) + try assertEveryRecordHasUUID(in: XCTUnwrap(store.data(forKey: "savedThemes"))) + try assertEveryRecordHasUUID(in: XCTUnwrap(store.data(forKey: "keyMappingProfiles"))) + } + + @MainActor + func testPartialLegacyRepairDoesNotReplaceExistingRecordIDs() throws { + let isolated = IsolatedDefaults() + isolated.bypassUnrelatedStartupMigrations(includeStableSelection: true) + let store = PreferencesStore(userDefaults: isolated.defaults) + let existingThemeID = UUID() + let existingLayoutID = UUID() + let themes = Data(""" + [ + { "id": "\(existingThemeID.uuidString)", "name": "Selected" }, + { "name": "Legacy" } + ] + """.utf8) + let layouts = Data(""" + [ + { + "id": "\(existingLayoutID.uuidString)", + "name": "Selected Layout", + "keyOffsets": {} + }, + { "name": "Legacy Layout", "keyOffsets": {} } + ] + """.utf8) + + store.set(themes, forKey: "savedThemes") + store.set("Selected", forKey: "currentThemeName") + store.set(existingThemeID.uuidString, forKey: "activeThemeID") + store.set(layouts, forKey: "keyMappingProfiles") + store.set("Selected Layout", forKey: "currentKeyMappingProfileName") + store.set(existingLayoutID.uuidString, forKey: "activeLayoutID") + + let settings = SettingsManager(preferencesStore: store) + + XCTAssertEqual(settings.savedThemes.first?.id, existingThemeID) + XCTAssertEqual(settings.savedKeyMappingProfiles.first?.id, existingLayoutID) + XCTAssertEqual(settings.activeThemeID, existingThemeID) + XCTAssertEqual(settings.activeLayoutID, existingLayoutID) + try assertEveryRecordHasUUID(in: XCTUnwrap(store.data(forKey: "savedThemes"))) + try assertEveryRecordHasUUID(in: XCTUnwrap(store.data(forKey: "keyMappingProfiles"))) + } + + @MainActor + func testLegacyNamesMigrateOnceToStableIDsAndRemainDualWritten() throws { + let isolated = IsolatedDefaults() + isolated.bypassUnrelatedStartupMigrations(includeStableSelection: false) + let store = PreferencesStore(userDefaults: isolated.defaults) + + let firstTheme = makeTheme(id: UUID(), name: "First") + let selectedTheme = makeTheme(id: UUID(), name: "Selected") + var firstLayout = KeyMappingProfile(name: "First Layout", keyOffsets: [122: 0.1]) + firstLayout.id = UUID() + var selectedLayout = KeyMappingProfile(name: "Selected Layout", keyOffsets: [120: -0.1]) + selectedLayout.id = UUID() + + store.set(try JSONEncoder().encode([firstTheme, selectedTheme]), forKey: "savedThemes") + store.set("Selected", forKey: "currentThemeName") + store.set(try JSONEncoder().encode([firstLayout, selectedLayout]), forKey: "keyMappingProfiles") + store.set("Selected Layout", forKey: "currentKeyMappingProfileName") + + let settings = SettingsManager(preferencesStore: store) + + XCTAssertEqual(settings.activeThemeID, selectedTheme.id) + XCTAssertEqual(settings.activeLayoutID, selectedLayout.id) + XCTAssertEqual(store.string(forKey: "activeThemeID"), selectedTheme.id.uuidString) + XCTAssertEqual(store.string(forKey: "activeLayoutID"), selectedLayout.id.uuidString) + XCTAssertEqual(store.string(forKey: "currentThemeName"), "Selected") + XCTAssertEqual(store.string(forKey: "currentKeyMappingProfileName"), "Selected Layout") + XCTAssertEqual(store.integer(forKey: "stableSelectionMigrationVersion"), 1) + } + + @MainActor + func testRenameKeepsIdentityAndActiveDeletionUsesStableFirstFallback() { + let isolated = IsolatedDefaults() + isolated.bypassUnrelatedStartupMigrations() + let store = PreferencesStore(userDefaults: isolated.defaults) + let settings = SettingsManager(preferencesStore: store) + + let fallbackTheme = makeTheme(id: UUID(), name: "Fallback") + let activeTheme = makeTheme(id: UUID(), name: "Active") + settings.savedThemes = [fallbackTheme, activeTheme] + settings.activeThemeID = activeTheme.id + settings.renameTheme(from: "Active", to: "Renamed") + + XCTAssertEqual(settings.activeThemeID, activeTheme.id) + XCTAssertEqual(settings.currentThemeName, "Renamed") + + settings.deleteTheme(named: "Renamed") + XCTAssertEqual(settings.activeThemeID, fallbackTheme.id) + XCTAssertEqual(settings.currentThemeName, "Fallback") + + var fallbackLayout = KeyMappingProfile(name: "Fallback Layout", keyOffsets: [:]) + fallbackLayout.id = UUID() + var activeLayout = KeyMappingProfile(name: "Active Layout", keyOffsets: [:]) + activeLayout.id = UUID() + settings.savedKeyMappingProfiles = [fallbackLayout, activeLayout] + settings.activeLayoutID = activeLayout.id + settings.renameKeyMappingProfile(from: "Active Layout", to: "Renamed Layout") + + XCTAssertEqual(settings.activeLayoutID, activeLayout.id) + XCTAssertEqual(settings.currentKeyMappingProfileName, "Renamed Layout") + + settings.deleteKeyMappingProfile(named: "Renamed Layout") + XCTAssertEqual(settings.activeLayoutID, fallbackLayout.id) + XCTAssertEqual(settings.currentKeyMappingProfileName, "Fallback Layout") + } + + @MainActor + func testDisplayRoutingPersistsStableSelectionAndValidatedLayoutBindings() { + let isolated = IsolatedDefaults() + isolated.bypassUnrelatedStartupMigrations() + let store = PreferencesStore(userDefaults: isolated.defaults) + let settings = SettingsManager(preferencesStore: store) + var profile = KeyMappingProfile(name: "Studio Layout", keyOffsets: [:]) + profile.id = UUID() + settings.savedKeyMappingProfiles = [profile] + + settings.overlayDisplaySelection = .specific("stable-display-uuid") + settings.setLayoutProfileBinding(profile.id, forDisplay: "stable-display-uuid") + + XCTAssertEqual(settings.overlayDisplaySelection, .specific("stable-display-uuid")) + XCTAssertEqual( + settings.displayLayoutProfileBindings["stable-display-uuid"], + profile.id + ) + XCTAssertEqual( + store.string(forKey: "overlayDisplaySelection"), + "display:stable-display-uuid" + ) + + store.set( + [ + "stable-display-uuid": profile.id.uuidString, + "invalid-profile": UUID().uuidString, + "malformed": "not-a-uuid" + ], + forKey: "displayLayoutProfileBindings" + ) + XCTAssertEqual(settings.displayLayoutProfileBindings, ["stable-display-uuid": profile.id]) + + settings.setLayoutProfileBinding(nil, forDisplay: "stable-display-uuid") + XCTAssertTrue(settings.displayLayoutProfileBindings.isEmpty) + XCTAssertNil(store.object(forKey: "displayLayoutProfileBindings")) + } + + @MainActor + func testCustomGlobalShortcutPersistsAndMalformedStorageFallsBackToDefault() throws { + let isolated = IsolatedDefaults() + isolated.bypassUnrelatedStartupMigrations() + let store = PreferencesStore(userDefaults: isolated.defaults) + let settings = SettingsManager(preferencesStore: store) + let shortcut = try XCTUnwrap(GlobalShortcut( + keyCode: UInt32(kVK_ANSI_L), + modifiers: UInt32(controlKey | optionKey) + )) + + settings.globalShortcut = shortcut + XCTAssertEqual(settings.globalShortcut, shortcut) + + store.set(Data("not-json".utf8), forKey: "globalShortcut") + XCTAssertEqual(settings.globalShortcut, .default) + } + + @MainActor + func testSameNameUpdatesPreserveExistingUUIDs() throws { + let isolated = IsolatedDefaults() + isolated.bypassUnrelatedStartupMigrations() + let settings = SettingsManager(preferencesStore: PreferencesStore(userDefaults: isolated.defaults)) + + let originalTheme = makeTheme(id: UUID(), name: "Stable") + settings.savedThemes = [originalTheme] + settings.activeThemeID = originalTheme.id + var replacementTheme = makeTheme(id: UUID(), name: "Stable") + replacementTheme.opacity = 0.25 + settings.saveTheme(replacementTheme) + + XCTAssertEqual(try XCTUnwrap(settings.savedThemes.first).id, originalTheme.id) + XCTAssertEqual(settings.activeThemeID, originalTheme.id) + + var originalLayout = KeyMappingProfile(name: "Stable Layout", keyOffsets: [122: 0.1]) + originalLayout.id = UUID() + var replacementLayout = KeyMappingProfile(name: "Stable Layout", keyOffsets: [122: 0.2]) + replacementLayout.id = UUID() + settings.savedKeyMappingProfiles = [originalLayout] + let persistedLayout = try XCTUnwrap(settings.saveKeyMappingProfile(replacementLayout)) + + XCTAssertEqual(try XCTUnwrap(settings.savedKeyMappingProfiles.first).id, originalLayout.id) + XCTAssertEqual(persistedLayout.id, originalLayout.id) + XCTAssertEqual(settings.activeLayoutID, originalLayout.id) + } + + @MainActor + func testEffectSnapshotUsesExistingKeysAndPermissionExplanationDefaultsFalse() { + let isolated = IsolatedDefaults() + isolated.bypassUnrelatedStartupMigrations() + let store = PreferencesStore(userDefaults: isolated.defaults) + let settings = SettingsManager(preferencesStore: store) + let effect = EffectConfiguration( + style: .physicalRefraction, + shapeProfile: .currentWave, + color: ColorConfiguration( + mode: .rainbow, + solidHex: "112233", + gradientStartHex: "445566", + gradientEndHex: "778899" + ), + opacity: 0.5, + refractionStrength: 2.2, + height: 90, + width: 1.5, + roundness: 0.4, + hardness: 0.3, + fadeDuration: 0.8 + ) + + settings.effectConfiguration = effect + + XCTAssertEqual(settings.effectConfiguration, effect) + XCTAssertEqual( + store.string(forKey: "effectStyle"), + "physicalRefraction" + ) + XCTAssertEqual( + store.string(forKey: "surfaceShapeProfile"), + "currentWave" + ) + XCTAssertEqual(store.string(forKey: "colorMode"), "rainbow") + XCTAssertEqual(store.object(forKey: "glowSize") as? Double, 90) + XCTAssertEqual( + store.object( + forKey: "physicalRefractionStrength" + ) as? Double, + 2.2 + ) + XCTAssertFalse(settings.hasSeenPermissionExplanation) + settings.hasSeenPermissionExplanation = true + XCTAssertEqual(store.object(forKey: "hasSeenPermissionExplanation") as? Bool, true) + } + + @MainActor + private func makeTheme(id: UUID, name: String) -> Theme { + Theme( + id: id, + name: name, + colorHex: "68B8FF", + opacity: 0.8, + size: 80, + width: 1, + glowRoundness: 0.7, + glowFullness: 0.6, + fadeDuration: 1, + colorMode: .positionGradient, + effectStyle: .classicGlow, + gradientStartHex: "68B8FF", + gradientEndHex: "00E69A" + ) + } + + private func assertEveryRecordHasUUID(in data: Data, file: StaticString = #filePath, line: UInt = #line) throws { + let records = try XCTUnwrap( + JSONSerialization.jsonObject(with: data) as? [[String: Any]], + file: file, + line: line + ) + XCTAssertFalse(records.isEmpty, file: file, line: line) + for record in records { + let rawID = try XCTUnwrap(record["id"] as? String, file: file, line: line) + XCTAssertNotNil(UUID(uuidString: rawID), file: file, line: line) + } + } +} + +final class LayoutImportPolicyTests: XCTestCase { + func testPolicyCountsUniqueKeysAcrossBothGeometryMaps() { + let keys = (0..=6.2) +@available(macOS 26.0, *) +@MainActor +private func makeLiquidGlassHarness() throws -> ( + window: GlowOverlayWindow, + renderer: LiquidGlassGlowView +) { + let window = GlowOverlayWindow(contentRect: CGRect(x: 0, y: 0, width: 1_000, height: 120)) + window.setEffectStyle(.systemGlass) + let renderer = try XCTUnwrap(window.glowRenderer as? LiquidGlassGlowView) + return (window, renderer) +} + +@available(macOS 26.0, *) +@MainActor +private func runLiquidGlassAnimation(for duration: TimeInterval) { + RunLoop.main.run(until: Date().addingTimeInterval(duration)) +} + +final class LiquidGlassBellShapeTests: XCTestCase { + func testEveryProfileHasAClosedFillAndAnOpenOpticalContour() throws { + guard #available(macOS 26.0, *) else { + throw XCTSkip("Native Liquid Glass requires macOS 26") + } + + let rect = CGRect(x: 0, y: 0, width: 300, height: 30) + for profile in SurfaceShapeProfile.allCases { + let fill = LiquidGlassBellShape( + emergence: 1, + profile: profile + ).path(in: rect) + let edge = LiquidGlassBellShape.edgePath( + in: rect, + emergence: 1, + smoothness: 0.7069, + flow: 0, + profile: profile + ) + + XCTAssertTrue( + containsCloseSubpath(fill.cgPath), + "\(profile) fill must remain closed" + ) + XCTAssertFalse( + containsCloseSubpath(edge.cgPath), + "\(profile) optical contour must not stroke the bezel baseline" + ) + XCTAssertEqual( + edge.boundingRect.maxY, + rect.height * 0.72, + accuracy: 0.000_001 + ) + } + } + + func testExpandedShapeHasLongFlatTopAndCurvedShouldersInsteadOfRectangleCorners() throws { + guard #available(macOS 26.0, *) else { + throw XCTSkip("Native Liquid Glass requires macOS 26") + } + + let rect = CGRect(x: 0, y: 0, width: 300, height: 30) + let path = LiquidGlassBellShape(emergence: 1).path(in: rect) + let bounds = path.boundingRect + + XCTAssertEqual(bounds.minX, rect.minX, accuracy: 0.000_001) + XCTAssertEqual(bounds.maxX, rect.maxX, accuracy: 0.000_001) + XCTAssertEqual(bounds.minY, 0, accuracy: 0.000_001) + XCTAssertEqual(bounds.maxY, rect.height * 0.72, accuracy: 0.000_001) + XCTAssertGreaterThan(bounds.width / bounds.height, 12) + + XCTAssertTrue(path.contains(CGPoint(x: rect.midX, y: 1))) + XCTAssertTrue(path.contains(CGPoint(x: rect.midX - 55, y: 1))) + XCTAssertTrue(path.contains(CGPoint(x: rect.midX + 55, y: 1))) + XCTAssertFalse(path.contains(CGPoint(x: 5, y: 1))) + XCTAssertFalse(path.contains(CGPoint(x: 295, y: 1))) + } + + func testCollapsedShapeStartsAtScreenEdgeAndExpandsHorizontallyWithEmergence() throws { + guard #available(macOS 26.0, *) else { + throw XCTSkip("Native Liquid Glass requires macOS 26") + } + + let rect = CGRect(x: 0, y: 0, width: 300, height: 30) + let collapsed = LiquidGlassBellShape(emergence: 0).path(in: rect).boundingRect + let solidBlackCollapsed = LiquidGlassBellShape( + emergence: 0, + minimumRise: 0 + ).path(in: rect).boundingRect + let expanded = LiquidGlassBellShape(emergence: 1).path(in: rect).boundingRect + + XCTAssertEqual(collapsed.maxY, rect.height * 0.72, accuracy: 0.000_001) + XCTAssertEqual(collapsed.height, 0.5, accuracy: 0.000_001) + XCTAssertEqual(collapsed.width, rect.width * 0.28, accuracy: 0.000_001) + XCTAssertEqual(solidBlackCollapsed.height, 0, accuracy: 0.000_001) + XCTAssertEqual(expanded.minY, 0, accuracy: 0.000_001) + XCTAssertEqual(expanded.width, rect.width, accuracy: 0.000_001) + } + + func testSmoothnessBroadensShouldersWhileRetainingAFlatTop() throws { + guard #available(macOS 26.0, *) else { + throw XCTSkip("Native Liquid Glass requires macOS 26") + } + + XCTAssertEqual( + LiquidGlassBellShape.shoulderShare(for: 0), + 0.12, + accuracy: 0.000_001 + ) + XCTAssertEqual( + LiquidGlassBellShape.shoulderShare(for: 1), + 0.42, + accuracy: 0.000_001 + ) + XCTAssertGreaterThan( + LiquidGlassBellShape.shoulderShare(for: 0.7069), + LiquidGlassBellShape.shoulderShare(for: 0) + ) + + let rect = CGRect(x: 0, y: 0, width: 300, height: 30) + let softPath = LiquidGlassBellShape(emergence: 1, smoothness: 1).path(in: rect) + XCTAssertTrue(softPath.contains(CGPoint(x: rect.midX - 25, y: 0.25))) + XCTAssertTrue(softPath.contains(CGPoint(x: rect.midX + 25, y: 0.25))) + } + + func testShapeSanitizesInvalidAndOutOfRangeEmergence() throws { + guard #available(macOS 26.0, *) else { + throw XCTSkip("Native Liquid Glass requires macOS 26") + } + + let rect = CGRect(x: 0, y: 0, width: 300, height: 30) + let collapsed = LiquidGlassBellShape(emergence: 0).path(in: rect).boundingRect + let expanded = LiquidGlassBellShape(emergence: 1).path(in: rect).boundingRect + + XCTAssertEqual( + LiquidGlassBellShape(emergence: .nan).path(in: rect).boundingRect, + collapsed + ) + XCTAssertEqual( + LiquidGlassBellShape(emergence: -20).path(in: rect).boundingRect, + collapsed + ) + XCTAssertEqual( + LiquidGlassBellShape(emergence: 20).path(in: rect).boundingRect, + expanded + ) + XCTAssertEqual( + LiquidGlassBellShape(emergence: 1, smoothness: .nan).path(in: rect).boundingRect, + LiquidGlassBellShape(emergence: 1, smoothness: 0.7069).path(in: rect).boundingRect + ) + } + + func testCohesiveBridgeCreatesAShallowSmoothSaddle() throws { + guard #available(macOS 26.0, *) else { + throw XCTSkip("Native Liquid Glass requires macOS 26") + } + + let compactSag = LiquidGlassCohesiveBridge.sag( + averageHeight: 23, + smoothness: 0 + ) + let smoothSag = LiquidGlassCohesiveBridge.sag( + averageHeight: 23, + smoothness: 1 + ) + XCTAssertGreaterThan(compactSag, smoothSag) + XCTAssertLessThanOrEqual(compactSag, 3.2) + + let bridge = LiquidGlassCohesiveBridge.path( + start: CGPoint(x: 420, y: 97), + end: CGPoint(x: 480, y: 97), + baselineY: 120, + averageHeight: 23, + smoothness: 0 + ) + XCTAssertTrue(bridge.contains(CGPoint(x: 450, y: 100))) + XCTAssertFalse(bridge.contains(CGPoint(x: 450, y: 96))) + } + + func testInteriorBridgePreservesBothExteriorEdges() throws { + guard #available(macOS 26.0, *) else { + throw XCTSkip("Native Liquid Glass requires macOS 26") + } + + let localRect = CGRect(x: 0, y: 0, width: 160, height: 30) + let left = LiquidGlassBellShape(emergence: 1) + .path(in: localRect) + .applying(CGAffineTransform( + translationX: 350, + y: 98.4 + )) + let right = LiquidGlassBellShape(emergence: 1) + .path(in: localRect) + .applying(CGAffineTransform( + translationX: 410, + y: 98.4 + )) + let individualUnion = left.union(right) + let bridge = LiquidGlassCohesiveBridge.path( + start: CGPoint(x: 430, y: 98.4), + end: CGPoint(x: 490, y: 98.4), + baselineY: 120, + averageHeight: 21.6, + smoothness: 0.7 + ) + let blended = individualUnion.union(bridge) + XCTAssertEqual( + blended.boundingRect.minX, + individualUnion.boundingRect.minX, + accuracy: 0.000_001 + ) + XCTAssertEqual( + blended.boundingRect.maxX, + individualUnion.boundingRect.maxX, + accuracy: 0.000_001 + ) + } + + private func containsCloseSubpath(_ path: CGPath) -> Bool { + var containsClose = false + path.applyWithBlock { element in + if element.pointee.type == .closeSubpath { + containsClose = true + } + } + return containsClose + } +} + +final class LiquidGlassRendererSmokeTests: XCTestCase { + func testSystemGlassPolicyUsesOnlySystemOptics() throws { + guard #available(macOS 26.0, *) else { + throw XCTSkip("System Glass requires macOS 26") + } + + XCTAssertTrue( + LiquidGlassPresentationMode.systemGlass + .extendsGlassBelowVisibleBaseline + ) + XCTAssertFalse( + LiquidGlassPresentationMode.solidBlack + .extendsGlassBelowVisibleBaseline + ) + } + + @MainActor + func testSystemGlassIsASeparateCaptureFreeRendererRoute() throws { + guard #available(macOS 26.0, *) else { + throw XCTSkip("System Glass requires macOS 26") + } + + let window = GlowOverlayWindow( + contentRect: CGRect(x: 0, y: 0, width: 1_000, height: 120) + ) + defer { window.close() } + + window.setEffectStyle(.systemGlass) + let renderer = try XCTUnwrap( + window.glowRenderer as? LiquidGlassGlowView + ) + + XCTAssertEqual(renderer.testPresentationMode, .systemGlass) + XCTAssertTrue(renderer.testExtendsGlassBelowVisibleBaseline) + XCTAssertTrue(renderer.supportsConcurrentPhysicalTargets) + XCTAssertEqual(renderer.testHostingViewCount, 1) + + window.setEffectStyle(.liquidGlass) + XCTAssertEqual(renderer.testPresentationMode, .systemGlass) + XCTAssertTrue( + try XCTUnwrap(window.glowRenderer as? LiquidGlassGlowView) + === renderer + ) + } + + @MainActor + func testPhysicalPermissionFallbackUsesSystemGlassAndNeverPrompts() throws { + guard #available(macOS 26.0, *) else { + throw XCTSkip("Native Liquid Glass requires macOS 26") + } + + let fallbackWindow = GlowOverlayWindow( + contentRect: CGRect(x: 0, y: 0, width: 1_000, height: 120), + screenCaptureAccessProvider: { false } + ) + defer { fallbackWindow.close() } + + fallbackWindow.setEffectStyle(.physicalRefraction) + let fallback = try XCTUnwrap( + fallbackWindow.glowRenderer as? LiquidGlassGlowView + ) + XCTAssertEqual(fallback.testPresentationMode, .systemGlass) + + let allowedWindow = GlowOverlayWindow( + contentRect: CGRect(x: 0, y: 0, width: 1_000, height: 120), + screenCaptureAccessProvider: { true } + ) + defer { allowedWindow.close() } + allowedWindow.setEffectStyle(.physicalRefraction) + let physical = try XCTUnwrap( + allowedWindow.glowRenderer as? LiquidGlassGlowView + ) + XCTAssertEqual(physical.testPresentationMode, .physicalRefraction) + } + + @MainActor + func testAutomaticPowerFallbackStopsCaptureAndReplaysEveryHeldIdentity() throws { + guard #available(macOS 26.0, *) else { + throw XCTSkip("Physical Refraction requires macOS 26") + } + + let panel = GlowOverlayWindow( + contentRect: CGRect(x: 0, y: 0, width: 1_000, height: 120), + screenCaptureAccessProvider: { true } + ) + defer { panel.close() } + let controller = OverlayController( + displayProvider: { + [OverlayDisplayCandidate( + id: 1, + isBuiltIn: true, + isMain: true, + frame: CGRect(x: 0, y: 0, width: 1_000, height: 800) + )] + }, + windowFactory: { _ in panel } + ) + var statuses: [EffectRuntimeStatus] = [] + controller.setRuntimeStatusHandler { statuses.append($0) } + controller.start() + controller.apply( + effectStyle: .physicalRefraction, + configuration: RendererConfiguration( + colorMode: .solid(.systemBlue), + powerSavingMode: .automatic, + powerEnvironmentState: .normal + ) + ) + let physical = try XCTUnwrap( + panel.glowRenderer as? LiquidGlassGlowView + ) + XCTAssertEqual(physical.testPresentationMode, .physicalRefraction) + + let left = GlowTarget.physicalKey(30, horizontalPosition: 0.35, keyWidth: 1) + let right = GlowTarget.physicalKey(31, horizontalPosition: 0.65, keyWidth: 1) + controller.handle( + .keyDown(30, source: .eventTap, timestamp: 1), + target: left + ) + controller.handle( + .keyDown(31, source: .eventTap, timestamp: 2), + target: right + ) + XCTAssertEqual(physical.testActiveTargetIDs, [left.id, right.id]) + + let constrained = PowerEnvironmentState( + isLowPowerModeEnabled: true, + thermalState: .serious + ) + controller.apply( + effectStyle: .physicalRefraction, + configuration: RendererConfiguration( + colorMode: .solid(.systemBlue), + powerSavingMode: .automatic, + powerEnvironmentState: constrained + ) + ) + let fallback = try XCTUnwrap( + panel.glowRenderer as? LiquidGlassGlowView + ) + XCTAssertEqual(fallback.testPresentationMode, .systemGlass) + XCTAssertTrue(physical.testActiveTargetIDs.isEmpty) + XCTAssertFalse(physical.testPhysicalCaptureIsReady) + XCTAssertEqual(fallback.testActiveTargetIDs, [left.id, right.id]) + XCTAssertEqual(statuses.last?.selectedEffect, .physicalRefraction) + XCTAssertEqual(statuses.last?.resolvedEffect, .systemGlass) + XCTAssertEqual(statuses.last?.automaticPowerSavingIsActive, true) + XCTAssertEqual(statuses.last?.powerEnvironmentState, constrained) + XCTAssertTrue(statuses.last?.fallbackReason?.contains("Low Power Mode") == true) + + controller.apply( + effectStyle: .physicalRefraction, + configuration: RendererConfiguration( + colorMode: .solid(.systemBlue), + powerSavingMode: .automatic, + powerEnvironmentState: .normal + ) + ) + let restored = try XCTUnwrap( + panel.glowRenderer as? LiquidGlassGlowView + ) + XCTAssertTrue(restored === physical) + XCTAssertEqual(restored.testPresentationMode, .physicalRefraction) + XCTAssertEqual(restored.testActiveTargetIDs, [left.id, right.id]) + } + + func testPhysicalRefractionStrengthPreservesBaselineAndExpandsSamplingBound() { + XCTAssertEqual( + PhysicalRefractionOptics.sanitizedStrength(.nan), + 1, + accuracy: 0.000_001 + ) + XCTAssertEqual( + PhysicalRefractionOptics.sanitizedStrength(0), + 0.5, + accuracy: 0.000_001 + ) + XCTAssertEqual( + PhysicalRefractionOptics.sanitizedStrength(4), + 2.5, + accuracy: 0.000_001 + ) + XCTAssertEqual( + PhysicalRefractionOptics.transmissionLimit(for: 1), + 26, + accuracy: 0.000_001 + ) + XCTAssertEqual( + PhysicalRefractionOptics.transmissionLimit(for: 2.5), + 65, + accuracy: 0.000_001 + ) + XCTAssertEqual( + PhysicalRefractionOptics.opticalMargin(for: 1), + 28, + accuracy: 0.000_001 + ) + } + + func testPhysicalRefractionUsesTheDisplayBackingScale() throws { + guard #available(macOS 26.0, *) else { + throw XCTSkip("Physical Refraction requires macOS 26") + } + + XCTAssertEqual( + PhysicalRefractionMetalView.renderScale(for: 1), + 1, + accuracy: 0.000_001 + ) + XCTAssertEqual( + PhysicalRefractionMetalView.renderScale(for: 2), + 2, + accuracy: 0.000_001 + ) + XCTAssertEqual( + PhysicalRefractionMetalView.renderScale(for: .nan), + 2, + accuracy: 0.000_001 + ) + } + + func testPhysicalFallbackClosesBelowTheVisibleBaseline() throws { + guard #available(macOS 26.0, *) else { + throw XCTSkip("Physical Refraction requires macOS 26") + } + + let rect = CGRect(x: 0, y: 0, width: 160, height: 30) + let regular = LiquidGlassBellShape(emergence: 1).path(in: rect) + let physical = LiquidGlassBellShape( + emergence: 1, + extendsBelowBaseline: true + ).path(in: rect) + + XCTAssertEqual( + regular.boundingRect.maxY, + rect.height * 0.72, + accuracy: 0.000_001 + ) + XCTAssertEqual( + physical.boundingRect.maxY, + rect.maxY, + accuracy: 0.000_001 + ) + XCTAssertEqual( + physical.boundingRect.minY, + regular.boundingRect.minY, + accuracy: 0.000_001 + ) + } + + func testPhysicalShaderDrawsTheOpenEdgeWithoutABottomLine() throws { + guard #available(macOS 26.0, *) else { + throw XCTSkip("Physical Refraction requires macOS 26") + } + guard let device = MTLCreateSystemDefaultDevice(), + let queue = device.makeCommandQueue(), + let library = try? device.makeDefaultLibrary( + bundle: .main + ) else { + throw XCTSkip("Metal device or compiled shader library is unavailable") + } + + let pipelineDescriptor = MTLRenderPipelineDescriptor() + pipelineDescriptor.vertexFunction = library.makeFunction( + name: "keyLightRefractionVertex" + ) + pipelineDescriptor.fragmentFunction = library.makeFunction( + name: "keyLightRefractionFragment" + ) + pipelineDescriptor.colorAttachments[0].pixelFormat = .bgra8Unorm + let pipeline = try device.makeRenderPipelineState( + descriptor: pipelineDescriptor + ) + + let outputWidth = 1_024 + let outputHeight = 240 + let outputDescriptor = MTLTextureDescriptor.texture2DDescriptor( + pixelFormat: .bgra8Unorm, + width: outputWidth, + height: outputHeight, + mipmapped: false + ) + outputDescriptor.storageMode = .shared + outputDescriptor.usage = [.renderTarget] + let output = try XCTUnwrap( + device.makeTexture(descriptor: outputDescriptor) + ) + + let backdropWidth = 512 + let backdropHeight = 200 + let backdropDescriptor = MTLTextureDescriptor.texture2DDescriptor( + pixelFormat: .bgra8Unorm, + width: backdropWidth, + height: backdropHeight, + mipmapped: false + ) + backdropDescriptor.storageMode = .shared + backdropDescriptor.usage = [.shaderRead] + let backdrop = try XCTUnwrap( + device.makeTexture(descriptor: backdropDescriptor) + ) + var backdropBytes = [UInt8]( + repeating: 0, + count: backdropWidth * backdropHeight * 4 + ) + for y in 0.. + var optics: SIMD4 + var tuning: SIMD4 + var counts: SIMD4 + } + struct TestSurface { + var frame: SIMD4 + var optical: SIMD4 + } + var uniforms = TestUniforms( + viewport: SIMD4(512, 120, 200, 0.05), + optics: SIMD4(2.05, 1, 1_024, 240), + tuning: SIMD4(1, 26, 0, 0), + counts: SIMD4(1, 0, 0, 0) + ) + var surface = TestSurface( + frame: SIMD4(176, 98.4, 160, 30), + optical: SIMD4(1, 1, 0.7, 0) + ) + + let pass = MTLRenderPassDescriptor() + pass.colorAttachments[0].texture = output + pass.colorAttachments[0].loadAction = .clear + pass.colorAttachments[0].storeAction = .store + pass.colorAttachments[0].clearColor = MTLClearColorMake(0, 0, 0, 0) + + func renderBytes() throws -> [UInt8] { + let commandBuffer = try XCTUnwrap(queue.makeCommandBuffer()) + let encoder = try XCTUnwrap( + commandBuffer.makeRenderCommandEncoder(descriptor: pass) + ) + encoder.setRenderPipelineState(pipeline) + encoder.setFragmentBytes( + &uniforms, + length: MemoryLayout.stride, + index: 0 + ) + encoder.setFragmentBytes( + &surface, + length: MemoryLayout.stride, + index: 1 + ) + encoder.setFragmentTexture(backdrop, index: 0) + encoder.drawPrimitives( + type: .triangle, + vertexStart: 0, + vertexCount: 3 + ) + encoder.endEncoding() + commandBuffer.commit() + commandBuffer.waitUntilCompleted() + XCTAssertNil(commandBuffer.error) + + var bytes = [UInt8]( + repeating: 0, + count: outputWidth * outputHeight * 4 + ) + bytes.withUnsafeMutableBytes { storage in + output.getBytes( + storage.baseAddress!, + bytesPerRow: outputWidth * 4, + from: MTLRegionMake2D( + 0, + 0, + outputWidth, + outputHeight + ), + mipmapLevel: 0 + ) + } + return bytes + } + + let outputBytes = try renderBytes() + func alpha(x: Int, y: Int) -> UInt8 { + outputBytes[(y * outputWidth + x) * 4 + 3] + } + + var topEdgeMaximum: UInt8 = 0 + for y in 188...214 { + for x in 420...604 { + topEdgeMaximum = max(topEdgeMaximum, alpha(x: x, y: y)) + } + } + var bottomCenterMaximum: UInt8 = 0 + for x in 420...604 { + bottomCenterMaximum = max( + bottomCenterMaximum, + alpha(x: x, y: outputHeight - 1) + ) + } + + XCTAssertGreaterThan(topEdgeMaximum, 64) + XCTAssertLessThanOrEqual(bottomCenterMaximum, 2) + XCTAssertEqual(alpha(x: 512, y: 228), 0) + + let baselineBytes = outputBytes + uniforms.tuning = SIMD4(2.5, 65, 0, 0) + let strongBytes = try renderBytes() + + var changedEdgePixelCount = 0 + for y in 188...214 { + for x in 420...604 { + let offset = (y * outputWidth + x) * 4 + let baselineAlpha = baselineBytes[offset + 3] + let strongAlpha = strongBytes[offset + 3] + guard baselineAlpha > 24, strongAlpha > 24 else { continue } + let colorDelta = (0..<3).reduce(0) { partial, channel in + partial + abs( + Int(strongBytes[offset + channel]) + - Int(baselineBytes[offset + channel]) + ) + } + if colorDelta > 4 { + changedEdgePixelCount += 1 + } + } + } + + var strongBottomCenterMaximum: UInt8 = 0 + for x in 420...604 { + strongBottomCenterMaximum = max( + strongBottomCenterMaximum, + strongBytes[ + ((outputHeight - 1) * outputWidth + x) * 4 + 3 + ] + ) + } + XCTAssertGreaterThan(changedEdgePixelCount, 50) + XCTAssertLessThanOrEqual(strongBottomCenterMaximum, 2) + + // Reproduce the reported pale-ridge failure: a dark captured strip with + // one bright final row. Strong refraction must use real on-screen pixels + // above the bezel instead of stretching that terminal row over the lens. + var brightBoundaryBackdrop = [UInt8]( + repeating: 0, + count: backdropWidth * backdropHeight * 4 + ) + for y in 0.. GlowTarget { + .physicalKey( + keyCode, + horizontalPosition: position ?? Double(keyCode) / 100, + keyWidth: 1 + ) + } + + private func down( + _ keyCode: UInt16, + repeat isRepeat: Bool = false, + timestamp: TimeInterval = 1 + ) -> KeyboardEvent { + .keyDown( + keyCode, + isRepeat: isRepeat, + source: .eventTap, + timestamp: timestamp + ) + } + + private func up(_ keyCode: UInt16) -> KeyboardEvent { + .keyUp(keyCode, source: .eventTap, timestamp: 2) + } + + func testNewestPhysicalKeyWinsAndReleaseRestoresMostRecentRemainingKey() { + var state = GlowInteractionState() + let first = target(10) + let second = target(20) + let third = target(30) + + state.handle(down(10), target: first) + state.handle(down(20), target: second) + let transition = state.handle(down(30), target: third) + + XCTAssertEqual(state.heldPhysicalKeyCodes, [10, 20, 30]) + XCTAssertEqual( + state.activePhysicalTargetsInPressOrder, + [first, second, third] + ) + XCTAssertEqual(transition.previous, second) + XCTAssertEqual(transition.current, third) + + let firstRelease = state.handle(up(30)) + XCTAssertEqual(firstRelease.previous, third) + XCTAssertEqual(firstRelease.current, second) + XCTAssertEqual( + state.activePhysicalTargetsInPressOrder, + [first, second] + ) + + let secondRelease = state.handle(up(20)) + XCTAssertEqual(secondRelease.current, first) + + let finalRelease = state.handle(up(10)) + XCTAssertNil(finalRelease.current) + XCTAssertTrue(state.heldPhysicalKeyCodes.isEmpty) + } + + func testDuplicateAndRepeatKeyDownAreIdempotentAndDoNotReorderHeldKeys() { + var state = GlowInteractionState() + let first = target(10) + let second = target(20) + + state.handle(down(10), target: first) + state.handle(down(20), target: second) + + let duplicate = state.handle(down(10), target: first) + let repeated = state.handle(down(10, repeat: true), target: first) + + XCTAssertTrue(duplicate.isNoOp) + XCTAssertTrue(repeated.isNoOp) + XCTAssertEqual(state.heldPhysicalKeyCodes, [10, 20]) + XCTAssertEqual(state.resolvedTarget, second) + } + + func testDuplicateDownCanRefreshStoredGeometryWithoutStealingPriority() { + var state = GlowInteractionState() + let updatedFirst = target(10, position: 0.75) + + state.handle(down(10), target: target(10, position: 0.1)) + state.handle(down(20), target: target(20)) + let suppressedUpdate = state.handle(down(10, repeat: true), target: updatedFirst) + + XCTAssertTrue(suppressedUpdate.isNoOp) + XCTAssertEqual(state.heldPhysicalKeyCodes, [10, 20]) + + state.handle(up(20)) + XCTAssertEqual(state.resolvedTarget, updatedFirst) + } + + func testReleasingNonDominantOrUnknownKeyDoesNotChangeResolution() { + var state = GlowInteractionState() + state.handle(down(10), target: target(10)) + state.handle(down(20), target: target(20)) + + XCTAssertTrue(state.handle(up(10)).isNoOp) + XCTAssertTrue(state.handle(up(99)).isNoOp) + XCTAssertEqual(state.heldPhysicalKeyCodes, [20]) + XCTAssertEqual(state.resolvedTarget, target(20)) + } + + func testPhysicalKeyBeatsEditorAndSettingsPreviews() { + var state = GlowInteractionState() + let settings = GlowTarget.preview( + .settings, + horizontalPosition: 0.5, + keyWidth: 1 + ) + let editor = GlowTarget.preview( + .keyEditor, + colorReferenceKeyCode: 42, + horizontalPosition: 0.25, + keyWidth: 1.5 + ) + + state.setPreview(settings, for: .settings) + state.setPreview(editor, for: .keyEditor) + + XCTAssertEqual(state.activePreviewSourcesInPriorityOrder, [.keyEditor, .settings]) + XCTAssertEqual(state.resolvedTarget, editor) + + let physical = target(18) + state.handle(down(18), target: physical) + XCTAssertEqual(state.resolvedTarget, physical) + } + + func testChordPreviewIsGroupedEphemeralAndBelowPhysicalInput() { + var state = GlowInteractionState() + let settings = GlowTarget.preview(.settings, horizontalPosition: 0.5, keyWidth: 1) + let chordTargets = PreviewSource.chordTestSources.enumerated().map { index, source in + GlowTarget.preview( + source, + colorReferenceKeyCode: UInt16(index), + horizontalPosition: 0.3 + Double(index) * 0.1, + keyWidth: 1 + ) + } + state.setPreview(settings, for: .settings) + + state.replaceChordTestTargets(chordTargets) + XCTAssertEqual(state.activeChordTestTargetsInSourceOrder, chordTargets) + XCTAssertEqual(state.resolvedTarget, chordTargets.first) + + state.handle(down(18), target: target(18)) + XCTAssertEqual(state.resolvedTarget, target(18)) + state.handle(up(18)) + XCTAssertEqual(state.resolvedTarget, chordTargets.first) + + let transition = state.clearChordTestTargets() + XCTAssertEqual(transition.current, settings) + XCTAssertTrue(state.activeChordTestTargetsInSourceOrder.isEmpty) + } + + func testEditorPreviewBeatsSettingsAndClearingItFallsBackToSettings() { + var state = GlowInteractionState() + let settings = GlowTarget.preview( + .settings, + horizontalPosition: 0.5, + keyWidth: 1 + ) + let editor = GlowTarget.preview( + .keyEditor, + horizontalPosition: 0.2, + keyWidth: 1 + ) + + state.setPreview(settings, for: .settings) + state.setPreview(editor, for: .keyEditor) + let transition = state.clearPreview(.keyEditor) + + XCTAssertEqual(transition.previous, editor) + XCTAssertEqual(transition.current, settings) + XCTAssertEqual(state.resolvedTarget, settings) + } + + func testPhysicalReleaseResumesStillActivePreview() { + var state = GlowInteractionState() + let settings = GlowTarget.preview( + .settings, + horizontalPosition: 0.5, + keyWidth: 1 + ) + let physical = target(18) + + state.setPreview(settings, for: .settings) + state.handle(down(18), target: physical) + let transition = state.handle(up(18)) + + XCTAssertEqual(transition.previous, physical) + XCTAssertEqual(transition.current, settings) + } + + func testStreamResetClearsPhysicalStateAndResumesPreview() { + var state = GlowInteractionState() + let editor = GlowTarget.preview( + .keyEditor, + horizontalPosition: 0.4, + keyWidth: 1 + ) + + state.setPreview(editor, for: .keyEditor) + state.handle(down(10), target: target(10)) + state.handle(down(20), target: target(20)) + + let transition = state.handle(.streamReset(source: .eventTap, timestamp: 3)) + + XCTAssertEqual(transition.previous, target(20)) + XCTAssertEqual(transition.current, editor) + XCTAssertTrue(state.heldPhysicalKeyCodes.isEmpty) + XCTAssertEqual(state.activePreviewSourcesInPriorityOrder, [.keyEditor]) + } + + func testClearAllRemovesPhysicalAndPreviewState() { + var state = GlowInteractionState() + state.setPreview( + .preview(.settings, horizontalPosition: 0.5, keyWidth: 1), + for: .settings + ) + state.handle(down(10), target: target(10)) + + let transition = state.clearAll() + + XCTAssertEqual(transition.previous, target(10)) + XCTAssertNil(transition.current) + XCTAssertTrue(state.heldPhysicalKeyCodes.isEmpty) + XCTAssertTrue(state.activePreviewSourcesInPriorityOrder.isEmpty) + } + + func testMismatchedPhysicalOrPreviewTargetIsIgnored() { + var state = GlowInteractionState() + + XCTAssertTrue(state.handle(down(10), target: target(20)).isNoOp) + XCTAssertTrue( + state.setPreview( + .preview(.settings, horizontalPosition: 0.5, keyWidth: 1), + for: .keyEditor + ).isNoOp + ) + XCTAssertNil(state.resolvedTarget) + } + + func testGlowTargetSanitizesGeometryAndCarriesOnlyColorReferenceCode() { + let physical = GlowTarget.physicalKey( + 42, + horizontalPosition: .nan, + keyWidth: .infinity + ) + let bounded = GlowTarget.preview( + .keyEditor, + colorReferenceKeyCode: 7, + horizontalPosition: -3, + keyWidth: 100 + ) + + XCTAssertEqual(physical.id, .physicalKey(42)) + XCTAssertEqual(physical.colorReferenceKeyCode, 42) + XCTAssertEqual(physical.horizontalPosition, 0.5) + XCTAssertEqual(physical.keyWidth, 1) + XCTAssertEqual(bounded.id, .preview(.keyEditor)) + XCTAssertEqual(bounded.colorReferenceKeyCode, 7) + XCTAssertEqual(bounded.horizontalPosition, 0) + XCTAssertEqual(bounded.keyWidth, 5) + } + + func testKeyboardEventMetadataIsCanonicalAndSanitizesTimestamp() { + let down = KeyboardEvent.keyDown( + 42, + isRepeat: true, + source: .consumerHID, + timestamp: .nan + ) + let reset = KeyboardEvent.streamReset(timestamp: -1) + + XCTAssertEqual(down.action, .down) + XCTAssertEqual(down.canonicalKeyCode, 42) + XCTAssertTrue(down.isRepeat) + XCTAssertEqual(down.source, .consumerHID) + XCTAssertEqual(down.timestamp, 0) + + XCTAssertEqual(reset.action, .streamReset) + XCTAssertNil(reset.canonicalKeyCode) + XCTAssertFalse(reset.isRepeat) + XCTAssertEqual(reset.source, .lifecycle) + XCTAssertEqual(reset.timestamp, 0) + } +} diff --git a/KeyLightTests/SystemServiceTests.swift b/KeyLightTests/SystemServiceTests.swift new file mode 100644 index 0000000..a0eff1a --- /dev/null +++ b/KeyLightTests/SystemServiceTests.swift @@ -0,0 +1,897 @@ +import Foundation +import AppKit +import Carbon.HIToolbox +import XCTest +@testable import KeyLight + +final class UpdateServiceConfigurationTests: XCTestCase { + func testSecureUpdaterConfigurationRequiresHTTPSAndAnEd25519PublicKey() { + let validKey = Data(repeating: 0xA5, count: 32).base64EncodedString() + + XCTAssertTrue(UpdateService.hasSecureConfiguration( + feed: "https://keylight.example/appcast.xml", + publicKey: validKey + )) + XCTAssertFalse(UpdateService.hasSecureConfiguration( + feed: "http://keylight.example/appcast.xml", + publicKey: validKey + )) + XCTAssertFalse(UpdateService.hasSecureConfiguration( + feed: "https://placeholder:placeholder@keylight.example/appcast.xml", + publicKey: validKey + )) + XCTAssertFalse(UpdateService.hasSecureConfiguration( + feed: "https://keylight.example/appcast.xml", + publicKey: Data(repeating: 0xA5, count: 31).base64EncodedString() + )) + XCTAssertFalse(UpdateService.hasSecureConfiguration( + feed: nil, + publicKey: validKey + )) + } +} + +final class BuildIdentityTests: XCTestCase { + func testExplicitBuildChannelProducesCopyableSupportSummary() { + let identity = KeyLightBuildIdentity( + info: [ + "CFBundleDisplayName": "KeyLight Motion Preview", + "CFBundleShortVersionString": "2.1.0", + "CFBundleVersion": "25", + "KeyLightBuildChannel": "Motion Preview Local" + ], + bundleIdentifier: "com.keylight.app.motionpreview", + bundlePath: "/Applications/KeyLight Motion Preview.app" + ) + + XCTAssertEqual(identity.versionDescription, "2.1.0 (25)") + XCTAssertEqual(identity.channel, "Motion Preview Local") + XCTAssertEqual( + identity.supportSummary, + """ + KeyLight Motion Preview 2.1.0 (25) + Channel: Motion Preview Local + Bundle ID: com.keylight.app.motionpreview + Bundle path: /Applications/KeyLight Motion Preview.app + """ + ) + } + + func testMissingChannelFallsBackFromKnownBundleIdentity() { + let identity = KeyLightBuildIdentity( + info: [:], + bundleIdentifier: "com.keylight.app.motionpreview", + bundlePath: "/tmp/KeyLight Motion Preview.app" + ) + + XCTAssertEqual(identity.displayName, "KeyLight") + XCTAssertEqual(identity.versionDescription, "—") + XCTAssertEqual(identity.channel, "Motion Preview") + } +} + +final class CalibrationKeyPolicyTests: XCTestCase { + func testSpaceIsReservedForPhysicalPreviewInsteadOfButtonActivation() { + XCTAssertTrue( + KeyLightCalibrationKeyPolicy.consumesLocalControlActivation( + keyCode: 49, + modifierFlags: [] + ) + ) + XCTAssertTrue( + KeyLightCalibrationKeyPolicy.consumesLocalControlActivation( + keyCode: 49, + modifierFlags: [.shift] + ) + ) + XCTAssertFalse( + KeyLightCalibrationKeyPolicy.consumesLocalControlActivation( + keyCode: 49, + modifierFlags: [.command] + ) + ) + XCTAssertFalse( + KeyLightCalibrationKeyPolicy.consumesLocalControlActivation( + keyCode: 36, + modifierFlags: [] + ) + ) + } +} + +final class LocalPrivacyAndOnboardingTests: XCTestCase { + @MainActor + func testFreshOnboardingCanBeDeferredAndCompletedWithoutChangingEffect() { + let isolated = IsolatedSystemServiceDefaults() + defer { isolated.remove() } + let store = PreferencesStore(userDefaults: isolated.defaults) + let service = FakeLaunchAtLoginService(status: .disabled) + let settings = SettingsManager( + preferencesStore: store, + launchAtLoginService: service + ) + + XCTAssertTrue(settings.shouldPresentOnboarding) + settings.effectStyle = .systemGlass + settings.deferOnboarding() + XCTAssertFalse(settings.shouldPresentOnboarding) + + let deferred = SettingsManager( + preferencesStore: store, + launchAtLoginService: service + ) + XCTAssertFalse(deferred.shouldPresentOnboarding) + XCTAssertEqual(deferred.effectStyle, .systemGlass) + + deferred.completeOnboarding() + let completed = SettingsManager( + preferencesStore: store, + launchAtLoginService: service + ) + XCTAssertFalse(completed.shouldPresentOnboarding) + XCTAssertEqual(completed.effectStyle, .systemGlass) + } +} + +final class LaunchAtLoginServiceTests: XCTestCase { + @MainActor + func testSuccessfulRegistrationUsesReadBackStatus() { + let client = FakeLaunchAtLoginSystemClient(status: .disabled) + client.statusAfterRegister = .enabled + let service = LaunchAtLoginService(systemClient: client) + + let result = service.setEnabled(true) + + XCTAssertEqual(client.registerCount, 1) + XCTAssertEqual(client.unregisterCount, 0) + XCTAssertEqual(result, LaunchAtLoginChangeResult( + requestedEnabled: true, + status: .enabled, + outcome: .applied + )) + XCTAssertTrue(result.isApplied) + XCTAssertEqual(service.status, .enabled) + } + + @MainActor + func testApprovalRequiredIsReportedWithoutPretendingRequestApplied() { + let client = FakeLaunchAtLoginSystemClient(status: .disabled) + client.statusAfterRegister = .requiresApproval + let service = LaunchAtLoginService(systemClient: client) + + let result = service.setEnabled(true) + + XCTAssertEqual(result.status, .requiresApproval) + XCTAssertEqual(result.outcome, .requiresApproval) + XCTAssertFalse(result.isApplied) + XCTAssertFalse(result.status.isEnabled) + } + + @MainActor + func testThrownRegistrationReportsFailureAndActualState() { + let client = FakeLaunchAtLoginSystemClient(status: .disabled) + client.registerShouldThrow = true + let service = LaunchAtLoginService(systemClient: client) + + let result = service.setEnabled(true) + + XCTAssertEqual(result, LaunchAtLoginChangeResult( + requestedEnabled: true, + status: .disabled, + outcome: .failed(.registrationFailed) + )) + XCTAssertFalse(result.isApplied) + } + + @MainActor + func testSuccessfulUnregistrationUsesReadBackStatus() { + let client = FakeLaunchAtLoginSystemClient(status: .enabled) + client.statusAfterUnregister = .disabled + let service = LaunchAtLoginService(systemClient: client) + + let result = service.setEnabled(false) + + XCTAssertEqual(client.registerCount, 0) + XCTAssertEqual(client.unregisterCount, 1) + XCTAssertEqual(result.status, .disabled) + XCTAssertEqual(result.outcome, .applied) + } + + @MainActor + func testAlreadySatisfiedRequestDoesNotCallSystemOperationAgain() { + let enabledClient = FakeLaunchAtLoginSystemClient(status: .enabled) + let enabledService = LaunchAtLoginService(systemClient: enabledClient) + let disabledClient = FakeLaunchAtLoginSystemClient(status: .disabled) + let disabledService = LaunchAtLoginService(systemClient: disabledClient) + + XCTAssertTrue(enabledService.setEnabled(true).isApplied) + XCTAssertTrue(disabledService.setEnabled(false).isApplied) + + XCTAssertEqual(enabledClient.registerCount, 0) + XCTAssertEqual(enabledClient.unregisterCount, 0) + XCTAssertEqual(disabledClient.registerCount, 0) + XCTAssertEqual(disabledClient.unregisterCount, 0) + } + + @MainActor + func testDisablingPendingApprovalUnregistersTheLoginItem() { + let client = FakeLaunchAtLoginSystemClient(status: .requiresApproval) + client.statusAfterUnregister = .disabled + let service = LaunchAtLoginService(systemClient: client) + + let result = service.setEnabled(false) + + XCTAssertEqual(client.unregisterCount, 1) + XCTAssertEqual(result.status, .disabled) + XCTAssertEqual(result.outcome, .applied) + XCTAssertTrue(result.isApplied) + } + + @MainActor + func testSettingsManagerMirrorsOnlyAuthoritativeSystemState() { + let isolated = IsolatedSystemServiceDefaults() + defer { isolated.remove() } + let store = PreferencesStore( + userDefaults: isolated.defaults, + usesSystemPreferences: true + ) + let service = FakeLaunchAtLoginService(status: .disabled) + service.nextResult = LaunchAtLoginChangeResult( + requestedEnabled: true, + status: .requiresApproval, + outcome: .requiresApproval + ) + let settings = SettingsManager( + preferencesStore: store, + launchAtLoginService: service + ) + + let result = settings.setLaunchAtLogin(true) + + XCTAssertEqual(result.outcome, .requiresApproval) + XCTAssertEqual(service.requests, [true]) + XCTAssertFalse(isolated.defaults.bool(forKey: "launchAtLogin")) + XCTAssertFalse(settings.launchAtLogin) + + service.nextResult = LaunchAtLoginChangeResult( + requestedEnabled: false, + status: .enabled, + outcome: .failed(.unregistrationFailed) + ) + let failure = settings.setLaunchAtLogin(false) + + XCTAssertEqual(failure.outcome, .failed(.unregistrationFailed)) + XCTAssertTrue(isolated.defaults.bool(forKey: "launchAtLogin")) + XCTAssertTrue(settings.launchAtLogin) + } + + @MainActor + func testIsolatedSettingsStoreKeepsLegacyBehaviorWithoutCallingSystem() { + let isolated = IsolatedSystemServiceDefaults() + defer { isolated.remove() } + let service = FakeLaunchAtLoginService(status: .unavailable) + let settings = SettingsManager( + preferencesStore: PreferencesStore(userDefaults: isolated.defaults), + launchAtLoginService: service + ) + + let result = settings.setLaunchAtLogin(true) + + XCTAssertEqual(result.outcome, .applied) + XCTAssertTrue(settings.launchAtLogin) + XCTAssertTrue(isolated.defaults.bool(forKey: "launchAtLogin")) + XCTAssertTrue(service.requests.isEmpty) + XCTAssertEqual(service.statusReadCount, 0) + } + + @MainActor + func testKeyLightModelRollsBackApprovalRequiredLaunchRequestWithTypedFeedback() { + let isolated = IsolatedSystemServiceDefaults() + defer { isolated.remove() } + let service = FakeLaunchAtLoginService(status: .disabled) + let settings = SettingsManager( + preferencesStore: PreferencesStore( + userDefaults: isolated.defaults, + usesSystemPreferences: true + ), + launchAtLoginService: service + ) + let model = KeyLightModel(settings: settings) + service.nextResult = LaunchAtLoginChangeResult( + requestedEnabled: true, + status: .requiresApproval, + outcome: .requiresApproval + ) + + model.launchAtLogin = true + + XCTAssertFalse(model.launchAtLogin) + XCTAssertEqual(model.feedback?.severity, .warning) + XCTAssertEqual(model.feedback?.title, "Launch at Login Needs Approval") + XCTAssertEqual(service.requests, [true]) + } + + @MainActor + func testKeyLightModelRollsBackFailedLaunchRequestToAuthoritativeState() { + let isolated = IsolatedSystemServiceDefaults() + defer { isolated.remove() } + let service = FakeLaunchAtLoginService(status: .enabled) + let settings = SettingsManager( + preferencesStore: PreferencesStore( + userDefaults: isolated.defaults, + usesSystemPreferences: true + ), + launchAtLoginService: service + ) + let model = KeyLightModel(settings: settings) + service.nextResult = LaunchAtLoginChangeResult( + requestedEnabled: false, + status: .enabled, + outcome: .failed(.unregistrationFailed) + ) + + model.launchAtLogin = false + + XCTAssertTrue(model.launchAtLogin) + XCTAssertEqual(model.feedback?.severity, .error) + XCTAssertEqual(model.feedback?.title, "Launch at Login Failed") + XCTAssertEqual(service.requests, [false]) + } + + @MainActor + func testKeyLightModelRefreshesExternallyChangedLaunchStatus() { + let isolated = IsolatedSystemServiceDefaults() + defer { isolated.remove() } + let service = FakeLaunchAtLoginService(status: .disabled) + let settings = SettingsManager( + preferencesStore: PreferencesStore( + userDefaults: isolated.defaults, + usesSystemPreferences: true + ), + launchAtLoginService: service + ) + let model = KeyLightModel(settings: settings, feedbackAnnouncer: { _ in }) + XCTAssertFalse(model.launchAtLogin) + + service.simulateStatus(.enabled) + model.refreshLaunchAtLoginStatus() + + XCTAssertTrue(model.launchAtLogin) + XCTAssertTrue(isolated.defaults.bool(forKey: "launchAtLogin")) + XCTAssertTrue(service.requests.isEmpty) + } +} + +final class KeyLightModelRuntimeTests: XCTestCase { + @MainActor + func testSavedThemeIdentityAndEditedStateLiveInModel() { + let isolated = IsolatedSystemServiceDefaults() + defer { isolated.remove() } + let settings = SettingsManager( + preferencesStore: PreferencesStore(userDefaults: isolated.defaults), + launchAtLoginService: FakeLaunchAtLoginService(status: .disabled) + ) + let theme = Theme( + id: UUID(), + name: "Baseline", + colorHex: settings.glowColorHex, + opacity: settings.glowOpacity, + refractionStrength: settings.physicalRefractionStrength, + size: settings.glowSize, + width: settings.glowWidth, + glowRoundness: settings.glowRoundness, + glowFullness: settings.glowFullness, + fadeDuration: settings.fadeDuration, + colorMode: settings.colorMode, + effectStyle: settings.effectStyle, + gradientStartHex: settings.gradientStartHex, + gradientEndHex: settings.gradientEndHex + ) + settings.savedThemes = [theme] + settings.activeThemeID = theme.id + + let model = KeyLightModel(settings: settings, feedbackAnnouncer: { _ in }) + + XCTAssertEqual(model.selectedTheme?.id, theme.id) + XCTAssertFalse(model.selectedThemeIsEdited) + + model.glowOpacity = max(0, theme.opacity - 0.1) + XCTAssertTrue(model.selectedThemeIsEdited) + + model.applyTheme(theme) + XCTAssertFalse(model.selectedThemeIsEdited) + + settings.renameTheme(from: theme.name, to: "Renamed") + model.reloadSavedThemes() + XCTAssertEqual(model.selectedTheme?.id, theme.id) + XCTAssertEqual(model.selectedTheme?.name, "Renamed") + XCTAssertFalse(model.selectedThemeIsEdited) + } + + @MainActor + func testRuntimeCallbacksAreDirectImmediateAndDisconnectable() { + let isolated = IsolatedSystemServiceDefaults() + defer { isolated.remove() } + let settings = SettingsManager( + preferencesStore: PreferencesStore(userDefaults: isolated.defaults), + launchAtLoginService: FakeLaunchAtLoginService(status: .disabled) + ) + let model = KeyLightModel(settings: settings, feedbackAnnouncer: { _ in }) + var enabledValues: [Bool] = [] + var configurationChangeCount = 0 + var permissionRequestCount = 0 + var permissionRetryCount = 0 + var openSettingsCount = 0 + var previewTargets: [(GlowTarget, PreviewSource)] = [] + var clearedPreviewSources: [PreviewSource] = [] + var chordPreviewTargets: [[GlowTarget]] = [] + var chordPreviewClearCount = 0 + model.connectRuntime( + onEnabledChange: { enabledValues.append($0) }, + onConfigurationChange: { configurationChangeCount += 1 }, + onPermissionRequest: { permissionRequestCount += 1 }, + onPermissionRetry: { permissionRetryCount += 1 }, + onOpenInputMonitoringSettings: { openSettingsCount += 1 }, + onPreviewSet: { previewTargets.append(($0, $1)) }, + onPreviewClear: { clearedPreviewSources.append($0) }, + onChordPreviewSet: { chordPreviewTargets.append($0) }, + onChordPreviewClear: { chordPreviewClearCount += 1 } + ) + + model.glowOpacity = 0.42 + model.isEnabled = false + model.requestInputMonitoringPermission() + model.retryInputMonitoring() + model.openInputMonitoringSettings() + let preview = GlowTarget.preview( + .settings, + horizontalPosition: 0.5, + keyWidth: 1 + ) + model.setPreview(preview, source: .settings) + model.clearPreview(.settings) + let chord = [GlowTarget.preview( + .chordTest1, + colorReferenceKeyCode: 0, + horizontalPosition: 0.4, + keyWidth: 1 + )] + model.setChordPreview(chord) + model.clearChordPreview() + + XCTAssertEqual(enabledValues, [false]) + XCTAssertEqual(configurationChangeCount, 2) + XCTAssertEqual(permissionRequestCount, 1) + XCTAssertEqual(permissionRetryCount, 1) + XCTAssertEqual(openSettingsCount, 1) + XCTAssertEqual(previewTargets.first?.0, preview) + XCTAssertEqual(previewTargets.first?.1, .settings) + XCTAssertEqual(clearedPreviewSources, [.settings]) + XCTAssertEqual(chordPreviewTargets, [chord]) + XCTAssertEqual(chordPreviewClearCount, 1) + + model.disconnectRuntime() + model.isEnabled = true + model.requestInputMonitoringPermission() + model.retryInputMonitoring() + model.openInputMonitoringSettings() + model.setPreview(preview, source: .settings) + model.clearPreview(.settings) + model.setChordPreview(chord) + model.clearChordPreview() + XCTAssertEqual(enabledValues, [false]) + XCTAssertEqual(permissionRequestCount, 1) + XCTAssertEqual(permissionRetryCount, 1) + XCTAssertEqual(openSettingsCount, 1) + XCTAssertEqual(previewTargets.count, 1) + XCTAssertEqual(clearedPreviewSources, [.settings]) + XCTAssertEqual(chordPreviewTargets, [chord]) + XCTAssertEqual(chordPreviewClearCount, 1) + model.flushPendingPersist() + } + + @MainActor + func testPhysicalActivityIsEphemeralCanonicalMetadataOnly() { + let isolated = IsolatedSystemServiceDefaults() + defer { isolated.remove() } + let model = KeyLightModel( + settings: SettingsManager( + preferencesStore: PreferencesStore(userDefaults: isolated.defaults), + launchAtLoginService: FakeLaunchAtLoginService(status: .disabled) + ), + feedbackAnnouncer: { _ in } + ) + + model.receivePhysicalKeyboardEvent(.keyDown( + 12, + source: .eventTap, + timestamp: 1 + )) + XCTAssertEqual(model.physicalKeyActivity, PhysicalKeyActivity( + sequence: 1, + keyCode: 12, + isDown: true + )) + + model.receivePhysicalKeyboardEvent(.keyUp( + 12, + source: .eventTap, + timestamp: 2 + )) + XCTAssertEqual(model.physicalKeyActivity, PhysicalKeyActivity( + sequence: 2, + keyCode: 12, + isDown: false + )) + + model.receivePhysicalKeyboardEvent(.streamReset(timestamp: 3)) + XCTAssertNil(model.physicalKeyActivity) + } + + @MainActor + func testTypedFeedbackProducesOneAccessibilityAnnouncement() { + let isolated = IsolatedSystemServiceDefaults() + defer { isolated.remove() } + var announcements: [String] = [] + let model = KeyLightModel( + settings: SettingsManager( + preferencesStore: PreferencesStore(userDefaults: isolated.defaults), + launchAtLoginService: FakeLaunchAtLoginService(status: .disabled) + ), + feedbackAnnouncer: { announcements.append($0) } + ) + let feedback = UserFeedback( + severity: .success, + title: "Theme Saved", + detail: "Ocean is now up to date." + ) + + model.feedback = feedback + model.feedback = feedback + + XCTAssertEqual(announcements, ["Theme Saved. Ocean is now up to date."]) + } + + @MainActor + func testPermissionTransitionsProduceTypedRecoverableFeedbackOnce() { + let isolated = IsolatedSystemServiceDefaults() + defer { isolated.remove() } + var announcements: [String] = [] + let model = KeyLightModel( + settings: SettingsManager( + preferencesStore: PreferencesStore(userDefaults: isolated.defaults), + launchAtLoginService: FakeLaunchAtLoginService(status: .disabled) + ), + feedbackAnnouncer: { announcements.append($0) } + ) + + model.updateInputMonitoring( + state: .permissionRequired, + appPath: "/Applications/KeyLight.app", + installationIssue: nil + ) + model.updateInputMonitoring( + state: .permissionRequired, + appPath: "/Applications/KeyLight.app", + installationIssue: nil + ) + + XCTAssertEqual(model.feedback?.severity, .warning) + XCTAssertEqual(model.feedback?.recoveryAction, .openInputMonitoringSettings) + XCTAssertEqual(announcements.count, 1) + + model.updateInputMonitoring( + state: .active, + appPath: "/Applications/KeyLight.app", + installationIssue: nil + ) + XCTAssertEqual(model.feedback?.severity, .success) + XCTAssertEqual(announcements.count, 2) + } +} + +final class HotKeyServiceTests: XCTestCase { + @MainActor + func testStartRegistersOnceAndDeliversPresses() { + let registrar = FakeHotKeyRegistrar() + let recorder = HotKeyServiceRecorder() + let service = HotKeyService( + registrar: registrar, + onPress: recorder.recordPress, + onStatusChange: recorder.recordStatus + ) + + service.start() + service.start() + + XCTAssertEqual(service.status, .registered) + XCTAssertEqual(registrar.registerCount, 1) + XCTAssertEqual(registrar.shortcuts, [.default]) + XCTAssertEqual(recorder.statuses, [.registering, .registered]) + + registrar.registrations[0].press() + XCTAssertEqual(recorder.pressCount, 1) + } + + @MainActor + func testChangingShortcutAtomicallyReplacesRegistrationAndInvalidatesOldCallback() throws { + let registrar = FakeHotKeyRegistrar() + let recorder = HotKeyServiceRecorder() + let service = HotKeyService( + registrar: registrar, + onPress: recorder.recordPress, + onStatusChange: recorder.recordStatus + ) + let replacement = try XCTUnwrap(GlobalShortcut( + keyCode: UInt32(kVK_ANSI_L), + modifiers: UInt32(controlKey | optionKey) + )) + + service.start() + let staleRegistration = registrar.registrations[0] + service.setShortcut(replacement) + staleRegistration.press() + registrar.registrations[1].press() + + XCTAssertEqual(registrar.shortcuts, [.default, replacement]) + XCTAssertEqual(staleRegistration.unregisterCount, 1) + XCTAssertEqual(recorder.pressCount, 1) + XCTAssertEqual(service.status, .registered) + } + + func testShortcutValidationAndDisplayName() throws { + XCTAssertNil(GlobalShortcut(keyCode: UInt32(kVK_ANSI_K), modifiers: 0)) + XCTAssertNil(GlobalShortcut(keyCode: 999, modifiers: UInt32(cmdKey))) + XCTAssertEqual(GlobalShortcut.default.displayName, "⌘⇧K") + let custom = try XCTUnwrap(GlobalShortcut( + keyCode: UInt32(kVK_LeftArrow), + modifiers: UInt32(controlKey | optionKey | cmdKey) + )) + XCTAssertEqual(custom.displayName, "⌘⌃⌥←") + } + + @MainActor + func testStopIsIdempotentAndInvalidatesStalePresses() { + let registrar = FakeHotKeyRegistrar() + let recorder = HotKeyServiceRecorder() + let service = HotKeyService( + registrar: registrar, + onPress: recorder.recordPress, + onStatusChange: recorder.recordStatus + ) + service.start() + let registration = registrar.registrations[0] + + service.stop() + service.stop() + registration.press() + + XCTAssertEqual(service.status, .stopped) + XCTAssertEqual(registration.unregisterCount, 1) + XCTAssertEqual(recorder.pressCount, 0) + XCTAssertEqual(recorder.statuses, [.registering, .registered, .stopped]) + } + + @MainActor + func testTypedRegistrationFailureCanBeRetried() { + let registrar = FakeHotKeyRegistrar(failures: [ + .hotKeyRegistrationFailed(status: -9876) + ]) + let recorder = HotKeyServiceRecorder() + let service = HotKeyService( + registrar: registrar, + onPress: recorder.recordPress, + onStatusChange: recorder.recordStatus + ) + + service.start() + XCTAssertEqual(service.status, .unavailable( + .hotKeyRegistrationFailed(status: -9876) + )) + XCTAssertEqual(registrar.registerCount, 1) + + service.start() + XCTAssertEqual(service.status, .registered) + XCTAssertEqual(registrar.registerCount, 2) + XCTAssertEqual(registrar.registrations.count, 1) + XCTAssertEqual(recorder.statuses, [ + .registering, + .unavailable(.hotKeyRegistrationFailed(status: -9876)), + .registering, + .registered + ]) + } + + @MainActor + func testHandlerInstallationFailureAndStopRemainTypedAndIdempotent() { + let failure = HotKeyRegistrationFailure.eventHandlerInstallationFailed(status: -50) + let registrar = FakeHotKeyRegistrar(failures: [failure, failure]) + let recorder = HotKeyServiceRecorder() + let service = HotKeyService( + registrar: registrar, + onPress: recorder.recordPress, + onStatusChange: recorder.recordStatus + ) + + service.start() + service.stop() + service.stop() + + XCTAssertEqual(service.status, .stopped) + XCTAssertEqual(registrar.registerCount, 1) + XCTAssertTrue(registrar.registrations.isEmpty) + XCTAssertEqual(recorder.statuses, [ + .registering, + .unavailable(failure), + .stopped + ]) + } + + @MainActor + func testStopDuringRegistrationCleansUpLateRegistration() { + let registrar = FakeHotKeyRegistrar() + var service: HotKeyService? + service = HotKeyService( + registrar: registrar, + onPress: {}, + onStatusChange: { status in + if status == .registering { + service?.stop() + } + } + ) + + service?.start() + + XCTAssertEqual(service?.status, .stopped) + XCTAssertEqual(registrar.registerCount, 1) + XCTAssertEqual(registrar.registrations.first?.unregisterCount, 1) + } +} + +@MainActor +private final class FakeLaunchAtLoginSystemClient: LaunchAtLoginSystemClient { + var status: LaunchAtLoginStatus + var statusAfterRegister: LaunchAtLoginStatus? + var statusAfterUnregister: LaunchAtLoginStatus? + var registerShouldThrow = false + var unregisterShouldThrow = false + private(set) var registerCount = 0 + private(set) var unregisterCount = 0 + + init(status: LaunchAtLoginStatus) { + self.status = status + } + + func register() throws { + registerCount += 1 + if registerShouldThrow { + throw FakeSystemServiceError.operationFailed + } + if let statusAfterRegister { + status = statusAfterRegister + } + } + + func unregister() throws { + unregisterCount += 1 + if unregisterShouldThrow { + throw FakeSystemServiceError.operationFailed + } + if let statusAfterUnregister { + status = statusAfterUnregister + } + } +} + +@MainActor +private final class FakeLaunchAtLoginService: LaunchAtLoginServicing { + private var storedStatus: LaunchAtLoginStatus + var nextResult: LaunchAtLoginChangeResult? + private(set) var requests: [Bool] = [] + private(set) var statusReadCount = 0 + + var status: LaunchAtLoginStatus { + statusReadCount += 1 + return storedStatus + } + + init(status: LaunchAtLoginStatus) { + storedStatus = status + } + + func simulateStatus(_ status: LaunchAtLoginStatus) { + storedStatus = status + } + + func setEnabled(_ enabled: Bool) -> LaunchAtLoginChangeResult { + requests.append(enabled) + let result = nextResult ?? LaunchAtLoginChangeResult( + requestedEnabled: enabled, + status: enabled ? .enabled : .disabled, + outcome: .applied + ) + storedStatus = result.status + return result + } +} + +@MainActor +private final class FakeHotKeyRegistration: HotKeyRegistration { + private let onPress: @MainActor @Sendable () -> Void + private(set) var unregisterCount = 0 + + init(onPress: @escaping @MainActor @Sendable () -> Void) { + self.onPress = onPress + } + + func unregister() { + unregisterCount += 1 + } + + func press() { + onPress() + } +} + +@MainActor +private final class FakeHotKeyRegistrar: HotKeyRegistering { + private var failures: [HotKeyRegistrationFailure] + private(set) var registerCount = 0 + private(set) var registrations: [FakeHotKeyRegistration] = [] + private(set) var shortcuts: [GlobalShortcut] = [] + + init(failures: [HotKeyRegistrationFailure] = []) { + self.failures = failures + } + + func register( + _ shortcut: GlobalShortcut, + onPress: @escaping @MainActor @Sendable () -> Void + ) -> Result { + registerCount += 1 + shortcuts.append(shortcut) + if !failures.isEmpty { + return .failure(failures.removeFirst()) + } + + let registration = FakeHotKeyRegistration(onPress: onPress) + registrations.append(registration) + return .success(registration) + } +} + +@MainActor +private final class HotKeyServiceRecorder { + private(set) var pressCount = 0 + private(set) var statuses: [HotKeyServiceStatus] = [] + + func recordPress() { + pressCount += 1 + } + + func recordStatus(_ status: HotKeyServiceStatus) { + statuses.append(status) + } +} + +private enum FakeSystemServiceError: Error { + case operationFailed +} + +private final class IsolatedSystemServiceDefaults { + let suiteName = "KeyLight.SystemServiceTests.\(UUID().uuidString)" + let defaults: UserDefaults + + init() { + guard let defaults = UserDefaults(suiteName: suiteName) else { + fatalError("Could not create isolated defaults suite") + } + self.defaults = defaults + defaults.removePersistentDomain(forName: suiteName) + } + + func remove() { + defaults.removePersistentDomain(forName: suiteName) + } +} diff --git a/PRIVACY.md b/PRIVACY.md index 7943b5d..df1418c 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -1,48 +1,47 @@ # KeyLight Privacy Policy -Last updated: February 27, 2026 +Last updated: August 30, 2026 + This privacy policy is informational and not legal advice. ## Summary -KeyLight is a local macOS app. It does not collect personal data and does not send data to external servers or require an internet connection. +KeyLight is a local macOS app. It does not collect personal data, use analytics, show advertising, require an account, or upload your typing or settings. ## What KeyLight accesses -- Input Monitoring events (required to detect global key press/release state). -- Local app settings stored in `UserDefaults`. -- Local files only when you explicitly transfer data: - - layout profiles via JSON import/export - - theme strings via copy/import +- Input Monitoring is required to detect global key press and release state. KeyLight keeps only the key identity, direction, repeat state, source, and timing needed to draw the effect. It does not keep typed characters or a typing history. +- Physical Refraction can optionally use Screen Recording after you press its permission button. The other effects do not need it. +- Settings, themes, keyboard layouts, and configuration snapshots are stored locally on your Mac. +- Files are read or written only when you explicitly import or export a theme, layout, or configuration snapshot. -## What KeyLight does not do +## Physical Refraction -- No telemetry -- No analytics SDKs -- No cloud sync (use the import function to transfer layouts and themes) -- No account/login system -- No background network upload of your data -- No keystroke content logging by design +Physical Refraction captures only a shallow strip along the bottom of each selected display while the effect is visible. KeyLight keeps at most the newest frame for each display and passes it directly to the GPU. -## Data storage +The captured image is not saved, copied to the clipboard, read with OCR, logged, or sent anywhere. Capture stops when the effect finishes, when KeyLight is disabled, when the effect changes, when the Mac sleeps, or when KeyLight quits. + +## What KeyLight does not do -KeyLight stores settings locally on your Mac, including: +- No telemetry or analytics +- No cloud sync or account system +- No keystroke content logging +- No saved screenshots or captured-image history +- No background upload of settings, themes, layouts, or snapshots +- No scripts, plugins, or executable code loaded from imported files -- Effect settings (color, size, fade, mode) -- Saved themes/profiles -- Key position/width adjustments +## Updates and network access -You can export and delete this data at any time from within the app. +The unsigned KeyLight v2.0.0 release has no configured update feed or update key. Its update controls remain unavailable and it does not make update-check requests. Download future versions manually from the official GitHub Releases page. -## Community presets / imported files +The source includes support for a signed Sparkle update feed if a future Developer ID release configures one. Automatic checks remain off until explicitly enabled, and KeyLight does not send a system profile or custom identifiers. -Imported presets are treated as untrusted data and parsed as JSON or text only. -No scripts, plugins, or executable code are loaded from imported files. But users should treat all such as insecure and not blindly trust others (general caution and common sense). +## Local data -## Security note +KeyLight stores its preferences in the normal macOS application preferences. This includes effect settings, themes, keyboard layouts, calibration, display routing, shortcuts, and saved configuration snapshots. -Keep macOS and KeyLight updated. There is not yet an auto update function, you will need to uninstall and install a new version from the GitHub repo. Only import presets/settings from sources you trust. +You can remove this data by deleting KeyLight's preferences. Input Monitoring and Screen Recording permissions are controlled separately by macOS in System Settings. ## General -KeyLight is distributed under the MIT License. The MIT License already includes warranty/liability disclaimer language ("AS IS", without warranty). +KeyLight is distributed under the MIT License and comes without warranty as described in that license. diff --git a/README.md b/README.md index dcf85bf..5aba1a5 100644 --- a/README.md +++ b/README.md @@ -14,93 +14,88 @@ Hi, KeyLight was inspired by a [YouTube video](https://www.youtube.com/watch?v=e ## Why KeyLight -- Ambient typing effect for your Mac. +- Ambient typing effects for your Mac. - Lightweight runtime designed to stay out of your way. -- No noticeable battery drain in normal use on Apple Silicon (tested on M4). -- Highly customizable effects: colors, gradients, per-key behavior, dimensions, roundness, and fade timing. -- Built-in key position editor to calibrate glow placement to your keyboard and monitor combination. +- Multiple styles ranging from the original glow to glass and refraction effects. +- Highly customizable colors, gradients, dimensions, roundness, fade timing, and chord behavior. +- Built-in guided and manual calibration for your keyboard and display combination. -KeyLight Hero +KeyLight demo ## System Requirements - macOS **14.0+** (Sonoma and higher) -- Input Monitoring permission (required for global key listening) +- macOS **14.0+** for Classic Glow +- macOS **26.0+** for System Glass, Physical Refraction, and Solid Black +- Input Monitoring permission to detect key presses globally +- Screen Recording permission only if you choose Physical Refraction -## Installing KeyLight +Classic Glow remains available if your Mac does not support the newer effects. Classic Glow, System Glass, and Solid Black do not need Screen Recording. -1. Download the .dmg from the releases page. -2. Open `KeyLight-.dmg`. -3. Drag `KeyLight.app` to `Applications`. -4. Launch from `Applications`. +## Installing KeyLight -## First-Run Setup +1. Download `KeyLight-2.0.0.dmg` from the Releases page. +2. Open the DMG. +3. Drag `KeyLight.app` to Applications and replace the old version if macOS asks. +4. Launch KeyLight from Applications. -KeyLight currently shows the macOS verification warning because this build is unsigned (I'm currently not enrolled in the Apple Developer Program, which is US$99/year). +KeyLight v2.0.0 is unsigned because I am still not enrolled in the Apple Developer Program. macOS will therefore show a verification warning. -1. Launch `KeyLight.app` from `Applications`. -2. If macOS shows `"KeyLight" Not Opened` / `Apple could not verify "KeyLight"...`: - - Click `Done` in that first warning popup. - - Open `System Settings` -> `Privacy & Security`. - - Scroll down to the `Security` section. - - Click `Open Anyway` for `KeyLight`. - - In the second popup (`Open "KeyLight"?`), click `Open Anyway` again. - - Enter your macOS password (or Touch ID) to confirm. -3. Grant **Input Monitoring** when macOS requests it. -4. Click **Quit & Reopen** the app when the prompt appears. -5. Start typing. +1. Try opening `KeyLight.app` once and click `Done` in the warning. +2. Open **System Settings › Privacy & Security**. +3. Scroll to the Security section and click **Open Anyway** for KeyLight. +4. Confirm **Open Anyway** once more and enter your password or use Touch ID. +5. In KeyLight's setup window, choose **Allow Input Monitoring…** and enable KeyLight. -If the prompt does not appear try typing this into the terminal to reset permissions in macOS: -```bash -killall KeyLight 2>/dev/null || true -tccutil reset ListenEvent com.keylight.app -open "x-apple.systempreferences:com.apple.preference.security?Privacy_ListenEvent" -``` +Because this is an unsigned update, macOS may ask for Input Monitoring again. If KeyLight is enabled but does not react, remove the old KeyLight row from Input Monitoring, add `/Applications/KeyLight.app` again, and enable it. -More troubleshooting info: +More troubleshooting: - [docs/TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md) ## Features - Menubar-only app -- Global hotkey: `Cmd + Shift + K` to toggle KeyLight -- Color modes: Solid, Position Gradient, Random Per Key, Rainbow -- Theme save/load with rename and delete actions -- Layout profile system (save, load, export, import) -- Key position editor with drag calibration and glow preview for maximum customizability +- Customizable global shortcut, with `Cmd + Shift + K` as the default +- Multiple held keys stay lit until each key is released +- Natural Merge or Independent chord appearance with adjustable intensity +- Classic Glow, System Glass, Physical Refraction, and Solid Black effects +- Solid, Position Gradient, Random Per Key, and Rainbow color modes +- Theme and keyboard-layout libraries with import and export +- Guided nine-key calibration plus the original manual key editor +- MacBook ANSI, MacBook ISO, and compact Magic Keyboard starting presets +- Main, built-in, or selected-display routing with optional additional-display mirroring +- Separate keyboard layouts bound to different displays +- Named configuration snapshots with apply, import, export, and restore +- Automatic power saving during Low Power Mode or higher thermal pressure - Launch at login -## Community Keyboard Layouts/Presets +## Keyboard Layouts and Presets -Current checks for submissions: -- .JSON-only parsing -- No executable/plugin/script loading -- Import size cap (max. 5MB) -- Strict validation, normalization, and clamping -- Allowed-key filtering and max entry limits -- Numeric and string sanitation on import paths +This repo includes calibrated MacBook Air 13" (2024) and MacBook Pro 14" (2024) layouts plus neutral MacBook ANSI, MacBook ISO, and compact Magic Keyboard starting profiles. The calibrated MacBook Air profile is selected by default. -## MacBook Air 13" (2024) & Pro 14" (2024) layouts +- See `docs/variants/` for the bundled layouts. +- Use **Keyboard › Add Preset…** to make an editable profile from a starting point. +- Use **Keyboard › Import** and **Export** to move layout profiles. +- Use **Share Theme** and **Import Theme…** to move appearance settings. -This repo includes a ready-to-share layout for a MacBook Air 13" (2024) bundle which is selected by default. It also supports the Macbook Pro 14" (2024) as a preset. +My baseline is the **German ISO** layout of the MacBook Air 13" (2024), with guidance for US keyboard (**ANSI**) layouts. Most keys should still map 1:1 for different layouts of the same MacBook variant. -- check `docs/variants/` for more layouts (hopefuly to come soon) +## Building -Import flow: -- Use `keylight-layout-profile-template.json` via **Key Layout -> Import** for layout (offsets + width) transfer. -- Use `Copy Theme String` / `Import Theme String` in **Themes** for shareable custom glow themes. +Open `KeyLight.xcodeproj` in Xcode 26 or newer and build the KeyLight scheme. The verified unsigned release package can be created with: -My baseline is the **German ISO** layout of the Macbook Air 13" (2024), with guidance for US keyboard (**ANSI**) layouts. However, most keys should just map 1:1 for different keyboard layouts of the same variant. +```bash +./scripts/build-dmg.sh --release-unsigned 2.0.0 +``` -## Next Steps & Known Issues +Run `./scripts/build-dmg.sh` without arguments to see the other local, preview, and signed packaging modes. -Here are some features I would still like to implement to this app in the future if I come around to it: -- Liquid glass-like effect similar to the button presses +## Known Issues -Known issues are: -- Media key handling is missing/wrong for the media keys corresponding to F4 (maps to F5 media key), F5 (fallback to middle), F6, F7, F9 (no keylight). This is due to difficult handling with the HID, but to be honest I have no idea why it does not work. If you have a fix please let me know. -- Caps lock release (from ON to OFF) does not give a KeyLight effect. This is due to the handling of the effect in macOS. Right now, I force it to light up only briefly. Otherwise it would stay on as long caps lock is ON. +- Updates are manual in this unsigned release. Download newer versions from the GitHub Releases page. +- Some hardware and macOS combinations still report the media actions corresponding to F4–F9 inconsistently. An unrecognized action may use the center fallback or show no glow. +- macOS exposes Caps Lock as a state change rather than a normal key-up event. KeyLight shows a short pulse so it cannot remain stuck while Caps Lock is on. ## Privacy and License diff --git a/docs/COMPATIBILITY_CONTRACT.md b/docs/COMPATIBILITY_CONTRACT.md new file mode 100644 index 0000000..f42a43c --- /dev/null +++ b/docs/COMPATIBILITY_CONTRACT.md @@ -0,0 +1,58 @@ +# KeyLight Compatibility Contract + +This file freezes the behavior that the incremental rewrite must preserve. A change to one of these contracts requires an explicit migration and a compatibility test; architectural cleanup alone is not a reason to change it. + +## Product boundary + +- KeyLight is a local-only, menu-bar macOS utility with no Dock icon or persistent dashboard. +- The passive overlay remains nonactivating, click-through, absent from the accessibility tree, and anchored to the bottom edge of the keyboard display. +- Input Monitoring uses a listen-only event tap. Decoding may retain canonical key codes, direction, repeat state, source, and timestamp only. Character data, typed content, positions, colors, layouts, and imported payloads are never logged or transmitted. +- Classic Glow remains available on macOS 14 and later. System Glass, Physical Refraction, and Solid Black are available on macOS 26 and resolve to Classic Glow below it; all macOS 26 API references stay compiler- and availability-guarded. +- Classic+ and the custom prismatic Liquid Glass preview are retired migration values. Existing preferences and themes map to Classic Glow and System Glass respectively, but neither retired route is selectable or rendered. +- There are no accounts, analytics, crash-upload SDKs, plug-ins, typing history, or per-app rules. The only network feature is the signed updater, which stays inactive until a manual check or explicit automatic-check opt-in. + +## Persistent compatibility + +The authoritative key list is asserted by `SettingsManager._testUserDefaultsKeyContract`. Existing keys retain their names, defaults, clamps, and meanings. The optional `activeThemeID`, `activeLayoutID`, `hasSeenPermissionExplanation`, `overlayDisplaySelection`, `mirroredDisplayIDs`, `displayLayoutProfileBindings`, `globalShortcut`, `chordSurfaceStyle`, `chordIntensityMultiplier`, `powerSavingMode`, `configurationSnapshotsV1`, and `configurationSnapshotRecoveryV1` keys are additive. Active theme/layout names continue to be written for older builds. Display selection uses a stable CoreGraphics display UUID and always falls back to the original built-in-first policy if the requested display is unavailable. Mirror IDs remain persisted while unavailable and are deduplicated from the resolved primary display. The global shortcut defaults to Command-Shift-K; custom shortcuts persist key-code and modifier metadata only. + +- Theme share strings retain the `keylight-theme-v1` and `keylight-theme-v2` grammar and field ordering. +- Layout profiles retain the current JSON schema, canonical key allow-list, and media-key aliases. +- Existing saved theme and layout UUIDs are stable identities. Legacy records without IDs are upgraded deterministically by name. +- Imports are transactional: invalid input changes neither live state nor persisted state. +- Import limits are 1 MB, 512 unique key entries, and 100 characters per saved name. +- Configuration snapshot documents use `kind: keylightConfigurationSnapshot`, version 1, the `.keylight-snapshot.json` suffix, a 1 MB import limit, and a 500 KB persistent-data limit. Applying a snapshot may write only the typed exportable-key registry; unknown JSON keys are ignored and excluded settings are never replaced. +- `SMAppService.mainApp.status` is the source of truth for launch-at-login; the legacy preference is only a compatibility mirror after a successful change. + +## Input and overlay behavior + +- Every physically held key renders concurrently without a logical key limit. Natural Merge preserves the established cohesive surface behavior; Independent keeps one stable surface identity per key with no bridge geometry. Releasing one key does not move or hide any remaining key. +- Priority is physical key, then the temporary chord test, then calibration preview, then Settings preview. The chord test is never persisted and records no physical input. +- Tap failure, permission loss, sleep, disablement, or monitor restart clears physical interaction state. Still-active previews may resume after a physical reset. +- Start, stop, reset, show, refresh, hide, and clear operations are safe to repeat. +- Classic Glow pixels and timings remain unchanged unless a visual contract is deliberately revised. +- Surface effects use the shared persistent motion engine with bounded surfaces during interruption and style changes. Physical Refraction uses a separate event-driven Metal renderer with on-demand capture. +- Automatic Power Saving preserves the selected Physical Refraction preference, stops capture under Low Power Mode or serious/critical thermal pressure, re-renders held keys through the supported fallback, and restores once the condition clears. +- Selected mirror displays share the active layout and central interaction state. Input, previews, configuration, power changes, resets, and clears are broadcast to every live overlay panel; unavailable mirror IDs remain saved for reconnect. +- Reduce Motion suppresses geometry animation. Reduce Transparency and Increase Contrast adjust material visibility without changing the selected supported route. + +## Release gates + +- Swift 6 strict-concurrency Debug build for the generic macOS destination. +- Full isolated unit/integration suite, including migration and import fixtures. +- Universal arm64/x86_64 Release build, weak-link and privacy scans, signing/notarization checks when credentials are available, mounted-DMG verification, and launch smoke test. +- Shell syntax validation for checked-in release scripts. +- Manual checks on ANSI and ISO keyboards, media/Fn/Caps Lock, clamshell and built-in displays, Spaces/full-screen, Light/Dark and textured backgrounds, VoiceOver, Reduce Motion, Reduce Transparency, Increase Contrast, macOS 14 Classic Glow, and all three macOS 26 surface routes. +- Real-hardware results must describe the exact candidate DMG hash. A release-ready report set has at least one pass for every gate; `not-applicable` never counts as coverage. + +## Performance gates + +Measurements are taken from a signed Release build on reference Apple Silicon after a 30-second settling period: + +- input-event receipt to render submission p99 below 16.7 ms; +- idle median CPU below 0.5%; +- no event-tap timeout during a 60-second synthetic stress run; +- bounded views and layers after 100,000 synthetic transitions. + +Automated tests enforce deterministic state and allocation bounds. CPU and end-to-end event latency remain hardware release checks because virtualized CI measurements are not comparable. + +The measurement procedure and current verification boundary are documented in [PERFORMANCE_BASELINE.md](PERFORMANCE_BASELINE.md). diff --git a/docs/HARDWARE_VALIDATION.md b/docs/HARDWARE_VALIDATION.md new file mode 100644 index 0000000..7c1d5d3 --- /dev/null +++ b/docs/HARDWARE_VALIDATION.md @@ -0,0 +1,84 @@ +# Motion Preview Hardware Validation + +Automated tests prove state transitions, renderer bounds, migration behavior, +privacy policy, universal architecture, and package integrity. They cannot prove +keyboard rollover behavior, display topology, macOS permission continuity, visual +quality, or real input-to-render latency. Those remain real-hardware gates. + +## Build the candidate + +Use the end-to-end preview harness. It protects `/Applications/KeyLight.app` by +fingerprinting its file contents before and after the isolated preview build. + +```bash +# Local/ad-hoc candidate +KEYLIGHT_CLONED_SOURCE_PACKAGES_DIR=/path/to/SourcePackages \ + ./scripts/validate-motion-preview.sh --local 2.2.0 + +# Developer ID-signed, notarized candidate +KEYLIGHT_CLONED_SOURCE_PACKAGES_DIR=/path/to/SourcePackages \ + ./scripts/validate-motion-preview.sh --signed 2.2.0 +``` + +The signed route requires an installed Developer ID Application identity and the +`KeyLightNotary` notarytool Keychain profile. It deliberately keeps the isolated +`KeyLight Motion Preview.app` name and `com.keylight.app.motionpreview` bundle ID, +and embeds no production Sparkle feed or update key. + +The harness publishes a DMG, a SHA-256 sidecar, and a property-list validation +record in `dist/validation/`. Every real-hardware gate starts as `pending`; an +automated run never marks a human observation as passed. + +## Record real-hardware results + +List the gate IDs, then record one result at a time: + +```bash +./scripts/hardware-validation.sh list dist/validation/REPORT.plist + +./scripts/hardware-validation.sh record \ + dist/validation/REPORT.plist \ + ansi_chords pass \ + '2, 3, and 4 adjacent and distant keys; staggered release passed' + +./scripts/hardware-validation.sh record \ + dist/validation/REPORT.plist \ + multi_display_mirroring pass \ + 'Two physical displays; resize, disconnect, reconnect, sleep, and wake passed' +``` + +Allowed statuses are `pending`, `pass`, `fail`, `blocked`, and +`not-applicable`. A failure, blocker, or not-applicable result requires a note. +Performance passes also require the measured value in the note. Do not put names, +serial numbers, hardware UUIDs, display UUIDs, typed content, or key codes in +notes. + +One machine cannot cover both macOS 14 and macOS 26, and one keyboard may not +cover both ANSI and ISO. Complete separate reports against the exact same DMG. +An individual report may use `not-applicable`, but that does not count as suite +coverage. + +## Verify the candidate set + +```bash +./scripts/hardware-validation.sh verify \ + dist/validation/REPORT.plist \ + dist/KeyLight-2.2.0-motion-preview-signed.dmg + +./scripts/hardware-validation.sh verify-suite \ + dist/KeyLight-2.2.0-motion-preview-signed.dmg \ + dist/validation/macOS14-REPORT.plist \ + dist/validation/macOS26-REPORT.plist +``` + +`verify` rejects a report that has pending, failing, or blocked gates or whose +DMG hash differs. `verify-suite` additionally requires at least one actual pass +for every gate across all supplied reports. + +## Measurement boundary + +For latency and CPU, use the signed universal Release candidate and the process +described in [PERFORMANCE_BASELINE.md](PERFORMANCE_BASELINE.md). The report stores +only the Mac model identifier, architecture, macOS version/build, candidate +identity, trust status, source commit, and gate results. It does not query or +store device serial numbers or stable hardware/display identifiers. diff --git a/docs/PERFORMANCE_BASELINE.md b/docs/PERFORMANCE_BASELINE.md new file mode 100644 index 0000000..e707a73 --- /dev/null +++ b/docs/PERFORMANCE_BASELINE.md @@ -0,0 +1,34 @@ +# KeyLight Performance Baseline + +Performance is a release contract. KeyLight now keeps keyboard decoding on a dedicated user-interactive CFRunLoop thread, forwards only normalized value events to the MainActor, and drives the Physical Refraction MTKView only when motion, backdrop, configuration, resize, or clearing actually requires a frame. + +The normal input hot path resolves established raw key codes directly. It does not construct an AppKit event or inspect character metadata unless an otherwise-unresolved special/function-key event needs that platform hint. + +## Automated baseline + +`PerformanceContractTests` runs 100,000 synthetic press/release pairs through the pure held-key and preview model. It verifies that physical state returns to empty, preview priority remains correct, and the event value exposes no character or text field. Renderer smoke tests separately assert the fixed view/surface budget and cleanup after style changes. The authoritative total test count comes from the current `.xcresult` bundle so this document cannot drift when coverage grows. + +These tests are deterministic correctness and bounded-state gates. They are not presented as end-to-end latency or CPU measurements. + +## Hardware release baseline + +Measure a signed universal Release build on the reference Apple Silicon Mac after 30 seconds of idle settling. Record the Mac model, macOS build, KeyLight version/build, effect style, display topology, and accessibility display options with each result. + +Required gates: + +- input receipt to render submission p99 below 16.7 ms; +- idle median CPU below 0.5% over five minutes; +- no event-tap timeout during a 60-second typing stress run; +- stable view/layer counts after 100,000 synthetic transitions. + +Use Instruments Points of Interest with the DEBUG-only anonymous sequence signposts for the latency sample, and Activity Monitor/Instruments for idle CPU. The signposts cover input receipt, normalization, MainActor dispatch, overlay update, renderer submission, capture start/stop, and presented/dropped frames. They never include key codes, characters, positions, colors, theme values, captured pixels, or imported data. + +## Current verification status + +The deterministic 100,000-transition test, bounded renderer assertions, event-driven draw contract, dedicated input-loop behavior, and privacy-safe signpost contract are checked in. Hardware CPU/GPU measurements, p95/p99 latency, 60/120 Hz frame pacing, signed-build stress, and macOS 14/macOS 26 visual results remain explicit release gates and must be recorded from real hardware; CI or an unsigned debug build is not an acceptable substitute. + +Create and verify candidate-bound hardware records with +[`scripts/hardware-validation.sh`](../scripts/hardware-validation.sh) using the +workflow in [HARDWARE_VALIDATION.md](HARDWARE_VALIDATION.md). The verifier rejects +pending/failing gates, measured performance passes without notes, a mismatched DMG +hash, and validation suites with no actual pass for any required gate. diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index ac32eda..ce6bc6d 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -1,73 +1,58 @@ # KeyLight Troubleshooting -This guide covers first-run setup and recovery for Input Monitoring issues. -The expected bundle identifier for this project is `com.keylight.app`. Some of this is repeated from the README.md of this repo. +This guide covers the unsigned macOS warning, Input Monitoring, and the optional Screen Recording permission for Physical Refraction. -## Unsigned Build +## macOS Says KeyLight Cannot Be Verified -This warning happens because the app is currently unsigned (no paid Apple Developer Program membership yet, US$99/year). +KeyLight v2.0.0 is ad-hoc signed and not notarized because I do not have an Apple Developer account. Download it only from the official KeyLight GitHub Releases page. -If you see: +1. Try opening `/Applications/KeyLight.app` once. +2. Click `Done` in the first warning. +3. Open **System Settings › Privacy & Security**. +4. Scroll to the Security section and click **Open Anyway** for KeyLight. +5. Confirm **Open Anyway** again and enter your password or use Touch ID. -- `"KeyLight" Not Opened` -- `Apple could not verify "KeyLight" is free of malware...` +You can also Control-click `KeyLight.app`, choose **Open**, and confirm once more. -then do this once: +## KeyLight Does Not React After Updating -1. Try opening `KeyLight.app` once from `Applications`. -2. In the first warning popup, click `Done`. -3. Open `System Settings` -> `Privacy & Security`. -4. Scroll down to the `Security` section. -5. Click `Open Anyway` for `KeyLight`. -6. In the second popup (`Open "KeyLight"?`), click `Open Anyway` again. -7. Enter your macOS password (or Touch ID) to confirm. +An unsigned app receives a new ad-hoc code identity when it is rebuilt. macOS may therefore keep the old Input Monitoring entry without accepting the new app. -Alternative: Control-click `KeyLight.app` -> `Open` -> `Open`. +1. Quit every copy of KeyLight. +2. Open **System Settings › Privacy & Security › Input Monitoring**. +3. Remove the old KeyLight row. +4. Add `/Applications/KeyLight.app` again and enable it. +5. Reopen KeyLight and choose **Check Again** in its setup window. -## If No Prompt Appears - -It may happen that macOS suppresses the native prompt if a prior prompt already exists. - -Run: +If the native prompt still does not appear, reset only KeyLight's Input Monitoring decision: ```bash killall KeyLight 2>/dev/null || true tccutil reset ListenEvent com.keylight.app -``` - -Relaunch KeyLight and enable the effect again. - -If still no native prompt appears, open Input Monitoring manually and enable KeyLight: - -```bash open "x-apple.systempreferences:com.apple.preference.security?Privacy_ListenEvent" ``` -If this still does not work, then please just restart your machine and hope it works. +## Physical Refraction Uses System Glass Instead -## Prompt Was Denied +Physical Refraction is available on macOS 26 or newer and needs Screen Recording so it can refract what is behind the bottom overlay. Selecting the effect does not request permission by itself. -1. Quit KeyLight. -2. Open Input Monitoring settings. -3. Remove the KeyLight entry. -4. Reset TCC state: +1. Open **Settings › Appearance** and select **Physical Refraction**. +2. Click **Allow Screen Recording…**. +3. Enable KeyLight in **Privacy & Security › Screen & System Audio Recording**. +4. Return to KeyLight and click **Check Again**. +5. If macOS asks for a relaunch, quit and reopen the same app. -```bash -tccutil reset ListenEvent com.keylight.app -``` +Until access and the first usable frame are available, KeyLight keeps your Physical Refraction choice but temporarily renders System Glass. -## Manual Recovery Commands +If you also have an older Motion Preview installed, make sure you enable the correct copy: -Reset the permission decision: +- Normal release: `com.keylight.app` +- Motion Preview: `com.keylight.app.motionpreview` -```bash -tccutil reset ListenEvent com.keylight.app -``` +## Physical Refraction Has Little or No Rainbow -Force close the app, reset, and the open settings pane: +The color separation comes from the captured background instead of a painted tint. A flat, single-color background therefore produces very little separation. Try a high-contrast colored or light/dark boundary behind the glass. -```bash -killall KeyLight 2>/dev/null || true -tccutil reset ListenEvent com.keylight.app -open "x-apple.systempreferences:com.apple.preference.security?Privacy_ListenEvent" -``` +## Updates Say Unavailable + +This is expected in the unsigned v2.0.0 release. It has no update feed or update key and makes no update-check requests. Download future versions manually from the GitHub Releases page. diff --git a/docs/assets/dmg-background-preview.svg b/docs/assets/dmg-background-preview.svg new file mode 100644 index 0000000..d29f817 --- /dev/null +++ b/docs/assets/dmg-background-preview.svg @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + KeyLight Motion Preview + Drag the preview to Applications + + + + + + + + Separate test app • macOS 14+ • System Glass on macOS 26+ + diff --git a/docs/assets/dmg-background-preview@2x.png b/docs/assets/dmg-background-preview@2x.png new file mode 100644 index 0000000..e94831e Binary files /dev/null and b/docs/assets/dmg-background-preview@2x.png differ diff --git a/docs/assets/dmg-background-v2.png b/docs/assets/dmg-background-v2.png new file mode 100644 index 0000000..1f2fa67 Binary files /dev/null and b/docs/assets/dmg-background-v2.png differ diff --git a/docs/assets/dmg-background-v2.svg b/docs/assets/dmg-background-v2.svg new file mode 100644 index 0000000..64090eb --- /dev/null +++ b/docs/assets/dmg-background-v2.svg @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + KeyLight 2.0 + Drag KeyLight 2.0 to Applications + + + + + + + + Installs beside KeyLight 1.0 • macOS 14+ • System Glass on macOS 26+ + diff --git a/docs/assets/dmg-background-v2@2x.png b/docs/assets/dmg-background-v2@2x.png new file mode 100644 index 0000000..abbff6d Binary files /dev/null and b/docs/assets/dmg-background-v2@2x.png differ diff --git a/docs/assets/dmg-background.png b/docs/assets/dmg-background.png index 6306c54..62783ad 100644 Binary files a/docs/assets/dmg-background.png and b/docs/assets/dmg-background.png differ diff --git a/docs/assets/dmg-background.svg b/docs/assets/dmg-background.svg new file mode 100644 index 0000000..d192898 --- /dev/null +++ b/docs/assets/dmg-background.svg @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + KeyLight + Quit KeyLight, then drag it to Applications + + + + + + + + + Choose Replace when asked • macOS 14+ • System Glass on macOS 26+ + diff --git a/project.yml b/project.yml deleted file mode 100644 index a8b77d6..0000000 --- a/project.yml +++ /dev/null @@ -1,55 +0,0 @@ -name: KeyLight -options: - bundleIdPrefix: com.keylight - deploymentTarget: - macOS: "14.0" - xcodeVersion: "16.0" - generateEmptyDirectories: false - -targets: - KeyLight: - type: application - platform: macOS - scheme: - testTargets: - - KeyLightTests - sources: - - KeyLight - resources: - - KeyLight/Resources - settings: - base: - PRODUCT_BUNDLE_IDENTIFIER: com.keylight.app - PRODUCT_NAME: KeyLight - MARKETING_VERSION: "1.0" - CURRENT_PROJECT_VERSION: "1" - SWIFT_VERSION: "6" - MACOSX_DEPLOYMENT_TARGET: "14.0" - INFOPLIST_GENERATION_MODE: GeneratedFile - GENERATE_INFOPLIST_FILE: YES - INFOPLIST_KEY_LSUIElement: YES - CODE_SIGN_ENTITLEMENTS: KeyLight/KeyLight.entitlements - CODE_SIGN_IDENTITY: "-" - CODE_SIGNING_REQUIRED: NO - CODE_SIGN_STYLE: Manual - configs: - Release: - CODE_SIGN_INJECT_BASE_ENTITLEMENTS: NO - ENABLE_HARDENED_RUNTIME: YES - entitlements: - path: KeyLight/KeyLight.entitlements - - KeyLightTests: - type: bundle.unit-test - platform: macOS - sources: - - KeyLightTests - dependencies: - - target: KeyLight - settings: - base: - PRODUCT_BUNDLE_IDENTIFIER: com.keylight.tests - PRODUCT_NAME: KeyLightTests - SWIFT_VERSION: "6" - MACOSX_DEPLOYMENT_TARGET: "14.0" - GENERATE_INFOPLIST_FILE: YES diff --git a/script/build_and_run.sh b/script/build_and_run.sh new file mode 100755 index 0000000..03fc19e --- /dev/null +++ b/script/build_and_run.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +set -euo pipefail + +MODE="${1:-run}" +APP_NAME="KeyLight" +BUNDLE_ID="com.keylight.app.debug" + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +PROJECT_PATH="$ROOT_DIR/KeyLight.xcodeproj" +BUILD_ROOT="${KEYLIGHT_LOCAL_BUILD_ROOT:-/tmp/KeyLightLocalBuild}" +DERIVED_DATA_PATH="$BUILD_ROOT/DerivedData" +APP_BUNDLE="$DERIVED_DATA_PATH/Build/Products/Debug/$APP_NAME.app" +APP_BINARY="$APP_BUNDLE/Contents/MacOS/$APP_NAME" +XCODE_DEVELOPER_DIR="${DEVELOPER_DIR:-/Applications/Xcode.app/Contents/Developer}" + +pkill -x "$APP_NAME" >/dev/null 2>&1 || true + +DEVELOPER_DIR="$XCODE_DEVELOPER_DIR" xcodebuild \ + -project "$PROJECT_PATH" \ + -scheme "$APP_NAME" \ + -configuration Debug \ + -destination "generic/platform=macOS" \ + -derivedDataPath "$DERIVED_DATA_PATH" \ + SWIFT_VERSION=6 \ + SWIFT_STRICT_CONCURRENCY=complete \ + build + +if [[ ! -d "$APP_BUNDLE" || ! -x "$APP_BINARY" ]]; then + echo "error: built app not found at $APP_BUNDLE" >&2 + exit 1 +fi + +open_app() { + /usr/bin/open -n "$APP_BUNDLE" +} + +case "$MODE" in + run) + open_app + ;; + --debug|debug) + /usr/bin/lldb -- "$APP_BINARY" + ;; + --logs|logs) + open_app + /usr/bin/log stream --info --style compact --predicate "process == \"$APP_NAME\"" + ;; + --telemetry|telemetry) + open_app + /usr/bin/log stream --info --style compact --predicate "subsystem == \"$BUNDLE_ID\" OR process == \"$APP_NAME\"" + ;; + --verify|verify) + open_app + launched=false + for _ in {1..25}; do + if pgrep -x "$APP_NAME" >/dev/null; then + launched=true + break + fi + sleep 0.2 + done + if [[ "$launched" != true ]]; then + echo "error: $APP_NAME did not launch" >&2 + exit 1 + fi + sleep 1 + if ! pgrep -x "$APP_NAME" >/dev/null; then + echo "error: $APP_NAME did not remain running after launch" >&2 + exit 1 + fi + ;; + *) + echo "usage: $0 [run|--debug|--logs|--telemetry|--verify]" >&2 + exit 2 + ;; +esac diff --git a/scripts/audit-update-feed.sh b/scripts/audit-update-feed.sh new file mode 100755 index 0000000..e7ddc13 --- /dev/null +++ b/scripts/audit-update-feed.sh @@ -0,0 +1,165 @@ +#!/usr/bin/env bash +set -euo pipefail + +die() { + echo "error: $*" >&2 + exit 1 +} + +APPCAST_URL="${KEYLIGHT_APPCAST_URL:-}" +PUBLIC_KEY="${KEYLIGHT_SPARKLE_PUBLIC_ED_KEY:-}" +EXPECTED_TEAM_ID="${KEYLIGHT_EXPECTED_TEAM_ID:-}" +XCODE_DEVELOPER_DIR="${DEVELOPER_DIR:-/Applications/Xcode.app/Contents/Developer}" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SIGNATURE_VERIFIER="$SCRIPT_DIR/verify-sparkle-signature.swift" +AUDIT_ROOT="$(mktemp -d /tmp/keylight-feed-audit.XXXXXX)" +MOUNT_POINT="$AUDIT_ROOT/mount" +ATTACHED_DEVICE="" + +cleanup() { + local exit_code=$? + trap - EXIT + if [[ -n "$ATTACHED_DEVICE" ]]; then + hdiutil detach "$ATTACHED_DEVICE" >/dev/null 2>&1 || true + fi + rm -rf "$AUDIT_ROOT" + exit "$exit_code" +} +trap cleanup EXIT + +[[ "$APPCAST_URL" == https://* ]] || die "KEYLIGHT_APPCAST_URL must use HTTPS" +[[ -n "$PUBLIC_KEY" ]] || die "KEYLIGHT_SPARKLE_PUBLIC_ED_KEY is required" +[[ "$EXPECTED_TEAM_ID" =~ ^[A-Z0-9]{10}$ ]] || \ + die "KEYLIGHT_EXPECTED_TEAM_ID must be a ten-character Apple Team ID" +[[ -f "$SIGNATURE_VERIFIER" ]] || die "signature verifier is missing" + +APPCAST_PATH="$AUDIT_ROOT/appcast.xml" +curl \ + --fail \ + --silent \ + --show-error \ + --location \ + --proto '=https' \ + --proto-redir '=https' \ + --tlsv1.2 \ + "$APPCAST_URL" \ + --output "$APPCAST_PATH" +xmllint --noout "$APPCAST_PATH" + +MODULE_CACHE="$AUDIT_ROOT/module-cache" +CLANG_MODULE_CACHE_PATH="$MODULE_CACHE" \ +SWIFT_MODULECACHE_PATH="$MODULE_CACHE" \ +DEVELOPER_DIR="$XCODE_DEVELOPER_DIR" \ + xcrun swift "$SIGNATURE_VERIFIER" \ + appcast \ + "$APPCAST_PATH" \ + "$PUBLIC_KEY" + +xpath_string() { + xmllint --xpath "string($1)" "$APPCAST_PATH" +} + +ENCLOSURE='(//*[local-name()="item"]/*[local-name()="enclosure"])[1]' +UPDATE_URL="$(xpath_string "$ENCLOSURE/@url")" +UPDATE_SIGNATURE="$(xpath_string "$ENCLOSURE/@*[local-name()='edSignature']")" +UPDATE_LENGTH="$(xpath_string "$ENCLOSURE/@length")" +UPDATE_BUILD="$(xpath_string '(//*[local-name()="item"])[1]/*[local-name()="version"][1]')" +UPDATE_VERSION="$(xpath_string '(//*[local-name()="item"])[1]/*[local-name()="shortVersionString"][1]')" + +[[ "$UPDATE_URL" == https://* ]] || die "latest update URL is not HTTPS" +[[ -n "$UPDATE_SIGNATURE" ]] || die "latest update has no EdDSA signature" +[[ "$UPDATE_LENGTH" =~ ^[1-9][0-9]*$ ]] || die "latest update length is invalid" +[[ "$UPDATE_BUILD" =~ ^[1-9][0-9]*$ ]] || die "latest update build is invalid" + +UPDATE_PATH="$AUDIT_ROOT/update.dmg" +curl \ + --fail \ + --silent \ + --show-error \ + --location \ + --proto '=https' \ + --proto-redir '=https' \ + --tlsv1.2 \ + "$UPDATE_URL" \ + --output "$UPDATE_PATH" +[[ "$(stat -f '%z' "$UPDATE_PATH")" == "$UPDATE_LENGTH" ]] || \ + die "downloaded update length does not match the signed appcast" +CLANG_MODULE_CACHE_PATH="$MODULE_CACHE" \ +SWIFT_MODULECACHE_PATH="$MODULE_CACHE" \ +DEVELOPER_DIR="$XCODE_DEVELOPER_DIR" \ + xcrun swift "$SIGNATURE_VERIFIER" \ + archive \ + "$UPDATE_PATH" \ + "$PUBLIC_KEY" \ + "$UPDATE_SIGNATURE" \ + "$UPDATE_LENGTH" + +RELEASE_NOTES='(//*[local-name()="item"])[1]/*[local-name()="releaseNotesLink"][1]' +RELEASE_NOTES_URL="$(xpath_string "$RELEASE_NOTES")" +if [[ -n "$RELEASE_NOTES_URL" ]]; then + RELEASE_NOTES_SIGNATURE="$(xpath_string "$RELEASE_NOTES/@*[local-name()='edSignature']")" + RELEASE_NOTES_LENGTH="$(xpath_string "$RELEASE_NOTES/@*[local-name()='length']")" + [[ "$RELEASE_NOTES_URL" == https://* ]] || die "release-notes URL is not HTTPS" + [[ -n "$RELEASE_NOTES_SIGNATURE" ]] || die "external release notes are unsigned" + [[ "$RELEASE_NOTES_LENGTH" =~ ^[1-9][0-9]*$ ]] || die "release-notes length is invalid" + RELEASE_NOTES_PATH="$AUDIT_ROOT/release-notes" + curl \ + --fail \ + --silent \ + --show-error \ + --location \ + --proto '=https' \ + --proto-redir '=https' \ + --tlsv1.2 \ + "$RELEASE_NOTES_URL" \ + --output "$RELEASE_NOTES_PATH" + CLANG_MODULE_CACHE_PATH="$MODULE_CACHE" \ + SWIFT_MODULECACHE_PATH="$MODULE_CACHE" \ + DEVELOPER_DIR="$XCODE_DEVELOPER_DIR" \ + xcrun swift "$SIGNATURE_VERIFIER" \ + archive \ + "$RELEASE_NOTES_PATH" \ + "$PUBLIC_KEY" \ + "$RELEASE_NOTES_SIGNATURE" \ + "$RELEASE_NOTES_LENGTH" +fi + +mkdir "$MOUNT_POINT" +hdiutil attach \ + -nobrowse \ + -readonly \ + -mountpoint "$MOUNT_POINT" \ + "$UPDATE_PATH" > "$AUDIT_ROOT/hdiutil.txt" +ATTACHED_DEVICE="$(awk '/^\/dev\// {print $1; exit}' "$AUDIT_ROOT/hdiutil.txt")" +[[ -n "$ATTACHED_DEVICE" ]] || die "could not determine mounted update device" +APP_PATH="$(find "$MOUNT_POINT" -mindepth 1 -maxdepth 1 -type d -name 'KeyLight.app' -print -quit)" +[[ -d "$APP_PATH" ]] || die "update does not contain KeyLight.app" +INFO_PATH="$APP_PATH/Contents/Info.plist" +[[ "$(plutil -extract CFBundleIdentifier raw -o - "$INFO_PATH")" == "com.keylight.app" ]] || \ + die "update has the wrong production bundle identifier" +[[ "$(plutil -extract CFBundleVersion raw -o - "$INFO_PATH")" == "$UPDATE_BUILD" ]] || \ + die "app build does not match signed appcast build" +if [[ -n "$UPDATE_VERSION" ]]; then + [[ "$(plutil -extract CFBundleShortVersionString raw -o - "$INFO_PATH")" == "$UPDATE_VERSION" ]] || \ + die "app version does not match signed appcast version" +fi + +CODESIGN_REPORT="$AUDIT_ROOT/codesign.txt" +codesign -dv --verbose=4 "$APP_PATH" >/dev/null 2> "$CODESIGN_REPORT" +codesign --verify --deep --strict --verbose=2 "$APP_PATH" >> "$CODESIGN_REPORT" 2>&1 +rg -F "TeamIdentifier=$EXPECTED_TEAM_ID" "$CODESIGN_REPORT" >/dev/null || \ + die "update has the wrong Developer ID TeamIdentifier" +rg -F 'Authority=Developer ID Application:' "$CODESIGN_REPORT" >/dev/null || \ + die "update is not signed by Developer ID Application" +rg '^Timestamp=' "$CODESIGN_REPORT" >/dev/null || die "update lacks a secure timestamp" +DEVELOPER_DIR="$XCODE_DEVELOPER_DIR" xcrun stapler validate "$APP_PATH" +spctl --assess --type execute --verbose=4 "$APP_PATH" + +hdiutil detach "$ATTACHED_DEVICE" >/dev/null +ATTACHED_DEVICE="" + +echo "Signed update feed audit passed." +echo "Version: ${UPDATE_VERSION:-unknown}" +echo "Build: $UPDATE_BUILD" +echo "Bundle ID: com.keylight.app" +echo "Team ID: $EXPECTED_TEAM_ID" diff --git a/scripts/build-dmg.sh b/scripts/build-dmg.sh index 614d291..74555a9 100755 --- a/scripts/build-dmg.sh +++ b/scripts/build-dmg.sh @@ -1,79 +1,308 @@ #!/usr/bin/env bash set -euo pipefail +usage() { + cat <<'USAGE' +Usage: + ./scripts/build-dmg.sh --local VERSION + ./scripts/build-dmg.sh --preview-local VERSION + ./scripts/build-dmg.sh --preview-signed VERSION + ./scripts/build-dmg.sh --side-by-side-local VERSION + ./scripts/build-dmg.sh --release-unsigned VERSION + ./scripts/build-dmg.sh --release VERSION + +Modes: + --local Build an ad-hoc-signed app in an explicitly local, unsigned DMG. + Its local-only signature relaxes library validation solely so + the ad-hoc app can load the independently ad-hoc Sparkle binary. + Output: dist/KeyLight-VERSION-local-unsigned.dmg + + --preview-local + Build KeyLight Motion Preview.app with an isolated bundle ID. + The same local-only Sparkle compatibility exception applies. + Output: dist/KeyLight-VERSION-motion-preview-local-unsigned.dmg + + --preview-signed + Build the isolated Motion Preview with Developer ID, notarize and + staple the app, then sign, notarize, and staple its DMG. No + production update feed or key is embedded. + Output: dist/KeyLight-VERSION-motion-preview-signed.dmg + + --side-by-side-local + Build the isolated KeyLight 2.0.app beside KeyLight.app. + Output: dist/KeyLight-VERSION-side-by-side-local-unsigned.dmg + + --release-unsigned + Build the normal KeyLight.app / com.keylight.app identity as an + ad-hoc-signed, unnotarized public release. No update feed or key + is embedded. Output: dist/KeyLight-VERSION.dmg + + --release Archive and export with Developer ID, notarize and staple the app, + then sign, notarize, and staple the DMG. + Output: dist/KeyLight-VERSION.dmg + +Signed packaging environment: + KEYLIGHT_DEVELOPER_ID_APPLICATION + Optional exact certificate name when more than one valid Developer ID + Application identity is installed, for example: + Developer ID Application: Example Name (TEAMID1234) + KEYLIGHT_DEVELOPMENT_TEAM + Optional ten-character team selector. The selected certificate hash and + Team ID are always derived from the installed Keychain identity. + KEYLIGHT_BUILD_NUMBER + Optional positive build number. It must match Shared.xcconfig. + KEYLIGHT_SPARKLE_FEED_URL + Optional HTTPS appcast override. Release mode defaults to the stable + KeyLight GitHub Releases appcast URL. + KEYLIGHT_SPARKLE_PUBLIC_ED_KEY + Required base64 Ed25519 public key for release mode. + KEYLIGHT_SPARKLE_TOOLS_DIR + Required path to the pinned Sparkle 2.9.5 bin directory containing + generate_keys, generate_appcast, and sign_update. + KEYLIGHT_SPARKLE_KEY_ACCOUNT + Optional Sparkle Keychain account. Defaults to ed25519. + KEYLIGHT_CLONED_SOURCE_PACKAGES_DIR + Optional Xcode SourcePackages directory for an already verified local + cache. Resolution remains restricted to Package.resolved. + +Both signed modes require a notarytool keychain profile named +KeyLightNotary. Create it with `xcrun notarytool store-credentials`. +USAGE +} + +die() { + echo "error: $*" >&2 + exit 1 +} + +log() { + echo "==> $*" +} + +require_command() { + local command_name="$1" + command -v "$command_name" >/dev/null 2>&1 || die "$command_name is required" +} + ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SHARED_CONFIG_PATH="$ROOT_DIR/Configurations/Shared.xcconfig" + +xcconfig_value() { + local key="$1" + sed -n -E "s/^[[:space:]]*$key[[:space:]]*=[[:space:]]*(.*[^[:space:]])[[:space:]]*$/\\1/p" \ + "$SHARED_CONFIG_PATH" | tail -n 1 +} + +if [[ "$#" -ne 2 ]]; then + usage >&2 + exit 2 +fi + +case "$1" in + --local) + MODE="local" + ;; + --preview-local) + MODE="preview-local" + ;; + --preview-signed) + MODE="preview-signed" + ;; + --side-by-side-local) + MODE="side-by-side-local" + ;; + --release-unsigned) + MODE="release-unsigned" + ;; + --release) + MODE="release" + ;; + *) + usage >&2 + exit 2 + ;; +esac + +is_signed_mode() { + [[ "$MODE" == "release" || "$MODE" == "preview-signed" ]] +} + +is_release_artifact_mode() { + [[ "$MODE" == "release" || "$MODE" == "release-unsigned" ]] +} + +requires_full_quality_gates() { + is_signed_mode || [[ "$MODE" == "release-unsigned" ]] +} + +VERSION="$2" +if [[ ! "$VERSION" =~ ^[0-9]+(\.[0-9]+){1,2}$ ]]; then + die "VERSION must contain two or three numeric components (for example 1.1 or 1.1.0)" +fi + +CONFIGURED_VERSION="$(xcconfig_value MARKETING_VERSION)" +CONFIGURED_BUILD="$(xcconfig_value CURRENT_PROJECT_VERSION)" +[[ "$VERSION" == "$CONFIGURED_VERSION" ]] || \ + die "VERSION '$VERSION' must match Shared.xcconfig MARKETING_VERSION '$CONFIGURED_VERSION'" + +BUILD_NUMBER="${KEYLIGHT_BUILD_NUMBER:-$CONFIGURED_BUILD}" +if [[ ! "$BUILD_NUMBER" =~ ^[1-9][0-9]*$ ]]; then + die "KEYLIGHT_BUILD_NUMBER must be a positive integer" +fi +[[ "$BUILD_NUMBER" == "$CONFIGURED_BUILD" ]] || \ + die "KEYLIGHT_BUILD_NUMBER '$BUILD_NUMBER' must match Shared.xcconfig CURRENT_PROJECT_VERSION '$CONFIGURED_BUILD'" + PROJECT_PATH="$ROOT_DIR/KeyLight.xcodeproj" SCHEME_NAME="KeyLight" APP_NAME="KeyLight" +PRODUCTION_BUNDLE_ID="com.keylight.app" +LOCAL_BUNDLE_ID="com.keylight.app.debug" +PREVIEW_BUNDLE_ID="com.keylight.app.motionpreview" +SIDE_BY_SIDE_BUNDLE_ID="com.keylight.app.v2" DIST_DIR="$ROOT_DIR/dist" -WORK_ROOT="${KEYLIGHT_DMG_WORK_ROOT:-/tmp/KeyLightDMG}" -BUILD_ROOT="$WORK_ROOT/build" -DERIVED_DATA_PATH="$BUILD_ROOT/DerivedData" -STAGE_DIR="$BUILD_ROOT/stage" -OUTPUT_ROOT="$WORK_ROOT/output" -VERIFY_ROOT="$WORK_ROOT/verify" -VERIFY_MOUNT_POINT="$VERIFY_ROOT/mount" DMG_BG_ASSET_PATH="$ROOT_DIR/docs/assets/dmg-background.png" -DMG_BG_STAGED_NAME="KeyLightInstallerBackground.png" ENTITLEMENTS_PATH="$ROOT_DIR/KeyLight/KeyLight.entitlements" -FINAL_DMG_PATH="" -WORK_DMG_PATH="" +PACKAGE_RESOLVED_PATH="$ROOT_DIR/KeyLight.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved" +PRIVACY_MANIFEST_PATH="$ROOT_DIR/KeyLight/Resources/PrivacyInfo.xcprivacy" +RELEASE_METADATA_GENERATOR="$ROOT_DIR/scripts/generate-release-metadata.swift" +SPARKLE_SIGNATURE_VERIFIER="$ROOT_DIR/scripts/verify-sparkle-signature.swift" +SPARKLE_SIGNATURE_TEST="$ROOT_DIR/scripts/test-update-signature-verifier.swift" +PROJECT_POLICY_VERIFIER="$ROOT_DIR/scripts/verify-project-policy.sh" +FINDER_METADATA_VERIFIER="$ROOT_DIR/scripts/verify-dmg-finder-metadata.swift" +XCODE_DEVELOPER_DIR="${DEVELOPER_DIR:-/Applications/Xcode.app/Contents/Developer}" +NOTARY_PROFILE="KeyLightNotary" +DEVELOPER_ID_IDENTITY="${KEYLIGHT_DEVELOPER_ID_APPLICATION:-}" +DEVELOPMENT_TEAM="${KEYLIGHT_DEVELOPMENT_TEAM:-}" +DEVELOPER_ID_CERT_HASH="" +SPARKLE_FEED_URL="${KEYLIGHT_SPARKLE_FEED_URL:-}" +SPARKLE_PUBLIC_ED_KEY="${KEYLIGHT_SPARKLE_PUBLIC_ED_KEY:-}" +SPARKLE_TOOLS_DIR="${KEYLIGHT_SPARKLE_TOOLS_DIR:-}" +SPARKLE_KEY_ACCOUNT="${KEYLIGHT_SPARKLE_KEY_ACCOUNT:-ed25519}" +CLONED_SOURCE_PACKAGES_DIR="${KEYLIGHT_CLONED_SOURCE_PACKAGES_DIR:-}" ATTACHED_DEVICE="" -EXPECT_CUSTOM_LAYOUT=0 -ROOT_DIR_NAME="$(basename "$ROOT_DIR")" -HOME_NAME="" -if [[ -n "${HOME:-}" ]]; then - HOME_NAME="$(basename "$HOME")" -fi +SOURCE_APP_PATH="" +SMOKE_PID="" +EXPECTED_SPARKLE_VERSION="2.9.5" +EXPECTED_SPARKLE_REVISION="79bc9e872948e47877e76f194cb0c8e0412b0b90" +EXPECTED_SIGN_UPDATE_SHA256="bfb52400c3da18bb4c251ac4818c2c2e1e31c2e649a45b31c11109b6e57b34ad" +EXPECTED_GENERATE_APPCAST_SHA256="669a5ed0f90ce06fb1de3e36aba35c5da8b98f66928a185fd4029174071be700" +EXPECTED_GENERATE_KEYS_SHA256="2d18ed3a9c744e58150513d9b2e3c2eb76fd0b9621e3e4678d46dd972547e8fe" -BANNED_PATTERNS=( - "/Users/" - "$ROOT_DIR" - "$ROOT_DIR_NAME" - "chatgpt.com" - "cheekyleo" - "@users.noreply.github.com" - "noreply@github.com" +XCODE_PACKAGE_ARGUMENTS=( + -disableAutomaticPackageResolution + -onlyUsePackageVersionsFromResolvedFile ) +if [[ -n "$CLONED_SOURCE_PACKAGES_DIR" ]]; then + XCODE_PACKAGE_ARGUMENTS+=( + -clonedSourcePackagesDirPath "$CLONED_SOURCE_PACKAGES_DIR" + ) +fi -if [[ -n "${HOME:-}" ]]; then - BANNED_PATTERNS+=("$HOME") +if [[ "$MODE" == "release" ]]; then + BUNDLE_ID="$PRODUCTION_BUNDLE_ID" + BUILD_CHANNEL="Production" + FINAL_DMG_NAME="$APP_NAME-$VERSION.dmg" + VOLUME_NAME="$APP_NAME $VERSION" +elif [[ "$MODE" == "release-unsigned" ]]; then + BUNDLE_ID="$PRODUCTION_BUNDLE_ID" + BUILD_CHANNEL="Unsigned Release" + FINAL_DMG_NAME="$APP_NAME-$VERSION.dmg" + VOLUME_NAME="$APP_NAME $VERSION" +elif [[ "$MODE" == "preview-signed" ]]; then + APP_NAME="KeyLight Motion Preview" + BUNDLE_ID="$PREVIEW_BUNDLE_ID" + BUILD_CHANNEL="Motion Preview Signed" + FINAL_DMG_NAME="KeyLight-$VERSION-motion-preview-signed.dmg" + VOLUME_NAME="KeyLight Motion Preview" + DMG_BG_ASSET_PATH="$ROOT_DIR/docs/assets/dmg-background-preview@2x.png" +elif [[ "$MODE" == "preview-local" ]]; then + APP_NAME="KeyLight Motion Preview" + BUNDLE_ID="$PREVIEW_BUNDLE_ID" + BUILD_CHANNEL="Motion Preview Local" + FINAL_DMG_NAME="KeyLight-$VERSION-motion-preview-local-unsigned.dmg" + VOLUME_NAME="KeyLight Motion Preview" + DMG_BG_ASSET_PATH="$ROOT_DIR/docs/assets/dmg-background-preview@2x.png" +elif [[ "$MODE" == "side-by-side-local" ]]; then + APP_NAME="KeyLight 2.0" + BUNDLE_ID="$SIDE_BY_SIDE_BUNDLE_ID" + BUILD_CHANNEL="Side-by-Side Local" + FINAL_DMG_NAME="KeyLight-$VERSION-side-by-side-local-unsigned.dmg" + VOLUME_NAME="KeyLight 2.0" + DMG_BG_ASSET_PATH="$ROOT_DIR/docs/assets/dmg-background-v2@2x.png" +else + BUNDLE_ID="$LOCAL_BUNDLE_ID" + BUILD_CHANNEL="Local Debug" + FINAL_DMG_NAME="$APP_NAME-$VERSION-local-unsigned.dmg" + VOLUME_NAME="$APP_NAME $VERSION Local" fi -if [[ -n "$HOME_NAME" ]]; then - BANNED_PATTERNS+=("/Users/$HOME_NAME/") - BANNED_PATTERNS+=("/$HOME_NAME/") - BANNED_PATTERNS+=(":$HOME_NAME:") +if [[ "$MODE" == "release" && -z "$SPARKLE_FEED_URL" ]]; then + SPARKLE_FEED_URL="https://github.com/keylight-macos/keylight/releases/latest/download/appcast.xml" fi +WORK_ROOT="/tmp/KeyLightDMG-${MODE}-${VERSION}-$$" +BUILD_ROOT="$WORK_ROOT/build" +DERIVED_DATA_PATH="$BUILD_ROOT/DerivedData" +STAGE_DIR="$BUILD_ROOT/stage" +STAGED_APP_PATH="$STAGE_DIR/$APP_NAME.app" +OUTPUT_ROOT="$WORK_ROOT/output" +VERIFY_ROOT="$WORK_ROOT/verify" +VERIFY_MOUNT_POINT="$VERIFY_ROOT/mount" +ARCHIVE_PATH="$BUILD_ROOT/$APP_NAME.xcarchive" +EXPORT_PATH="$BUILD_ROOT/export" +EXPORT_OPTIONS_PATH="$BUILD_ROOT/ExportOptions.plist" +APP_NOTARY_ZIP="$BUILD_ROOT/$APP_NAME-notary.zip" +AUDIT_ROOT="$WORK_ROOT/audit" +LOCAL_ADHOC_ENTITLEMENTS_PATH="$AUDIT_ROOT/local-adhoc.entitlements" +APP_NOTARY_RESULT="$AUDIT_ROOT/app-notary.json" +APP_NOTARY_LOG="$AUDIT_ROOT/app-notary-log.json" +DMG_NOTARY_RESULT="$AUDIT_ROOT/dmg-notary.json" +DMG_NOTARY_LOG="$AUDIT_ROOT/dmg-notary-log.json" +DMG_BG_FILE_NAME="$(basename "$DMG_BG_ASSET_PATH")" +FINAL_DMG_PATH="$DIST_DIR/$FINAL_DMG_NAME" +WORK_DMG_PATH="$OUTPUT_ROOT/$FINAL_DMG_NAME" +WORK_CHECKSUM_PATH="$OUTPUT_ROOT/$FINAL_DMG_NAME.sha256" +WORK_SBOM_PATH="$OUTPUT_ROOT/KeyLight-$VERSION.spdx.json" +WORK_PROVENANCE_PATH="$OUTPUT_ROOT/KeyLight-$VERSION.provenance.json" +WORK_SPARKLE_SIGNATURE_PATH="$OUTPUT_ROOT/KeyLight-$VERSION.sparkle-signature.txt" +PUBLISH_DMG_PATH="$DIST_DIR/.$FINAL_DMG_NAME.pending.$$" +FINAL_CHECKSUM_PATH="$FINAL_DMG_PATH.sha256" +FINAL_SBOM_PATH="$DIST_DIR/KeyLight-$VERSION.spdx.json" +FINAL_PROVENANCE_PATH="$DIST_DIR/KeyLight-$VERSION.provenance.json" +FINAL_SPARKLE_SIGNATURE_PATH="$DIST_DIR/KeyLight-$VERSION.sparkle-signature.txt" +PUBLISH_CHECKSUM_PATH="$DIST_DIR/.$(basename "$FINAL_CHECKSUM_PATH").pending.$$" +PUBLISH_SBOM_PATH="$DIST_DIR/.$(basename "$FINAL_SBOM_PATH").pending.$$" +PUBLISH_PROVENANCE_PATH="$DIST_DIR/.$(basename "$FINAL_PROVENANCE_PATH").pending.$$" +PUBLISH_SPARKLE_SIGNATURE_PATH="$DIST_DIR/.$(basename "$FINAL_SPARKLE_SIGNATURE_PATH").pending.$$" + +[[ ! -e "$WORK_ROOT" && ! -L "$WORK_ROOT" ]] || die "temporary work path already exists: $WORK_ROOT" +[[ ! -e "$PUBLISH_DMG_PATH" && ! -L "$PUBLISH_DMG_PATH" ]] || die "temporary publish path already exists: $PUBLISH_DMG_PATH" + cleanup() { local exit_code=$? + trap - EXIT + if [[ -n "${SMOKE_PID:-}" ]] && kill -0 "$SMOKE_PID" 2>/dev/null; then + kill -TERM "$SMOKE_PID" 2>/dev/null || true + wait "$SMOKE_PID" 2>/dev/null || true + fi if [[ -n "${ATTACHED_DEVICE:-}" ]]; then hdiutil detach "$ATTACHED_DEVICE" >/dev/null 2>&1 || true fi + rm -f \ + "$PUBLISH_DMG_PATH" \ + "$PUBLISH_CHECKSUM_PATH" \ + "$PUBLISH_SBOM_PATH" \ + "$PUBLISH_PROVENANCE_PATH" \ + "$PUBLISH_SPARKLE_SIGNATURE_PATH" exit "$exit_code" } trap cleanup EXIT -log() { - echo "==> $*" -} - -require_command() { - local command_name="$1" - if ! command -v "$command_name" >/dev/null 2>&1; then - echo "error: $command_name is required" >&2 - exit 1 - fi -} - sanitize_tree() { local path="$1" - if [[ ! -e "$path" ]]; then - return - fi + [[ -e "$path" ]] || return if command -v xattr >/dev/null 2>&1; then xattr -cr "$path" 2>/dev/null || true @@ -83,14 +312,57 @@ sanitize_tree() { find "$path" -name '.fseventsd' -prune -exec rm -rf {} + 2>/dev/null || true } +remove_unsigned_release_update_keys() { + local info_path="$1/Contents/Info.plist" + local update_key="" + + [[ "$MODE" == "release-unsigned" ]] || return + + for update_key in SUFeedURL SUPublicEDKey; do + if plutil -extract "$update_key" raw -o - "$info_path" >/dev/null 2>&1; then + plutil -remove "$update_key" "$info_path" + fi + done +} + +ROOT_DIR_NAME="$(basename "$ROOT_DIR")" +HOME_NAME="" +GIT_AUTHOR_NAME="$(git -C "$ROOT_DIR" config --get user.name 2>/dev/null || true)" +GIT_AUTHOR_EMAIL="$(git -C "$ROOT_DIR" config --get user.email 2>/dev/null || true)" +if [[ -n "${HOME:-}" ]]; then + HOME_NAME="$(basename "$HOME")" +fi + +BANNED_PATTERNS=( + "/Users/" + "$ROOT_DIR" + "$ROOT_DIR_NAME" +) + +if [[ -n "${HOME:-}" ]]; then + BANNED_PATTERNS+=("$HOME") +fi + +if [[ -n "$HOME_NAME" ]]; then + BANNED_PATTERNS+=("/Users/$HOME_NAME/") + BANNED_PATTERNS+=("/$HOME_NAME/") + BANNED_PATTERNS+=(":$HOME_NAME:") +fi + +if [[ -n "$GIT_AUTHOR_NAME" ]]; then + BANNED_PATTERNS+=("$GIT_AUTHOR_NAME") +fi + +if [[ -n "$GIT_AUTHOR_EMAIL" ]]; then + BANNED_PATTERNS+=("$GIT_AUTHOR_EMAIL") +fi + scan_text_file() { local label="$1" local file_path="$2" local pattern="" - if [[ ! -f "$file_path" ]]; then - return - fi + [[ -f "$file_path" ]] || return for pattern in "${BANNED_PATTERNS[@]}"; do [[ -z "$pattern" ]] && continue @@ -126,14 +398,266 @@ scan_binary_tree() { done } +verify_package_lock() { + local pin_count="" + local identity="" + local version="" + local revision="" + + [[ -f "$PACKAGE_RESOLVED_PATH" ]] || die "Package.resolved is missing" + pin_count="$(rg -c '"identity"[[:space:]]*:' "$PACKAGE_RESOLVED_PATH" || true)" + [[ "$pin_count" == "1" ]] || die "Package.resolved must contain exactly one dependency pin" + identity="$(plutil -extract pins.0.identity raw -o - "$PACKAGE_RESOLVED_PATH")" + version="$(plutil -extract pins.0.state.version raw -o - "$PACKAGE_RESOLVED_PATH")" + revision="$(plutil -extract pins.0.state.revision raw -o - "$PACKAGE_RESOLVED_PATH")" + [[ "$identity" == "sparkle" ]] || die "unexpected dependency identity '$identity'" + [[ "$version" == "$EXPECTED_SPARKLE_VERSION" ]] || \ + die "Sparkle must remain exactly pinned to $EXPECTED_SPARKLE_VERSION" + [[ "$revision" == "$EXPECTED_SPARKLE_REVISION" ]] || \ + die "Sparkle revision does not match the reviewed $EXPECTED_SPARKLE_VERSION source" +} + +verify_release_source_state() { + local configured_version="" + local configured_build="" + local tag_name="v$VERSION" + local tag_type="" + + [[ -z "$(git -C "$ROOT_DIR" status --porcelain --untracked-files=all)" ]] || \ + die "release mode requires a completely clean source tree" + git -C "$ROOT_DIR" merge-base --is-ancestor HEAD HEAD >/dev/null || \ + die "could not verify release commit" + tag_type="$(git -C "$ROOT_DIR" cat-file -t "refs/tags/$tag_name" 2>/dev/null || true)" + [[ "$tag_type" == "tag" ]] || \ + die "release mode requires annotated tag '$tag_name' at HEAD" + [[ "$(git -C "$ROOT_DIR" rev-list -n 1 "$tag_name")" == "$(git -C "$ROOT_DIR" rev-parse HEAD)" ]] || \ + die "release tag '$tag_name' does not point to HEAD" + + configured_version="$(xcconfig_value MARKETING_VERSION)" + configured_build="$(xcconfig_value CURRENT_PROJECT_VERSION)" + [[ "$configured_version" == "$VERSION" ]] || \ + die "Shared.xcconfig MARKETING_VERSION '$configured_version' does not match '$VERSION'" + [[ "$configured_build" == "$BUILD_NUMBER" ]] || \ + die "Shared.xcconfig CURRENT_PROJECT_VERSION '$configured_build' does not match '$BUILD_NUMBER'" +} + +verify_signed_preview_source_state() { + local configured_version="" + local configured_build="" + + [[ -z "$(git -C "$ROOT_DIR" status --porcelain --untracked-files=all)" ]] || \ + die "signed preview mode requires a completely clean source tree" + git -C "$ROOT_DIR" merge-base --is-ancestor HEAD HEAD >/dev/null || \ + die "could not verify signed preview commit" + + configured_version="$(xcconfig_value MARKETING_VERSION)" + configured_build="$(xcconfig_value CURRENT_PROJECT_VERSION)" + [[ "$configured_version" == "$VERSION" ]] || \ + die "Shared.xcconfig MARKETING_VERSION '$configured_version' does not match '$VERSION'" + [[ "$configured_build" == "$BUILD_NUMBER" ]] || \ + die "Shared.xcconfig CURRENT_PROJECT_VERSION '$configured_build' does not match '$BUILD_NUMBER'" +} + +verify_unsigned_release_source_state() { + local configured_version="" + local configured_build="" + + [[ -z "$(git -C "$ROOT_DIR" status --porcelain --untracked-files=all)" ]] || \ + die "unsigned release mode requires a completely clean source tree" + git -C "$ROOT_DIR" merge-base --is-ancestor HEAD HEAD >/dev/null || \ + die "could not verify unsigned release commit" + + configured_version="$(xcconfig_value MARKETING_VERSION)" + configured_build="$(xcconfig_value CURRENT_PROJECT_VERSION)" + [[ "$configured_version" == "$VERSION" ]] || \ + die "Shared.xcconfig MARKETING_VERSION '$configured_version' does not match '$VERSION'" + [[ "$configured_build" == "$BUILD_NUMBER" ]] || \ + die "Shared.xcconfig CURRENT_PROJECT_VERSION '$configured_build' does not match '$BUILD_NUMBER'" +} + +verify_sparkle_tool() { + local tool_name="$1" + local expected_hash="$2" + local tool_path="$SPARKLE_TOOLS_DIR/$tool_name" + local actual_hash="" + + [[ -x "$tool_path" ]] || die "pinned Sparkle tool is missing or not executable: $tool_path" + actual_hash="$(shasum -a 256 "$tool_path" | awk '{print $1}')" + [[ "$actual_hash" == "$expected_hash" ]] || \ + die "$tool_name does not match the reviewed Sparkle $EXPECTED_SPARKLE_VERSION binary" +} + +verify_release_update_configuration() { + local decoded_key_size="" + local keychain_public_key="" + + [[ "$SPARKLE_FEED_URL" == https://* ]] || \ + die "KEYLIGHT_SPARKLE_FEED_URL must be an HTTPS URL" + [[ "$SPARKLE_FEED_URL" != *[[:space:]]* ]] || \ + die "KEYLIGHT_SPARKLE_FEED_URL must not contain whitespace" + [[ -n "$SPARKLE_PUBLIC_ED_KEY" ]] || \ + die "KEYLIGHT_SPARKLE_PUBLIC_ED_KEY is required" + decoded_key_size="$(printf '%s' "$SPARKLE_PUBLIC_ED_KEY" | base64 -D 2>/dev/null | wc -c | tr -d ' ')" + [[ "$decoded_key_size" == "32" ]] || \ + die "KEYLIGHT_SPARKLE_PUBLIC_ED_KEY must decode to a 32-byte Ed25519 key" + [[ -d "$SPARKLE_TOOLS_DIR" ]] || \ + die "KEYLIGHT_SPARKLE_TOOLS_DIR must point to the Sparkle $EXPECTED_SPARKLE_VERSION bin directory" + + verify_sparkle_tool sign_update "$EXPECTED_SIGN_UPDATE_SHA256" + verify_sparkle_tool generate_appcast "$EXPECTED_GENERATE_APPCAST_SHA256" + verify_sparkle_tool generate_keys "$EXPECTED_GENERATE_KEYS_SHA256" + keychain_public_key="$( + "$SPARKLE_TOOLS_DIR/generate_keys" \ + --account "$SPARKLE_KEY_ACCOUNT" \ + -p + )" + [[ "$keychain_public_key" == "$SPARKLE_PUBLIC_ED_KEY" ]] || \ + die "the Sparkle Keychain private key does not match KEYLIGHT_SPARKLE_PUBLIC_ED_KEY" +} + +run_release_quality_gates() { + local quality_root="$WORK_ROOT/quality" + mkdir -p "$quality_root" + + log "Testing signed-update verification failure cases" + CLANG_MODULE_CACHE_PATH="$quality_root/module-cache" \ + SWIFT_MODULECACHE_PATH="$quality_root/module-cache" \ + DEVELOPER_DIR="$XCODE_DEVELOPER_DIR" \ + xcrun swift "$SPARKLE_SIGNATURE_TEST" + + log "Running the complete release test suite" + DEVELOPER_DIR="$XCODE_DEVELOPER_DIR" xcodebuild \ + "${XCODE_PACKAGE_ARGUMENTS[@]}" \ + -project "$PROJECT_PATH" \ + -scheme "$SCHEME_NAME" \ + -configuration Debug \ + -destination "platform=macOS" \ + -derivedDataPath "$quality_root/tests" \ + SWIFT_VERSION=6 \ + SWIFT_STRICT_CONCURRENCY=complete \ + CODE_SIGNING_ALLOWED=NO \ + test + + log "Running Xcode static analysis" + DEVELOPER_DIR="$XCODE_DEVELOPER_DIR" xcodebuild \ + "${XCODE_PACKAGE_ARGUMENTS[@]}" \ + -project "$PROJECT_PATH" \ + -scheme "$SCHEME_NAME" \ + -configuration Release \ + -destination "generic/platform=macOS" \ + -derivedDataPath "$quality_root/analyze" \ + SWIFT_VERSION=6 \ + SWIFT_STRICT_CONCURRENCY=complete \ + CODE_SIGNING_ALLOWED=NO \ + analyze +} + +sign_sparkle_update_archive() { + local signature_output="" + local signature="" + local expected_length="" + + signature_output="$( + "$SPARKLE_TOOLS_DIR/sign_update" \ + --account "$SPARKLE_KEY_ACCOUNT" \ + "$WORK_DMG_PATH" + )" + printf '%s\n' "$signature_output" > "$WORK_SPARKLE_SIGNATURE_PATH" + signature="$( + printf '%s\n' "$signature_output" | + sed -n -E 's/.*sparkle:edSignature="([^"]+)".*/\1/p' + )" + expected_length="$(stat -f '%z' "$WORK_DMG_PATH")" + [[ -n "$signature" ]] || die "Sparkle did not produce an EdDSA archive signature" + rg -F "length=\"$expected_length\"" "$WORK_SPARKLE_SIGNATURE_PATH" >/dev/null || \ + die "Sparkle signature metadata contains the wrong archive length" + "$SPARKLE_TOOLS_DIR/sign_update" \ + --account "$SPARKLE_KEY_ACCOUNT" \ + --verify \ + "$WORK_DMG_PATH" \ + "$signature" + CLANG_MODULE_CACHE_PATH="$WORK_ROOT/module-cache" \ + SWIFT_MODULECACHE_PATH="$WORK_ROOT/module-cache" \ + DEVELOPER_DIR="$XCODE_DEVELOPER_DIR" \ + xcrun swift "$SPARKLE_SIGNATURE_VERIFIER" \ + archive \ + "$WORK_DMG_PATH" \ + "$SPARKLE_PUBLIC_ED_KEY" \ + "$signature" \ + "$expected_length" +} + +generate_release_metadata() { + local artifact_sha256="" + local package_lock_sha256="" + local source_commit="" + local xcode_version="" + + artifact_sha256="$(shasum -a 256 "$WORK_DMG_PATH" | awk '{print $1}')" + package_lock_sha256="$(shasum -a 256 "$PACKAGE_RESOLVED_PATH" | awk '{print $1}')" + source_commit="$(git -C "$ROOT_DIR" rev-parse HEAD)" + xcode_version="$( + DEVELOPER_DIR="$XCODE_DEVELOPER_DIR" xcodebuild -version | + tr '\n' ' ' | + sed -E 's/[[:space:]]+$//' + )" + + CLANG_MODULE_CACHE_PATH="$WORK_ROOT/module-cache" \ + DEVELOPER_DIR="$XCODE_DEVELOPER_DIR" \ + xcrun swift "$RELEASE_METADATA_GENERATOR" \ + "$(basename "$WORK_DMG_PATH")" \ + "$artifact_sha256" \ + "$package_lock_sha256" \ + "$VERSION" \ + "$BUILD_NUMBER" \ + "$BUNDLE_ID" \ + "$source_commit" \ + "v$VERSION" \ + "$xcode_version" \ + "$MODE" \ + "$DEVELOPER_ID_CERT_HASH" \ + "$DEVELOPMENT_TEAM" \ + "$WORK_CHECKSUM_PATH" \ + "$WORK_SBOM_PATH" \ + "$WORK_PROVENANCE_PATH" + + rg -F "$artifact_sha256 $(basename "$WORK_DMG_PATH")" "$WORK_CHECKSUM_PATH" >/dev/null || \ + die "release checksum verification failed" + jq -e . "$WORK_SBOM_PATH" >/dev/null + jq -e . "$WORK_PROVENANCE_PATH" >/dev/null +} + +verify_background_asset() { + local path="$1" + local metadata="" + local scale=1 + local expected_width=600 + local expected_height=400 + local expected_dpi=72 + + [[ -f "$path" ]] || die "DMG background not found at $path" + if [[ "$(basename "$path")" == *@2x.* ]]; then + scale=2 + fi + expected_width=$((600 * scale)) + expected_height=$((400 * scale)) + expected_dpi=$((72 * scale)) + metadata="$(sips -g pixelWidth -g pixelHeight -g dpiWidth -g dpiHeight -g format -g hasAlpha -g profile "$path" 2>/dev/null)" + echo "$metadata" | rg -F "pixelWidth: $expected_width" >/dev/null || die "DMG background must be $expected_width pixels wide" + echo "$metadata" | rg -F "pixelHeight: $expected_height" >/dev/null || die "DMG background must be $expected_height pixels high" + echo "$metadata" | rg -F "dpiWidth: $expected_dpi.000" >/dev/null || die "DMG background must use $expected_dpi DPI" + echo "$metadata" | rg -F "dpiHeight: $expected_dpi.000" >/dev/null || die "DMG background must use $expected_dpi DPI" + echo "$metadata" | rg -F "format: png" >/dev/null || die "DMG background must be PNG" + echo "$metadata" | rg -F "hasAlpha: no" >/dev/null || die "DMG background must be opaque" + echo "$metadata" | rg -i "profile:.*sRGB" >/dev/null || die "DMG background must embed an sRGB profile" +} + verify_xattrs() { local target_path="$1" local report_path="$VERIFY_ROOT/app-xattrs.txt" local disallowed_attribute="" - if ! command -v xattr >/dev/null 2>&1; then - return - fi + command -v xattr >/dev/null 2>&1 || return xattr -lr "$target_path" > "$report_path" 2>/dev/null || true for disallowed_attribute in \ @@ -149,200 +673,846 @@ verify_xattrs() { done } -verify_codesign_metadata() { +verify_app_signature() { local target_path="$1" - local report_path="$VERIFY_ROOT/codesign.txt" + local report_path="$2" + local requirement_path="${report_path%.txt}-requirement.txt" codesign -dv --verbose=4 "$target_path" >/dev/null 2> "$report_path" - - if ! rg -n -F -- "Identifier=com.keylight.app" "$report_path" >/dev/null 2>&1; then - echo "error: expected bundle identifier not found in codesign metadata" >&2 + if ! codesign --verify --deep --strict --verbose=2 "$target_path" >> "$report_path" 2>&1; then cat "$report_path" >&2 - exit 1 + die "code-signature verification failed for $target_path" fi - if ! rg -n -F -- "TeamIdentifier=not set" "$report_path" >/dev/null 2>&1; then - echo "error: expected ad-hoc team identifier metadata not found" >&2 - cat "$report_path" >&2 - exit 1 + rg -F "Identifier=$BUNDLE_ID" "$report_path" >/dev/null || die "expected bundle identifier not found in code signature" + rg -F "Runtime Version=" "$report_path" >/dev/null || die "hardened runtime metadata not found in code signature" + + if is_signed_mode; then + rg -F "TeamIdentifier=$DEVELOPMENT_TEAM" "$report_path" >/dev/null || die "signed app has the wrong TeamIdentifier" + rg -F "Authority=Developer ID Application:" "$report_path" >/dev/null || die "signed app does not use a Developer ID Application identity" + rg '^Timestamp=' "$report_path" >/dev/null || die "signed app lacks a secure signing timestamp" + codesign -d -r- "$target_path" >/dev/null 2> "$requirement_path" + rg -F "identifier \"$BUNDLE_ID\"" "$requirement_path" >/dev/null || \ + die "signed app designated requirement has the wrong identifier" + rg -F "certificate leaf[subject.OU] = $DEVELOPMENT_TEAM" "$requirement_path" >/dev/null || \ + die "signed app designated requirement has the wrong Team ID" + verify_embedded_certificate_hash "$target_path" "app" + else + rg -F "Signature=adhoc" "$report_path" >/dev/null || die "local app must be ad-hoc signed" + rg -F "TeamIdentifier=not set" "$report_path" >/dev/null || die "local app unexpectedly has a signing team" + if rg -F "Authority=Developer ID Application:" "$report_path" >/dev/null 2>&1; then + die "local app unexpectedly contains a Developer ID signature" + fi + fi +} + +adhoc_sign_local_app() { + local target_path="$1" + local sparkle_framework="$target_path/Contents/Frameworks/Sparkle.framework" + local sparkle_version="$sparkle_framework/Versions/Current" + local nested_code="" + local nested_components=( + "$sparkle_version/Updater.app" + "$sparkle_version/XPCServices/Downloader.xpc" + "$sparkle_version/XPCServices/Installer.xpc" + "$sparkle_version/Autoupdate" + ) + + plutil -create xml1 "$LOCAL_ADHOC_ENTITLEMENTS_PATH" + /usr/libexec/PlistBuddy \ + -c "Add :com.apple.security.cs.disable-library-validation bool true" \ + "$LOCAL_ADHOC_ENTITLEMENTS_PATH" + + # Xcode's embed phase intentionally removes the XCFramework's development + # Headers, PrivateHeaders, and Modules. That changes Sparkle.framework's + # sealed resources, so a CODE_SIGNING_ALLOWED=NO build cannot safely reuse + # the artifact's original signature. Re-sign the pinned framework from the + # inside out, retaining each helper's identifier and exact entitlement set. + if [[ -d "$sparkle_framework" ]]; then + for nested_code in "${nested_components[@]}"; do + [[ -e "$nested_code" ]] || die "expected embedded Sparkle component is missing: $nested_code" + codesign \ + --force \ + --sign - \ + --options runtime \ + --preserve-metadata=identifier,entitlements \ + "$nested_code" + codesign --verify --strict --verbose=2 "$nested_code" + done + + codesign \ + --force \ + --sign - \ + --options runtime \ + --preserve-metadata=identifier,entitlements \ + "$sparkle_framework" + codesign --verify --strict --verbose=2 "$sparkle_framework" + fi + + codesign \ + --force \ + --sign - \ + --options runtime \ + --entitlements "$LOCAL_ADHOC_ENTITLEMENTS_PATH" \ + "$target_path" +} + +run_packaged_launch_smoke_test() { + local target_path="$1" + local executable_path="$target_path/Contents/MacOS/$APP_NAME" + local smoke_log="$VERIFY_ROOT/app-launch-smoke.txt" + local profile_path="$VERIFY_ROOT/app-launch-smoke.profraw" + local attempt=0 + local smoke_status=0 + + [[ -x "$executable_path" ]] || die "packaged launch executable is missing: $executable_path" + log "Launching the staged app in side-effect-free smoke-test mode" + rm -f "$profile_path" + LLVM_PROFILE_FILE="$profile_path" \ + KEYLIGHT_PACKAGE_LAUNCH_SMOKE_TEST=1 \ + "$executable_path" > "$smoke_log" 2>&1 & + SMOKE_PID=$! + + for ((attempt = 0; attempt < 100; attempt++)); do + if ! kill -0 "$SMOKE_PID" 2>/dev/null; then + break + fi + sleep 0.05 + done + + if kill -0 "$SMOKE_PID" 2>/dev/null; then + kill -TERM "$SMOKE_PID" 2>/dev/null || true + wait "$SMOKE_PID" 2>/dev/null || true + SMOKE_PID="" + cat "$smoke_log" >&2 || true + die "packaged app did not finish its launch smoke test within five seconds" + fi + + if wait "$SMOKE_PID"; then + smoke_status=0 + else + smoke_status=$? + fi + SMOKE_PID="" + + if [[ "$smoke_status" -ne 0 ]]; then + cat "$smoke_log" >&2 || true + die "packaged app launch smoke test exited with status $smoke_status" + fi + if rg -i \ + 'Library not loaded|different Team IDs|library validation|fatal dyld' \ + "$smoke_log" >/dev/null 2>&1; then + cat "$smoke_log" >&2 + die "packaged app launch smoke test reported a dynamic-loader failure" + fi + rm -f "$profile_path" +} + +verify_entitlement_contract() { + local target_path="$1" + local code_path="" + local report_path="" + local signature_report="" + local index=0 + local forbidden_entitlements=( + "com.apple.security.get-task-allow" + "com.apple.security.cs.allow-jit" + "com.apple.security.cs.allow-unsigned-executable-memory" + "com.apple.security.cs.disable-library-validation" + "com.apple.security.network.client" + "com.apple.security.network.server" + ) + local entitlement="" + + while IFS= read -r code_path; do + [[ -n "$code_path" && ! -L "$code_path" ]] || continue + index=$((index + 1)) + report_path="$VERIFY_ROOT/entitlements-$index.plist" + signature_report="$VERIFY_ROOT/nested-signature-$index.txt" + codesign -d --entitlements :- "$code_path" > "$report_path" 2>/dev/null || true + codesign -dv --verbose=4 "$code_path" >/dev/null 2> "$signature_report" + codesign --verify --strict --verbose=2 "$code_path" >> "$signature_report" 2>&1 + + for entitlement in "${forbidden_entitlements[@]}"; do + if rg -F "$entitlement" "$report_path" >/dev/null 2>&1; then + if ! is_signed_mode && [[ \ + "$code_path" == "$target_path" && \ + "$entitlement" == \ + "com.apple.security.cs.disable-library-validation" ]]; then + continue + fi + die "forbidden entitlement '$entitlement' found in $code_path" + fi + done + + if is_signed_mode; then + rg -F "TeamIdentifier=$DEVELOPMENT_TEAM" "$signature_report" >/dev/null || \ + die "nested code has the wrong TeamIdentifier: $code_path" + rg '^Timestamp=' "$signature_report" >/dev/null || \ + die "nested signed code lacks a secure timestamp: $code_path" + fi + done < <( + { + printf '%s\n' "$target_path" + find "$target_path/Contents" -type d \ + \( -name '*.app' -o -name '*.framework' -o -name '*.xpc' \) -print + find "$target_path/Contents" -type f -name '*.dylib' -print + find "$target_path/Contents" -type f -name 'Autoupdate' -print + } | sort -u + ) + + codesign -d --entitlements :- "$target_path" > "$VERIFY_ROOT/main-entitlements.plist" 2>/dev/null || true + if is_signed_mode; then + if rg -F '' "$VERIFY_ROOT/main-entitlements.plist" >/dev/null 2>&1; then + die "the Developer ID app executable must retain an empty entitlement set" + fi + else + local main_entitlement_count="" + local local_library_validation_exception="" + main_entitlement_count="$( + rg -o '' "$VERIFY_ROOT/main-entitlements.plist" \ + | wc -l \ + | tr -d '[:space:]' + )" + local_library_validation_exception="$( + /usr/libexec/PlistBuddy \ + -c "Print :com.apple.security.cs.disable-library-validation" \ + "$VERIFY_ROOT/main-entitlements.plist" 2>/dev/null || true + )" + [[ "$main_entitlement_count" == "1" && \ + "$local_library_validation_exception" == "true" ]] || \ + die "local ad-hoc app must contain only the Sparkle library-validation compatibility entitlement" + fi +} + +verify_embedded_certificate_hash() { + local target_path="$1" + local label="$2" + local certificate_prefix="$VERIFY_ROOT/$label-signing-cert-" + local certificate_path="${certificate_prefix}0" + local embedded_hash="" + + rm -f "${certificate_prefix}"* + codesign -d --extract-certificates "$certificate_prefix" "$target_path" >/dev/null 2>&1 || \ + die "could not extract the $label signing certificate" + [[ -f "$certificate_path" ]] || die "$label signing certificate was not extracted" + embedded_hash="$(shasum -a 1 "$certificate_path" | awk '{print toupper($1)}')" + rm -f "${certificate_prefix}"* + [[ "$embedded_hash" == "$DEVELOPER_ID_CERT_HASH" ]] || die "$label was signed by an unexpected certificate" +} + +verify_app_binary_contract() { + local target_path="$1" + local binary_path="$target_path/Contents/MacOS/$APP_NAME" + local info_path="$target_path/Contents/Info.plist" + local resources_path="$target_path/Contents/Resources" + local metallib_path="$target_path/Contents/Resources/default.metallib" + local privacy_path="$target_path/Contents/Resources/PrivacyInfo.xcprivacy" + local sparkle_framework="$target_path/Contents/Frameworks/Sparkle.framework" + local sparkle_info="$sparkle_framework/Resources/Info.plist" + local sparkle_binary="$sparkle_framework/Sparkle" + local build_report="$VERIFY_ROOT/app-build-version.txt" + local imports_report="$VERIFY_ROOT/app-symbol-imports.txt" + local metallib_strings="$VERIFY_ROOT/default-metallib.strings" + local minimum_system_version="" + local minimum_count="" + local sparkle_version="" + local updater_value="" + local embedded_feed_url="" + local embedded_public_key="" + local embedded_build_channel="" + + minimum_system_version="$(plutil -extract LSMinimumSystemVersion raw -o - "$info_path")" + [[ "$minimum_system_version" == "14.0" ]] || die "mounted app minimum system version is '$minimum_system_version', expected 14.0" + embedded_build_channel="$(plutil -extract KeyLightBuildChannel raw -o - "$info_path")" + [[ "$embedded_build_channel" == "$BUILD_CHANNEL" ]] || \ + die "mounted app build channel '$embedded_build_channel' does not match '$BUILD_CHANNEL'" + + DEVELOPER_DIR="$XCODE_DEVELOPER_DIR" xcrun vtool -show-build "$binary_path" > "$build_report" + minimum_count="$(rg -c '^ minos 14\.0$' "$build_report" || true)" + [[ "$minimum_count" == "2" ]] || die "expected macOS 14.0 minimum on both universal slices" + + nm -m "$binary_path" > "$imports_report" + # System Glass and the shared surface engine use SwiftUI's custom-Shape APIs + # so the material boundary can be a flat bell rather than + # NSGlassEffectView's rounded rectangle. Every macOS 26-only symbol must + # remain weak imported so the universal binary still launches on macOS 14/15. + rg -F 'weak external _$s7SwiftUI20GlassEffectContainerV7spacing7content' "$imports_report" >/dev/null || \ + die "SwiftUI GlassEffectContainer is not weak imported" + rg -F 'weak external _$s7SwiftUI4ViewPAAE11glassEffect_2in' "$imports_report" >/dev/null || \ + die "SwiftUI custom-shape glassEffect is not weak imported" + rg -F 'weak external _$s7SwiftUI5GlassV5clearACvgZ' "$imports_report" >/dev/null || \ + die "SwiftUI clear glass material is not weak imported" + + [[ -f "$metallib_path" ]] || \ + die "precompiled Physical Refraction default.metallib is missing" + strings "$metallib_path" > "$metallib_strings" + rg -F 'keyLightRefractionVertex' "$metallib_strings" >/dev/null || \ + die "Physical Refraction vertex entry point is missing" + rg -F 'keyLightRefractionFragment' "$metallib_strings" >/dev/null || \ + die "Physical Refraction fragment entry point is missing" + if find "$resources_path" -type f -name '*.metal' -print -quit | rg . >/dev/null; then + die "raw Metal shader source must not ship in the app bundle" + fi + + [[ -f "$privacy_path" ]] || die "PrivacyInfo.xcprivacy is missing from app resources" + plutil -lint "$privacy_path" >/dev/null + rg -F 'NSPrivacyAccessedAPICategoryFileTimestamp' "$privacy_path" >/dev/null || \ + die "privacy manifest lacks the user-selected file metadata declaration" + rg -F 'NSPrivacyAccessedAPICategorySystemBootTime' "$privacy_path" >/dev/null || \ + die "privacy manifest lacks the monotonic clock declaration" + rg -F 'NSPrivacyAccessedAPICategoryUserDefaults' "$privacy_path" >/dev/null || \ + die "privacy manifest lacks the local settings declaration" + [[ "$(plutil -extract NSPrivacyTracking raw -o - "$privacy_path")" == "false" ]] || \ + die "privacy manifest unexpectedly enables tracking" + + [[ -d "$sparkle_framework" ]] || die "Sparkle.framework is missing" + sparkle_version="$(plutil -extract CFBundleShortVersionString raw -o - "$sparkle_info")" + [[ "$sparkle_version" == "$EXPECTED_SPARKLE_VERSION" ]] || \ + die "bundled Sparkle version '$sparkle_version' is not $EXPECTED_SPARKLE_VERSION" + [[ " $(lipo -archs "$sparkle_binary") " == *" arm64 "* ]] || \ + die "bundled Sparkle framework is missing arm64" + [[ " $(lipo -archs "$sparkle_binary") " == *" x86_64 "* ]] || \ + die "bundled Sparkle framework is missing x86_64" + + for updater_value in \ + SUEnableAutomaticChecks:false \ + SUAutomaticallyUpdate:false \ + SUSendProfileInfo:false \ + SUVerifyUpdateBeforeExtraction:true \ + SURequireSignedFeed:true; do + local updater_key="${updater_value%%:*}" + local expected_value="${updater_value##*:}" + [[ "$(plutil -extract "$updater_key" raw -o - "$info_path")" == "$expected_value" ]] || \ + die "updater policy '$updater_key' is not '$expected_value'" + done + [[ "$(plutil -extract SUSignedFeedFailureExpirationInterval raw -o - "$info_path")" == "0" ]] || \ + die "signed feed failures must fail closed without expiration" + + if [[ "$MODE" == "release" ]]; then + [[ "$(plutil -extract SUFeedURL raw -o - "$info_path")" == "$SPARKLE_FEED_URL" ]] || \ + die "release app contains the wrong Sparkle feed URL" + [[ "$(plutil -extract SUPublicEDKey raw -o - "$info_path")" == "$SPARKLE_PUBLIC_ED_KEY" ]] || \ + die "release app contains the wrong Sparkle public key" + elif [[ "$MODE" == "release-unsigned" ]]; then + if plutil -extract SUFeedURL raw -o - "$info_path" >/dev/null 2>&1; then + die "unsigned release app must not contain a Sparkle feed key" + fi + if plutil -extract SUPublicEDKey raw -o - "$info_path" >/dev/null 2>&1; then + die "unsigned release app must not contain a Sparkle public-key entry" + fi + else + embedded_feed_url="$(plutil -extract SUFeedURL raw -o - "$info_path" 2>/dev/null || true)" + embedded_public_key="$(plutil -extract SUPublicEDKey raw -o - "$info_path" 2>/dev/null || true)" + [[ -z "$embedded_feed_url" ]] || \ + die "local preview unexpectedly contains a production update feed" + [[ -z "$embedded_public_key" ]] || \ + die "local preview unexpectedly contains a production update key" + fi +} + +verify_release_credentials() { + local identities="" + local candidates="" + local selected="" + local candidate_count="" + local requested_identity="$DEVELOPER_ID_IDENTITY" + local requested_team="$DEVELOPMENT_TEAM" + + if [[ -n "$requested_identity" && "$requested_identity" != "Developer ID Application:"* ]]; then + die "KEYLIGHT_DEVELOPER_ID_APPLICATION must name a Developer ID Application certificate" + fi + if [[ -n "$requested_team" && ! "$requested_team" =~ ^[A-Z0-9]{10}$ ]]; then + die "KEYLIGHT_DEVELOPMENT_TEAM must be a ten-character team identifier" + fi + + identities="$(security find-identity -v -p codesigning 2>/dev/null || true)" + candidates="$(echo "$identities" | rg '^[[:space:]]*[0-9]+\) [A-F0-9]+ "Developer ID Application:' || true)" + + if [[ -n "$requested_identity" ]]; then + candidates="$(echo "$candidates" | rg -F -- "\"$requested_identity\"" || true)" + fi + if [[ -n "$requested_team" ]]; then + candidates="$(echo "$candidates" | rg -F -- "($requested_team)\"" || true)" + fi + + candidate_count="$(echo "$candidates" | rg -c . || true)" + candidate_count="${candidate_count:-0}" + [[ "$candidate_count" != "0" ]] || die "no matching Developer ID Application identity is available in the keychain" + [[ "$candidate_count" == "1" ]] || \ + die "multiple Developer ID Application identities match; select one with KEYLIGHT_DEVELOPER_ID_APPLICATION or KEYLIGHT_DEVELOPMENT_TEAM" + + selected="$candidates" + DEVELOPER_ID_CERT_HASH="$(echo "$selected" | sed -E 's/^[[:space:]]*[0-9]+\) ([A-F0-9]+) ".*$/\1/')" + DEVELOPER_ID_IDENTITY="$(echo "$selected" | sed -E 's/^[[:space:]]*[0-9]+\) [A-F0-9]+ "(.*)"$/\1/')" + DEVELOPMENT_TEAM="$(echo "$DEVELOPER_ID_IDENTITY" | sed -E 's/^.*\(([A-Z0-9]{10})\)$/\1/')" + + [[ "$DEVELOPER_ID_CERT_HASH" =~ ^[A-F0-9]{40}$ ]] || die "could not derive the Developer ID certificate hash" + [[ "$DEVELOPMENT_TEAM" =~ ^[A-Z0-9]{10}$ ]] || die "could not derive the Apple Developer Team ID" + if [[ -n "$requested_team" && "$requested_team" != "$DEVELOPMENT_TEAM" ]]; then + die "selected Developer ID identity does not match KEYLIGHT_DEVELOPMENT_TEAM" + fi + + security find-generic-password \ + -a "$NOTARY_PROFILE" \ + -s "com.apple.gke.notary.tool" >/dev/null 2>&1 || \ + die "notarytool keychain profile '$NOTARY_PROFILE' is not available" +} + +submit_for_notarization() { + local artifact_path="$1" + local result_path="$2" + local label="$3" + local log_path="$4" + local status="" + local submission_id="" + + log "Submitting $label for notarization" + DEVELOPER_DIR="$XCODE_DEVELOPER_DIR" xcrun notarytool submit \ + "$artifact_path" \ + --keychain-profile "$NOTARY_PROFILE" \ + --wait \ + --output-format json > "$result_path" + + status="$(plutil -extract status raw -o - "$result_path" 2>/dev/null || true)" + if [[ "$status" != "Accepted" ]]; then + cat "$result_path" >&2 + die "$label notarization status was '${status:-unknown}', expected Accepted" + fi + + submission_id="$(plutil -extract id raw -o - "$result_path" 2>/dev/null || true)" + [[ -n "$submission_id" ]] || die "$label notarization result omitted its submission ID" + DEVELOPER_DIR="$XCODE_DEVELOPER_DIR" xcrun notarytool log \ + "$submission_id" \ + --keychain-profile "$NOTARY_PROFILE" \ + "$log_path" + [[ -s "$log_path" ]] || die "$label notarization log was not downloaded" + if rg -i '"severity"[[:space:]]*:[[:space:]]*"error"' "$log_path" >/dev/null; then + cat "$log_path" >&2 + die "$label notarization log contains an error despite acceptance" + fi +} + +verify_root_contents() { + local entry="" + local name="" + + while IFS= read -r entry; do + name="${entry##*/}" + case "$name" in + .background|.DS_Store|Applications|"$APP_NAME.app") + ;; + *) + die "unexpected item at DMG root: $name" + ;; + esac + done < <(find "$VERIFY_MOUNT_POINT" -mindepth 1 -maxdepth 1 -print) + + [[ -d "$VERIFY_MOUNT_POINT/$APP_NAME.app" ]] || die "mounted app bundle is missing" + [[ -L "$VERIFY_MOUNT_POINT/Applications" ]] || die "Applications drop link is missing" + [[ "$(readlink "$VERIFY_MOUNT_POINT/Applications")" == "/Applications" ]] || die "Applications drop link has the wrong destination" + [[ -f "$VERIFY_MOUNT_POINT/.DS_Store" ]] || die "Finder layout metadata is missing" + [[ -f "$VERIFY_MOUNT_POINT/.background/$DMG_BG_FILE_NAME" ]] || die "supported create-dmg background is missing" + + if find "$VERIFY_MOUNT_POINT/.background" -mindepth 1 -maxdepth 1 ! -name "$DMG_BG_FILE_NAME" -print -quit | rg . >/dev/null; then + die "unexpected item in the DMG .background directory" + fi +} + +verify_dmg_signature_mode() { + local target_path="$1" + local report_path="$VERIFY_ROOT/dmg-codesign.txt" + + if is_signed_mode; then + codesign -dv --verbose=4 "$target_path" >/dev/null 2> "$report_path" + codesign --verify --strict --verbose=2 "$target_path" >> "$report_path" 2>&1 + rg -F "TeamIdentifier=$DEVELOPMENT_TEAM" "$report_path" >/dev/null || die "signed DMG has the wrong TeamIdentifier" + rg -F "Authority=Developer ID Application:" "$report_path" >/dev/null || die "signed DMG does not use a Developer ID Application identity" + verify_embedded_certificate_hash "$target_path" "dmg" + DEVELOPER_DIR="$XCODE_DEVELOPER_DIR" xcrun stapler validate "$target_path" + spctl --assess --type open --context context:primary-signature --verbose=4 "$target_path" + else + if codesign -dv "$target_path" >/dev/null 2>&1; then + die "local DMG unexpectedly has a code signature" + fi + echo "Local DMG intentionally has no Developer ID signature or notarization ticket." > "$report_path" fi } verify_dmg() { + local target_path="$1" local mounted_app_path="" + local app_version="" + local app_build_number="" + local architectures="" + local ds_store_hex="" + local app_name_utf16_hex="" + local background_name_utf16_hex="" + local app_bundle_name="" + local app_display_name="" + local app_executable_name="" local ds_store_strings_path="$VERIFY_ROOT/root-dsstore.strings" local dmg_strings_path="$VERIFY_ROOT/dmg.strings" rm -rf "$VERIFY_ROOT" mkdir -p "$VERIFY_MOUNT_POINT" + verify_dmg_signature_mode "$target_path" + log "Verifying DMG bytes for privacy leaks" - scan_strings_file "DMG bytes" "$WORK_DMG_PATH" "$dmg_strings_path" + scan_strings_file "DMG bytes" "$target_path" "$dmg_strings_path" - log "Mounting DMG for bundle verification" - hdiutil attach -nobrowse -readonly -mountpoint "$VERIFY_MOUNT_POINT" "$WORK_DMG_PATH" > "$VERIFY_ROOT/hdiutil-attach.txt" + log "Mounting DMG read-only for bundle and layout verification" + hdiutil attach -nobrowse -readonly -mountpoint "$VERIFY_MOUNT_POINT" "$target_path" > "$VERIFY_ROOT/hdiutil-attach.txt" ATTACHED_DEVICE="$(awk '/^\/dev\// {print $1; exit}' "$VERIFY_ROOT/hdiutil-attach.txt")" if [[ -z "$ATTACHED_DEVICE" ]]; then - echo "error: failed to determine mounted DMG device" >&2 cat "$VERIFY_ROOT/hdiutil-attach.txt" >&2 - exit 1 + die "failed to determine mounted DMG device" fi + verify_root_contents + verify_background_asset "$VERIFY_MOUNT_POINT/.background/$DMG_BG_FILE_NAME" + + scan_strings_file "mounted DMG .DS_Store" "$VERIFY_MOUNT_POINT/.DS_Store" "$ds_store_strings_path" + rg -F "$DMG_BG_FILE_NAME" "$ds_store_strings_path" >/dev/null || die "Finder background marker is missing from .DS_Store" + CLANG_MODULE_CACHE_PATH="$WORK_ROOT/module-cache" \ + DEVELOPER_DIR="$XCODE_DEVELOPER_DIR" \ + xcrun swift "$FINDER_METADATA_VERIFIER" \ + "$VERIFY_MOUNT_POINT/.DS_Store" 600 432 112 14 + + # Finder stores icon locations as UTF-16 names followed by Iloc blobs. Check + # the exact create-dmg coordinates without relying on `strings`, which does + # not expose the UTF-16 app name on every macOS release. + ds_store_hex="$(xxd -p "$VERIFY_MOUNT_POINT/.DS_Store" | tr -d '\n')" + app_name_utf16_hex="$(printf '%s' "$APP_NAME.app" | iconv -f UTF-8 -t UTF-16BE | xxd -p | tr -d '\n')" + background_name_utf16_hex="$(printf '%s' ".background" | iconv -f UTF-8 -t UTF-16BE | xxd -p | tr -d '\n')" + echo "$ds_store_hex" | rg -F "${app_name_utf16_hex}496c6f63626c6f6200000010000000a5000000cd" >/dev/null || \ + die "$APP_NAME.app is not positioned at (165, 205)" + echo "$ds_store_hex" | rg -F "004100700070006c00690063006100740069006f006e0073496c6f63626c6f6200000010000001b3000000cd" >/dev/null || \ + die "Applications is not positioned at (435, 205)" + echo "$ds_store_hex" | rg -F "${background_name_utf16_hex}496c6f63626c6f620000001000000dac00000064" >/dev/null || \ + die ".background is not parked outside the resizable Finder canvas" + mounted_app_path="$VERIFY_MOUNT_POINT/$APP_NAME.app" - if [[ ! -d "$mounted_app_path" ]]; then - echo "error: mounted app bundle not found at $mounted_app_path" >&2 - exit 1 + if find "$mounted_app_path" -name 'KeyLightInstallerBackground.png' -print -quit | rg . >/dev/null; then + die "installer-only artwork leaked into the installed app bundle" fi - if [[ "$EXPECT_CUSTOM_LAYOUT" == "1" ]]; then - if [[ ! -f "$VERIFY_MOUNT_POINT/.DS_Store" ]]; then - echo "error: expected DMG Finder metadata (.DS_Store) was not created" >&2 - exit 1 - fi + app_version="$(plutil -extract CFBundleShortVersionString raw -o - "$mounted_app_path/Contents/Info.plist")" + [[ "$app_version" == "$VERSION" ]] || die "mounted app version '$app_version' does not match requested version '$VERSION'" + app_build_number="$(plutil -extract CFBundleVersion raw -o - "$mounted_app_path/Contents/Info.plist")" + [[ "$app_build_number" == "$BUILD_NUMBER" ]] || die "mounted app build '$app_build_number' does not match requested build '$BUILD_NUMBER'" + app_bundle_name="$(plutil -extract CFBundleName raw -o - "$mounted_app_path/Contents/Info.plist")" + [[ "$app_bundle_name" == "$APP_NAME" ]] || die "mounted app bundle name '$app_bundle_name' does not match '$APP_NAME'" + app_display_name="$(plutil -extract CFBundleDisplayName raw -o - "$mounted_app_path/Contents/Info.plist")" + [[ "$app_display_name" == "$APP_NAME" ]] || die "mounted app display name '$app_display_name' does not match '$APP_NAME'" + app_executable_name="$(plutil -extract CFBundleExecutable raw -o - "$mounted_app_path/Contents/Info.plist")" + [[ "$app_executable_name" == "$APP_NAME" ]] || die "mounted app executable '$app_executable_name' does not match '$APP_NAME'" - scan_strings_file "mounted DMG .DS_Store" "$VERIFY_MOUNT_POINT/.DS_Store" "$ds_store_strings_path" - for required_marker in "$DMG_BG_STAGED_NAME" "$APP_NAME.app"; do - if ! rg -n -F -- "$required_marker" "$ds_store_strings_path" >/dev/null 2>&1; then - echo "error: expected custom DMG marker '$required_marker' not found in mounted .DS_Store" >&2 - exit 1 - fi - done - fi + architectures="$(lipo -archs "$mounted_app_path/Contents/MacOS/$APP_NAME")" + [[ " $architectures " == *" arm64 "* ]] || die "mounted app is missing arm64" + [[ " $architectures " == *" x86_64 "* ]] || die "mounted app is missing x86_64" scan_binary_tree "mounted app bundle" "$mounted_app_path" verify_xattrs "$mounted_app_path" - verify_codesign_metadata "$mounted_app_path" + verify_app_signature "$mounted_app_path" "$VERIFY_ROOT/app-codesign.txt" + verify_entitlement_contract "$mounted_app_path" + verify_app_binary_contract "$mounted_app_path" + + if is_signed_mode; then + DEVELOPER_DIR="$XCODE_DEVELOPER_DIR" xcrun stapler validate "$mounted_app_path" + spctl --assess --type execute --verbose=4 "$mounted_app_path" + fi hdiutil detach "$ATTACHED_DEVICE" >/dev/null ATTACHED_DEVICE="" } -if [[ ! -d "$PROJECT_PATH" ]]; then - echo "error: KeyLight.xcodeproj not found at $PROJECT_PATH" >&2 - exit 1 +[[ -d "$PROJECT_PATH" ]] || die "KeyLight.xcodeproj not found at $PROJECT_PATH" +[[ -f "$ENTITLEMENTS_PATH" ]] || die "entitlements file not found at $ENTITLEMENTS_PATH" +[[ -f "$PACKAGE_RESOLVED_PATH" ]] || die "Package.resolved not found at $PACKAGE_RESOLVED_PATH" +[[ -f "$SHARED_CONFIG_PATH" ]] || die "Shared.xcconfig not found at $SHARED_CONFIG_PATH" +[[ -f "$PRIVACY_MANIFEST_PATH" ]] || die "privacy manifest not found at $PRIVACY_MANIFEST_PATH" +[[ -f "$RELEASE_METADATA_GENERATOR" ]] || die "release metadata generator not found at $RELEASE_METADATA_GENERATOR" +[[ -f "$SPARKLE_SIGNATURE_VERIFIER" ]] || die "Sparkle signature verifier not found at $SPARKLE_SIGNATURE_VERIFIER" +[[ -f "$SPARKLE_SIGNATURE_TEST" ]] || die "Sparkle signature verifier test not found at $SPARKLE_SIGNATURE_TEST" +[[ -x "$PROJECT_POLICY_VERIFIER" ]] || die "project policy verifier not found at $PROJECT_POLICY_VERIFIER" +[[ -f "$FINDER_METADATA_VERIFIER" ]] || die "Finder metadata verifier not found at $FINDER_METADATA_VERIFIER" +[[ -d "$XCODE_DEVELOPER_DIR" ]] || die "Xcode developer directory not found at $XCODE_DEVELOPER_DIR" + +require_command awk +require_command base64 +require_command codesign +require_command cmp +require_command create-dmg +require_command ditto +require_command file +require_command find +require_command git +require_command hdiutil +require_command iconv +require_command jq +require_command lipo +require_command nm +require_command plutil +require_command rg +require_command security +require_command sed +require_command shasum +require_command sips +require_command spctl +require_command stat +require_command strings +require_command strip +require_command tr +require_command wc +require_command xxd +require_command xcrun +require_command xcodebuild + +[[ "$(create-dmg --version)" == "create-dmg 1.2.3" ]] || \ + die "create-dmg 1.2.3 is required for the verified Finder layout" +XCODE_MAJOR_VERSION="$(DEVELOPER_DIR="$XCODE_DEVELOPER_DIR" xcodebuild -version | awk 'NR == 1 {split($2, version, "."); print version[1]}')" +[[ "$XCODE_MAJOR_VERSION" =~ ^[0-9]+$ && "$XCODE_MAJOR_VERSION" -ge 26 ]] || \ + die "Xcode 26 or newer is required to package the native glass effects" + +verify_background_asset "$DMG_BG_ASSET_PATH" +verify_package_lock +"$PROJECT_POLICY_VERIFIER" + +if [[ "${KEYLIGHT_OVERWRITE:-0}" != "1" ]]; then + artifacts_to_protect=("$FINAL_DMG_PATH" "$FINAL_CHECKSUM_PATH") + if is_release_artifact_mode; then + artifacts_to_protect+=( + "$FINAL_SBOM_PATH" + "$FINAL_PROVENANCE_PATH" + ) + if [[ "$MODE" == "release" ]]; then + artifacts_to_protect+=("$FINAL_SPARKLE_SIGNATURE_PATH") + fi + fi + for protected_artifact in "${artifacts_to_protect[@]}"; do + [[ ! -e "$protected_artifact" ]] || \ + die "refusing to overwrite existing artifact at $protected_artifact (remove it or set KEYLIGHT_OVERWRITE=1 intentionally)" + done fi -VERSION="${1:-}" -if [[ -z "$VERSION" ]]; then - if command -v git >/dev/null 2>&1 && git -C "$ROOT_DIR" describe --tags --abbrev=0 >/dev/null 2>&1; then - VERSION="$(git -C "$ROOT_DIR" describe --tags --abbrev=0 | sed 's/^v//')" +if is_signed_mode; then + if [[ "$MODE" == "release" ]]; then + verify_release_source_state + verify_release_update_configuration else - VERSION="$(date +%Y.%m.%d)" + verify_signed_preview_source_state fi + verify_release_credentials +fi +if [[ "$MODE" == "release-unsigned" ]]; then + verify_unsigned_release_source_state fi -if ! command -v xcodebuild >/dev/null 2>&1; then - echo "error: xcodebuild is required to build KeyLight.app" >&2 - exit 1 +log "Preparing isolated packaging workspace" +mkdir -p "$DIST_DIR" +mkdir -m 700 "$WORK_ROOT" +mkdir -p "$BUILD_ROOT" "$OUTPUT_ROOT" "$STAGE_DIR" "$VERIFY_ROOT" "$AUDIT_ROOT" "$WORK_ROOT/module-cache" + +if requires_full_quality_gates; then + run_release_quality_gates fi -require_command codesign -require_command hdiutil -require_command rg -require_command strip -require_command strings +if is_signed_mode; then + log "Archiving $APP_NAME.app with Developer ID" + DEVELOPER_DIR="$XCODE_DEVELOPER_DIR" xcodebuild \ + -quiet \ + "${XCODE_PACKAGE_ARGUMENTS[@]}" \ + -project "$PROJECT_PATH" \ + -scheme "$SCHEME_NAME" \ + -configuration Release \ + -destination "generic/platform=macOS" \ + -archivePath "$ARCHIVE_PATH" \ + SWIFT_VERSION=6 \ + SWIFT_STRICT_CONCURRENCY=complete \ + ARCHS="arm64 x86_64" \ + ONLY_ACTIVE_ARCH=NO \ + MARKETING_VERSION="$VERSION" \ + CURRENT_PROJECT_VERSION="$BUILD_NUMBER" \ + PRODUCT_BUNDLE_IDENTIFIER="$BUNDLE_ID" \ + PRODUCT_NAME="$APP_NAME" \ + KEYLIGHT_BUILD_CHANNEL="$BUILD_CHANNEL" \ + KEYLIGHT_SPARKLE_FEED_URL="$SPARKLE_FEED_URL" \ + KEYLIGHT_SPARKLE_PUBLIC_ED_KEY="$SPARKLE_PUBLIC_ED_KEY" \ + DEVELOPMENT_TEAM="$DEVELOPMENT_TEAM" \ + CODE_SIGN_STYLE=Manual \ + CODE_SIGN_IDENTITY="$DEVELOPER_ID_CERT_HASH" \ + CODE_SIGNING_ALLOWED=YES \ + CODE_SIGNING_REQUIRED=YES \ + OTHER_CODE_SIGN_FLAGS="--timestamp" \ + archive + + plutil -create xml1 "$EXPORT_OPTIONS_PATH" + plutil -insert method -string developer-id "$EXPORT_OPTIONS_PATH" + plutil -insert destination -string export "$EXPORT_OPTIONS_PATH" + plutil -insert signingStyle -string manual "$EXPORT_OPTIONS_PATH" + plutil -insert signingCertificate -string "$DEVELOPER_ID_CERT_HASH" "$EXPORT_OPTIONS_PATH" + plutil -insert teamID -string "$DEVELOPMENT_TEAM" "$EXPORT_OPTIONS_PATH" + plutil -insert stripSwiftSymbols -bool true "$EXPORT_OPTIONS_PATH" -DMG_TOOL=() -CREATE_DMG_BIN="" -if command -v create-dmg >/dev/null 2>&1; then - CREATE_DMG_BIN="$(command -v create-dmg)" - DMG_TOOL=("$CREATE_DMG_BIN") -elif command -v npx >/dev/null 2>&1; then - DMG_TOOL=(npx --yes create-dmg) + log "Exporting Developer ID app" + DEVELOPER_DIR="$XCODE_DEVELOPER_DIR" xcodebuild \ + -exportArchive \ + -archivePath "$ARCHIVE_PATH" \ + -exportPath "$EXPORT_PATH" \ + -exportOptionsPlist "$EXPORT_OPTIONS_PATH" + + SOURCE_APP_PATH="$EXPORT_PATH/$APP_NAME.app" else - echo "error: create-dmg not found. Install with: brew install create-dmg" >&2 - exit 1 -fi + log "Building $APP_NAME.app for a local unsigned installer" + DEVELOPER_DIR="$XCODE_DEVELOPER_DIR" xcodebuild \ + -quiet \ + "${XCODE_PACKAGE_ARGUMENTS[@]}" \ + -project "$PROJECT_PATH" \ + -scheme "$SCHEME_NAME" \ + -configuration Release \ + -destination "generic/platform=macOS" \ + -derivedDataPath "$DERIVED_DATA_PATH" \ + SWIFT_VERSION=6 \ + SWIFT_STRICT_CONCURRENCY=complete \ + ARCHS="arm64 x86_64" \ + ONLY_ACTIVE_ARCH=NO \ + MARKETING_VERSION="$VERSION" \ + CURRENT_PROJECT_VERSION="$BUILD_NUMBER" \ + PRODUCT_BUNDLE_IDENTIFIER="$BUNDLE_ID" \ + PRODUCT_NAME="$APP_NAME" \ + KEYLIGHT_BUILD_CHANNEL="$BUILD_CHANNEL" \ + CODE_SIGNING_ALLOWED=NO \ + CODE_SIGNING_REQUIRED=NO \ + build -FINAL_DMG_PATH="$DIST_DIR/${APP_NAME}-${VERSION}.dmg" -WORK_DMG_PATH="$OUTPUT_ROOT/${APP_NAME}-${VERSION}.dmg" - -log "Cleaning build folders" -mkdir -p "$DIST_DIR" "$WORK_ROOT" -rm -rf "$BUILD_ROOT" "$OUTPUT_ROOT" "$VERIFY_ROOT" "$FINAL_DMG_PATH" -mkdir -p "$BUILD_ROOT" "$OUTPUT_ROOT" -mkdir -p "$STAGE_DIR" - -log "Building $APP_NAME.app (Release)" -DEVELOPER_DIR="${DEVELOPER_DIR:-/Applications/Xcode.app/Contents/Developer}" \ -xcodebuild \ - -project "$PROJECT_PATH" \ - -scheme "$SCHEME_NAME" \ - -configuration Release \ - -destination "generic/platform=macOS" \ - -derivedDataPath "$DERIVED_DATA_PATH" \ - SWIFT_VERSION=6 \ - SWIFT_STRICT_CONCURRENCY=complete \ - build - -APP_PATH="$DERIVED_DATA_PATH/Build/Products/Release/$APP_NAME.app" -if [[ ! -d "$APP_PATH" ]]; then - echo "error: built app not found at $APP_PATH" >&2 - exit 1 + SOURCE_APP_PATH="$DERIVED_DATA_PATH/Build/Products/Release/$APP_NAME.app" fi +[[ -d "$SOURCE_APP_PATH" ]] || die "built app not found at $SOURCE_APP_PATH" + log "Staging app bundle" -cp -R "$APP_PATH" "$STAGE_DIR/" -sanitize_tree "$STAGE_DIR" -strip -S -x "$STAGE_DIR/$APP_NAME.app/Contents/MacOS/$APP_NAME" -if [[ "${KEYLIGHT_DMG_HEADLESS:-0}" != "1" ]] && [[ -f "$DMG_BG_ASSET_PATH" ]]; then - EXPECT_CUSTOM_LAYOUT=1 - cp "$DMG_BG_ASSET_PATH" "$STAGE_DIR/$APP_NAME.app/Contents/Resources/$DMG_BG_STAGED_NAME" - sanitize_tree "$STAGE_DIR/$APP_NAME.app/Contents/Resources/$DMG_BG_STAGED_NAME" -fi -# Re-sign after stripping binary symbols and injecting the background asset so -# the staged app bundle stays internally consistent inside the final DMG. -codesign --force --sign - --options runtime --entitlements "$ENTITLEMENTS_PATH" "$STAGE_DIR/$APP_NAME.app" -sanitize_tree "$STAGE_DIR" - -log "Creating DMG in neutral workspace: $WORK_DMG_PATH" -CREATE_DMG_ARGS=( - --format UDZO - --volname "$APP_NAME $VERSION" - --window-pos 180 120 - --window-size 600 400 - --icon-size 128 - --icon "$APP_NAME.app" 179 150 - --hide-extension "$APP_NAME.app" - --app-drop-link 439 150 - --no-internet-enable -) +ditto "$SOURCE_APP_PATH" "$STAGED_APP_PATH" +sanitize_tree "$STAGED_APP_PATH" +remove_unsigned_release_update_keys "$STAGED_APP_PATH" -if [[ "${KEYLIGHT_DMG_HEADLESS:-0}" == "1" ]]; then - CREATE_DMG_ARGS+=(--skip-jenkins) -elif [[ ! -f "$DMG_BG_ASSET_PATH" ]]; then - echo "warning: DMG background not found at $DMG_BG_ASSET_PATH; using Finder default background" +if is_signed_mode; then + verify_app_signature "$STAGED_APP_PATH" "$VERIFY_ROOT/pre-notary-app-codesign.txt" + ditto -c -k --sequesterRsrc --keepParent "$STAGED_APP_PATH" "$APP_NOTARY_ZIP" + submit_for_notarization \ + "$APP_NOTARY_ZIP" \ + "$APP_NOTARY_RESULT" \ + "app" \ + "$APP_NOTARY_LOG" + DEVELOPER_DIR="$XCODE_DEVELOPER_DIR" xcrun stapler staple "$STAGED_APP_PATH" + DEVELOPER_DIR="$XCODE_DEVELOPER_DIR" xcrun stapler validate "$STAGED_APP_PATH" +else + strip -S -x "$STAGED_APP_PATH/Contents/MacOS/$APP_NAME" + adhoc_sign_local_app "$STAGED_APP_PATH" fi -if [[ -n "$CREATE_DMG_BIN" ]] && [[ "${KEYLIGHT_DMG_HEADLESS:-0}" != "1" ]] && [[ -f "$DMG_BG_ASSET_PATH" ]]; then - # Use a patched create-dmg copy that resolves support files from Homebrew and - # sets the background from inside KeyLight.app to avoid a visible .background folder. - PATCHED_CREATE_DMG="$BUILD_ROOT/create-dmg-no-scroll.sh" - CREATE_DMG_SUPPORT_DIR="$(cd "$(dirname "$CREATE_DMG_BIN")/../share/create-dmg/support" && pwd)" - BACKGROUND_CLAUSE="set background picture of opts to file \"$APP_NAME.app:Contents:Resources:$DMG_BG_STAGED_NAME\"" - sed \ - -e "s|SKIP_JENKINS=0|SKIP_JENKINS=0\\nBACKGROUND_CLAUSE='$BACKGROUND_CLAUSE'|" \ - -e "s|CDMG_SUPPORT_DIR=\"\\\$prefix_dir/share/create-dmg/support\"|CDMG_SUPPORT_DIR=\"$CREATE_DMG_SUPPORT_DIR\"|" \ - "$CREATE_DMG_BIN" > "$PATCHED_CREATE_DMG" - chmod +x "$PATCHED_CREATE_DMG" - DMG_TOOL=("$PATCHED_CREATE_DMG") -fi +verify_app_signature "$STAGED_APP_PATH" "$VERIFY_ROOT/staged-app-codesign.txt" +verify_entitlement_contract "$STAGED_APP_PATH" +run_packaged_launch_smoke_test "$STAGED_APP_PATH" -"${DMG_TOOL[@]}" \ - "${CREATE_DMG_ARGS[@]}" \ +log "Creating professional drag-to-Applications DMG" +create-dmg \ + --format UDZO \ + --volname "$VOLUME_NAME" \ + --background "$DMG_BG_ASSET_PATH" \ + --window-pos 180 120 \ + --window-size 600 432 \ + --text-size 14 \ + --icon-size 112 \ + --icon "$APP_NAME.app" 165 205 \ + --icon ".background" 3500 100 \ + --hide-extension "$APP_NAME.app" \ + --app-drop-link 435 205 \ + --no-internet-enable \ "$WORK_DMG_PATH" \ "$STAGE_DIR" -verify_dmg +[[ -f "$WORK_DMG_PATH" ]] || die "create-dmg did not produce $WORK_DMG_PATH" -log "Copying verified DMG to $FINAL_DMG_PATH" -cp "$WORK_DMG_PATH" "$FINAL_DMG_PATH" -if command -v xattr >/dev/null 2>&1; then - xattr -c "$FINAL_DMG_PATH" 2>/dev/null || true +if is_signed_mode; then + log "Signing Developer ID DMG" + codesign \ + --force \ + --sign "$DEVELOPER_ID_CERT_HASH" \ + --identifier "$BUNDLE_ID.dmg" \ + --timestamp \ + "$WORK_DMG_PATH" + submit_for_notarization \ + "$WORK_DMG_PATH" \ + "$DMG_NOTARY_RESULT" \ + "DMG" \ + "$DMG_NOTARY_LOG" + DEVELOPER_DIR="$XCODE_DEVELOPER_DIR" xcrun stapler staple "$WORK_DMG_PATH" + if [[ "$MODE" == "release" ]]; then + log "Signing the final notarized update archive with Sparkle EdDSA" + sign_sparkle_update_archive + fi +else + if command -v xattr >/dev/null 2>&1; then + xattr -c "$WORK_DMG_PATH" 2>/dev/null || true + fi fi -echo "==> Removing staged app bundle to avoid stale launch collisions" -rm -rf "$STAGE_DIR/$APP_NAME.app" +verify_dmg "$WORK_DMG_PATH" + +if is_release_artifact_mode; then + log "Generating checksum, SPDX SBOM, and build provenance" + generate_release_metadata +else + log "Generating installer checksum" + ( + cd "$OUTPUT_ROOT" + shasum -a 256 "$FINAL_DMG_NAME" > "$FINAL_DMG_NAME.sha256" + ) +fi + +log "Publishing verified installer atomically to $FINAL_DMG_PATH" +ditto "$WORK_DMG_PATH" "$PUBLISH_DMG_PATH" +cmp -s "$WORK_DMG_PATH" "$PUBLISH_DMG_PATH" || die "published installer bytes differ from the verified artifact" +ditto "$WORK_CHECKSUM_PATH" "$PUBLISH_CHECKSUM_PATH" +cmp -s "$WORK_CHECKSUM_PATH" "$PUBLISH_CHECKSUM_PATH" || die "published checksum bytes differ" +if is_release_artifact_mode; then + ditto "$WORK_SBOM_PATH" "$PUBLISH_SBOM_PATH" + ditto "$WORK_PROVENANCE_PATH" "$PUBLISH_PROVENANCE_PATH" + cmp -s "$WORK_SBOM_PATH" "$PUBLISH_SBOM_PATH" || die "published SBOM bytes differ" + cmp -s "$WORK_PROVENANCE_PATH" "$PUBLISH_PROVENANCE_PATH" || die "published provenance bytes differ" + mv -f "$PUBLISH_SBOM_PATH" "$FINAL_SBOM_PATH" + mv -f "$PUBLISH_PROVENANCE_PATH" "$FINAL_PROVENANCE_PATH" + if [[ "$MODE" == "release" ]]; then + ditto "$WORK_SPARKLE_SIGNATURE_PATH" "$PUBLISH_SPARKLE_SIGNATURE_PATH" + cmp -s "$WORK_SPARKLE_SIGNATURE_PATH" "$PUBLISH_SPARKLE_SIGNATURE_PATH" || die "published Sparkle signature bytes differ" + mv -f "$PUBLISH_SPARKLE_SIGNATURE_PATH" "$FINAL_SPARKLE_SIGNATURE_PATH" + fi +fi +mv -f "$PUBLISH_CHECKSUM_PATH" "$FINAL_CHECKSUM_PATH" +mv -f "$PUBLISH_DMG_PATH" "$FINAL_DMG_PATH" + +log "Removing staged app bundle to prevent stale launch collisions" +rm -rf "$STAGED_APP_PATH" echo "==> Done" +echo "Mode: $MODE" +echo "Build channel: $BUILD_CHANNEL" echo "DMG path: $FINAL_DMG_PATH" +echo "Checksum: $FINAL_CHECKSUM_PATH" +if [[ "$MODE" == "release-unsigned" ]]; then + echo "Trust status: public unsigned release; DMG is unsigned and unnotarized; contained app is ad-hoc signed" + echo "SPDX SBOM: $FINAL_SBOM_PATH" + echo "Build provenance: $FINAL_PROVENANCE_PATH" +elif ! is_signed_mode; then + echo "Trust status: local-only; DMG is unsigned and unnotarized; contained app is ad-hoc signed" +else + echo "Trust status: Developer ID signed, notarized, and stapled" + if [[ "$MODE" == "release" ]]; then + echo "SPDX SBOM: $FINAL_SBOM_PATH" + echo "Build provenance: $FINAL_PROVENANCE_PATH" + echo "Sparkle EdDSA signature: $FINAL_SPARKLE_SIGNATURE_PATH" + fi + echo "Notarization receipts: $AUDIT_ROOT" +fi diff --git a/scripts/generate-release-metadata.swift b/scripts/generate-release-metadata.swift new file mode 100644 index 0000000..d503227 --- /dev/null +++ b/scripts/generate-release-metadata.swift @@ -0,0 +1,179 @@ +#!/usr/bin/env swift + +import Foundation + +private func fail(_ message: String) -> Never { + FileHandle.standardError.write(Data("error: \(message)\n".utf8)) + exit(1) +} + +let arguments = CommandLine.arguments +guard arguments.count == 16 else { + fail( + "usage: generate-release-metadata.swift ARTIFACT_NAME ARTIFACT_SHA256 " + + "PACKAGE_LOCK_SHA256 VERSION BUILD BUNDLE_ID COMMIT TAG XCODE_VERSION " + + "RELEASE_MODE SIGNER_SHA1 TEAM_ID CHECKSUM_OUTPUT SBOM_OUTPUT PROVENANCE_OUTPUT" + ) +} + +let artifactName = arguments[1] +let artifactSHA256 = arguments[2] +let packageLockSHA256 = arguments[3] +let version = arguments[4] +let build = arguments[5] +let bundleIdentifier = arguments[6] +let commit = arguments[7] +let tag = arguments[8] +let xcodeVersion = arguments[9] +let releaseMode = arguments[10] +let signerSHA1 = arguments[11] +let teamID = arguments[12] +let checksumURL = URL(fileURLWithPath: arguments[13]) +let sbomURL = URL(fileURLWithPath: arguments[14]) +let provenanceURL = URL(fileURLWithPath: arguments[15]) + +guard releaseMode == "release" || releaseMode == "release-unsigned" else { + fail("release mode must be release or release-unsigned") +} + +guard artifactSHA256.range( + of: #"^[a-f0-9]{64}$"#, + options: .regularExpression +) != nil else { + fail("artifact SHA-256 is malformed") +} +guard packageLockSHA256.range( + of: #"^[a-f0-9]{64}$"#, + options: .regularExpression +) != nil else { + fail("Package.resolved SHA-256 is malformed") +} + +let created = ISO8601DateFormatter().string(from: Date()) +let documentNamespace = + "https://github.com/keylight-macos/keylight/releases/tag/\(tag)/spdx/\(commit)" + +let sbom: [String: Any] = [ + "spdxVersion": "SPDX-2.3", + "dataLicense": "CC0-1.0", + "SPDXID": "SPDXRef-DOCUMENT", + "name": "KeyLight-\(version)-SBOM", + "documentNamespace": documentNamespace, + "creationInfo": [ + "created": created, + "creators": ["Tool: KeyLight verified release pipeline"] + ], + "packages": [ + [ + "name": "KeyLight", + "SPDXID": "SPDXRef-Package-KeyLight", + "versionInfo": version, + "downloadLocation": "NOASSERTION", + "filesAnalyzed": false, + "licenseConcluded": "MIT", + "licenseDeclared": "MIT", + "copyrightText": "NOASSERTION", + "checksums": [[ + "algorithm": "SHA256", + "checksumValue": artifactSHA256 + ]] + ], + [ + "name": "Sparkle", + "SPDXID": "SPDXRef-Package-Sparkle", + "versionInfo": "2.9.5", + "downloadLocation": "https://github.com/sparkle-project/Sparkle", + "filesAnalyzed": false, + "licenseConcluded": "MIT", + "licenseDeclared": "MIT", + "copyrightText": "NOASSERTION", + "externalRefs": [[ + "referenceCategory": "PACKAGE-MANAGER", + "referenceType": "purl", + "referenceLocator": "pkg:github/sparkle-project/Sparkle@2.9.5" + ]] + ] + ], + "relationships": [ + [ + "spdxElementId": "SPDXRef-DOCUMENT", + "relationshipType": "DESCRIBES", + "relatedSpdxElement": "SPDXRef-Package-KeyLight" + ], + [ + "spdxElementId": "SPDXRef-Package-KeyLight", + "relationshipType": "DEPENDS_ON", + "relatedSpdxElement": "SPDXRef-Package-Sparkle" + ] + ] +] + +let signing: [String: Any] +if releaseMode == "release" { + signing = [ + "type": "Developer ID Application", + "certificateSHA1": signerSHA1, + "teamIdentifier": teamID, + "hardenedRuntime": true, + "notarized": true, + "stapled": true, + "sparkleEdDSA": true + ] +} else { + signing = [ + "type": "ad hoc", + "certificateSHA1": "", + "teamIdentifier": "not set", + "hardenedRuntime": true, + "notarized": false, + "stapled": false, + "sparkleEdDSA": false + ] +} + +let provenance: [String: Any] = [ + "schemaVersion": 1, + "generatedAt": created, + "source": [ + "repository": "https://github.com/keylight-macos/keylight", + "commit": commit, + "tag": tag, + "dirty": false + ], + "build": [ + "xcode": xcodeVersion, + "configuration": "Release", + "architectures": ["arm64", "x86_64"], + "minimumMacOS": "14.0", + "packageResolvedSHA256": packageLockSHA256 + ], + "application": [ + "version": version, + "build": build, + "bundleIdentifier": bundleIdentifier + ], + "artifact": [ + "name": artifactName, + "sha256": artifactSHA256 + ], + "signing": signing, + "dependencies": [[ + "name": "Sparkle", + "version": "2.9.5", + "revision": "79bc9e872948e47877e76f194cb0c8e0412b0b90" + ]] +] + +let encoder = JSONSerialization.self +let options: JSONSerialization.WritingOptions = [.prettyPrinted, .sortedKeys] +let sbomData = try encoder.data(withJSONObject: sbom, options: options) +let provenanceData = try encoder.data(withJSONObject: provenance, options: options) +let checksumData = Data("\(artifactSHA256) \(artifactName)\n".utf8) + +do { + try checksumData.write(to: checksumURL, options: .atomic) + try sbomData.write(to: sbomURL, options: .atomic) + try provenanceData.write(to: provenanceURL, options: .atomic) +} catch { + fail("could not write release metadata: \(error.localizedDescription)") +} diff --git a/scripts/generate-signed-appcast.sh b/scripts/generate-signed-appcast.sh new file mode 100755 index 0000000..aeec2ed --- /dev/null +++ b/scripts/generate-signed-appcast.sh @@ -0,0 +1,94 @@ +#!/usr/bin/env bash +set -euo pipefail + +die() { + echo "error: $*" >&2 + exit 1 +} + +if [[ "$#" -lt 2 || "$#" -gt 3 ]]; then + echo "usage: $0 ARCHIVES_DIR HTTPS_DOWNLOAD_URL_PREFIX [APPCAST_FILENAME]" >&2 + exit 2 +fi + +ARCHIVES_DIR="$1" +DOWNLOAD_URL_PREFIX="$2" +APPCAST_FILENAME="${3:-appcast.xml}" +SPARKLE_TOOLS_DIR="${KEYLIGHT_SPARKLE_TOOLS_DIR:-}" +SPARKLE_PUBLIC_ED_KEY="${KEYLIGHT_SPARKLE_PUBLIC_ED_KEY:-}" +SPARKLE_KEY_ACCOUNT="${KEYLIGHT_SPARKLE_KEY_ACCOUNT:-ed25519}" +EXPECTED_SIGN_UPDATE_SHA256="bfb52400c3da18bb4c251ac4818c2c2e1e31c2e649a45b31c11109b6e57b34ad" +EXPECTED_GENERATE_APPCAST_SHA256="669a5ed0f90ce06fb1de3e36aba35c5da8b98f66928a185fd4029174071be700" +EXPECTED_GENERATE_KEYS_SHA256="2d18ed3a9c744e58150513d9b2e3c2eb76fd0b9621e3e4678d46dd972547e8fe" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PUBLIC_SIGNATURE_VERIFIER="$SCRIPT_DIR/verify-sparkle-signature.swift" +XCODE_DEVELOPER_DIR="${DEVELOPER_DIR:-/Applications/Xcode.app/Contents/Developer}" + +[[ -d "$ARCHIVES_DIR" ]] || die "archive directory does not exist: $ARCHIVES_DIR" +ARCHIVES_DIR="$(cd "$ARCHIVES_DIR" && pwd -P)" +[[ "$ARCHIVES_DIR" != "/" ]] || die "refusing to use the filesystem root" +if [[ -n "${HOME:-}" ]]; then + [[ "$ARCHIVES_DIR" != "$HOME" ]] || die "refusing to use the home directory" +fi +[[ "$DOWNLOAD_URL_PREFIX" == https://* ]] || die "download prefix must use HTTPS" +[[ "$DOWNLOAD_URL_PREFIX" == */ ]] || die "download prefix must end with a slash" +[[ "$APPCAST_FILENAME" == "$(basename "$APPCAST_FILENAME")" && "$APPCAST_FILENAME" == *.xml ]] || \ + die "appcast filename must be a plain .xml filename" +[[ -d "$SPARKLE_TOOLS_DIR" ]] || die "KEYLIGHT_SPARKLE_TOOLS_DIR is required" +[[ -n "$SPARKLE_PUBLIC_ED_KEY" ]] || die "KEYLIGHT_SPARKLE_PUBLIC_ED_KEY is required" +[[ -f "$PUBLIC_SIGNATURE_VERIFIER" ]] || die "public Sparkle signature verifier is missing" +[[ -d "$XCODE_DEVELOPER_DIR" ]] || die "Xcode developer directory is missing" + +verify_tool() { + local name="$1" + local expected_hash="$2" + local path="$SPARKLE_TOOLS_DIR/$name" + local actual_hash="" + [[ -x "$path" ]] || die "missing executable Sparkle tool: $path" + actual_hash="$(shasum -a 256 "$path" | awk '{print $1}')" + [[ "$actual_hash" == "$expected_hash" ]] || die "$name is not the reviewed Sparkle 2.9.5 tool" +} + +verify_tool sign_update "$EXPECTED_SIGN_UPDATE_SHA256" +verify_tool generate_appcast "$EXPECTED_GENERATE_APPCAST_SHA256" +verify_tool generate_keys "$EXPECTED_GENERATE_KEYS_SHA256" + +[[ "$("$SPARKLE_TOOLS_DIR/generate_keys" --account "$SPARKLE_KEY_ACCOUNT" -p)" == "$SPARKLE_PUBLIC_ED_KEY" ]] || \ + die "the Keychain signing key does not match KEYLIGHT_SPARKLE_PUBLIC_ED_KEY" + +archive_count="$(find "$ARCHIVES_DIR" -maxdepth 1 -type f -name 'KeyLight-*.dmg' | wc -l | tr -d ' ')" +[[ "$archive_count" -gt 0 ]] || die "no KeyLight DMG archives were found" + +"$SPARKLE_TOOLS_DIR/generate_appcast" \ + --account "$SPARKLE_KEY_ACCOUNT" \ + --download-url-prefix "$DOWNLOAD_URL_PREFIX" \ + -o "$APPCAST_FILENAME" \ + "$ARCHIVES_DIR" + +APPCAST_PATH="$ARCHIVES_DIR/$APPCAST_FILENAME" +[[ -s "$APPCAST_PATH" ]] || die "generate_appcast did not create $APPCAST_PATH" +xmllint --noout "$APPCAST_PATH" +"$SPARKLE_TOOLS_DIR/sign_update" \ + --account "$SPARKLE_KEY_ACCOUNT" \ + --verify \ + "$APPCAST_PATH" +MODULE_CACHE_PATH="$(mktemp -d /tmp/keylight-appcast-verifier.XXXXXX)" +trap 'rm -rf "$MODULE_CACHE_PATH"' EXIT +CLANG_MODULE_CACHE_PATH="$MODULE_CACHE_PATH" \ +SWIFT_MODULECACHE_PATH="$MODULE_CACHE_PATH" \ +DEVELOPER_DIR="$XCODE_DEVELOPER_DIR" \ + xcrun swift "$PUBLIC_SIGNATURE_VERIFIER" \ + appcast \ + "$APPCAST_PATH" \ + "$SPARKLE_PUBLIC_ED_KEY" + +if rg -U 'enclosure[^>]+url="http://' "$APPCAST_PATH" >/dev/null; then + die "signed appcast contains an insecure update URL" +fi +if rg -U 'releaseNotesLink[^>]*>[^<]*http://' "$APPCAST_PATH" >/dev/null; then + die "signed appcast contains an insecure release-notes URL" +fi + +echo "Signed appcast: $APPCAST_PATH" +echo "Archives inspected: $archive_count" +echo "Private key source: macOS Keychain account $SPARKLE_KEY_ACCOUNT" diff --git a/scripts/hardware-validation.sh b/scripts/hardware-validation.sh new file mode 100755 index 0000000..90a06ce --- /dev/null +++ b/scripts/hardware-validation.sh @@ -0,0 +1,432 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'USAGE' +Usage: + ./scripts/hardware-validation.sh prepare DMG [REPORT] + ./scripts/hardware-validation.sh record REPORT GATE STATUS [NOTES] + ./scripts/hardware-validation.sh list REPORT + ./scripts/hardware-validation.sh verify REPORT DMG + ./scripts/hardware-validation.sh verify-suite DMG REPORT [REPORT ...] + +Statuses: pending, pass, fail, blocked, not-applicable + +`prepare` records only non-identifying machine/build metadata. It never records +serial numbers, hardware UUIDs, display UUIDs, key codes, or typed content. +Use `record` after each real-hardware check. `verify-suite` requires every gate +to pass in at least one report, which allows macOS 14 and macOS 26 coverage to +come from separate machines without treating "not-applicable" as coverage. +USAGE +} + +die() { + echo "error: $*" >&2 + exit 1 +} + +require_command() { + local command_name="$1" + command -v "$command_name" >/dev/null 2>&1 || die "$command_name is required" +} + +timestamp() { + date -u '+%Y-%m-%dT%H:%M:%SZ' +} + +GATE_IDS=( + ansi_chords + iso_chords + modifier_chords + chord_appearance + guided_calibration + media_fn_caps_lock + display_routing_builtin + display_routing_external_clamshell + multi_display_mirroring + spaces_full_screen + appearance_routes + automatic_power_saving + snapshot_recovery + accessibility_modes + customizable_shortcut + macos14_classic + macos26_surfaces + latency_p99 + idle_cpu + typing_stress +) + +GATE_TITLES=( + "ANSI keyboard: two-, three-, and four-key chords remain lit through staggered release" + "ISO keyboard: two-, three-, and four-key chords remain lit through staggered release" + "Modifier chords: Command, Option, Control, Shift, and mixed chords retain every held surface" + "Natural Merge and Independent chords render correctly at 50, 100, and 150 percent through arbitrary release order" + "Guided calibration completes all nine anchors, review, cancel/close safety, unique naming, and new-profile activation" + "Media, Fn, and Caps Lock actions render without stuck surfaces" + "Built-in display routing follows the selected display and bound layout profile" + "External display and clamshell routing follow the selected display and fallback policy" + "One primary plus at least one mirrored physical display survives resize, disconnect/reconnect, sleep, and wake without stale keys" + "Spaces and full-screen transitions keep the overlay positioned and click-through" + "Light, Dark, textured backgrounds, and supported appearance routes remain visually correct" + "Automatic power saving stops Physical Refraction capture, preserves held keys and the selected setting, then restores once" + "Configuration snapshot apply, failed-apply rollback, and Restore Previous Setup preserve the complete managed setup" + "VoiceOver, Reduce Motion, Reduce Transparency, and Increase Contrast behave correctly" + "A recorded global shortcut re-registers, persists, toggles once, and can be reset" + "macOS 14 runs Classic Glow with no unavailable-symbol launch failure" + "macOS 26 runs Classic Glow, System Glass, Physical Refraction, and Solid Black" + "Signed Release input-to-render submission p99 is below 16.7 ms" + "Signed Release idle median CPU is below 0.5 percent over five minutes" + "Sixty-second typing stress has no event-tap timeout, stuck glow, or unbounded renderer growth" +) + +gate_index() { + local requested="$1" + local index=0 + for ((index = 0; index < ${#GATE_IDS[@]}; index++)); do + if [[ "${GATE_IDS[$index]}" == "$requested" ]]; then + printf '%s\n' "$index" + return 0 + fi + done + return 1 +} + +plist_value() { + local report_path="$1" + local key_path="$2" + plutil -extract "$key_path" raw -o - "$report_path" 2>/dev/null || true +} + +validate_report_schema() { + local report_path="$1" + [[ -f "$report_path" ]] || die "validation report not found: $report_path" + plutil -lint "$report_path" >/dev/null || die "validation report is not a valid property list" + [[ "$(plist_value "$report_path" schemaVersion)" == "1" ]] || \ + die "validation report has an unsupported schema" + local gate_id="" + for gate_id in "${GATE_IDS[@]}"; do + [[ -n "$(plist_value "$report_path" "gates.$gate_id.title")" ]] || \ + die "validation report is missing gate '$gate_id'" + done +} + +artifact_hash() { + shasum -a 256 "$1" | awk '{print $1}' +} + +verify_candidate_hash() { + local report_path="$1" + local dmg_path="$2" + [[ -f "$dmg_path" ]] || die "candidate DMG not found: $dmg_path" + local expected_hash="" + local actual_hash="" + expected_hash="$(plist_value "$report_path" candidate.dmgSHA256)" + actual_hash="$(artifact_hash "$dmg_path")" + [[ "$expected_hash" == "$actual_hash" ]] || \ + die "validation report does not describe the supplied DMG" +} + +prepare_report() { + local dmg_path="$1" + local requested_report_path="${2:-}" + local temp_root="" + local mount_point="" + local attach_report="" + local attached_device="" + local app_path="" + local app_count="" + local info_path="" + local executable_name="" + local binary_path="" + local app_version="" + local app_build="" + local bundle_id="" + local build_channel="" + local architectures="" + local dmg_sha256="" + local report_path="" + local work_report="" + local signature_report="" + local team_id="" + local trust="ad-hoc local" + local stapled=false + local source_commit="unavailable" + local gate_id="" + local gate_title="" + local index=0 + + [[ -f "$dmg_path" ]] || die "candidate DMG not found: $dmg_path" + for command_name in awk codesign date ditto find hdiutil lipo mktemp mkdir plutil rg shasum sw_vers sysctl wc xcrun; do + require_command "$command_name" + done + + temp_root="$(mktemp -d /tmp/KeyLightHardwareValidation.XXXXXX)" + mount_point="$temp_root/mount" + attach_report="$temp_root/hdiutil-attach.txt" + mkdir -p "$mount_point" + + cleanup_prepare() { + local exit_code=$? + trap - EXIT + if [[ -n "$attached_device" ]]; then + hdiutil detach "$attached_device" >/dev/null 2>&1 || true + fi + rm -rf "$temp_root" + exit "$exit_code" + } + trap cleanup_prepare EXIT + + hdiutil attach -nobrowse -readonly -mountpoint "$mount_point" "$dmg_path" > "$attach_report" + attached_device="$(awk '/^\/dev\// {print $1; exit}' "$attach_report")" + [[ -n "$attached_device" ]] || die "could not determine the mounted DMG device" + + app_count="$(find "$mount_point" -mindepth 1 -maxdepth 1 -type d -name '*.app' | wc -l | tr -d '[:space:]')" + [[ "$app_count" == "1" ]] || die "candidate DMG must contain exactly one app" + app_path="$(find "$mount_point" -mindepth 1 -maxdepth 1 -type d -name '*.app' -print -quit)" + info_path="$app_path/Contents/Info.plist" + [[ -f "$info_path" ]] || die "candidate app Info.plist is missing" + + executable_name="$(plist_value "$info_path" CFBundleExecutable)" + app_version="$(plist_value "$info_path" CFBundleShortVersionString)" + app_build="$(plist_value "$info_path" CFBundleVersion)" + bundle_id="$(plist_value "$info_path" CFBundleIdentifier)" + build_channel="$(plist_value "$info_path" KeyLightBuildChannel)" + binary_path="$app_path/Contents/MacOS/$executable_name" + [[ -x "$binary_path" ]] || die "candidate app executable is missing" + architectures="$(lipo -archs "$binary_path")" + dmg_sha256="$(artifact_hash "$dmg_path")" + + signature_report="$temp_root/codesign.txt" + codesign -dv --verbose=4 "$app_path" >/dev/null 2> "$signature_report" + team_id="$(sed -n -E 's/^TeamIdentifier=(.*)$/\1/p' "$signature_report" | tail -n 1)" + if [[ -n "$team_id" && "$team_id" != "not set" ]]; then + trust="Developer ID" + else + team_id="not set" + fi + if xcrun stapler validate "$app_path" >/dev/null 2>&1; then + stapled=true + fi + + if git -C "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" rev-parse HEAD >/dev/null 2>&1; then + source_commit="$(git -C "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" rev-parse HEAD)" + fi + + if [[ -n "$requested_report_path" ]]; then + report_path="$requested_report_path" + else + report_path="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/dist/validation/KeyLight-$app_version-$app_build-hardware-validation.plist" + fi + if [[ -e "$report_path" && "${KEYLIGHT_OVERWRITE:-0}" != "1" ]]; then + die "refusing to overwrite existing report at $report_path (set KEYLIGHT_OVERWRITE=1 intentionally)" + fi + mkdir -p "$(dirname "$report_path")" + + work_report="$temp_root/report.plist" + plutil -create xml1 "$work_report" + plutil -insert schemaVersion -integer 1 "$work_report" + plutil -insert preparedAt -string "$(timestamp)" "$work_report" + plutil -insert lastUpdatedAt -string "$(timestamp)" "$work_report" + plutil -insert privacyNotice -string "No serial number, hardware UUID, display UUID, key code, or typed content is recorded. Keep free-form notes equally non-identifying." "$work_report" + + plutil -insert candidate -dictionary "$work_report" + plutil -insert candidate.dmgName -string "$(basename "$dmg_path")" "$work_report" + plutil -insert candidate.dmgSHA256 -string "$dmg_sha256" "$work_report" + plutil -insert candidate.appName -string "$(basename "$app_path")" "$work_report" + plutil -insert candidate.version -string "$app_version" "$work_report" + plutil -insert candidate.build -string "$app_build" "$work_report" + plutil -insert candidate.bundleIdentifier -string "$bundle_id" "$work_report" + plutil -insert candidate.buildChannel -string "$build_channel" "$work_report" + plutil -insert candidate.architectures -string "$architectures" "$work_report" + plutil -insert candidate.trust -string "$trust" "$work_report" + plutil -insert candidate.teamIdentifier -string "$team_id" "$work_report" + plutil -insert candidate.notarizationStapled -bool "$stapled" "$work_report" + + plutil -insert environment -dictionary "$work_report" + plutil -insert environment.hardwareModel -string "$(sysctl -n hw.model)" "$work_report" + plutil -insert environment.architecture -string "$(uname -m)" "$work_report" + plutil -insert environment.macOSVersion -string "$(sw_vers -productVersion)" "$work_report" + plutil -insert environment.macOSBuild -string "$(sw_vers -buildVersion)" "$work_report" + plutil -insert sourceCommit -string "$source_commit" "$work_report" + + plutil -insert gates -dictionary "$work_report" + for ((index = 0; index < ${#GATE_IDS[@]}; index++)); do + gate_id="${GATE_IDS[$index]}" + gate_title="${GATE_TITLES[$index]}" + plutil -insert "gates.$gate_id" -dictionary "$work_report" + plutil -insert "gates.$gate_id.title" -string "$gate_title" "$work_report" + plutil -insert "gates.$gate_id.status" -string pending "$work_report" + plutil -insert "gates.$gate_id.notes" -string "" "$work_report" + plutil -insert "gates.$gate_id.updatedAt" -string "" "$work_report" + done + + plutil -lint "$work_report" >/dev/null + ditto "$work_report" "$report_path" + hdiutil detach "$attached_device" >/dev/null + attached_device="" + trap - EXIT + rm -rf "$temp_root" + + echo "Prepared hardware validation report: $report_path" + echo "Candidate SHA-256: $dmg_sha256" + echo "All gates are pending until recorded from real hardware." +} + +record_gate() { + local report_path="$1" + local gate_id="$2" + local status="$3" + local notes="${4:-}" + local index="" + local temp_report="" + + validate_report_schema "$report_path" + index="$(gate_index "$gate_id" || true)" + [[ -n "$index" ]] || die "unknown gate '$gate_id'" + case "$status" in + pending|pass|fail|blocked|not-applicable) + ;; + *) + die "status must be pending, pass, fail, blocked, or not-applicable" + ;; + esac + if [[ "$status" == "not-applicable" || "$status" == "fail" || "$status" == "blocked" ]]; then + [[ -n "$notes" ]] || die "$status requires a concise note" + fi + case "$gate_id" in + latency_p99|idle_cpu|typing_stress) + if [[ "$status" == "pass" ]]; then + [[ -n "$notes" ]] || die "$gate_id pass requires the measured result in notes" + fi + ;; + esac + + temp_report="$(mktemp "${report_path}.tmp.XXXXXX")" + ditto "$report_path" "$temp_report" + plutil -replace "gates.$gate_id.status" -string "$status" "$temp_report" + plutil -replace "gates.$gate_id.notes" -string "$notes" "$temp_report" + plutil -replace "gates.$gate_id.updatedAt" -string "$(timestamp)" "$temp_report" + plutil -replace lastUpdatedAt -string "$(timestamp)" "$temp_report" + plutil -lint "$temp_report" >/dev/null + mv -f "$temp_report" "$report_path" + echo "Recorded $gate_id: $status" +} + +list_report() { + local report_path="$1" + local gate_id="" + local title="" + local status="" + local notes="" + validate_report_schema "$report_path" + echo "Candidate: $(plist_value "$report_path" candidate.dmgName)" + echo "SHA-256: $(plist_value "$report_path" candidate.dmgSHA256)" + for gate_id in "${GATE_IDS[@]}"; do + title="$(plist_value "$report_path" "gates.$gate_id.title")" + status="$(plist_value "$report_path" "gates.$gate_id.status")" + notes="$(plist_value "$report_path" "gates.$gate_id.notes")" + printf '%-36s %-14s %s\n' "$gate_id" "$status" "$title" + if [[ -n "$notes" ]]; then + printf ' notes: %s\n' "$notes" + fi + done +} + +verify_report() { + local report_path="$1" + local dmg_path="$2" + local gate_id="" + local status="" + local notes="" + local incomplete=0 + + validate_report_schema "$report_path" + verify_candidate_hash "$report_path" "$dmg_path" + for gate_id in "${GATE_IDS[@]}"; do + status="$(plist_value "$report_path" "gates.$gate_id.status")" + notes="$(plist_value "$report_path" "gates.$gate_id.notes")" + case "$status" in + pass) + ;; + not-applicable) + [[ -n "$notes" ]] || die "$gate_id is not-applicable without a note" + ;; + pending|fail|blocked) + echo "$gate_id: $status" >&2 + incomplete=1 + ;; + *) + die "$gate_id has invalid status '$status'" + ;; + esac + case "$gate_id" in + latency_p99|idle_cpu|typing_stress) + if [[ "$status" == "pass" && -z "$notes" ]]; then + die "$gate_id pass is missing its measured result" + fi + ;; + esac + done + [[ "$incomplete" == "0" ]] || die "hardware validation report is incomplete or failing" + echo "Hardware validation report is internally complete for $(basename "$dmg_path")." +} + +verify_suite() { + local dmg_path="$1" + shift + [[ "$#" -gt 0 ]] || die "verify-suite requires at least one report" + local report_path="" + local gate_id="" + local status="" + local covered=0 + + for report_path in "$@"; do + verify_report "$report_path" "$dmg_path" >/dev/null + done + for gate_id in "${GATE_IDS[@]}"; do + covered=0 + for report_path in "$@"; do + status="$(plist_value "$report_path" "gates.$gate_id.status")" + if [[ "$status" == "pass" ]]; then + covered=1 + break + fi + done + [[ "$covered" == "1" ]] || die "validation suite has no passing coverage for '$gate_id'" + done + echo "Hardware validation suite passes all ${#GATE_IDS[@]} gates for $(basename "$dmg_path")." +} + +[[ "$#" -ge 1 ]] || { + usage >&2 + exit 2 +} + +case "$1" in + prepare) + [[ "$#" -ge 2 && "$#" -le 3 ]] || { usage >&2; exit 2; } + prepare_report "$2" "${3:-}" + ;; + record) + [[ "$#" -ge 4 && "$#" -le 5 ]] || { usage >&2; exit 2; } + record_gate "$2" "$3" "$4" "${5:-}" + ;; + list) + [[ "$#" == 2 ]] || { usage >&2; exit 2; } + list_report "$2" + ;; + verify) + [[ "$#" == 3 ]] || { usage >&2; exit 2; } + verify_report "$2" "$3" + ;; + verify-suite) + [[ "$#" -ge 3 ]] || { usage >&2; exit 2; } + shift + verify_suite "$@" + ;; + *) + usage >&2 + exit 2 + ;; +esac diff --git a/scripts/render-dmg-background.sh b/scripts/render-dmg-background.sh new file mode 100755 index 0000000..961596f --- /dev/null +++ b/scripts/render-dmg-background.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +if [[ "$#" -eq 0 ]]; then + SOURCE_SVG="$ROOT_DIR/docs/assets/dmg-background.svg" + OUTPUT_PNG="$ROOT_DIR/docs/assets/dmg-background.png" +elif [[ "$#" -eq 2 ]]; then + SOURCE_SVG="$1" + OUTPUT_PNG="$2" +else + echo "usage: $0 [SOURCE_SVG OUTPUT_PNG]" >&2 + exit 2 +fi +RENDERER="$ROOT_DIR/scripts/render-dmg-background.swift" +WORK_ROOT="/tmp/KeyLightDMGBackgroundRender-$$" +XCODE_DEVELOPER_DIR="${DEVELOPER_DIR:-/Applications/Xcode.app/Contents/Developer}" +SCALE=1 + +if [[ "$(basename "$OUTPUT_PNG")" == *@2x.* ]]; then + SCALE=2 +fi + +if [[ -e "$WORK_ROOT" || -L "$WORK_ROOT" ]]; then + echo "error: temporary render path already exists: $WORK_ROOT" >&2 + exit 1 +fi +mkdir -m 700 "$WORK_ROOT" +mkdir -p "$WORK_ROOT/module-cache" + +qlmanage -t -s "$((600 * SCALE))" -o "$WORK_ROOT" "$SOURCE_SVG" >/dev/null +THUMBNAIL="$WORK_ROOT/$(basename "$SOURCE_SVG").png" +if [[ ! -f "$THUMBNAIL" ]]; then + echo "error: Quick Look did not render $THUMBNAIL" >&2 + exit 1 +fi + +CLANG_MODULE_CACHE_PATH="$WORK_ROOT/module-cache" \ +DEVELOPER_DIR="$XCODE_DEVELOPER_DIR" \ + xcrun swift "$RENDERER" "$THUMBNAIL" "$OUTPUT_PNG" "$SCALE" + +METADATA="$(sips -g pixelWidth -g pixelHeight -g dpiWidth -g dpiHeight -g format -g hasAlpha -g profile "$OUTPUT_PNG")" +echo "$METADATA" +echo "$METADATA" | rg -F "pixelWidth: $((600 * SCALE))" >/dev/null +echo "$METADATA" | rg -F "pixelHeight: $((400 * SCALE))" >/dev/null +echo "$METADATA" | rg -F "dpiWidth: $((72 * SCALE)).000" >/dev/null +echo "$METADATA" | rg -F "dpiHeight: $((72 * SCALE)).000" >/dev/null +echo "$METADATA" | rg -F "format: png" >/dev/null +echo "$METADATA" | rg -F "hasAlpha: no" >/dev/null +echo "$METADATA" | rg -i "profile:.*sRGB" >/dev/null diff --git a/scripts/render-dmg-background.swift b/scripts/render-dmg-background.swift new file mode 100644 index 0000000..fb01759 --- /dev/null +++ b/scripts/render-dmg-background.swift @@ -0,0 +1,60 @@ +import CoreGraphics +import Foundation +import ImageIO +import UniformTypeIdentifiers + +guard CommandLine.arguments.count == 4, + let scale = Int(CommandLine.arguments[3]), + (1...2).contains(scale) else { + fatalError("usage: render-dmg-background.swift INPUT_THUMBNAIL OUTPUT_PNG SCALE") +} + +let inputURL = URL(fileURLWithPath: CommandLine.arguments[1]) +let outputURL = URL(fileURLWithPath: CommandLine.arguments[2]) +let pointWidth = 600 +let pointHeight = 400 +let pixelWidth = pointWidth * scale +let pixelHeight = pointHeight * scale +guard let source = CGImageSourceCreateWithURL(inputURL as CFURL, nil), + let sourceImage = CGImageSourceCreateImageAtIndex(source, 0, nil), + sourceImage.width >= pixelWidth, + sourceImage.height >= pixelHeight, + let croppedImage = sourceImage.cropping( + to: CGRect(x: 0, y: 0, width: pixelWidth, height: pixelHeight) + ), + let colorSpace = CGColorSpace(name: CGColorSpace.sRGB), + let context = CGContext( + data: nil, + width: pixelWidth, + height: pixelHeight, + bitsPerComponent: 8, + bytesPerRow: 0, + space: colorSpace, + bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue + ) else { + fatalError("Unable to create the opaque \(pixelWidth)×\(pixelHeight) background image") +} + +context.setFillColor(CGColor(red: 0.933, green: 0.957, blue: 0.980, alpha: 1)) +context.fill(CGRect(x: 0, y: 0, width: pixelWidth, height: pixelHeight)) +context.interpolationQuality = .high +context.draw(croppedImage, in: CGRect(x: 0, y: 0, width: pixelWidth, height: pixelHeight)) + +guard let renderedImage = context.makeImage(), + let destination = CGImageDestinationCreateWithURL( + outputURL as CFURL, + UTType.png.identifier as CFString, + 1, + nil + ) else { + fatalError("Unable to create the PNG destination") +} + +CGImageDestinationAddImage(destination, renderedImage, [ + kCGImagePropertyDPIWidth: 72 * scale, + kCGImagePropertyDPIHeight: 72 * scale, + kCGImagePropertyPNGDictionary: [:] +] as CFDictionary) +guard CGImageDestinationFinalize(destination) else { + fatalError("Unable to write the PNG") +} diff --git a/scripts/test-update-signature-verifier.swift b/scripts/test-update-signature-verifier.swift new file mode 100644 index 0000000..2fc5399 --- /dev/null +++ b/scripts/test-update-signature-verifier.swift @@ -0,0 +1,132 @@ +#!/usr/bin/env swift + +import CryptoKit +import Foundation + +private enum FixtureFailure: Error, LocalizedError { + case unexpectedResult(label: String, output: String) + + var errorDescription: String? { + switch self { + case .unexpectedResult(let label, let output): + return "\(label) produced the wrong verification result: \(output)" + } + } +} + +private let scriptURL = URL( + fileURLWithPath: CommandLine.arguments[0] +).standardizedFileURL +private let repositoryRoot = scriptURL + .deletingLastPathComponent() + .deletingLastPathComponent() +private let verifier = repositoryRoot + .appendingPathComponent("scripts/verify-sparkle-signature.swift") +private let fixtureRoot = FileManager.default.temporaryDirectory + .appendingPathComponent("keylight-signature-verifier-\(UUID().uuidString)") +private let moduleCache = fixtureRoot.appendingPathComponent("module-cache") + +try FileManager.default.createDirectory( + at: moduleCache, + withIntermediateDirectories: true +) +defer { try? FileManager.default.removeItem(at: fixtureRoot) } + +private func runVerifier( + _ arguments: [String], + expectingSuccess: Bool, + label: String +) throws { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/xcrun") + process.arguments = ["swift", verifier.path] + arguments + var environment = ProcessInfo.processInfo.environment + environment["CLANG_MODULE_CACHE_PATH"] = moduleCache.path + environment["SWIFT_MODULECACHE_PATH"] = moduleCache.path + process.environment = environment + let output = Pipe() + process.standardOutput = output + process.standardError = output + try process.run() + process.waitUntilExit() + let outputData = output.fileHandleForReading.readDataToEndOfFile() + let outputText = String(data: outputData, encoding: .utf8) ?? "" + guard (process.terminationStatus == 0) == expectingSuccess else { + throw FixtureFailure.unexpectedResult( + label: label, + output: outputText.trimmingCharacters(in: .whitespacesAndNewlines) + ) + } +} + +let privateKey = Curve25519.Signing.PrivateKey() +let publicKey = privateKey.publicKey.rawRepresentation.base64EncodedString() +let archive = Data("KeyLight signed update fixture\n".utf8) +let archiveSignature = try privateKey.signature(for: archive) + .base64EncodedString() +let archiveURL = fixtureRoot.appendingPathComponent("archive.dmg") +try archive.write(to: archiveURL) + +var tamperedArchive = archive +tamperedArchive[tamperedArchive.startIndex] ^= 1 +let tamperedArchiveURL = fixtureRoot + .appendingPathComponent("archive-tampered.dmg") +try tamperedArchive.write(to: tamperedArchiveURL) + +let feedContent = Data( + "\nKeyLight Fixture\n".utf8 +) +let feedSignature = try privateKey.signature(for: feedContent) + .base64EncodedString() +let feedBlock = Data( + "\n".utf8 +) +let feedURL = fixtureRoot.appendingPathComponent("appcast.xml") +try (feedContent + feedBlock).write(to: feedURL) + +var tamperedFeed = feedContent +tamperedFeed[tamperedFeed.startIndex] ^= 1 +let tamperedFeedURL = fixtureRoot + .appendingPathComponent("appcast-tampered.xml") +try (tamperedFeed + feedBlock).write(to: tamperedFeedURL) +let unsignedFeedURL = fixtureRoot.appendingPathComponent("appcast-unsigned.xml") +try feedContent.write(to: unsignedFeedURL) + +try runVerifier( + [ + "archive", archiveURL.path, publicKey, archiveSignature, + String(archive.count) + ], + expectingSuccess: true, + label: "valid archive" +) +try runVerifier( + ["appcast", feedURL.path, publicKey], + expectingSuccess: true, + label: "valid signed feed" +) +try runVerifier( + [ + "archive", tamperedArchiveURL.path, publicKey, archiveSignature, + String(archive.count) + ], + expectingSuccess: false, + label: "tampered archive" +) +try runVerifier( + ["appcast", tamperedFeedURL.path, publicKey], + expectingSuccess: false, + label: "tampered signed feed" +) +try runVerifier( + ["appcast", unsignedFeedURL.path, publicKey], + expectingSuccess: false, + label: "unsigned feed" +) +try runVerifier( + ["archive", archiveURL.path, publicKey, archiveSignature, "1"], + expectingSuccess: false, + label: "incorrect signed length" +) + +print("Sparkle verifier accepts valid fixtures and rejects tampering.") diff --git a/scripts/validate-motion-preview.sh b/scripts/validate-motion-preview.sh new file mode 100755 index 0000000..a83ea89 --- /dev/null +++ b/scripts/validate-motion-preview.sh @@ -0,0 +1,184 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'USAGE' +Usage: + ./scripts/validate-motion-preview.sh --local VERSION + ./scripts/validate-motion-preview.sh --signed VERSION + +Runs the automated Motion Preview quality gates, builds the isolated preview +DMG, verifies its checksum, confirms the protected original app was unchanged, +and prepares a privacy-safe real-hardware validation report with pending gates. + +--local Produces the ad-hoc local-only Motion Preview candidate. +--signed Produces the Developer ID/notarized Motion Preview candidate. The + signed packager runs the complete tests and analyzer internally. + +Optional environment: + KEYLIGHT_PROTECTED_APP_PATH + App bundle whose file-content fingerprint must remain unchanged. + Defaults to /Applications/KeyLight.app when it exists. + KEYLIGHT_CLONED_SOURCE_PACKAGES_DIR + Verified local Xcode SourcePackages cache forwarded to all builds. +USAGE +} + +die() { + echo "error: $*" >&2 + exit 1 +} + +require_command() { + local command_name="$1" + command -v "$command_name" >/dev/null 2>&1 || die "$command_name is required" +} + +bundle_fingerprint() { + local bundle_path="$1" + { + find -s "$bundle_path" -type f -print | while IFS= read -r file_path; do + printf 'file %s %s\n' "$(shasum -a 256 "$file_path" | awk '{print $1}')" "${file_path#"$bundle_path"/}" + done + find -s "$bundle_path" -type l -print | while IFS= read -r link_path; do + printf 'link %s %s\n' "$(readlink "$link_path")" "${link_path#"$bundle_path"/}" + done + } | shasum -a 256 | awk '{print $1}' +} + +[[ "$#" == 2 ]] || { + usage >&2 + exit 2 +} + +case "$1" in + --local) + validation_mode="local" + package_mode="--preview-local" + dmg_suffix="motion-preview-local-unsigned" + ;; + --signed) + validation_mode="signed" + package_mode="--preview-signed" + dmg_suffix="motion-preview-signed" + ;; + *) + usage >&2 + exit 2 + ;; +esac + +version="$2" +[[ "$version" =~ ^[0-9]+(\.[0-9]+){1,2}$ ]] || \ + die "VERSION must contain two or three numeric components" + +root_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +project_path="$root_dir/KeyLight.xcodeproj" +dist_dir="$root_dir/dist" +dmg_path="$dist_dir/KeyLight-$version-$dmg_suffix.dmg" +checksum_path="$dmg_path.sha256" +report_path="$dist_dir/validation/KeyLight-$version-$dmg_suffix-hardware-validation.plist" +protected_app_path="${KEYLIGHT_PROTECTED_APP_PATH:-}" +xcode_developer_dir="${DEVELOPER_DIR:-/Applications/Xcode.app/Contents/Developer}" +validation_root="$(mktemp -d /tmp/KeyLightMotionPreviewValidation.XXXXXX)" +protected_before="" +protected_after="" +source_packages_dir="${KEYLIGHT_CLONED_SOURCE_PACKAGES_DIR:-}" +xcode_package_arguments=( + -disableAutomaticPackageResolution + -onlyUsePackageVersionsFromResolvedFile +) + +cleanup() { + local exit_code=$? + trap - EXIT + rm -rf "$validation_root" + exit "$exit_code" +} +trap cleanup EXIT + +if [[ -n "$source_packages_dir" ]]; then + xcode_package_arguments+=( + -clonedSourcePackagesDirPath "$source_packages_dir" + ) +fi + +for command_name in awk bash find git jq plutil readlink shasum xcodebuild; do + require_command "$command_name" +done +[[ -d "$xcode_developer_dir" ]] || die "Xcode developer directory not found at $xcode_developer_dir" +[[ -x "$root_dir/scripts/build-dmg.sh" ]] || die "build-dmg.sh is missing or not executable" +[[ -x "$root_dir/scripts/hardware-validation.sh" ]] || die "hardware-validation.sh is missing or not executable" + +if [[ -z "$protected_app_path" && -d "/Applications/KeyLight.app" ]]; then + protected_app_path="/Applications/KeyLight.app" +fi +if [[ -n "$protected_app_path" ]]; then + [[ -d "$protected_app_path" ]] || die "protected app not found: $protected_app_path" + protected_before="$(bundle_fingerprint "$protected_app_path")" + echo "Protected original fingerprint before: $protected_before" +fi + +echo "==> Checking shell, project, and bundled-preset policy" +for shell_script in "$root_dir"/scripts/*.sh; do + bash -n "$shell_script" +done +"$root_dir/scripts/verify-project-policy.sh" +jq empty "$root_dir"/KeyLight/Resources/VariantPresets/*.json +while IFS= read -r resource_path; do + [[ -f "$root_dir/KeyLight/Resources/VariantPresets/$resource_path" ]] || \ + die "bundled layout manifest references missing resource '$resource_path'" +done < <(jq -r '.presets[].resourcePath' "$root_dir/KeyLight/Resources/VariantPresets/variant-presets-manifest.json") + +if [[ "$validation_mode" == "local" ]]; then + echo "==> Running complete Motion Preview test suite" + DEVELOPER_DIR="$xcode_developer_dir" xcodebuild \ + -quiet \ + "${xcode_package_arguments[@]}" \ + -project "$project_path" \ + -scheme KeyLight \ + -configuration Debug \ + -destination "platform=macOS" \ + -derivedDataPath "$validation_root/tests" \ + SWIFT_VERSION=6 \ + SWIFT_STRICT_CONCURRENCY=complete \ + CODE_SIGNING_ALLOWED=NO \ + test + + echo "==> Running Motion Preview static analysis" + DEVELOPER_DIR="$xcode_developer_dir" xcodebuild \ + -quiet \ + "${xcode_package_arguments[@]}" \ + -project "$project_path" \ + -scheme KeyLight \ + -configuration Release \ + -destination "generic/platform=macOS" \ + -derivedDataPath "$validation_root/analyze" \ + SWIFT_VERSION=6 \ + SWIFT_STRICT_CONCURRENCY=complete \ + CODE_SIGNING_ALLOWED=NO \ + analyze +fi + +echo "==> Building verified Motion Preview candidate" +"$root_dir/scripts/build-dmg.sh" "$package_mode" "$version" +[[ -f "$dmg_path" && -f "$checksum_path" ]] || die "packager did not publish the expected candidate and checksum" +( + cd "$dist_dir" + shasum -a 256 -c "$(basename "$checksum_path")" +) + +if [[ -n "$protected_app_path" ]]; then + protected_after="$(bundle_fingerprint "$protected_app_path")" + [[ "$protected_before" == "$protected_after" ]] || \ + die "protected original app changed during Motion Preview validation" + echo "Protected original fingerprint after: $protected_after (unchanged)" +fi + +"$root_dir/scripts/hardware-validation.sh" prepare "$dmg_path" "$report_path" + +echo "==> Automated Motion Preview gates passed" +echo "Candidate: $dmg_path" +echo "Checksum: $checksum_path" +echo "Hardware report: $report_path" +echo "Real-hardware gates remain pending until recorded and verified." diff --git a/scripts/verify-dmg-finder-metadata.swift b/scripts/verify-dmg-finder-metadata.swift new file mode 100644 index 0000000..8ef2225 --- /dev/null +++ b/scripts/verify-dmg-finder-metadata.swift @@ -0,0 +1,113 @@ +import Foundation + +enum VerificationError: Error, CustomStringConvertible { + case usage + case missingRecord(String) + case invalidRecord(String) + case mismatch(String) + + var description: String { + switch self { + case .usage: + return "usage: verify-dmg-finder-metadata.swift DS_STORE WIDTH HEIGHT ICON_SIZE TEXT_SIZE" + case .missingRecord(let name): + return "missing Finder metadata record: \(name)" + case .invalidRecord(let name): + return "invalid Finder metadata record: \(name)" + case .mismatch(let message): + return message + } + } +} + +func record(named name: String, in data: Data) throws -> Data { + let marker = Data(name.utf8) + guard let markerRange = data.range(of: marker) else { + throw VerificationError.missingRecord(name) + } + + let lengthOffset = markerRange.upperBound + guard lengthOffset + 4 <= data.count else { + throw VerificationError.invalidRecord(name) + } + + let length = data[lengthOffset..<(lengthOffset + 4)].reduce(0) { + ($0 << 8) | Int($1) + } + let payloadOffset = lengthOffset + 4 + guard length > 0, payloadOffset + length <= data.count else { + throw VerificationError.invalidRecord(name) + } + return data.subdata(in: payloadOffset..<(payloadOffset + length)) +} + +func dictionaryRecord(named name: String, in data: Data) throws -> [String: Any] { + let payload = try record(named: name, in: data) + let plist = try PropertyListSerialization.propertyList(from: payload, options: [], format: nil) + guard let dictionary = plist as? [String: Any] else { + throw VerificationError.invalidRecord(name) + } + return dictionary +} + +func requireFalse(_ key: String, in dictionary: [String: Any]) throws { + guard let value = dictionary[key] as? Bool, value == false else { + throw VerificationError.mismatch("Finder metadata \(key) must be false") + } +} + +func requireNumber(_ key: String, equals expected: Double, in dictionary: [String: Any]) throws { + guard let number = dictionary[key] as? NSNumber, + abs(number.doubleValue - expected) < 0.000_001 else { + throw VerificationError.mismatch("Finder metadata \(key) must equal \(expected)") + } +} + +do { + guard CommandLine.arguments.count == 6, + let width = Int(CommandLine.arguments[2]), + let height = Int(CommandLine.arguments[3]), + let iconSize = Double(CommandLine.arguments[4]), + let textSize = Double(CommandLine.arguments[5]) else { + throw VerificationError.usage + } + + let dsStoreURL = URL(fileURLWithPath: CommandLine.arguments[1]) + let dsStore = try Data(contentsOf: dsStoreURL) + let browser = try dictionaryRecord(named: ".bwspblob", in: dsStore) + let iconView = try dictionaryRecord(named: ".icvpblob", in: dsStore) + + for key in ["ContainerShowSidebar", "ShowSidebar", "ShowStatusBar", "ShowTabView", "ShowToolbar"] { + try requireFalse(key, in: browser) + } + + guard let windowBounds = browser["WindowBounds"] as? String else { + throw VerificationError.mismatch("Finder metadata WindowBounds is missing") + } + let escapedSize = NSRegularExpression.escapedPattern(for: "{\(width), \(height)}") + let sizePattern = #"^\{\{-?\d+, -?\d+\}, \#(escapedSize)\}$"# + guard windowBounds.range(of: sizePattern, options: .regularExpression) != nil else { + throw VerificationError.mismatch( + "Finder window bounds '\(windowBounds)' do not contain the expected \(width)×\(height) size" + ) + } + + try requireNumber("iconSize", equals: iconSize, in: iconView) + try requireNumber("textSize", equals: textSize, in: iconView) + try requireNumber("backgroundType", equals: 2, in: iconView) + guard iconView["arrangeBy"] as? String == "none" else { + throw VerificationError.mismatch("Finder icon arrangement must be none") + } + guard iconView["labelOnBottom"] as? Bool == true else { + throw VerificationError.mismatch("Finder icon labels must be below icons") + } + guard let backgroundAlias = iconView["backgroundImageAlias"] as? Data, + !backgroundAlias.isEmpty else { + throw VerificationError.mismatch("Finder background image alias is missing") + } + + print("Finder metadata verified: \(width)×\(height), icons \(iconSize), labels \(textSize)") +} catch { + FileHandle.standardError.write(Data("error: \(error)\n".utf8)) + exit(1) +} diff --git a/scripts/verify-privacy-logging.awk b/scripts/verify-privacy-logging.awk new file mode 100644 index 0000000..b661ea4 --- /dev/null +++ b/scripts/verify-privacy-logging.awk @@ -0,0 +1,54 @@ +BEGIN { + forbidden[1] = "keyCode" + forbidden[2] = "rawKeyCode" + forbidden[3] = "charactersIgnoringModifiers" + forbidden[4] = "horizontalPosition" + forbidden[5] = "themeTransferString" + forbidden[6] = "importedProfile" + forbidden[7] = "importedTheme" + failed = 0 + capturing = 0 +} + +function character_count(value, expression, copy) { + copy = value + return gsub(expression, "", copy) +} + +function inspect_statement(term_index) { + for (term_index = 1; term_index <= 7; term_index++) { + if (index(statement, forbidden[term_index]) != 0) { + printf "%s:%d: privacy-sensitive logging payload contains %s\n", \ + FILENAME, starting_line, forbidden[term_index] > "/dev/stderr" + failed = 1 + } + } +} + +FNR == 1 { + capturing = 0 + statement = "" + depth = 0 +} + +{ + if (!capturing && $0 ~ /KeyLight(Logger|Signposts)\.[A-Za-z0-9_]+\(/) { + capturing = 1 + starting_line = FNR + statement = $0 + depth = character_count($0, /\(/) - character_count($0, /\)/) + } else if (capturing) { + statement = statement "\n" $0 + depth += character_count($0, /\(/) - character_count($0, /\)/) + } + + if (capturing && depth <= 0) { + inspect_statement() + capturing = 0 + statement = "" + } +} + +END { + exit failed +} diff --git a/scripts/verify-project-policy.sh b/scripts/verify-project-policy.sh new file mode 100755 index 0000000..0b7a5ec --- /dev/null +++ b/scripts/verify-project-policy.sh @@ -0,0 +1,235 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +PROJECT_FILE="$ROOT_DIR/KeyLight.xcodeproj/project.pbxproj" +PACKAGE_RESOLVED="$ROOT_DIR/KeyLight.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved" +PRIVACY_MANIFEST="$ROOT_DIR/KeyLight/Resources/PrivacyInfo.xcprivacy" +ENTITLEMENTS="$ROOT_DIR/KeyLight/KeyLight.entitlements" +INFO_PLIST="$ROOT_DIR/KeyLight/Info.plist" +SHARED_CONFIG="$ROOT_DIR/Configurations/Shared.xcconfig" +APP_DELEGATE="$ROOT_DIR/KeyLight/AppDelegate.swift" +BUILD_DMG_SCRIPT="$ROOT_DIR/scripts/build-dmg.sh" +RELEASE_METADATA_GENERATOR="$ROOT_DIR/scripts/generate-release-metadata.swift" +HARDWARE_VALIDATION_SCRIPT="$ROOT_DIR/scripts/hardware-validation.sh" +MOTION_PREVIEW_VALIDATOR="$ROOT_DIR/scripts/validate-motion-preview.sh" +VARIANT_PROFILE="$ROOT_DIR/docs/variants/macbook-air-13-m4/keylight-layout-profile-template.json" +PRIVACY_LOGGING_AWK="$ROOT_DIR/scripts/verify-privacy-logging.awk" + +die() { + echo "error: $*" >&2 + exit 1 +} + +for command_name in awk plutil rg sed tr xargs; do + command -v "$command_name" >/dev/null 2>&1 || die "$command_name is required" +done + +for required_path in \ + "$PROJECT_FILE" \ + "$PACKAGE_RESOLVED" \ + "$PRIVACY_MANIFEST" \ + "$ENTITLEMENTS" \ + "$INFO_PLIST" \ + "$SHARED_CONFIG" \ + "$APP_DELEGATE" \ + "$BUILD_DMG_SCRIPT" \ + "$RELEASE_METADATA_GENERATOR" \ + "$HARDWARE_VALIDATION_SCRIPT" \ + "$MOTION_PREVIEW_VALIDATOR" \ + "$VARIANT_PROFILE" \ + "$PRIVACY_LOGGING_AWK"; do + [[ -f "$required_path" ]] || die "required policy input is missing: $required_path" +done + +[[ "$(rg -c 'Add :com\.apple\.security\.cs\.disable-library-validation bool true' \ + "$BUILD_DMG_SCRIPT")" == "1" ]] || \ + die "local packaging must declare exactly one scoped Sparkle library-validation exception" +rg -F -- '--entitlements "$LOCAL_ADHOC_ENTITLEMENTS_PATH"' \ + "$BUILD_DMG_SCRIPT" >/dev/null || \ + die "local packaging must sign only from its generated ad-hoc entitlement file" +rg -F 'KEYLIGHT_PACKAGE_LAUNCH_SMOKE_TEST' "$APP_DELEGATE" >/dev/null || \ + die "the app entry point must retain side-effect-free package smoke mode" +rg -F 'run_packaged_launch_smoke_test "$STAGED_APP_PATH"' \ + "$BUILD_DMG_SCRIPT" >/dev/null || \ + die "packaging must launch-test the final staged app before DMG creation" +rg -F './scripts/build-dmg.sh --preview-signed VERSION' \ + "$BUILD_DMG_SCRIPT" >/dev/null || \ + die "packaging must retain the signed Motion Preview mode" +rg -F 'BUILD_CHANNEL="Motion Preview Signed"' \ + "$BUILD_DMG_SCRIPT" >/dev/null || \ + die "signed Motion Preview packaging must identify its build channel" +rg -F 'FINAL_DMG_NAME="KeyLight-$VERSION-motion-preview-signed.dmg"' \ + "$BUILD_DMG_SCRIPT" >/dev/null || \ + die "signed Motion Preview packaging must use an unmistakable artifact name" +rg -F 'verify_signed_preview_source_state' \ + "$BUILD_DMG_SCRIPT" >/dev/null || \ + die "signed Motion Preview packaging must require a clean committed candidate" +rg -F 'hardware-validation.sh" prepare' \ + "$MOTION_PREVIEW_VALIDATOR" >/dev/null || \ + die "Motion Preview validation must prepare a candidate-bound hardware report" +rg -F 'Protected original fingerprint before:' \ + "$MOTION_PREVIEW_VALIDATOR" >/dev/null || \ + die "Motion Preview validation must fingerprint the protected original app" +rg -F './scripts/build-dmg.sh --release-unsigned VERSION' \ + "$BUILD_DMG_SCRIPT" >/dev/null || \ + die "packaging must retain the unsigned production-release mode" +rg -F 'BUILD_CHANNEL="Unsigned Release"' \ + "$BUILD_DMG_SCRIPT" >/dev/null || \ + die "unsigned production packaging must identify its trust channel" +rg -F 'verify_unsigned_release_source_state' \ + "$BUILD_DMG_SCRIPT" >/dev/null || \ + die "unsigned production packaging must require a clean committed candidate" +rg -F 'requires_full_quality_gates' \ + "$BUILD_DMG_SCRIPT" >/dev/null || \ + die "unsigned production packaging must run full quality gates" +rg -F 'remove_unsigned_release_update_keys "$STAGED_APP_PATH"' \ + "$BUILD_DMG_SCRIPT" >/dev/null || \ + die "unsigned production packaging must remove inactive update keys before signing" +rg -F 'plutil -remove "$update_key" "$info_path"' \ + "$BUILD_DMG_SCRIPT" >/dev/null || \ + die "unsigned production packaging must delete update keys from its staged app" +rg -F 'releaseMode == "release" || releaseMode == "release-unsigned"' \ + "$RELEASE_METADATA_GENERATOR" >/dev/null || \ + die "release provenance must distinguish signed and unsigned releases" +for forbidden_hardware_identifier in \ + IOPlatformSerialNumber \ + IOPlatformUUID \ + SPHardwareDataType \ + CGDisplayCreateUUIDFromDisplayID; do + if rg -F "$forbidden_hardware_identifier" \ + "$HARDWARE_VALIDATION_SCRIPT" >/dev/null; then + die "hardware validation must not query $forbidden_hardware_identifier" + fi +done + +[[ "$(rg -c '"identity"[[:space:]]*:' "$PACKAGE_RESOLVED")" == "1" ]] || \ + die "Package.resolved must contain exactly one dependency" +[[ "$(plutil -extract pins.0.identity raw -o - "$PACKAGE_RESOLVED")" == "sparkle" ]] || \ + die "the only dependency must be Sparkle" +[[ "$(plutil -extract pins.0.state.version raw -o - "$PACKAGE_RESOLVED")" == "2.9.5" ]] || \ + die "Sparkle must be exactly pinned to 2.9.5" +[[ "$(plutil -extract pins.0.state.revision raw -o - "$PACKAGE_RESOLVED")" == \ + "79bc9e872948e47877e76f194cb0c8e0412b0b90" ]] || \ + die "Sparkle revision does not match the reviewed 2.9.5 source" +rg -U 'kind = exactVersion;[[:space:]]+version = 2\.9\.5;' "$PROJECT_FILE" >/dev/null || \ + die "the Xcode project does not require exact Sparkle 2.9.5" + +plutil -lint "$PRIVACY_MANIFEST" >/dev/null +[[ "$(plutil -extract NSPrivacyTracking raw -o - "$PRIVACY_MANIFEST")" == "false" ]] || \ + die "privacy manifest must disable tracking" +for expected_privacy_value in \ + NSPrivacyAccessedAPICategoryFileTimestamp \ + 3B52.1 \ + NSPrivacyAccessedAPICategorySystemBootTime \ + 35F9.1 \ + NSPrivacyAccessedAPICategoryUserDefaults \ + CA92.1; do + rg -F "$expected_privacy_value" "$PRIVACY_MANIFEST" >/dev/null || \ + die "privacy manifest is missing $expected_privacy_value" +done + +[[ "$(plutil -p "$ENTITLEMENTS" | tr -d '[:space:]')" == "{}" ]] || \ + die "KeyLight's main entitlement set must remain empty" +plutil -lint "$INFO_PLIST" >/dev/null +for updater_policy in \ + SUEnableAutomaticChecks:false \ + SUAutomaticallyUpdate:false \ + SUSendProfileInfo:false \ + SUVerifyUpdateBeforeExtraction:true \ + SURequireSignedFeed:true; do + updater_key="${updater_policy%%:*}" + expected_value="${updater_policy##*:}" + [[ "$(plutil -extract "$updater_key" raw -o - "$INFO_PLIST")" == "$expected_value" ]] || \ + die "Info.plist updater policy '$updater_key' must be '$expected_value'" +done +[[ "$(plutil -extract SUSignedFeedFailureExpirationInterval raw -o - "$INFO_PLIST")" == "0" ]] || \ + die "signed feed verification must fail closed" +[[ "$(plutil -extract SUFeedURL raw -o - "$INFO_PLIST")" == '$(KEYLIGHT_SPARKLE_FEED_URL)' ]] || \ + die "Info.plist feed URL must come only from the release build setting" +[[ "$(plutil -extract SUPublicEDKey raw -o - "$INFO_PLIST")" == '$(KEYLIGHT_SPARKLE_PUBLIC_ED_KEY)' ]] || \ + die "Info.plist public key must come only from the release build setting" +[[ "$(plutil -extract KeyLightBuildChannel raw -o - "$INFO_PLIST")" == '$(KEYLIGHT_BUILD_CHANNEL)' ]] || \ + die "Info.plist build channel must come only from the shared build setting" +rg -U '^MARKETING_VERSION = [0-9]+\.[0-9]+\.[0-9]+$' "$SHARED_CONFIG" >/dev/null || \ + die "Shared.xcconfig must contain one semantic MARKETING_VERSION" +rg -U '^CURRENT_PROJECT_VERSION = [1-9][0-9]*$' "$SHARED_CONFIG" >/dev/null || \ + die "Shared.xcconfig must contain one positive CURRENT_PROJECT_VERSION" +rg -F 'must match Shared.xcconfig MARKETING_VERSION' "$BUILD_DMG_SCRIPT" >/dev/null || \ + die "packaging must reject a version that differs from Shared.xcconfig" +rg -F 'must match Shared.xcconfig CURRENT_PROJECT_VERSION' "$BUILD_DMG_SCRIPT" >/dev/null || \ + die "packaging must reject a build number that differs from Shared.xcconfig" +rg -F 'ENABLE_HARDENED_RUNTIME = YES;' "$PROJECT_FILE" >/dev/null || \ + die "Release must enable Hardened Runtime" +rg -F 'CODE_SIGN_INJECT_BASE_ENTITLEMENTS = NO;' "$PROJECT_FILE" >/dev/null || \ + die "Release must reject injected base entitlements" + +for forbidden_entitlement in \ + com.apple.security.get-task-allow \ + com.apple.security.cs.allow-jit \ + com.apple.security.cs.allow-unsigned-executable-memory \ + com.apple.security.cs.disable-library-validation \ + com.apple.security.network.client \ + com.apple.security.network.server; do + if rg -F "$forbidden_entitlement" \ + "$ROOT_DIR/KeyLight" \ + "$ROOT_DIR/Configurations" \ + "$PROJECT_FILE" >/dev/null; then + die "forbidden entitlement declared in source: $forbidden_entitlement" + fi +done + +rg -U 'static let eventTapOptions:[[:space:]]*CGEventTapOptions[[:space:]]*=[[:space:]]*\.listenOnly' \ + "$ROOT_DIR/KeyLight/Services/KeyboardMonitor.swift" >/dev/null || \ + die "the global event-tap contract must remain listen-only" +rg -U 'CGEvent\.tapCreate\([\s\S]{0,800}options:[[:space:]]*Self\.eventTapOptions' \ + "$ROOT_DIR/KeyLight/Services/KeyboardMonitor.swift" >/dev/null || \ + die "the global event tap must use the tested listen-only contract" + +[[ "$(plutil -extract 'keyOffsets.10' raw -o - "$VARIANT_PROFILE")" == "0.012000" ]] || \ + die "the bundled MacBook Air profile key 10 offset changed unexpectedly" +[[ "$(plutil -extract 'keyOffsets.44' raw -o - "$VARIANT_PROFILE")" == "-0.008000" ]] || \ + die "the bundled MacBook Air profile key 44 offset changed unexpectedly" +[[ "$(plutil -extract 'keyOffsets.123' raw -o - "$VARIANT_PROFILE")" == "0.006000" ]] || \ + die "the bundled MacBook Air profile key 123 offset changed unexpectedly" +[[ "$(plutil -extract 'keyWidthOverrides.10' raw -o - "$VARIANT_PROFILE")" == "1.120000" ]] || \ + die "the bundled MacBook Air profile key 10 width changed unexpectedly" +[[ "$(plutil -extract 'keyWidthOverrides.49' raw -o - "$VARIANT_PROFILE")" == "1.030000" ]] || \ + die "the bundled MacBook Air profile key 49 width changed unexpectedly" +[[ "$(plutil -extract 'keyWidthOverrides.123' raw -o - "$VARIANT_PROFILE")" == "0.950000" ]] || \ + die "the bundled MacBook Air profile key 123 width changed unexpectedly" + +if rg -n \ + --glob '!UpdateService.swift' \ + --glob '!*.xcstrings' \ + '\b(URLSession|NSURLConnection|NWConnection|NWTCPConnection)\b' \ + "$ROOT_DIR/KeyLight" >/dev/null; then + die "an unreviewed network client exists outside UpdateService/Sparkle" +fi + +if ! rg --files "$ROOT_DIR/KeyLight" -g '*.swift' -0 \ + | xargs -0 awk -f "$PRIVACY_LOGGING_AWK"; then + die "a log or signpost contains privacy-sensitive input or imported-data metadata" +fi + +secret_patterns=( + 'AKIA[0-9A-Z]{16}' + 'gh[pousr]_[A-Za-z0-9]{30,}' + 'github_pat_[A-Za-z0-9_]{30,}' + 'xox[baprs]-[A-Za-z0-9-]{20,}' + 'sk_live_[A-Za-z0-9]{20,}' + '-----BEGIN (RSA |EC |OPENSSH )?PRIVATE KEY-----' +) +for secret_pattern in "${secret_patterns[@]}"; do + if rg -n \ + --hidden \ + --glob '!.git/**' \ + --glob '!dist/**' \ + --glob '!.build/**' \ + -- "$secret_pattern" \ + "$ROOT_DIR" >/dev/null; then + die "potential secret matched source policy pattern: $secret_pattern" + fi +done + +echo "Project security, dependency, privacy, and secret policy checks passed." diff --git a/scripts/verify-sparkle-signature.swift b/scripts/verify-sparkle-signature.swift new file mode 100644 index 0000000..c592ce6 --- /dev/null +++ b/scripts/verify-sparkle-signature.swift @@ -0,0 +1,145 @@ +#!/usr/bin/env swift + +import CryptoKit +import Foundation + +private enum VerificationError: Error, LocalizedError { + case usage + case invalidPublicKey + case invalidSignature + case invalidLength + case unsignedFeed + case malformedFeedSignature + case verificationFailed + + var errorDescription: String? { + switch self { + case .usage: + return "usage: verify-sparkle-signature.swift archive FILE PUBLIC_KEY_BASE64 SIGNATURE_BASE64 [EXPECTED_LENGTH] | appcast FILE PUBLIC_KEY_BASE64" + case .invalidPublicKey: + return "public key must be a base64-encoded 32-byte Ed25519 key" + case .invalidSignature: + return "signature must be a base64-encoded 64-byte Ed25519 signature" + case .invalidLength: + return "signed content length does not match" + case .unsignedFeed: + return "appcast does not contain a Sparkle signed-feed block" + case .malformedFeedSignature: + return "appcast signed-feed block is malformed" + case .verificationFailed: + return "Ed25519 signature verification failed" + } + } +} + +private func decodedPublicKey(_ value: String) throws -> Curve25519.Signing.PublicKey { + guard let data = Data(base64Encoded: value), data.count == 32 else { + throw VerificationError.invalidPublicKey + } + return try Curve25519.Signing.PublicKey(rawRepresentation: data) +} + +private func decodedSignature(_ value: String) throws -> Data { + guard let data = Data(base64Encoded: value), data.count == 64 else { + throw VerificationError.invalidSignature + } + return data +} + +private func verify( + data: Data, + signatureValue: String, + publicKeyValue: String, + expectedLength: Int? +) throws { + if let expectedLength, data.count != expectedLength { + throw VerificationError.invalidLength + } + let publicKey = try decodedPublicKey(publicKeyValue) + let signature = try decodedSignature(signatureValue) + guard publicKey.isValidSignature(signature, for: data) else { + throw VerificationError.verificationFailed + } +} + +private func verifyArchive(arguments: ArraySlice) throws { + guard arguments.count == 3 || arguments.count == 4 else { + throw VerificationError.usage + } + let values = Array(arguments) + let fileData = try Data(contentsOf: URL(fileURLWithPath: values[0])) + let expectedLength: Int? + if values.count == 4 { + guard let parsed = Int(values[3]), parsed >= 0 else { + throw VerificationError.invalidLength + } + expectedLength = parsed + } else { + expectedLength = nil + } + try verify( + data: fileData, + signatureValue: values[2], + publicKeyValue: values[1], + expectedLength: expectedLength + ) +} + +private func verifyAppcast(arguments: ArraySlice) throws { + guard arguments.count == 2 else { throw VerificationError.usage } + let values = Array(arguments) + let appcastData = try Data(contentsOf: URL(fileURLWithPath: values[0])) + let marker = Data("\n?$"# + ) + let fullRange = NSRange(block.startIndex..= 2 else { throw VerificationError.usage } + switch arguments[1] { + case "archive": + try verifyArchive(arguments: arguments.dropFirst(2)) + case "appcast": + try verifyAppcast(arguments: arguments.dropFirst(2)) + default: + throw VerificationError.usage + } + print("Sparkle Ed25519 signature is valid.") +} catch { + let message = (error as? LocalizedError)?.errorDescription + ?? error.localizedDescription + FileHandle.standardError.write(Data("error: \(message)\n".utf8)) + exit(1) +}