diff --git a/HomeAssistant.xcodeproj/project.pbxproj b/HomeAssistant.xcodeproj/project.pbxproj index 2ba567f098..e3336db304 100644 --- a/HomeAssistant.xcodeproj/project.pbxproj +++ b/HomeAssistant.xcodeproj/project.pbxproj @@ -181,6 +181,7 @@ 1169B7AD25AA76E30035F2AE /* MaterialDesignIcons+Eureka.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1169B7AC25AA76E30035F2AE /* MaterialDesignIcons+Eureka.swift */; }; 116C0C2F267EB90F00A992E4 /* UserDefaultsValueSync.swift in Sources */ = {isa = PBXBuildFile; fileRef = 116C0C2E267EB90F00A992E4 /* UserDefaultsValueSync.swift */; }; 116C0C30267EB90F00A992E4 /* UserDefaultsValueSync.swift in Sources */ = {isa = PBXBuildFile; fileRef = 116C0C2E267EB90F00A992E4 /* UserDefaultsValueSync.swift */; }; + 116C1811BE5DE6660E00C5C9 /* TamperDetectionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CA8930E79EDD7D8F8618A04 /* TamperDetectionManager.swift */; }; 116D3A3D2724D83300EF5D21 /* OnboardingAuth.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 116D3A3C2724D83300EF5D21 /* OnboardingAuth.test.swift */; }; 116D3A442724EFFB00EF5D21 /* OnboardingAuthTokenExchange.swift in Sources */ = {isa = PBXBuildFile; fileRef = 116D3A422724EDF100EF5D21 /* OnboardingAuthTokenExchange.swift */; }; 116D3A4627252C3200EF5D21 /* OnboardingAuthStepConfig.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 116D3A4527252C3200EF5D21 /* OnboardingAuthStepConfig.test.swift */; }; @@ -405,6 +406,7 @@ 11DE9FBE25B6186E0081C0ED /* Home Assistant Launcher.app in Embed Mac Launcher */ = {isa = PBXBuildFile; fileRef = 11DE9D8325B6103C0081C0ED /* Home Assistant Launcher.app */; platformFilter = maccatalyst; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 11E1639A250B1B760076D612 /* OnboardingStateObservation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11E16399250B1B760076D612 /* OnboardingStateObservation.swift */; }; 11E1639B250B1B760076D612 /* OnboardingStateObservation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11E16399250B1B760076D612 /* OnboardingStateObservation.swift */; }; + 11E223412C7ADB8CB2448EBE /* CameraStreamViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 525260B1250DB1950697F048 /* CameraStreamViewController.swift */; }; 11E5CF8124BBCE1B009AC30F /* ProcessInfo+BackgroundTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11E5CF8024BBCE1B009AC30F /* ProcessInfo+BackgroundTask.swift */; }; 11E5CF8224BBCE1B009AC30F /* ProcessInfo+BackgroundTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11E5CF8024BBCE1B009AC30F /* ProcessInfo+BackgroundTask.swift */; }; 11ED43962726599D00B5FD45 /* OnboardingAuthStepModels.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11ED43952726599D00B5FD45 /* OnboardingAuthStepModels.test.swift */; }; @@ -472,14 +474,21 @@ 11FA53F2251071D2008D9506 /* NSItemProvider+Additions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11FA53F1251071D2008D9506 /* NSItemProvider+Additions.swift */; }; 11FA53F3251071D2008D9506 /* NSItemProvider+Additions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11FA53F1251071D2008D9506 /* NSItemProvider+Additions.swift */; }; 12D447D93F82395EF40487B5 /* Pods-iOS-Shared-iOS-metadata.plist in Resources */ = {isa = PBXBuildFile; fileRef = ADC769271BB34C474C2D1E24 /* Pods-iOS-Shared-iOS-metadata.plist */; }; + 12E6CFB82C976566F3B22394 /* QuickLaunchPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70D27D907EF695BFF7D5ABFC /* QuickLaunchPanelView.swift */; }; + 12F43B813C977CE396990DF3 /* ScreensaverViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F7F6A2E3E0DB9DA0DA7D066 /* ScreensaverViewController.swift */; }; 165955E006864CFE23355451 /* Pods_Tests_App.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 57B9C3C07B5A002D749B5CDA /* Pods_Tests_App.framework */; }; 177E4B39B7BA296CCB68A27D /* Pods-iOS-Extensions-Widgets-metadata.plist in Resources */ = {isa = PBXBuildFile; fileRef = 6723A4E97E50C3C9141428D0 /* Pods-iOS-Extensions-Widgets-metadata.plist */; }; 1A0BF50187A921289B3BA4AE /* Pods-Tests-App-metadata.plist in Resources */ = {isa = PBXBuildFile; fileRef = B67C3F1DA02199833DA64AF8 /* Pods-Tests-App-metadata.plist */; }; 1A3CC1D75E8EEF7294146BD1 /* ControlFanValueProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A34A5417D650BBBE9D2D7C0 /* ControlFanValueProvider.swift */; }; 20226C5AB77E1229852ADDC8 /* Pods_iOS_Extensions_Widgets.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D27653D385E4CEB58E52A350 /* Pods_iOS_Extensions_Widgets.framework */; }; 237993F7E11DC585E29EDC7C /* Pods-iOS-Extensions-NotificationService-metadata.plist in Resources */ = {isa = PBXBuildFile; fileRef = 592EED7A6C2444872F11C17B /* Pods-iOS-Extensions-NotificationService-metadata.plist */; }; + 25AEE72D8153A0785B6C807D /* AudioManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E19549F17D9DBCC2EFF5846 /* AudioManager.swift */; }; + 25FDA1758BFCF798BDF0A076 /* AmbientAudioDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61FD603A08C940784D912AC8 /* AmbientAudioDetector.swift */; }; + 293AEC642DB67B7FE828B5B8 /* KioskSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0B0BCA2B73086D731D7BBB9 /* KioskSettings.swift */; }; 2F50FC61669812D485E608EC /* Pods-iOS-Extensions-PushProvider-metadata.plist in Resources */ = {isa = PBXBuildFile; fileRef = E3D5CF14402325076CA105EB /* Pods-iOS-Extensions-PushProvider-metadata.plist */; }; + 30B2CC96058A6ECFEEA923B0 /* KioskModeManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D690417A735F65729E7BDD6 /* KioskModeManager.swift */; }; 368048FC64829A4E4B82B631 /* Pods_watchOS_WatchExtension_Watch.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A90DD8FC6E4726B7E7187C59 /* Pods_watchOS_WatchExtension_Watch.framework */; }; + 377755C1793A27E08BA83D03 /* PhotoAlbumPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32FFAC39C0F6E16297080E5C /* PhotoAlbumPickerView.swift */; }; 38A4EBA18ADEEE555AD14F52 /* Pods-iOS-App-metadata.plist in Resources */ = {isa = PBXBuildFile; fileRef = 553A33E097387AA44265DB13 /* Pods-iOS-App-metadata.plist */; }; 3997926A2B7F904A00231B54 /* MobileAppConfigPushCategory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 399792692B7F904A00231B54 /* MobileAppConfigPushCategory.swift */; }; 3997926B2B7F904A00231B54 /* MobileAppConfigPushCategory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 399792692B7F904A00231B54 /* MobileAppConfigPushCategory.swift */; }; @@ -501,6 +510,7 @@ 3E4087EE2CE62B5A0085DF29 /* WidgetBasicViewProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E4087EC2CE62B5A0085DF29 /* WidgetBasicViewProtocol.swift */; }; 3E4087F02CEC7F210085DF29 /* WidgetBasicSensorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E4087EF2CEC7F210085DF29 /* WidgetBasicSensorView.swift */; }; 3E4087F12CEC7F210085DF29 /* WidgetBasicSensorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E4087EF2CEC7F210085DF29 /* WidgetBasicSensorView.swift */; }; + 3EECB0558C4CAA8C7AE179B7 /* DashboardManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 017F484FEE29CC536D70084C /* DashboardManager.swift */; }; 4008F0262C2D0A1A00E24001 /* WidgetCircularView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4008F0252C2D0A1A00E24001 /* WidgetCircularView.swift */; }; 403AE9092C2E220200D48147 /* WidgetGauge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 403AE9082C2E220200D48147 /* WidgetGauge.swift */; }; 403AE90E2C2E28B200D48147 /* WidgetGaugeAppIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 403AE90B2C2E28B200D48147 /* WidgetGaugeAppIntent.swift */; }; @@ -1194,18 +1204,36 @@ 46C62BA92D8A3AC5002C0001 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 46C62BA82D8A3AC5002C0001 /* SwiftUI.framework */; }; 46CC96822D7136FF00F784CA /* Array+SafeSubscripting.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46CC96812D7136F400F784CA /* Array+SafeSubscripting.swift */; }; 46F103262D721516002BC586 /* LocationHistoryListViewSnapshot.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46F103252D721504002BC586 /* LocationHistoryListViewSnapshot.test.swift */; }; + 478D295680462D35EEC3C8F3 /* QuickActionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4A22C18AEFDE3991847FCFF /* QuickActionsView.swift */; }; 491E98FF25D543560077BBE3 /* LogbookEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 491E98FE25D543560077BBE3 /* LogbookEntry.swift */; }; 491E990025D543560077BBE3 /* LogbookEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 491E98FE25D543560077BBE3 /* LogbookEntry.swift */; }; + 5230E9FA00FB26562745DA3E /* SecretExitGestureView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B6E8889A2DAECF515B37110 /* SecretExitGestureView.swift */; }; 539AA1653F4BCDB61FE7C696 /* Pods_iOS_Shared_iOS.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 213EF66D14F92AF8BF2E9E98 /* Pods_iOS_Shared_iOS.framework */; }; + 5ACE597D5A0D830F824035E3 /* CameraMotionDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28F45C773888D5517BE886F3 /* CameraMotionDetector.swift */; }; 5B715903CB3450FE351399BC /* Pods-iOS-Extensions-Share-metadata.plist in Resources */ = {isa = PBXBuildFile; fileRef = 207E35C8F1554A9AD616FFA2 /* Pods-iOS-Extensions-Share-metadata.plist */; }; + 5CF8F5856FE909105F930D74 /* CameraDetectionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7750BE73663BDC1CCAA60681 /* CameraDetectionManager.swift */; }; 5FFBC80F835393915C4748CF /* Pods_iOS_Extensions_PushProvider.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A370326321B07E5ACE0BCB65 /* Pods_iOS_Extensions_PushProvider.framework */; }; + 60694CF6119BCBF7E06CD80A /* BatteryManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBE6746FB1C93E4EED878AFF /* BatteryManager.swift */; }; + 63ABD3F5C77060864AC733A0 /* IconMapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 90E3186320463545E1611267 /* IconMapper.swift */; }; + 64E8DD8CA1605B3381C32A8A /* PhotoScreensaverView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E935313A9723B7D209AF7BF /* PhotoScreensaverView.swift */; }; 65286F3B745551AD4090EE6B /* Pods-iOS-SharedTesting-metadata.plist in Resources */ = {isa = PBXBuildFile; fileRef = 4053903E4C54A6803204286E /* Pods-iOS-SharedTesting-metadata.plist */; }; + 67BA06C90EE6E1D3EE9C5213 /* PhotoManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B5AC9B0D59FDBDF62FE3325 /* PhotoManager.swift */; }; 6FCEBAA2C8E9C5403055E73D /* IntentFanEntity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E5E2F9F8F008EEA30C533FD /* IntentFanEntity.swift */; }; + 749E20D0F351640C72F22C4E /* DashboardConfigurationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51545644216AA830A0F4946C /* DashboardConfigurationView.swift */; }; 78BE7D5D003D9F8C7486DD69 /* Pods_iOS_Shared_iOS_Tests_Shared.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3F4DFB087A3A43F9A526B851 /* Pods_iOS_Shared_iOS_Tests_Shared.framework */; }; + 7CD62B734FCBFA53E2E3785B /* KioskConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94806334A2D888994C97EADA /* KioskConstants.swift */; }; + 7DFFA7F8B5845C5B3C63A7CE /* KioskSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2E6E4473C61E4724685348B /* KioskSettingsView.swift */; }; 81A0C1BBDEFF4F8C5FC314BE /* Pods_iOS_Extensions_NotificationContent.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1F356D0219C7F8A24234511B /* Pods_iOS_Extensions_NotificationContent.framework */; }; + 838E6C59390DDD8C5902F5E3 /* WebViewController+Kiosk.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46481F22C9A2B4C7F0098AB1 /* WebViewController+Kiosk.swift */; }; 84F7755EFB03C3F463292ABF /* Pods-watchOS-Shared-watchOS-metadata.plist in Resources */ = {isa = PBXBuildFile; fileRef = 6B55CB9064A0477C9F456B6A /* Pods-watchOS-Shared-watchOS-metadata.plist */; }; + 861E62B10838155CE7221288 /* PresenceDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F63000BC5EB87A0BE406AC5 /* PresenceDetector.swift */; }; + 869A7684D1D2A510A7B01F61 /* SettingsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C68E6DB6E7CA3492ACB2B6D9 /* SettingsManager.swift */; }; 8E5FA96C740F1D671966CEA9 /* Pods-iOS-Extensions-NotificationContent-metadata.plist in Resources */ = {isa = PBXBuildFile; fileRef = B613440AEDD4209862503F5D /* Pods-iOS-Extensions-NotificationContent-metadata.plist */; }; + 8F633718DB79BFD6AF1C1216 /* GuidedAccessManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = F92BB3F4E1FA5D79D6866A32 /* GuidedAccessManager.swift */; }; + A2AC179F0CF994487503AC5A /* EntityStateProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9A5D349A8C5BD3E96E2B415 /* EntityStateProvider.swift */; }; A5A3C1932BE1F4A40EA78754 /* Pods-iOS-Extensions-Matter-metadata.plist in Resources */ = {isa = PBXBuildFile; fileRef = 392B0C44197C98E2653932A5 /* Pods-iOS-Extensions-Matter-metadata.plist */; }; + A8EBA7742C9E876C6F8F23D1 /* CameraOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86A819B23462C45622FF5305 /* CameraOverlayView.swift */; }; + B4477660EF1D088BA7B7AB72 /* TouchFeedbackManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D2B3B0A4973EB1E4803AC39 /* TouchFeedbackManager.swift */; }; B6022213226DAC9D00E8DBFE /* ScaledFont.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6022212226DAC9D00E8DBFE /* ScaledFont.swift */; }; B60248001FBD343000998205 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = B60247FE1FBD343000998205 /* InfoPlist.strings */; }; B605C891226E9DAC00EF46DD /* Permissions.swift in Sources */ = {isa = PBXBuildFile; fileRef = B605C890226E9DAC00EF46DD /* Permissions.swift */; }; @@ -1351,6 +1379,7 @@ B62817F2221D6CF4000BA86A /* Reachability+NetworkType.swift in Sources */ = {isa = PBXBuildFile; fileRef = B62817F1221D6CF4000BA86A /* Reachability+NetworkType.swift */; }; B62CD2A5225B099D008DF3C5 /* WebhookSensor.swift in Sources */ = {isa = PBXBuildFile; fileRef = B62CD2A4225B099C008DF3C5 /* WebhookSensor.swift */; }; B62CD2A6225B099D008DF3C5 /* WebhookSensor.swift in Sources */ = {isa = PBXBuildFile; fileRef = B62CD2A4225B099C008DF3C5 /* WebhookSensor.swift */; }; + B62F68C39ADF9368CE7B1878 /* KioskCommandHandlers.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB5F03781488D8613D625125 /* KioskCommandHandlers.swift */; }; B6393F881CB2561100503916 /* MapKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B6393F871CB2561100503916 /* MapKit.framework */; }; B63CAE6B2150D2E300A68AFB /* VoiceShortcutsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B63CAE6A2150D2E300A68AFB /* VoiceShortcutsManager.swift */; }; B63CCDC9216442BB00123C50 /* CameraViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B63CCDC8216442BB00123C50 /* CameraViewController.swift */; }; @@ -1457,11 +1486,15 @@ B6DD5E6A24940F6F003A0154 /* OpenInFirefoxControllerSwift.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6DD5E6924940F6F003A0154 /* OpenInFirefoxControllerSwift.swift */; }; B6E2D4D52270706300446DFA /* ha-loading.json in Resources */ = {isa = PBXBuildFile; fileRef = B6E2D4D42270706200446DFA /* ha-loading.json */; }; B6E42613215C4333007FEB7E /* Shared.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D03D891720E0A85200D4F28D /* Shared.framework */; }; + B7322A965898951157832984 /* CrashRecoveryManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A46B2976E7DCC7720BCCB936 /* CrashRecoveryManager.swift */; }; + B93808E5C8CEB9F60F1BDD89 /* SecurityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BED1107F689732E5A50D994 /* SecurityManager.swift */; }; B9820AF29664869FD0B25CDF /* Pods_iOS_App.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DD90A8F251D0671EFAC931ED /* Pods_iOS_App.framework */; }; + BD8A23607CAF96AF6E51157B /* EdgeProtectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07D31B5D140C051FAC6A2F4A /* EdgeProtectionView.swift */; }; C10D762EFE08D347D0538339 /* Pods-iOS-Shared-iOS-Tests-Shared-metadata.plist in Resources */ = {isa = PBXBuildFile; fileRef = B2F5238669D8A7416FBD2B55 /* Pods-iOS-Shared-iOS-Tests-Shared-metadata.plist */; }; C6478E5ADCB3EB7EC959EB53 /* Pods_iOS_Extensions_Intents.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 29FC93E25AB875716E2F35D4 /* Pods_iOS_Extensions_Intents.framework */; }; CA6886D02384DA18A91F37DD /* Pods-iOS-Extensions-Intents-metadata.plist in Resources */ = {isa = PBXBuildFile; fileRef = E41A4AAEF642A72ACDB6C006 /* Pods-iOS-Extensions-Intents-metadata.plist */; }; CB1983AFBFED0A03533DBE85 /* Pods_iOS_Extensions_Share.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C851CA22DDEEA359D12221C3 /* Pods_iOS_Extensions_Share.framework */; }; + CD025666829B3A9F377DB550 /* StatusOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 477CDB5EDE73F78722FCF696 /* StatusOverlayView.swift */; }; CF58E969432B36CC112701AC /* Pods_watchOS_Shared_watchOS.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 6F1D92E4B7A5CD1007EB0782 /* Pods_watchOS_Shared_watchOS.framework */; }; D014EEA92128E192008EA6F5 /* ConnectionInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = D014EEA82128E192008EA6F5 /* ConnectionInfo.swift */; }; D03D892920E0A85300D4F28D /* Shared.h in Headers */ = {isa = PBXBuildFile; fileRef = D03D891920E0A85300D4F28D /* Shared.h */; settings = {ATTRIBUTES = (Public, ); }; }; @@ -1499,6 +1532,12 @@ D0EEF322214DE56B00D1D360 /* LocationTrigger.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0EEF321214DE56B00D1D360 /* LocationTrigger.swift */; }; D0EEF324214DF2B700D1D360 /* Utils.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6E857A11CB1CCCC00F96925 /* Utils.swift */; }; D0EEF335214EB77100D1D360 /* CLLocation+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6C2C17E20D1F64D00BD810B /* CLLocation+Extensions.swift */; }; + D13CAADA97EEA80A3540F51D /* ScreensaverConfigView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E8C073F3D15AD414BF20228 /* ScreensaverConfigView.swift */; }; + D23D3A39F0FEE160871AA91D /* AppLauncherManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86FD86093BE25F6FFCF1C07D /* AppLauncherManager.swift */; }; + E852019F482791D503512FD1 /* AnimationUtilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = B076CB3C85BD28DDD3750A71 /* AnimationUtilities.swift */; }; + EFDED7BB97F3AD05861C81A4 /* EntityTriggersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC68377743E25B8A01F34B7E /* EntityTriggersView.swift */; }; + F1E9C96561DCCB98C50BB98E /* CustomURLScreensaverView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB1F681A555F012DEE38EE54 /* CustomURLScreensaverView.swift */; }; + F2A42FF0CC26643072351682 /* ClockScreensaverView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2739369F1FD5A379E3CB34D /* ClockScreensaverView.swift */; }; FC8E9421FDB864726918B612 /* Pods-watchOS-WatchExtension-Watch-metadata.plist in Resources */ = {isa = PBXBuildFile; fileRef = 9249824D575933DFA1530BB2 /* Pods-watchOS-WatchExtension-Watch-metadata.plist */; }; FD3BC66329B9FF8F00B19FBE /* CarPlaySceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3BC66229B9FF8F00B19FBE /* CarPlaySceneDelegate.swift */; }; FD3BC66C29BA00D600B19FBE /* CarPlayEntitiesListTemplate.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3BC66B29BA00D600B19FBE /* CarPlayEntitiesListTemplate.swift */; }; @@ -1789,10 +1828,14 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 017F484FEE29CC536D70084C /* DashboardManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DashboardManager.swift; sourceTree = ""; }; 0194775556E59C6E64735937 /* Pods-watchOS-Shared-watchOS.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-watchOS-Shared-watchOS.release.xcconfig"; path = "Pods/Target Support Files/Pods-watchOS-Shared-watchOS/Pods-watchOS-Shared-watchOS.release.xcconfig"; sourceTree = ""; }; 05C398FF0F9BA764B69CA36B /* Pods-iOS-Extensions-NotificationService.beta.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-Extensions-NotificationService.beta.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-Extensions-NotificationService/Pods-iOS-Extensions-NotificationService.beta.xcconfig"; sourceTree = ""; }; 05E6CF2BD91E8443547F3026 /* Pods-iOS-Extensions-Today.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-Extensions-Today.release.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-Extensions-Today/Pods-iOS-Extensions-Today.release.xcconfig"; sourceTree = ""; }; + 07D31B5D140C051FAC6A2F4A /* EdgeProtectionView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = EdgeProtectionView.swift; path = EdgeProtectionView.swift; sourceTree = ""; }; 0AC45831AE5C9F83C5B6269D /* Pods-iOS-Extensions-Share.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-Extensions-Share.debug.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-Extensions-Share/Pods-iOS-Extensions-Share.debug.xcconfig"; sourceTree = ""; }; + 0E8C073F3D15AD414BF20228 /* ScreensaverConfigView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ScreensaverConfigView.swift; path = ScreensaverConfigView.swift; sourceTree = ""; }; + 0F7F6A2E3E0DB9DA0DA7D066 /* ScreensaverViewController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ScreensaverViewController.swift; path = ScreensaverViewController.swift; sourceTree = ""; }; 1100D51C2496AECE00B1073C /* PermissionStatusRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionStatusRow.swift; sourceTree = ""; }; 1100D51E2496F63400B1073C /* ThemeColors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeColors.swift; sourceTree = ""; }; 1100D52024974D6700B1073C /* camera_notification.apns */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = camera_notification.apns; sourceTree = ""; }; @@ -2164,19 +2207,23 @@ 11FA53F1251071D2008D9506 /* NSItemProvider+Additions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSItemProvider+Additions.swift"; sourceTree = ""; }; 1A736E7381A523E7A888D24E /* Pods-iOS-Extensions-Today.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-Extensions-Today.debug.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-Extensions-Today/Pods-iOS-Extensions-Today.debug.xcconfig"; sourceTree = ""; }; 1C5C1EC99DF5FCB63422D279 /* Pods-watchOS-WatchExtension-Watch.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-watchOS-WatchExtension-Watch.debug.xcconfig"; path = "Pods/Target Support Files/Pods-watchOS-WatchExtension-Watch/Pods-watchOS-WatchExtension-Watch.debug.xcconfig"; sourceTree = ""; }; + 1E19549F17D9DBCC2EFF5846 /* AudioManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = AudioManager.swift; path = AudioManager.swift; sourceTree = ""; }; 1F356D0219C7F8A24234511B /* Pods_iOS_Extensions_NotificationContent.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_iOS_Extensions_NotificationContent.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 202360CC8C2C1193658F9359 /* Pods_iOS_SharedTesting.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_iOS_SharedTesting.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 207E35C8F1554A9AD616FFA2 /* Pods-iOS-Extensions-Share-metadata.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = "Pods-iOS-Extensions-Share-metadata.plist"; path = "Pods/Pods-iOS-Extensions-Share-metadata.plist"; sourceTree = ""; }; 213EF66D14F92AF8BF2E9E98 /* Pods_iOS_Shared_iOS.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_iOS_Shared_iOS.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 287FA864ED0E47B2BB71E1C8 /* Pods-iOS-Shared-iOS-Tests-Shared.beta.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-Shared-iOS-Tests-Shared.beta.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-Shared-iOS-Tests-Shared/Pods-iOS-Shared-iOS-Tests-Shared.beta.xcconfig"; sourceTree = ""; }; + 28F45C773888D5517BE886F3 /* CameraMotionDetector.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = CameraMotionDetector.swift; path = CameraMotionDetector.swift; sourceTree = ""; }; 29FC93E25AB875716E2F35D4 /* Pods_iOS_Extensions_Intents.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_iOS_Extensions_Intents.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 32DB55A889E2163C52C335D2 /* Pods-iOS-Shared-iOS-Tests-Shared.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-Shared-iOS-Tests-Shared.debug.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-Shared-iOS-Tests-Shared/Pods-iOS-Shared-iOS-Tests-Shared.debug.xcconfig"; sourceTree = ""; }; + 32FFAC39C0F6E16297080E5C /* PhotoAlbumPickerView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PhotoAlbumPickerView.swift; path = PhotoAlbumPickerView.swift; sourceTree = ""; }; 38BD687E2E320F27D6D576B5 /* Pods-iOS-SharedTesting.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-SharedTesting.release.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-SharedTesting/Pods-iOS-SharedTesting.release.xcconfig"; sourceTree = ""; }; 392B0C44197C98E2653932A5 /* Pods-iOS-Extensions-Matter-metadata.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = "Pods-iOS-Extensions-Matter-metadata.plist"; path = "Pods/Pods-iOS-Extensions-Matter-metadata.plist"; sourceTree = ""; }; 399792692B7F904A00231B54 /* MobileAppConfigPushCategory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MobileAppConfigPushCategory.swift; sourceTree = ""; }; 3997926D2B7F907B00231B54 /* MobileAppConfigPush.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MobileAppConfigPush.swift; sourceTree = ""; }; 399792702B7F909900231B54 /* MobileAppConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MobileAppConfig.swift; sourceTree = ""; }; 39A32EE12C0E384E00985722 /* UIImage+scaledToSize.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIImage+scaledToSize.swift"; sourceTree = ""; }; + 3CA8930E79EDD7D8F8618A04 /* TamperDetectionManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = TamperDetectionManager.swift; path = TamperDetectionManager.swift; sourceTree = ""; }; 3E02C0E02CA7FCBF00102131 /* IntentSensorsAppEntity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntentSensorsAppEntity.swift; sourceTree = ""; }; 3E02C0E42CA7FCF400102131 /* WidgetSensors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetSensors.swift; sourceTree = ""; }; 3E02C0E92CA7FD2A00102131 /* WidgetSensorsAppIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetSensorsAppIntent.swift; sourceTree = ""; }; @@ -2931,6 +2978,7 @@ 42FDA9352DAFEA4E00111F22 /* DeviceClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceClass.swift; sourceTree = ""; }; 42FDCA252F0C7BFA00C92958 /* entityregistry.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = entityregistry.json; sourceTree = ""; }; 42FDCA292F0C88A100C92958 /* AppEntityRegistryTable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppEntityRegistryTable.swift; sourceTree = ""; }; + 46481F22C9A2B4C7F0098AB1 /* WebViewController+Kiosk.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "WebViewController+Kiosk.swift"; sourceTree = ""; }; 465BC1ED2D8DB87A00A30A60 /* LocationHistoryEntryListItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationHistoryEntryListItemView.swift; sourceTree = ""; }; 4697F4BE2D8A3F7500C5C467 /* XCTest.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = XCTest.framework; path = Platforms/iPhoneOS.platform/Developer/Library/Frameworks/XCTest.framework; sourceTree = DEVELOPER_DIR; }; 46BA6A4D2D734EAE002C2262 /* SnapshotTestingHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnapshotTestingHelper.swift; sourceTree = ""; }; @@ -2942,31 +2990,48 @@ 46C62BA82D8A3AC5002C0001 /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = Platforms/MacOSX.platform/Developer/SDKs/MacOSX15.2.sdk/System/iOSSupport/System/Library/Frameworks/SwiftUI.framework; sourceTree = DEVELOPER_DIR; }; 46CC96812D7136F400F784CA /* Array+SafeSubscripting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Array+SafeSubscripting.swift"; sourceTree = ""; }; 46F103252D721504002BC586 /* LocationHistoryListViewSnapshot.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationHistoryListViewSnapshot.test.swift; sourceTree = ""; }; + 477CDB5EDE73F78722FCF696 /* StatusOverlayView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = StatusOverlayView.swift; path = StatusOverlayView.swift; sourceTree = ""; }; 479C2CCB032E2A0ECDE45B87 /* Pods-Tests-App.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Tests-App.debug.xcconfig"; path = "Pods/Target Support Files/Pods-Tests-App/Pods-Tests-App.debug.xcconfig"; sourceTree = ""; }; 491E98FE25D543560077BBE3 /* LogbookEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogbookEntry.swift; sourceTree = ""; }; + 4BED1107F689732E5A50D994 /* SecurityManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SecurityManager.swift; sourceTree = ""; }; + 4F63000BC5EB87A0BE406AC5 /* PresenceDetector.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PresenceDetector.swift; path = PresenceDetector.swift; sourceTree = ""; }; + 51545644216AA830A0F4946C /* DashboardConfigurationView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = DashboardConfigurationView.swift; path = DashboardConfigurationView.swift; sourceTree = ""; }; + 525260B1250DB1950697F048 /* CameraStreamViewController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = CameraStreamViewController.swift; path = CameraStreamViewController.swift; sourceTree = ""; }; 553A33E097387AA44265DB13 /* Pods-iOS-App-metadata.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = "Pods-iOS-App-metadata.plist"; path = "Pods/Pods-iOS-App-metadata.plist"; sourceTree = ""; }; 574F428FD5AD613411644AE4 /* Pods-iOS-Extensions-PushProvider.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-Extensions-PushProvider.release.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-Extensions-PushProvider/Pods-iOS-Extensions-PushProvider.release.xcconfig"; sourceTree = ""; }; 57B9C3C07B5A002D749B5CDA /* Pods_Tests_App.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Tests_App.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 592EED7A6C2444872F11C17B /* Pods-iOS-Extensions-NotificationService-metadata.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = "Pods-iOS-Extensions-NotificationService-metadata.plist"; path = "Pods/Pods-iOS-Extensions-NotificationService-metadata.plist"; sourceTree = ""; }; + 61FD603A08C940784D912AC8 /* AmbientAudioDetector.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = AmbientAudioDetector.swift; path = AmbientAudioDetector.swift; sourceTree = ""; }; 6723A4E97E50C3C9141428D0 /* Pods-iOS-Extensions-Widgets-metadata.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = "Pods-iOS-Extensions-Widgets-metadata.plist"; path = "Pods/Pods-iOS-Extensions-Widgets-metadata.plist"; sourceTree = ""; }; 675CE4281FE5F1920B13D553 /* Pods-iOS-App.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-App.debug.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-App/Pods-iOS-App.debug.xcconfig"; sourceTree = ""; }; 6B55CB9064A0477C9F456B6A /* Pods-watchOS-Shared-watchOS-metadata.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = "Pods-watchOS-Shared-watchOS-metadata.plist"; path = "Pods/Pods-watchOS-Shared-watchOS-metadata.plist"; sourceTree = ""; }; 6CB9BB87D256D071215B4FF4 /* Pods-iOS-Extensions-PushProvider.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-Extensions-PushProvider.debug.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-Extensions-PushProvider/Pods-iOS-Extensions-PushProvider.debug.xcconfig"; sourceTree = ""; }; 6D00E1755885575FF8118933 /* ControlFan.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlFan.swift; sourceTree = ""; }; 6F1D92E4B7A5CD1007EB0782 /* Pods_watchOS_Shared_watchOS.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_watchOS_Shared_watchOS.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 70D27D907EF695BFF7D5ABFC /* QuickLaunchPanelView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = QuickLaunchPanelView.swift; path = QuickLaunchPanelView.swift; sourceTree = ""; }; 7150FCF154251F240E33FF76 /* Pods-iOS-Extensions-NotificationContent.beta.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-Extensions-NotificationContent.beta.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-Extensions-NotificationContent/Pods-iOS-Extensions-NotificationContent.beta.xcconfig"; sourceTree = ""; }; 755DF7AFFAA21F6CE428E998 /* Pods-watchOS-WatchExtension-Watch.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-watchOS-WatchExtension-Watch.release.xcconfig"; path = "Pods/Target Support Files/Pods-watchOS-WatchExtension-Watch/Pods-watchOS-WatchExtension-Watch.release.xcconfig"; sourceTree = ""; }; + 7750BE73663BDC1CCAA60681 /* CameraDetectionManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CameraDetectionManager.swift; sourceTree = ""; }; 7A6E8DF7DED57BAD4EF47D11 /* Pods_iOS_Extensions_Today.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_iOS_Extensions_Today.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 7D2B3B0A4973EB1E4803AC39 /* TouchFeedbackManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TouchFeedbackManager.swift; sourceTree = ""; }; + 7D690417A735F65729E7BDD6 /* KioskModeManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = KioskModeManager.swift; sourceTree = ""; }; 7D94AB7BD65F15C8FEE0912E /* Pods-iOS-App.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-App.release.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-App/Pods-iOS-App.release.xcconfig"; sourceTree = ""; }; 80854D28D2FCD1482E92ED31 /* Pods-Tests-App.beta.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Tests-App.beta.xcconfig"; path = "Pods/Target Support Files/Pods-Tests-App/Pods-Tests-App.beta.xcconfig"; sourceTree = ""; }; + 86A819B23462C45622FF5305 /* CameraOverlayView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = CameraOverlayView.swift; path = CameraOverlayView.swift; sourceTree = ""; }; + 86FD86093BE25F6FFCF1C07D /* AppLauncherManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AppLauncherManager.swift; sourceTree = ""; }; 8965FD50AC78F092CEB5F076 /* Pods-iOS-Extensions-Widgets.beta.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-Extensions-Widgets.beta.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-Extensions-Widgets/Pods-iOS-Extensions-Widgets.beta.xcconfig"; sourceTree = ""; }; 89E1823CF2D12BD3161FCC86 /* Pods-iOS-SharedTesting.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-SharedTesting.debug.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-SharedTesting/Pods-iOS-SharedTesting.debug.xcconfig"; sourceTree = ""; }; 8A34A5417D650BBBE9D2D7C0 /* ControlFanValueProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlFanValueProvider.swift; sourceTree = ""; }; + 8B6E8889A2DAECF515B37110 /* SecretExitGestureView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = SecretExitGestureView.swift; path = SecretExitGestureView.swift; sourceTree = ""; }; 8D6888525DCF492642BA7EA3 /* FanIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FanIntent.swift; sourceTree = ""; }; 8E00CA53EFBB621A8470C22A /* Pods-watchOS-WatchExtension-Watch.beta.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-watchOS-WatchExtension-Watch.beta.xcconfig"; path = "Pods/Target Support Files/Pods-watchOS-WatchExtension-Watch/Pods-watchOS-WatchExtension-Watch.beta.xcconfig"; sourceTree = ""; }; + 8E935313A9723B7D209AF7BF /* PhotoScreensaverView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PhotoScreensaverView.swift; path = PhotoScreensaverView.swift; sourceTree = ""; }; + 90E3186320463545E1611267 /* IconMapper.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = IconMapper.swift; sourceTree = ""; }; 9249824D575933DFA1530BB2 /* Pods-watchOS-WatchExtension-Watch-metadata.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = "Pods-watchOS-WatchExtension-Watch-metadata.plist"; path = "Pods/Pods-watchOS-WatchExtension-Watch-metadata.plist"; sourceTree = ""; }; 943E024774CF54EADF771379 /* Pods_iOS_Extensions_Matter.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_iOS_Extensions_Matter.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 94806334A2D888994C97EADA /* KioskConstants.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = KioskConstants.swift; sourceTree = ""; }; 97F089744D425CAB2755F843 /* Pods-iOS-Shared-iOS.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-Shared-iOS.debug.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-Shared-iOS/Pods-iOS-Shared-iOS.debug.xcconfig"; sourceTree = ""; }; + 9B5AC9B0D59FDBDF62FE3325 /* PhotoManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PhotoManager.swift; path = PhotoManager.swift; sourceTree = ""; }; 9C4E5E21229D98220044C8EC /* HomeAssistant.debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = HomeAssistant.debug.xcconfig; sourceTree = ""; }; 9C4E5E22229D98530044C8EC /* HomeAssistant.release.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = HomeAssistant.release.xcconfig; sourceTree = ""; }; 9C4E5E25229D986B0044C8EC /* HomeAssistant.beta.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = HomeAssistant.beta.xcconfig; sourceTree = ""; }; @@ -2974,9 +3039,12 @@ 9C7970E308CFEAEAFA05E004 /* Pods-iOS-Extensions-NotificationContent.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-Extensions-NotificationContent.release.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-Extensions-NotificationContent/Pods-iOS-Extensions-NotificationContent.release.xcconfig"; sourceTree = ""; }; A0CE1C12B4ACF0A6876B6F7F /* Pods-iOS-Extensions-Today.beta.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-Extensions-Today.beta.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-Extensions-Today/Pods-iOS-Extensions-Today.beta.xcconfig"; sourceTree = ""; }; A370326321B07E5ACE0BCB65 /* Pods_iOS_Extensions_PushProvider.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_iOS_Extensions_PushProvider.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + A46B2976E7DCC7720BCCB936 /* CrashRecoveryManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = CrashRecoveryManager.swift; path = CrashRecoveryManager.swift; sourceTree = ""; }; + A4A22C18AEFDE3991847FCFF /* QuickActionsView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = QuickActionsView.swift; path = QuickActionsView.swift; sourceTree = ""; }; A90DD8FC6E4726B7E7187C59 /* Pods_watchOS_WatchExtension_Watch.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_watchOS_WatchExtension_Watch.framework; sourceTree = BUILT_PRODUCTS_DIR; }; ADC769271BB34C474C2D1E24 /* Pods-iOS-Shared-iOS-metadata.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = "Pods-iOS-Shared-iOS-metadata.plist"; path = "Pods/Pods-iOS-Shared-iOS-metadata.plist"; sourceTree = ""; }; AF744211EE471EE671F7C928 /* Pods-iOS-Extensions-NotificationService.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-Extensions-NotificationService.debug.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-Extensions-NotificationService/Pods-iOS-Extensions-NotificationService.debug.xcconfig"; sourceTree = ""; }; + B076CB3C85BD28DDD3750A71 /* AnimationUtilities.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AnimationUtilities.swift; sourceTree = ""; }; B086E41966E89AE531E3C1A5 /* Pods-iOS-Extensions-Widgets.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-Extensions-Widgets.debug.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-Extensions-Widgets/Pods-iOS-Extensions-Widgets.debug.xcconfig"; sourceTree = ""; }; B2F5238669D8A7416FBD2B55 /* Pods-iOS-Shared-iOS-Tests-Shared-metadata.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = "Pods-iOS-Shared-iOS-Tests-Shared-metadata.plist"; path = "Pods/Pods-iOS-Shared-iOS-Tests-Shared-metadata.plist"; sourceTree = ""; }; B6022212226DAC9D00E8DBFE /* ScaledFont.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScaledFont.swift; sourceTree = ""; }; @@ -3293,14 +3361,17 @@ B6FD0571228411B200AC45BA /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Intents.strings; sourceTree = ""; }; B6FD0573228411B200AC45BA /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/InfoPlist.strings; sourceTree = ""; }; B6FD0574228411B200AC45BA /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = ""; }; + B9A5D349A8C5BD3E96E2B415 /* EntityStateProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = EntityStateProvider.swift; path = EntityStateProvider.swift; sourceTree = ""; }; B9B49F9D3E32AD45659A0A41 /* Pods-iOS-Extensions-Matter.beta.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-Extensions-Matter.beta.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-Extensions-Matter/Pods-iOS-Extensions-Matter.beta.xcconfig"; sourceTree = ""; }; BED1F3255FAD612BC4670B45 /* Pods-iOS-Extensions-Share.beta.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-Extensions-Share.beta.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-Extensions-Share/Pods-iOS-Extensions-Share.beta.xcconfig"; sourceTree = ""; }; BEE6D44D86AC3F2F3E43950D /* Pods-watchOS-Shared-watchOS.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-watchOS-Shared-watchOS.debug.xcconfig"; path = "Pods/Target Support Files/Pods-watchOS-Shared-watchOS/Pods-watchOS-Shared-watchOS.debug.xcconfig"; sourceTree = ""; }; C2563441A5A149C269C5F320 /* Pods-iOS-Shared-iOS.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-Shared-iOS.release.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-Shared-iOS/Pods-iOS-Shared-iOS.release.xcconfig"; sourceTree = ""; }; C5FC0E87961345302D630E28 /* Pods-iOS-Extensions-Intents.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-Extensions-Intents.debug.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-Extensions-Intents/Pods-iOS-Extensions-Intents.debug.xcconfig"; sourceTree = ""; }; + C68E6DB6E7CA3492ACB2B6D9 /* SettingsManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SettingsManager.swift; sourceTree = ""; }; C851CA22DDEEA359D12221C3 /* Pods_iOS_Extensions_Share.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_iOS_Extensions_Share.framework; sourceTree = BUILT_PRODUCTS_DIR; }; C8896D3548ECEBD337889277 /* Pods-iOS-Extensions-Matter.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-Extensions-Matter.debug.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-Extensions-Matter/Pods-iOS-Extensions-Matter.debug.xcconfig"; sourceTree = ""; }; CA1DE9B127B0A27EFB659904 /* Pods-iOS-Extensions-Intents.beta.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-Extensions-Intents.beta.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-Extensions-Intents/Pods-iOS-Extensions-Intents.beta.xcconfig"; sourceTree = ""; }; + CB5F03781488D8613D625125 /* KioskCommandHandlers.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = KioskCommandHandlers.swift; path = KioskCommandHandlers.swift; sourceTree = ""; }; CDB131E7598C0AC03BB5B998 /* Pods-watchOS-Shared-watchOS.beta.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-watchOS-Shared-watchOS.beta.xcconfig"; path = "Pods/Target Support Files/Pods-watchOS-Shared-watchOS/Pods-watchOS-Shared-watchOS.beta.xcconfig"; sourceTree = ""; }; CE950A9D74B3E7FF5665CB38 /* Pods_iOS_Extensions_NotificationService.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_iOS_Extensions_NotificationService.framework; sourceTree = BUILT_PRODUCTS_DIR; }; D00302BD20D4BEDB004C2CA9 /* Environment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Environment.swift; sourceTree = ""; }; @@ -3340,15 +3411,22 @@ DA2CE827B2DBBDBFB11559DF /* Pods-iOS-App.beta.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-App.beta.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-App/Pods-iOS-App.beta.xcconfig"; sourceTree = ""; }; DD90A8F251D0671EFAC931ED /* Pods_iOS_App.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_iOS_App.framework; sourceTree = BUILT_PRODUCTS_DIR; }; DFE9B91096F09C0E2A124B76 /* Pods-iOS-Extensions-Widgets.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-Extensions-Widgets.release.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-Extensions-Widgets/Pods-iOS-Extensions-Widgets.release.xcconfig"; sourceTree = ""; }; + E0B0BCA2B73086D731D7BBB9 /* KioskSettings.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = KioskSettings.swift; sourceTree = ""; }; E1A08868E5F1AEA7C24FAAAE /* Pods-iOS-Extensions-NotificationService.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-Extensions-NotificationService.release.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-Extensions-NotificationService/Pods-iOS-Extensions-NotificationService.release.xcconfig"; sourceTree = ""; }; + E2E6E4473C61E4724685348B /* KioskSettingsView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = KioskSettingsView.swift; path = KioskSettingsView.swift; sourceTree = ""; }; E3D5CF14402325076CA105EB /* Pods-iOS-Extensions-PushProvider-metadata.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = "Pods-iOS-Extensions-PushProvider-metadata.plist"; path = "Pods/Pods-iOS-Extensions-PushProvider-metadata.plist"; sourceTree = ""; }; E41A4AAEF642A72ACDB6C006 /* Pods-iOS-Extensions-Intents-metadata.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = "Pods-iOS-Extensions-Intents-metadata.plist"; path = "Pods/Pods-iOS-Extensions-Intents-metadata.plist"; sourceTree = ""; }; E805474FB6E532B5C40E83B4 /* Pods-iOS-Extensions-NotificationContent.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-Extensions-NotificationContent.debug.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-Extensions-NotificationContent/Pods-iOS-Extensions-NotificationContent.debug.xcconfig"; sourceTree = ""; }; E81F5CF42E9F5D95BD0E6019 /* Pods-iOS-SharedTesting.beta.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-SharedTesting.beta.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-SharedTesting/Pods-iOS-SharedTesting.beta.xcconfig"; sourceTree = ""; }; F0954F3919DBD03AC16B0391 /* Pods-iOS-Extensions-PushProvider.beta.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-Extensions-PushProvider.beta.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-Extensions-PushProvider/Pods-iOS-Extensions-PushProvider.beta.xcconfig"; sourceTree = ""; }; + F2739369F1FD5A379E3CB34D /* ClockScreensaverView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ClockScreensaverView.swift; path = ClockScreensaverView.swift; sourceTree = ""; }; F3A0FB3BD04C582E655168D0 /* Pods-Tests-App.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Tests-App.release.xcconfig"; path = "Pods/Target Support Files/Pods-Tests-App/Pods-Tests-App.release.xcconfig"; sourceTree = ""; }; F3E55AA06795782F04D0B261 /* Pods-iOS-Extensions-Intents.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-Extensions-Intents.release.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-Extensions-Intents/Pods-iOS-Extensions-Intents.release.xcconfig"; sourceTree = ""; }; F534C18A6FD4884F258341C9 /* Pods-iOS-Shared-iOS.beta.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-Shared-iOS.beta.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-Shared-iOS/Pods-iOS-Shared-iOS.beta.xcconfig"; sourceTree = ""; }; + F92BB3F4E1FA5D79D6866A32 /* GuidedAccessManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = GuidedAccessManager.swift; path = GuidedAccessManager.swift; sourceTree = ""; }; + FB1F681A555F012DEE38EE54 /* CustomURLScreensaverView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = CustomURLScreensaverView.swift; path = CustomURLScreensaverView.swift; sourceTree = ""; }; + FBE6746FB1C93E4EED878AFF /* BatteryManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = BatteryManager.swift; path = BatteryManager.swift; sourceTree = ""; }; + FC68377743E25B8A01F34B7E /* EntityTriggersView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = EntityTriggersView.swift; path = EntityTriggersView.swift; sourceTree = ""; }; FD3BC66229B9FF8F00B19FBE /* CarPlaySceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarPlaySceneDelegate.swift; sourceTree = ""; }; FD3BC66629BA003B00B19FBE /* HAEntity+CarPlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HAEntity+CarPlay.swift"; sourceTree = ""; }; FD3BC66B29BA00D600B19FBE /* CarPlayEntitiesListTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarPlayEntitiesListTemplate.swift; sourceTree = ""; }; @@ -4273,6 +4351,18 @@ path = Notifications; sourceTree = ""; }; + 227369CC310F1B31B9DB6180 /* Overlay */ = { + isa = PBXGroup; + children = ( + 07D31B5D140C051FAC6A2F4A /* EdgeProtectionView.swift */, + A4A22C18AEFDE3991847FCFF /* QuickActionsView.swift */, + 8B6E8889A2DAECF515B37110 /* SecretExitGestureView.swift */, + 477CDB5EDE73F78722FCF696 /* StatusOverlayView.swift */, + ); + name = Overlay; + path = Overlay; + sourceTree = ""; + }; 29278BB24639BA945D3D86B4 /* Frameworks */ = { isa = PBXGroup; children = ( @@ -5694,6 +5784,7 @@ 42E00D122E1E7807006D140D /* NotificationPermissionRequestView.swift */, 4206FFB52DAD58520087626C /* WebViewGestureHandler.swift */, 4206FFB92DAD58DB0087626C /* ConnectionInfo+WebView.swift */, + 46481F22C9A2B4C7F0098AB1 /* WebViewController+Kiosk.swift */, ); path = Extensions; sourceTree = ""; @@ -6254,6 +6345,17 @@ path = Views; sourceTree = ""; }; + 42FFE02BD35FDDE2B24E9D93 /* Utilities */ = { + isa = PBXGroup; + children = ( + 90E3186320463545E1611267 /* IconMapper.swift */, + 7D2B3B0A4973EB1E4803AC39 /* TouchFeedbackManager.swift */, + B076CB3C85BD28DDD3750A71 /* AnimationUtilities.swift */, + ); + name = Utilities; + path = Utilities; + sourceTree = ""; + }; 46C3EB192D721000009A893F /* Utilities */ = { isa = PBXGroup; children = ( @@ -6290,6 +6392,15 @@ path = ClientEvents; sourceTree = ""; }; + 4AD14250F669D042EDB6310F /* Dashboard */ = { + isa = PBXGroup; + children = ( + 017F484FEE29CC536D70084C /* DashboardManager.swift */, + ); + name = Dashboard; + path = Dashboard; + sourceTree = ""; + }; 55DB5351C79B716F7A464A95 /* Fan */ = { isa = PBXGroup; children = ( @@ -6301,6 +6412,74 @@ path = Fan; sourceTree = ""; }; + 588F4C270A55F55B0B895987 /* Security */ = { + isa = PBXGroup; + children = ( + 4BED1107F689732E5A50D994 /* SecurityManager.swift */, + C68E6DB6E7CA3492ACB2B6D9 /* SettingsManager.swift */, + FBE6746FB1C93E4EED878AFF /* BatteryManager.swift */, + A46B2976E7DCC7720BCCB936 /* CrashRecoveryManager.swift */, + F92BB3F4E1FA5D79D6866A32 /* GuidedAccessManager.swift */, + 3CA8930E79EDD7D8F8618A04 /* TamperDetectionManager.swift */, + ); + name = Security; + path = Security; + sourceTree = ""; + }; + 628607ABBFDAA13310FB776E /* Kiosk */ = { + isa = PBXGroup; + children = ( + E0B0BCA2B73086D731D7BBB9 /* KioskSettings.swift */, + 94806334A2D888994C97EADA /* KioskConstants.swift */, + 42FFE02BD35FDDE2B24E9D93 /* Utilities */, + 7D690417A735F65729E7BDD6 /* KioskModeManager.swift */, + 588F4C270A55F55B0B895987 /* Security */, + 4AD14250F669D042EDB6310F /* Dashboard */, + F68077BAB964C9405E811F41 /* Camera */, + 7C653420728C1D5001D4FFF9 /* AppLauncher */, + 820B82941A558C01306A1B6C /* Screensaver */, + 227369CC310F1B31B9DB6180 /* Overlay */, + A025DB906552503E324A1918 /* Audio */, + 8E2FC518BBEFD39261C568DD /* Commands */, + CF383721BF7E5A0DB41144DE /* Settings */, + ); + name = Kiosk; + path = Kiosk; + sourceTree = ""; + }; + 7C653420728C1D5001D4FFF9 /* AppLauncher */ = { + isa = PBXGroup; + children = ( + 86FD86093BE25F6FFCF1C07D /* AppLauncherManager.swift */, + 70D27D907EF695BFF7D5ABFC /* QuickLaunchPanelView.swift */, + ); + name = AppLauncher; + path = AppLauncher; + sourceTree = ""; + }; + 820B82941A558C01306A1B6C /* Screensaver */ = { + isa = PBXGroup; + children = ( + F2739369F1FD5A379E3CB34D /* ClockScreensaverView.swift */, + FB1F681A555F012DEE38EE54 /* CustomURLScreensaverView.swift */, + B9A5D349A8C5BD3E96E2B415 /* EntityStateProvider.swift */, + 9B5AC9B0D59FDBDF62FE3325 /* PhotoManager.swift */, + 8E935313A9723B7D209AF7BF /* PhotoScreensaverView.swift */, + 0F7F6A2E3E0DB9DA0DA7D066 /* ScreensaverViewController.swift */, + ); + name = Screensaver; + path = Screensaver; + sourceTree = ""; + }; + 8E2FC518BBEFD39261C568DD /* Commands */ = { + isa = PBXGroup; + children = ( + CB5F03781488D8613D625125 /* KioskCommandHandlers.swift */, + ); + name = Commands; + path = Commands; + sourceTree = ""; + }; 9C4E5E20229D97FA0044C8EC /* Configuration */ = { isa = PBXGroup; children = ( @@ -6314,6 +6493,16 @@ path = Configuration; sourceTree = ""; }; + A025DB906552503E324A1918 /* Audio */ = { + isa = PBXGroup; + children = ( + 61FD603A08C940784D912AC8 /* AmbientAudioDetector.swift */, + 1E19549F17D9DBCC2EFF5846 /* AudioManager.swift */, + ); + name = Audio; + path = Audio; + sourceTree = ""; + }; AAB60FA4DE371AD957F6907B /* Pods */ = { isa = PBXGroup; children = ( @@ -6619,6 +6808,7 @@ B679B1FA1E1F3D020071D366 /* Utilities */, 11A71C6924A463EE00D9565F /* ZoneManager */, B69933961E232AF50054453D /* Resources */, + 628607ABBFDAA13310FB776E /* Kiosk */, ); path = App; sourceTree = ""; @@ -6855,6 +7045,19 @@ path = Responses; sourceTree = ""; }; + CF383721BF7E5A0DB41144DE /* Settings */ = { + isa = PBXGroup; + children = ( + 51545644216AA830A0F4946C /* DashboardConfigurationView.swift */, + FC68377743E25B8A01F34B7E /* EntityTriggersView.swift */, + E2E6E4473C61E4724685348B /* KioskSettingsView.swift */, + 32FFAC39C0F6E16297080E5C /* PhotoAlbumPickerView.swift */, + 0E8C073F3D15AD414BF20228 /* ScreensaverConfigView.swift */, + ); + name = Settings; + path = Settings; + sourceTree = ""; + }; D00302BC20D4BEC0004C2CA9 /* Environment */ = { isa = PBXGroup; children = ( @@ -7182,6 +7385,19 @@ path = Common; sourceTree = ""; }; + F68077BAB964C9405E811F41 /* Camera */ = { + isa = PBXGroup; + children = ( + 7750BE73663BDC1CCAA60681 /* CameraDetectionManager.swift */, + 28F45C773888D5517BE886F3 /* CameraMotionDetector.swift */, + 86A819B23462C45622FF5305 /* CameraOverlayView.swift */, + 525260B1250DB1950697F048 /* CameraStreamViewController.swift */, + 4F63000BC5EB87A0BE406AC5 /* PresenceDetector.swift */, + ); + name = Camera; + path = Camera; + sourceTree = ""; + }; FD3BC66429BA000A00B19FBE /* CarPlay */ = { isa = PBXGroup; children = ( @@ -9321,6 +9537,45 @@ 42F1DA632B4D54CB002729BC /* CarPlayTemplateProvider.swift in Sources */, 4206FFBA2DAD58DB0087626C /* ConnectionInfo+WebView.swift in Sources */, 42C0FD0A2DDB2047001016D6 /* AVMetadataObject.ObjectType+HA.swift in Sources */, + 293AEC642DB67B7FE828B5B8 /* KioskSettings.swift in Sources */, + 7CD62B734FCBFA53E2E3785B /* KioskConstants.swift in Sources */, + 63ABD3F5C77060864AC733A0 /* IconMapper.swift in Sources */, + B4477660EF1D088BA7B7AB72 /* TouchFeedbackManager.swift in Sources */, + E852019F482791D503512FD1 /* AnimationUtilities.swift in Sources */, + 30B2CC96058A6ECFEEA923B0 /* KioskModeManager.swift in Sources */, + B93808E5C8CEB9F60F1BDD89 /* SecurityManager.swift in Sources */, + 869A7684D1D2A510A7B01F61 /* SettingsManager.swift in Sources */, + 3EECB0558C4CAA8C7AE179B7 /* DashboardManager.swift in Sources */, + 5CF8F5856FE909105F930D74 /* CameraDetectionManager.swift in Sources */, + D23D3A39F0FEE160871AA91D /* AppLauncherManager.swift in Sources */, + 838E6C59390DDD8C5902F5E3 /* WebViewController+Kiosk.swift in Sources */, + F2A42FF0CC26643072351682 /* ClockScreensaverView.swift in Sources */, + F1E9C96561DCCB98C50BB98E /* CustomURLScreensaverView.swift in Sources */, + A2AC179F0CF994487503AC5A /* EntityStateProvider.swift in Sources */, + 67BA06C90EE6E1D3EE9C5213 /* PhotoManager.swift in Sources */, + 64E8DD8CA1605B3381C32A8A /* PhotoScreensaverView.swift in Sources */, + 12F43B813C977CE396990DF3 /* ScreensaverViewController.swift in Sources */, + BD8A23607CAF96AF6E51157B /* EdgeProtectionView.swift in Sources */, + 478D295680462D35EEC3C8F3 /* QuickActionsView.swift in Sources */, + 5230E9FA00FB26562745DA3E /* SecretExitGestureView.swift in Sources */, + CD025666829B3A9F377DB550 /* StatusOverlayView.swift in Sources */, + 60694CF6119BCBF7E06CD80A /* BatteryManager.swift in Sources */, + B7322A965898951157832984 /* CrashRecoveryManager.swift in Sources */, + 8F633718DB79BFD6AF1C1216 /* GuidedAccessManager.swift in Sources */, + 116C1811BE5DE6660E00C5C9 /* TamperDetectionManager.swift in Sources */, + 25FDA1758BFCF798BDF0A076 /* AmbientAudioDetector.swift in Sources */, + 25AEE72D8153A0785B6C807D /* AudioManager.swift in Sources */, + B62F68C39ADF9368CE7B1878 /* KioskCommandHandlers.swift in Sources */, + 5ACE597D5A0D830F824035E3 /* CameraMotionDetector.swift in Sources */, + A8EBA7742C9E876C6F8F23D1 /* CameraOverlayView.swift in Sources */, + 11E223412C7ADB8CB2448EBE /* CameraStreamViewController.swift in Sources */, + 861E62B10838155CE7221288 /* PresenceDetector.swift in Sources */, + 749E20D0F351640C72F22C4E /* DashboardConfigurationView.swift in Sources */, + EFDED7BB97F3AD05861C81A4 /* EntityTriggersView.swift in Sources */, + 7DFFA7F8B5845C5B3C63A7CE /* KioskSettingsView.swift in Sources */, + 377755C1793A27E08BA83D03 /* PhotoAlbumPickerView.swift in Sources */, + D13CAADA97EEA80A3540F51D /* ScreensaverConfigView.swift in Sources */, + 12E6CFB82C976566F3B22394 /* QuickLaunchPanelView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/HomeAssistant.xcworkspace/xcshareddata/swiftpm/Package.resolved b/HomeAssistant.xcworkspace/xcshareddata/swiftpm/Package.resolved index f8dd159560..ae6276c7df 100644 --- a/HomeAssistant.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/HomeAssistant.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "e645ae6dc8974ff3c7eb7ca3f4abd6ad52ac47ade437f99359c7abcb8a8a97b8", + "originHash" : "1e8b5cd7cad843f081ebde3654465ff954a15e48c5b701716a08efd3c7d90f42", "pins" : [ { "identity" : "swift-custom-dump", diff --git a/Podfile.lock b/Podfile.lock index bf4bcbe71d..2467f0ea2b 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -270,7 +270,7 @@ SPEC CHECKSUMS: GoogleDataTransport: ea169759df570f4e37bdee1623ec32a7e64e67c4 GoogleUtilities: c2bdc4cf2ce786c4d2e6b3bcfd599a25ca78f06f GRDB.swift: 682e07f771a9100f0bdf40fd0bed57b83ca08e29 - HAKit: 2e0570970efe11fa54ad5cceb5d4c4c3fca4c603 + HAKit: 8628e7a8f87fc30e2b3b67b10920dc846f33a77b Improv-iOS: 8973990c1b1f3e3aed7fc600c8efce95359cadd0 KeychainAccess: c0c4f7f38f6fc7bbe58f5702e25f7bd2f65abf51 MBProgressHUD: 3ee5efcc380f6a79a7cc9b363dd669c5e1ae7406 @@ -297,4 +297,4 @@ SPEC CHECKSUMS: PODFILE CHECKSUM: 3e51f6f88d22cb69fd187779f567da9019ee707a -COCOAPODS: 1.15.2 +COCOAPODS: 1.16.2 diff --git a/Sources/App/Kiosk/AppLauncher/AppLauncherManager.swift b/Sources/App/Kiosk/AppLauncher/AppLauncherManager.swift new file mode 100755 index 0000000000..96333c365e --- /dev/null +++ b/Sources/App/Kiosk/AppLauncher/AppLauncherManager.swift @@ -0,0 +1,361 @@ +import Combine +import Foundation +import Shared +import UIKit +import UserNotifications + +// MARK: - App Launcher Manager + +/// Manages external app launching, return timeout, and away state tracking +@MainActor +public final class AppLauncherManager: ObservableObject { + // MARK: - Singleton + + public static let shared = AppLauncherManager() + + // MARK: - Published State + + /// Current app state (active, away, background) + @Published public private(set) var appState: AppState = .active + + /// Whether the app is currently "away" (another app was launched) + @Published public private(set) var isAway: Bool = false + + /// The app that was launched (if tracking) + @Published public private(set) var launchedApp: AppShortcut? + + /// Time when the app was launched + @Published public private(set) var launchTime: Date? + + /// Time remaining on return timeout (if active) + @Published public private(set) var returnTimeRemaining: TimeInterval = 0 + + // MARK: - Notifications + + public static let appStateDidChangeNotification = Notification.Name("AppLauncherManager.appStateDidChange") + public static let didReturnFromAppNotification = Notification.Name("AppLauncherManager.didReturnFromApp") + + // MARK: - Callbacks + + /// Called when return timeout expires + public var onReturnTimeoutExpired: (() -> Void)? + + /// Called when user returns from launched app + public var onReturnFromApp: ((AppShortcut?) -> Void)? + + // MARK: - Private + + private var settings: KioskSettings { KioskModeManager.shared.settings } + private var returnTimer: Timer? + private var countdownTimer: Timer? + private var sceneObservers: [NSObjectProtocol] = [] + + // MARK: - Initialization + + private init() { + setupSceneObservers() + requestNotificationPermission() + } + + deinit { + sceneObservers.forEach { NotificationCenter.default.removeObserver($0) } + } + + // MARK: - Public Methods + + /// Launch an app by URL scheme + public func launchApp(urlScheme: String, shortcut: AppShortcut? = nil) -> Bool { + guard let url = URL(string: urlScheme) else { + Current.Log.warning("Invalid URL scheme: \(urlScheme)") + return false + } + + return launchApp(url: url, shortcut: shortcut) + } + + /// Launch an app by URL + public func launchApp(url: URL, shortcut: AppShortcut? = nil) -> Bool { + guard UIApplication.shared.canOpenURL(url) else { + Current.Log.warning("Cannot open URL: \(url)") + return false + } + + Current.Log.info("Launching app: \(url.absoluteString)") + + // Record the launch + launchedApp = shortcut + launchTime = Date() + isAway = true + appState = .away + + // Start return timeout if configured + startReturnTimeout() + + // Open the URL + UIApplication.shared.open(url, options: [:]) { success in + if !success { + Current.Log.warning("Failed to open URL: \(url)") + Task { @MainActor in + self.cancelAwayState() + } + } + } + + // Record activity + KioskModeManager.shared.recordActivity(source: "app_launch") + + // Notify + NotificationCenter.default.post(name: Self.appStateDidChangeNotification, object: nil) + + return true + } + + /// Launch an app shortcut + public func launchShortcut(_ shortcut: AppShortcut) -> Bool { + launchApp(urlScheme: shortcut.urlScheme, shortcut: shortcut) + } + + /// Return to Home Assistant (called when app becomes active again) + public func handleReturn() { + guard isAway else { return } + + Current.Log.info("Returned from app: \(launchedApp?.name ?? "unknown")") + + cancelReturnTimeout() + + let returnedFromApp = launchedApp + launchedApp = nil + launchTime = nil + isAway = false + appState = .active + returnTimeRemaining = 0 + + // Notify + onReturnFromApp?(returnedFromApp) + NotificationCenter.default.post( + name: Self.didReturnFromAppNotification, + object: nil, + userInfo: ["app": returnedFromApp as Any] + ) + NotificationCenter.default.post(name: Self.appStateDidChangeNotification, object: nil) + + // Record activity + KioskModeManager.shared.recordActivity(source: "app_return") + } + + /// Cancel away state without triggering return callbacks + public func cancelAwayState() { + cancelReturnTimeout() + launchedApp = nil + launchTime = nil + isAway = false + appState = .active + returnTimeRemaining = 0 + } + + /// Get all configured app shortcuts + public var shortcuts: [AppShortcut] { + settings.appShortcuts + } + + /// Check if an app can be launched + public func canLaunch(urlScheme: String) -> Bool { + guard let url = URL(string: urlScheme) else { return false } + return UIApplication.shared.canOpenURL(url) + } + + /// Get duration since app was launched + public var awayDuration: TimeInterval? { + guard let launchTime else { return nil } + return Date().timeIntervalSince(launchTime) + } + + // MARK: - Private Methods + + private func setupSceneObservers() { + // Observe app becoming active (returning from another app) + let activeObserver = NotificationCenter.default.addObserver( + forName: UIScene.didActivateNotification, + object: nil, + queue: .main + ) { [weak self] _ in + self?.handleSceneActivation() + } + sceneObservers.append(activeObserver) + + // Observe app going to background + let backgroundObserver = NotificationCenter.default.addObserver( + forName: UIScene.didEnterBackgroundNotification, + object: nil, + queue: .main + ) { [weak self] _ in + self?.handleSceneBackground() + } + sceneObservers.append(backgroundObserver) + + // Observe app becoming inactive + let inactiveObserver = NotificationCenter.default.addObserver( + forName: UIScene.willDeactivateNotification, + object: nil, + queue: .main + ) { [weak self] _ in + self?.handleSceneDeactivation() + } + sceneObservers.append(inactiveObserver) + } + + private func handleSceneActivation() { + if isAway { + handleReturn() + } else if appState == .background { + appState = .active + NotificationCenter.default.post(name: Self.appStateDidChangeNotification, object: nil) + } + } + + private func handleSceneBackground() { + if !isAway { + appState = .background + NotificationCenter.default.post(name: Self.appStateDidChangeNotification, object: nil) + } + } + + private func handleSceneDeactivation() { + // This fires when the app is about to go to background + // Don't change state here - wait for didEnterBackground + } + + // MARK: - Return Timeout + + private func startReturnTimeout() { + let timeout = settings.appLaunchReturnTimeout + guard timeout > 0 else { return } + + cancelReturnTimeout() + returnTimeRemaining = timeout + + Current.Log.info("Starting return timeout: \(Int(timeout)) seconds") + + // Countdown timer for UI updates + countdownTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in + Task { @MainActor in + guard let self else { return } + if self.returnTimeRemaining > 0 { + self.returnTimeRemaining -= 1 + } + } + } + + // Main timeout timer + returnTimer = Timer.scheduledTimer(withTimeInterval: timeout, repeats: false) { [weak self] _ in + Task { @MainActor in + self?.handleReturnTimeout() + } + } + + // Schedule local notification + scheduleReturnNotification(timeout: timeout) + } + + private func cancelReturnTimeout() { + returnTimer?.invalidate() + returnTimer = nil + countdownTimer?.invalidate() + countdownTimer = nil + returnTimeRemaining = 0 + + // Cancel pending notification + cancelReturnNotification() + } + + private func handleReturnTimeout() { + Current.Log.info("Return timeout expired") + + cancelReturnTimeout() + onReturnTimeoutExpired?() + + // The notification will alert the user to return + } + + // MARK: - Local Notifications + + private func requestNotificationPermission() { + UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { granted, error in + if let error { + Current.Log.error("Failed to request notification permission: \(error)") + } else if granted { + Current.Log.info("Notification permission granted for return reminders") + } + } + } + + private func scheduleReturnNotification(timeout: TimeInterval) { + let content = UNMutableNotificationContent() + content.title = "Home Assistant" + content.body = "Time to return to your dashboard" + content.sound = .default + content.categoryIdentifier = "KIOSK_RETURN" + + let trigger = UNTimeIntervalNotificationTrigger(timeInterval: timeout, repeats: false) + let request = UNNotificationRequest( + identifier: "kiosk.return.reminder", + content: content, + trigger: trigger + ) + + UNUserNotificationCenter.current().add(request) { error in + if let error { + Current.Log.error("Failed to schedule return notification: \(error)") + } + } + } + + private func cancelReturnNotification() { + UNUserNotificationCenter.current().removePendingNotificationRequests( + withIdentifiers: ["kiosk.return.reminder"] + ) + } +} + +// MARK: - App Launcher Command Support + +extension AppLauncherManager { + /// Handle launch app command from HA notification + public func handleLaunchCommand(urlScheme: String) -> Bool { + // Find matching shortcut if exists + let shortcut = settings.appShortcuts.first { $0.urlScheme == urlScheme } + return launchApp(urlScheme: urlScheme, shortcut: shortcut) + } +} + +// MARK: - Sensor Attributes + +extension AppLauncherManager { + /// Sensor state for HA reporting + public var sensorState: String { + appState.rawValue + } + + /// Sensor attributes for HA reporting + public var sensorAttributes: [String: Any] { + var attrs: [String: Any] = [ + "is_away": isAway, + ] + + if let launchedApp { + attrs["launched_app"] = launchedApp.name + attrs["launched_scheme"] = launchedApp.urlScheme + } + + if let launchTime { + attrs["launch_time"] = ISO8601DateFormatter().string(from: launchTime) + attrs["away_duration_seconds"] = Int(awayDuration ?? 0) + } + + if returnTimeRemaining > 0 { + attrs["return_timeout_remaining"] = Int(returnTimeRemaining) + } + + return attrs + } +} diff --git a/Sources/App/Kiosk/AppLauncher/QuickLaunchPanelView.swift b/Sources/App/Kiosk/AppLauncher/QuickLaunchPanelView.swift new file mode 100755 index 0000000000..0422ee4155 --- /dev/null +++ b/Sources/App/Kiosk/AppLauncher/QuickLaunchPanelView.swift @@ -0,0 +1,450 @@ +import Combine +import Shared +import SwiftUI + +// MARK: - Quick Launch Panel View + +/// A slide-out panel showing app shortcuts for quick launching +public struct QuickLaunchPanelView: View { + @ObservedObject private var manager = AppLauncherManager.shared + @ObservedObject private var kioskManager = KioskModeManager.shared + @Binding var isPresented: Bool + + @State private var searchText = "" + + public init(isPresented: Binding) { + _isPresented = isPresented + } + + private var filteredShortcuts: [AppShortcut] { + let shortcuts = kioskManager.settings.appShortcuts + if searchText.isEmpty { + return shortcuts + } + return shortcuts.filter { + $0.name.localizedCaseInsensitiveContains(searchText) + } + } + + public var body: some View { + VStack(spacing: 0) { + // Header + headerView + + // Search (if many shortcuts) + if kioskManager.settings.appShortcuts.count > KioskConstants.Panel.searchThreshold { + searchBar + } + + // App grid + ScrollView { + LazyVGrid(columns: gridColumns, spacing: 16) { + ForEach(filteredShortcuts) { shortcut in + AppShortcutButton(shortcut: shortcut) { + launchApp(shortcut) + } + } + } + .padding() + } + + // Away status (if active) + if manager.isAway { + awayStatusView + } + } + .background(Color(.systemBackground).opacity(0.95)) + .cornerRadius(16) + .shadow(color: .black.opacity(0.3), radius: 10, x: 0, y: 5) + } + + private var gridColumns: [GridItem] { + [GridItem(.adaptive(minimum: 80, maximum: 100), spacing: 16)] + } + + private var headerView: some View { + HStack { + Text("Quick Launch") + .font(.headline) + .foregroundColor(.primary) + + Spacer() + + Button { + isPresented = false + } label: { + Image(systemName: "xmark.circle.fill") + .font(.title2) + .foregroundColor(.secondary) + } + .accessibilityLabel(KioskConstants.Accessibility.closeButton) + } + .padding() + .background(Color(.secondarySystemBackground)) + } + + private var searchBar: some View { + HStack { + Image(systemName: "magnifyingglass") + .foregroundColor(.secondary) + + TextField("Search apps...", text: $searchText) + .textFieldStyle(.plain) + + if !searchText.isEmpty { + Button { + searchText = "" + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundColor(.secondary) + } + } + } + .padding(10) + .background(Color(.tertiarySystemBackground)) + .cornerRadius(8) + .padding(.horizontal) + .padding(.bottom, 8) + } + + private var awayStatusView: some View { + HStack { + Image(systemName: "clock.arrow.circlepath") + .foregroundColor(.orange) + + if let app = manager.launchedApp { + Text("In \(app.name)") + .font(.caption) + } + + Spacer() + + if manager.returnTimeRemaining > 0 { + Text(formatTimeRemaining(manager.returnTimeRemaining)) + .font(.caption.monospacedDigit()) + .foregroundColor(.orange) + } + } + .padding() + .background(Color.orange.opacity(0.1)) + } + + private func launchApp(_ shortcut: AppShortcut) { + isPresented = false + + // Small delay to allow panel to dismiss + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + _ = manager.launchShortcut(shortcut) + } + } + + private func formatTimeRemaining(_ seconds: TimeInterval) -> String { + let mins = Int(seconds) / 60 + let secs = Int(seconds) % 60 + return String(format: "%d:%02d", mins, secs) + } +} + +// MARK: - App Shortcut Button + +struct AppShortcutButton: View { + let shortcut: AppShortcut + let action: () -> Void + + @State private var canLaunch: Bool = true + + var body: some View { + Button(action: action) { + VStack(spacing: 8) { + // Icon + iconView + .frame(width: KioskConstants.UI.appIconSize, height: KioskConstants.UI.appIconSize) + .background(Color(.tertiarySystemBackground)) + .cornerRadius(KioskConstants.UI.cornerRadius) + .overlay( + RoundedRectangle(cornerRadius: KioskConstants.UI.cornerRadius) + .stroke(Color(.separator), lineWidth: 0.5) + ) + + // Name + Text(shortcut.name) + .font(.caption) + .lineLimit(1) + .foregroundColor(.primary) + } + .opacity(canLaunch ? 1 : 0.5) + } + .disabled(!canLaunch) + .accessibilityLabel(KioskConstants.Accessibility.appShortcut(shortcut.name)) + .accessibilityHint(canLaunch ? "Double tap to launch" : "App not available") + .onAppear { + canLaunch = AppLauncherManager.shared.canLaunch(urlScheme: shortcut.urlScheme) + } + } + + @ViewBuilder + private var iconView: some View { + if let systemImage = shortcut.systemImage { + Image(systemName: systemImage) + .font(.title2) + .foregroundColor(.accentColor) + } else { + // Use IconMapper to convert MDI icon to SF Symbol + Image(systemName: IconMapper.sfSymbol(from: shortcut.icon, default: "app.fill")) + .font(.title2) + .foregroundColor(.accentColor) + } + } +} + +// MARK: - Quick Launch Panel Container + +/// Container view that handles gesture-based presentation of the quick launch panel +public struct QuickLaunchContainerView: View { + @ObservedObject private var kioskManager = KioskModeManager.shared + @ObservedObject private var panelManager = QuickLaunchPanelManager.shared + @State private var dragOffset: CGFloat = 0 + + private var gesture: QuickLaunchGesture { + kioskManager.settings.quickLaunchGesture + } + + private var isPanelPresented: Bool { + get { panelManager.isPresented } + } + + private func setPanelPresented(_ value: Bool) { + panelManager.isPresented = value + } + + public init() {} + + public var body: some View { + GeometryReader { geometry in + ZStack { + // Panel overlay (only when presented) + if isPanelPresented { + Color.black.opacity(0.3) + .ignoresSafeArea() + .onTapGesture { + withAnimation(.spring()) { + setPanelPresented(false) + } + } + + panelView(in: geometry) + .transition(.move(edge: panelEdge).combined(with: .opacity)) + } + } + } + .animation(.spring(), value: isPanelPresented) + // Only allow hit testing when panel is actually presented + .allowsHitTesting(isPanelPresented) + } + + @ViewBuilder + private func edgeGestureOverlay(in geometry: GeometryProxy) -> some View { + let edgeSize = KioskConstants.UI.edgeGestureSize + + // Only show edge detectors when panel is not presented + if !isPanelPresented { + switch gesture { + case .swipeFromBottom: + VStack { + Spacer() + Color.clear + .frame(height: edgeSize) + .contentShape(Rectangle()) + .gesture(edgeGesture(in: geometry)) + } + case .swipeFromTop: + VStack { + Color.clear + .frame(height: edgeSize) + .contentShape(Rectangle()) + .gesture(edgeGesture(in: geometry)) + Spacer() + } + case .swipeFromLeft: + HStack { + Color.clear + .frame(width: edgeSize) + .contentShape(Rectangle()) + .gesture(edgeGesture(in: geometry)) + Spacer() + } + case .swipeFromRight: + HStack { + Spacer() + Color.clear + .frame(width: edgeSize) + .contentShape(Rectangle()) + .gesture(edgeGesture(in: geometry)) + } + case .doubleTap, .longPress: + EmptyView() + } + } + } + + private var panelEdge: Edge { + switch gesture { + case .swipeFromBottom: return .bottom + case .swipeFromTop: return .top + case .swipeFromLeft: return .leading + case .swipeFromRight: return .trailing + case .doubleTap, .longPress: return .bottom + } + } + + @ViewBuilder + private func panelView(in geometry: GeometryProxy) -> some View { + let panelSize = panelSize(in: geometry) + + QuickLaunchPanelView(isPresented: $panelManager.isPresented) + .frame(width: panelSize.width, height: panelSize.height) + .position(panelPosition(in: geometry, size: panelSize)) + } + + private func panelSize(in geometry: GeometryProxy) -> CGSize { + let maxWidth = min(geometry.size.width * KioskConstants.Panel.maxWidthRatio, KioskConstants.Panel.maxWidth) + let maxHeight = min(geometry.size.height * KioskConstants.Panel.maxHeightRatio, KioskConstants.Panel.maxHeight) + return CGSize(width: maxWidth, height: maxHeight) + } + + private func panelPosition(in geometry: GeometryProxy, size: CGSize) -> CGPoint { + let centerX = geometry.size.width / 2 + let centerY = geometry.size.height / 2 + + switch gesture { + case .swipeFromBottom: + return CGPoint(x: centerX, y: geometry.size.height - size.height / 2 - 20) + case .swipeFromTop: + return CGPoint(x: centerX, y: size.height / 2 + 20) + case .swipeFromLeft: + return CGPoint(x: size.width / 2 + 20, y: centerY) + case .swipeFromRight: + return CGPoint(x: geometry.size.width - size.width / 2 - 20, y: centerY) + case .doubleTap, .longPress: + return CGPoint(x: centerX, y: centerY) + } + } + + private func edgeGesture(in geometry: GeometryProxy) -> some Gesture { + let threshold = KioskConstants.UI.swipeThreshold + + return DragGesture(minimumDistance: 20) + .onChanged { value in + switch gesture { + case .swipeFromBottom, .swipeFromTop: + dragOffset = value.translation.height + case .swipeFromLeft, .swipeFromRight: + dragOffset = value.translation.width + default: + break + } + } + .onEnded { value in + switch gesture { + case .swipeFromBottom: + if value.translation.height < -threshold { + setPanelPresented(true) + } + case .swipeFromTop: + if value.translation.height > threshold { + setPanelPresented(true) + } + case .swipeFromLeft: + if value.translation.width > threshold { + setPanelPresented(true) + } + case .swipeFromRight: + if value.translation.width < -threshold { + setPanelPresented(true) + } + default: + break + } + + dragOffset = 0 + } + } +} + +// MARK: - Quick Launch Panel Manager + +/// Manages the quick launch panel state and presentation +@MainActor +public final class QuickLaunchPanelManager: ObservableObject { + public static let shared = QuickLaunchPanelManager() + + @Published public var isPresented: Bool = false + + private init() {} + + public func show() { + isPresented = true + } + + public func hide() { + isPresented = false + } + + public func toggle() { + isPresented.toggle() + } +} + +// MARK: - Quick Launch Passthrough View + +/// Custom UIView that only intercepts touches when the quick launch panel is visible +public final class QuickLaunchPassthroughView: UIView { + public override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + // Only intercept touches when the panel is visible + guard QuickLaunchPanelManager.shared.isPresented else { + return nil + } + return super.hitTest(point, with: event) + } +} + +/// UIViewController that hosts QuickLaunchContainerView with proper touch passthrough +public final class QuickLaunchViewController: UIViewController { + private var hostingController: UIHostingController? + + public override func loadView() { + view = QuickLaunchPassthroughView() + view.backgroundColor = .clear + } + + public override func viewDidLoad() { + super.viewDidLoad() + + let containerView = QuickLaunchContainerView() + let hosting = UIHostingController(rootView: containerView) + hosting.view.backgroundColor = .clear + + addChild(hosting) + view.addSubview(hosting.view) + hosting.view.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + hosting.view.topAnchor.constraint(equalTo: view.topAnchor), + hosting.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), + hosting.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + hosting.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), + ]) + + hosting.didMove(toParent: self) + hostingController = hosting + } +} + +// MARK: - Preview + +#Preview { + QuickLaunchPanelView(isPresented: .constant(true)) + .frame(width: 350, height: 400) + .padding() + .background(Color.gray) +} diff --git a/Sources/App/Kiosk/Audio/AmbientAudioDetector.swift b/Sources/App/Kiosk/Audio/AmbientAudioDetector.swift new file mode 100755 index 0000000000..c95363884b --- /dev/null +++ b/Sources/App/Kiosk/Audio/AmbientAudioDetector.swift @@ -0,0 +1,240 @@ +import AVFoundation +import Combine +import Foundation +import Shared + +// MARK: - Ambient Audio Detector + +/// Detects ambient audio levels using the device microphone +@MainActor +public final class AmbientAudioDetector: ObservableObject { + // MARK: - Singleton + + public static let shared = AmbientAudioDetector() + + // MARK: - Published State + + /// Whether detection is currently active + @Published public private(set) var isActive: Bool = false + + /// Current ambient audio level in decibels (normalized 0-1) + @Published public private(set) var audioLevel: Float = 0 + + /// Current audio level in dB + @Published public private(set) var audioLevelDB: Float = -160 + + /// Whether loud audio is currently detected + @Published public private(set) var loudAudioDetected: Bool = false + + /// Microphone authorization status + @Published public private(set) var authorizationStatus: AVAudioSession.RecordPermission = .undetermined + + /// Error message if detection failed + @Published public private(set) var errorMessage: String? + + // MARK: - Callbacks + + /// Called when loud audio is detected + public var onLoudAudioDetected: (() -> Void)? + + /// Called when audio level crosses threshold + public var onThresholdCrossed: ((Bool) -> Void)? + + // MARK: - Private + + private var settings: KioskSettings { KioskModeManager.shared.settings } + private var audioRecorder: AVAudioRecorder? + private var meteringTimer: Timer? + + // Detection settings + private let sampleInterval: TimeInterval = 0.1 // 100ms + private var loudThresholdDB: Float = -20 // Adjustable + private var quietThresholdDB: Float = -50 + private var consecutiveLoudSamples: Int = 0 + private let loudSampleThreshold: Int = 3 // Samples needed to confirm loud + + // MARK: - Initialization + + private init() { + checkAuthorizationStatus() + } + + deinit { + audioRecorder?.stop() + audioRecorder = nil + meteringTimer?.invalidate() + meteringTimer = nil + } + + // MARK: - Public Methods + + /// Start ambient audio detection + public func start() { + guard !isActive else { return } + guard authorizationStatus == .granted else { + Current.Log.warning("Microphone not authorized for ambient detection") + return + } + + Current.Log.info("Starting ambient audio detection") + + setupAudioRecorder() + startMetering() + isActive = true + errorMessage = nil + } + + /// Stop ambient audio detection + public func stop() { + guard isActive else { return } + + Current.Log.info("Stopping ambient audio detection") + + stopMetering() + audioRecorder?.stop() + audioRecorder = nil + isActive = false + audioLevel = 0 + audioLevelDB = -160 + loudAudioDetected = false + } + + /// Request microphone authorization + public func requestAuthorization() async -> Bool { + return await withCheckedContinuation { continuation in + AVAudioSession.sharedInstance().requestRecordPermission { granted in + Task { @MainActor in + self.checkAuthorizationStatus() + continuation.resume(returning: granted) + } + } + } + } + + /// Set detection threshold in dB (e.g., -20 for loud, -50 for quiet) + public func setThreshold(loud: Float, quiet: Float) { + loudThresholdDB = loud + quietThresholdDB = quiet + } + + // MARK: - Private Methods + + private func checkAuthorizationStatus() { + authorizationStatus = AVAudioSession.sharedInstance().recordPermission + } + + private func setupAudioRecorder() { + let audioSession = AVAudioSession.sharedInstance() + + do { + try audioSession.setCategory(.playAndRecord, mode: .measurement, options: [.mixWithOthers, .defaultToSpeaker]) + try audioSession.setActive(true) + + // Create temporary file for recording (required by AVAudioRecorder but we don't use it) + let tempDir = FileManager.default.temporaryDirectory + let tempFile = tempDir.appendingPathComponent("ambient_meter.caf") + + let settings: [String: Any] = [ + AVFormatIDKey: Int(kAudioFormatAppleIMA4), + AVSampleRateKey: 44100.0, + AVNumberOfChannelsKey: 1, + AVEncoderAudioQualityKey: AVAudioQuality.low.rawValue, + ] + + audioRecorder = try AVAudioRecorder(url: tempFile, settings: settings) + audioRecorder?.isMeteringEnabled = true + audioRecorder?.record() + + Current.Log.info("Audio recorder initialized for ambient detection") + } catch { + errorMessage = "Failed to setup audio recorder: \(error.localizedDescription)" + Current.Log.error("Audio recorder setup error: \(error)") + } + } + + private func startMetering() { + meteringTimer = Timer.scheduledTimer(withTimeInterval: sampleInterval, repeats: true) { [weak self] _ in + Task { @MainActor in + self?.updateMetering() + } + } + } + + private func stopMetering() { + meteringTimer?.invalidate() + meteringTimer = nil + } + + private func updateMetering() { + guard let recorder = audioRecorder else { return } + + recorder.updateMeters() + + // Get average power in dB (-160 to 0) + let averagePower = recorder.averagePower(forChannel: 0) + audioLevelDB = averagePower + + // Normalize to 0-1 range + // -160 dB = 0, 0 dB = 1 + audioLevel = max(0, min(1, (averagePower + 160) / 160)) + + // Check for loud audio + if averagePower > loudThresholdDB { + consecutiveLoudSamples += 1 + + if consecutiveLoudSamples >= loudSampleThreshold && !loudAudioDetected { + loudAudioDetected = true + onLoudAudioDetected?() + onThresholdCrossed?(true) + Current.Log.info("Loud audio detected: \(averagePower) dB") + } + } else if averagePower < quietThresholdDB { + if loudAudioDetected { + loudAudioDetected = false + onThresholdCrossed?(false) + Current.Log.info("Audio returned to quiet: \(averagePower) dB") + } + consecutiveLoudSamples = 0 + } + } +} + +// MARK: - Sensor State Access + +extension AmbientAudioDetector { + /// Get audio level for HA sensor reporting (0-100) + public var audioLevelPercent: Int { + Int(audioLevel * 100) + } + + /// Get sensor attributes for HA + public var sensorAttributes: [String: Any] { + [ + "level_db": audioLevelDB, + "level_percent": audioLevelPercent, + "loud_detected": loudAudioDetected, + "detection_active": isActive, + "threshold_loud_db": loudThresholdDB, + "threshold_quiet_db": quietThresholdDB, + ] + } +} + +// MARK: - Use Cases + +extension AmbientAudioDetector { + /// Configure for voice activity detection + public func configureForVoiceDetection() { + setThreshold(loud: -30, quiet: -45) + } + + /// Configure for loud noise detection (e.g., smoke alarm) + public func configureForLoudNoiseDetection() { + setThreshold(loud: -15, quiet: -30) + } + + /// Configure for quiet room detection + public func configureForQuietRoomDetection() { + setThreshold(loud: -40, quiet: -55) + } +} diff --git a/Sources/App/Kiosk/Audio/AudioManager.swift b/Sources/App/Kiosk/Audio/AudioManager.swift new file mode 100644 index 0000000000..0bf00aa5f8 --- /dev/null +++ b/Sources/App/Kiosk/Audio/AudioManager.swift @@ -0,0 +1,446 @@ +import AVFoundation +import Combine +import MediaPlayer +import Shared +import UIKit + +// MARK: - Audio Manager + +/// Manages audio playback, TTS, and volume control for kiosk mode +@MainActor +public final class AudioManager: NSObject, ObservableObject { + // MARK: - Singleton + + public static let shared = AudioManager() + + // MARK: - Published State + + /// Current system volume (0.0 - 1.0) + @Published public private(set) var currentVolume: Float = 0.5 + + /// Whether TTS is currently speaking + @Published public private(set) var isSpeaking: Bool = false + + /// Whether audio is currently playing + @Published public private(set) var isPlaying: Bool = false + + /// Current audio playback progress (0.0 - 1.0) + @Published public private(set) var playbackProgress: Float = 0 + + // MARK: - Private + + private var settings: KioskSettings { KioskModeManager.shared.settings } + private let synthesizer = AVSpeechSynthesizer() + private var audioPlayer: AVAudioPlayer? + private var urlPlayer: AVPlayer? + private var progressTimer: Timer? + private var volumeView: MPVolumeView? + + // Alert sounds + private var alertSounds: [AlertType: SystemSoundID] = [:] + + // MARK: - Initialization + + private override init() { + super.init() + synthesizer.delegate = self + setupAudioSession() + setupVolumeObserver() + registerAlertSounds() + } + + deinit { + // Stop any playing audio + audioPlayer?.stop() + urlPlayer?.pause() + progressTimer?.invalidate() + + // Dispose alert sounds + for soundID in alertSounds.values { + AudioServicesDisposeSystemSoundID(soundID) + } + + // Deactivate audio session + try? AVAudioSession.sharedInstance().setActive(false) + + // Remove observers + NotificationCenter.default.removeObserver(self) + } + + // MARK: - Public Methods - TTS + + /// Speak text using text-to-speech + public func speak(_ text: String, priority: TTSPriority = .normal) { + guard settings.ttsEnabled else { return } + + // If high priority, stop current speech + if priority == .high && synthesizer.isSpeaking { + synthesizer.stopSpeaking(at: .immediate) + } + + // If already speaking and not high priority, queue it + if synthesizer.isSpeaking && priority != .high { + return + } + + let utterance = AVSpeechUtterance(string: text) + utterance.rate = AVSpeechUtteranceDefaultSpeechRate + utterance.pitchMultiplier = 1.0 + utterance.volume = settings.ttsVolume + + // Use system default voice + let languageCode = Locale.current.languageCode ?? "en-US" + if let voice = AVSpeechSynthesisVoice(language: languageCode) { + utterance.voice = voice + } + + synthesizer.speak(utterance) + isSpeaking = true + + Current.Log.info("TTS: \(text)") + } + + /// Stop current TTS speech + public func stopSpeaking() { + synthesizer.stopSpeaking(at: .immediate) + isSpeaking = false + } + + // MARK: - Public Methods - Audio Playback + + /// Play audio from a URL (local or remote) + public func playAudio(from url: URL, volume: Float? = nil) { + stopAudio() + + let playVolume = volume ?? settings.ttsVolume + + if url.isFileURL { + playLocalAudio(url: url, volume: playVolume) + } else { + playRemoteAudio(url: url, volume: playVolume) + } + } + + /// Play audio from a URL string + public func playAudio(from urlString: String, volume: Float? = nil) { + guard let url = URL(string: urlString) else { + Current.Log.warning("Invalid audio URL: \(urlString)") + return + } + playAudio(from: url, volume: volume) + } + + /// Stop current audio playback + public func stopAudio() { + // Remove observer to prevent memory leak and duplicate callbacks + NotificationCenter.default.removeObserver(self, name: .AVPlayerItemDidPlayToEndTime, object: nil) + + audioPlayer?.stop() + audioPlayer = nil + + urlPlayer?.pause() + urlPlayer = nil + + progressTimer?.invalidate() + progressTimer = nil + + isPlaying = false + playbackProgress = 0 + } + + /// Pause audio playback + public func pauseAudio() { + audioPlayer?.pause() + urlPlayer?.pause() + isPlaying = false + } + + /// Resume audio playback + public func resumeAudio() { + audioPlayer?.play() + urlPlayer?.play() + isPlaying = true + } + + // MARK: - Public Methods - Alerts + + /// Play an alert sound + public func playAlert(_ type: AlertType) { + guard settings.audioAlertsEnabled else { return } + + if let soundID = alertSounds[type] { + AudioServicesPlaySystemSound(soundID) + } else { + // Fallback to system sounds + switch type { + case .critical: + AudioServicesPlaySystemSound(1005) // System alert + case .warning: + AudioServicesPlaySystemSound(1007) // SMS received + case .info: + AudioServicesPlaySystemSound(1003) // Tweet + case .success: + AudioServicesPlaySystemSound(1001) // Received mail + case .doorbell: + AudioServicesPlaySystemSound(1016) // Ding dong (if available) + } + } + + Current.Log.info("Alert played: \(type)") + } + + /// Play haptic feedback + public func playHaptic(_ style: UIImpactFeedbackGenerator.FeedbackStyle = .medium) { + guard settings.touchHapticEnabled else { return } + + let generator = UIImpactFeedbackGenerator(style: style) + generator.prepare() + generator.impactOccurred() + } + + // MARK: - Public Methods - Volume Control + + /// Set system volume + public func setVolume(_ volume: Float) { + let clampedVolume = max(0, min(1, volume)) + + // Use MPVolumeView to set system volume + if volumeView == nil { + volumeView = MPVolumeView(frame: .zero) + } + + if let slider = volumeView?.subviews.first(where: { $0 is UISlider }) as? UISlider { + slider.value = clampedVolume + } + + currentVolume = clampedVolume + Current.Log.info("Volume set to: \(Int(clampedVolume * 100))%") + } + + /// Get current volume level + public func getVolume() -> Float { + currentVolume + } + + /// Mute audio + public func mute() { + setVolume(0) + } + + /// Unmute to previous volume or default + public func unmute(to volume: Float = 0.5) { + setVolume(volume) + } + + // MARK: - Private Methods + + private func setupAudioSession() { + do { + let session = AVAudioSession.sharedInstance() + try session.setCategory(.playback, mode: .default, options: [.mixWithOthers, .duckOthers]) + try session.setActive(true) + } catch { + Current.Log.error("Failed to setup audio session: \(error)") + } + } + + private func setupVolumeObserver() { + // Observe system volume changes + NotificationCenter.default.addObserver( + self, + selector: #selector(volumeDidChange), + name: NSNotification.Name("AVSystemController_SystemVolumeDidChangeNotification"), + object: nil + ) + + // Get initial volume + currentVolume = AVAudioSession.sharedInstance().outputVolume + } + + @objc private func volumeDidChange(_ notification: Notification) { + if let volume = notification.userInfo?["AVSystemController_AudioVolumeNotificationParameter"] as? Float { + currentVolume = volume + } + } + + private func registerAlertSounds() { + // Register custom alert sounds from bundle if available + let alertTypes: [AlertType] = [.critical, .warning, .info, .success, .doorbell] + + for type in alertTypes { + if let soundURL = Bundle.main.url(forResource: type.soundFileName, withExtension: "wav") { + var soundID: SystemSoundID = 0 + AudioServicesCreateSystemSoundID(soundURL as CFURL, &soundID) + alertSounds[type] = soundID + } + } + } + + private func playLocalAudio(url: URL, volume: Float) { + do { + audioPlayer = try AVAudioPlayer(contentsOf: url) + audioPlayer?.volume = volume + audioPlayer?.delegate = self + audioPlayer?.play() + isPlaying = true + startProgressTimer() + Current.Log.info("Playing local audio: \(url.lastPathComponent)") + } catch { + Current.Log.error("Failed to play local audio: \(error)") + } + } + + private func playRemoteAudio(url: URL, volume: Float) { + let playerItem = AVPlayerItem(url: url) + urlPlayer = AVPlayer(playerItem: playerItem) + urlPlayer?.volume = volume + urlPlayer?.play() + isPlaying = true + + // Observe playback end + NotificationCenter.default.addObserver( + self, + selector: #selector(playerDidFinish), + name: .AVPlayerItemDidPlayToEndTime, + object: playerItem + ) + + startProgressTimer() + Current.Log.info("Playing remote audio: \(url.absoluteString)") + } + + @objc private func playerDidFinish(_ notification: Notification) { + stopAudio() + } + + private func startProgressTimer() { + progressTimer?.invalidate() + progressTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] _ in + self?.updateProgress() + } + } + + private func updateProgress() { + if let player = audioPlayer { + if player.duration > 0 { + playbackProgress = Float(player.currentTime / player.duration) + } + } else if let player = urlPlayer, + let currentItem = player.currentItem { + let duration = currentItem.duration.seconds + let current = player.currentTime().seconds + if duration.isFinite && duration > 0 { + playbackProgress = Float(current / duration) + } + } + } +} + +// MARK: - AVSpeechSynthesizerDelegate + +extension AudioManager: AVSpeechSynthesizerDelegate { + nonisolated public func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) { + Task { @MainActor in + self.isSpeaking = false + } + } + + nonisolated public func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didCancel utterance: AVSpeechUtterance) { + Task { @MainActor in + self.isSpeaking = false + } + } +} + +// MARK: - AVAudioPlayerDelegate + +extension AudioManager: AVAudioPlayerDelegate { + nonisolated public func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) { + Task { @MainActor in + self.stopAudio() + } + } +} + +// MARK: - Supporting Types + +public enum TTSPriority { + case normal + case high +} + +public enum AlertType: String, CaseIterable { + case critical + case warning + case info + case success + case doorbell + + var soundFileName: String { + "alert_\(rawValue)" + } +} + +// MARK: - HA Audio Integration + +extension AudioManager { + /// Play media from Home Assistant media_player service + public func playHAMedia(contentId: String, contentType: String = "music") { + // Construct media URL from HA + guard let server = Current.servers.all.first, + let baseURL = server.info.connection.activeURL() else { + Current.Log.warning("No HA server available for media playback") + return + } + + // For media_source content IDs + if contentId.hasPrefix("media-source://") { + let mediaPath = "/api/media_source/local/\(contentId.replacingOccurrences(of: "media-source://", with: ""))" + if let url = URL(string: baseURL.absoluteString + mediaPath) { + playAudio(from: url) + } + } else if let url = URL(string: contentId) { + // Direct URL + playAudio(from: url) + } + } + + /// Handle HA notification command for audio + public func handleCommand(_ command: AudioCommand) { + switch command { + case let .tts(message, volume): + if let vol = volume { + let previousVolume = currentVolume + setVolume(vol) + speak(message, priority: .high) + // Restore volume after speech (approximation) + DispatchQueue.main.asyncAfter(deadline: .now() + Double(message.count) * 0.08) { [weak self] in + self?.setVolume(previousVolume) + } + } else { + speak(message) + } + + case let .playMedia(url, volume): + playAudio(from: url, volume: volume) + + case .stop: + stopAudio() + stopSpeaking() + + case let .setVolume(level): + setVolume(level) + + case let .alert(type): + playAlert(type) + } + } +} + +public enum AudioCommand { + case tts(message: String, volume: Float?) + case playMedia(url: String, volume: Float?) + case stop + case setVolume(level: Float) + case alert(type: AlertType) +} diff --git a/Sources/App/Kiosk/Camera/CameraDetectionManager.swift b/Sources/App/Kiosk/Camera/CameraDetectionManager.swift new file mode 100755 index 0000000000..ec2a0fd26b --- /dev/null +++ b/Sources/App/Kiosk/Camera/CameraDetectionManager.swift @@ -0,0 +1,318 @@ +import AVFoundation +import Combine +import Foundation +import Shared +import UIKit + +// MARK: - Camera Detection Manager + +/// Coordinates camera-based motion and presence detection for kiosk mode +@MainActor +public final class CameraDetectionManager: ObservableObject { + // MARK: - Singleton + + public static let shared = CameraDetectionManager() + + // MARK: - Published State + + /// Whether any camera detection is currently active + @Published public private(set) var isActive: Bool = false + + /// Current motion detected state + @Published public private(set) var motionDetected: Bool = false + + /// Current presence detected state + @Published public private(set) var presenceDetected: Bool = false + + /// Current face detected state + @Published public private(set) var faceDetected: Bool = false + + /// Number of faces detected + @Published public private(set) var faceCount: Int = 0 + + /// Camera authorization status + @Published public private(set) var authorizationStatus: AVAuthorizationStatus = .notDetermined + + /// Last motion detection time + @Published public private(set) var lastMotionTime: Date? + + /// Last presence detection time + @Published public private(set) var lastPresenceTime: Date? + + // MARK: - Callbacks + + /// Called when motion is detected (for wake trigger) + public var onMotionDetected: (() -> Void)? + + /// Called when presence state changes + public var onPresenceChanged: ((Bool) -> Void)? + + // MARK: - Private + + private var settings: KioskSettings { KioskModeManager.shared.settings } + private let motionDetector = CameraMotionDetector.shared + private let presenceDetector = PresenceDetector.shared + private var cancellables = Set() + + /// Timer for periodic activity updates while presence is detected + private var presenceActivityTimer: Timer? + + /// Interval for presence activity updates (keeps idle timer reset while someone is present) + private let presenceActivityInterval: TimeInterval = 5.0 + + // MARK: - Initialization + + private init() { + setupBindings() + checkAuthorizationStatus() + } + + deinit { + cancellables.forEach { $0.cancel() } + cancellables.removeAll() + } + + // MARK: - Public Methods + + /// Start camera detection based on settings + public func start() { + guard !isActive else { return } + + Current.Log.info("Starting camera detection manager") + + if settings.cameraMotionEnabled { + motionDetector.start() + } + + if settings.cameraPresenceEnabled || settings.cameraFaceDetectionEnabled { + presenceDetector.start() + } + + isActive = settings.cameraMotionEnabled || settings.cameraPresenceEnabled || settings.cameraFaceDetectionEnabled + } + + /// Stop all camera detection + public func stop() { + guard isActive else { return } + + Current.Log.info("Stopping camera detection manager") + + stopPresenceActivityTimer() + motionDetector.stop() + presenceDetector.stop() + isActive = false + } + + /// Restart detection (e.g., after settings change) + public func restart() { + stop() + start() + } + + /// Request camera authorization + public func requestAuthorization() async -> Bool { + // Request from either detector (they use the same permission) + let granted = await motionDetector.requestAuthorization() + checkAuthorizationStatus() + return granted + } + + // MARK: - Private Methods + + private func checkAuthorizationStatus() { + authorizationStatus = AVCaptureDevice.authorizationStatus(for: .video) + } + + private func setupBindings() { + // Bind motion detector state + motionDetector.$motionDetected + .receive(on: DispatchQueue.main) + .sink { [weak self] detected in + self?.motionDetected = detected + if detected { + self?.lastMotionTime = Date() + self?.handleMotionDetected() + } + } + .store(in: &cancellables) + + // Bind presence detector state + presenceDetector.$personDetected + .receive(on: DispatchQueue.main) + .sink { [weak self] detected in + let previousState = self?.presenceDetected ?? false + self?.presenceDetected = detected + if detected { + self?.lastPresenceTime = Date() + } + if detected != previousState { + self?.handlePresenceChanged(detected) + } + } + .store(in: &cancellables) + + // Bind face detection state + presenceDetector.$faceDetected + .receive(on: DispatchQueue.main) + .sink { [weak self] detected in + self?.faceDetected = detected + } + .store(in: &cancellables) + + presenceDetector.$faceCount + .receive(on: DispatchQueue.main) + .sink { [weak self] count in + self?.faceCount = count + } + .store(in: &cancellables) + + // Bind authorization status + Publishers.Merge( + motionDetector.$authorizationStatus, + presenceDetector.$authorizationStatus + ) + .receive(on: DispatchQueue.main) + .sink { [weak self] status in + self?.authorizationStatus = status + } + .store(in: &cancellables) + } + + private func handleMotionDetected() { + Current.Log.info("Camera motion detected") + + // Notify listeners + onMotionDetected?() + + // Post notification for sensor update + NotificationCenter.default.post(name: .kioskSensorUpdate, object: nil) + + // If wake on motion is enabled, trigger wake + if settings.wakeOnCameraMotion { + KioskModeManager.shared.wakeScreen(source: "camera_motion") + } + } + + private func handlePresenceChanged(_ detected: Bool) { + Current.Log.info("Presence changed: \(detected ? "detected" : "absent")") + + // Notify listeners + onPresenceChanged?(detected) + + // Post notification for sensor update + NotificationCenter.default.post(name: .kioskSensorUpdate, object: nil) + + // If presence is detected and screen is off, optionally wake + if detected && settings.wakeOnCameraPresence { + KioskModeManager.shared.wakeScreen(source: "camera_presence") + } + + // Start or stop the presence activity timer + if detected { + startPresenceActivityTimer() + } else { + stopPresenceActivityTimer() + } + } + + // MARK: - Presence Activity Timer + + /// Starts a timer that periodically records activity while presence is detected. + /// This prevents the screensaver from triggering while someone is standing in front of the device. + private func startPresenceActivityTimer() { + stopPresenceActivityTimer() + + guard settings.wakeOnCameraPresence else { return } + + presenceActivityTimer = Timer.scheduledTimer(withTimeInterval: presenceActivityInterval, repeats: true) { [weak self] _ in + guard let self, self.presenceDetected else { + self?.stopPresenceActivityTimer() + return + } + + Current.Log.verbose("Presence activity tick - keeping screen awake") + KioskModeManager.shared.recordActivity(source: "camera_presence") + } + } + + private func stopPresenceActivityTimer() { + presenceActivityTimer?.invalidate() + presenceActivityTimer = nil + } +} + +// MARK: - Sensor State Access + +extension CameraDetectionManager { + /// Get current motion sensor state for HA reporting + public var motionSensorState: String { + motionDetected ? "on" : "off" + } + + /// Get current presence sensor state for HA reporting + public var presenceSensorState: String { + presenceDetected ? "on" : "off" + } + + /// Get sensor attributes for motion sensor + public var motionSensorAttributes: [String: Any] { + var attrs: [String: Any] = [ + "detection_active": isActive && settings.cameraMotionEnabled, + "sensitivity": settings.cameraMotionSensitivity.rawValue, + ] + + if let lastTime = lastMotionTime { + attrs["last_motion"] = ISO8601DateFormatter().string(from: lastTime) + } + + return attrs + } + + /// Get sensor attributes for presence sensor + public var presenceSensorAttributes: [String: Any] { + var attrs: [String: Any] = [ + "detection_active": isActive && settings.cameraPresenceEnabled, + "face_detection_enabled": settings.cameraFaceDetectionEnabled, + ] + + if settings.cameraFaceDetectionEnabled { + attrs["face_detected"] = faceDetected + attrs["face_count"] = faceCount + } + + if let lastTime = lastPresenceTime { + attrs["last_presence"] = ISO8601DateFormatter().string(from: lastTime) + } + + return attrs + } +} + +// MARK: - Privacy Controls + +extension CameraDetectionManager { + /// Privacy mode - temporarily disable all camera detection + public func enablePrivacyMode() { + stop() + Current.Log.info("Camera detection privacy mode enabled") + } + + /// Disable privacy mode and resume detection + public func disablePrivacyMode() { + start() + Current.Log.info("Camera detection privacy mode disabled") + } + + /// Check if camera usage is properly disclosed + public var hasProperDisclosure: Bool { + // In a real app, this would check Info.plist for camera usage description + return Bundle.main.object(forInfoDictionaryKey: "NSCameraUsageDescription") != nil + } +} + +// MARK: - Notification Names + +extension Notification.Name { + static let kioskMotionDetected = Notification.Name("kioskMotionDetected") + static let kioskPresenceChanged = Notification.Name("kioskPresenceChanged") +} diff --git a/Sources/App/Kiosk/Camera/CameraMotionDetector.swift b/Sources/App/Kiosk/Camera/CameraMotionDetector.swift new file mode 100644 index 0000000000..8c01db496b --- /dev/null +++ b/Sources/App/Kiosk/Camera/CameraMotionDetector.swift @@ -0,0 +1,285 @@ +import AVFoundation +import Combine +import CoreImage +import Shared +import UIKit + +// MARK: - Camera Motion Detector + +/// Detects motion using the device camera for wake-on-motion functionality +@MainActor +public final class CameraMotionDetector: NSObject, ObservableObject { + // MARK: - Singleton + + public static let shared = CameraMotionDetector() + + // MARK: - Published State + + /// Whether motion detection is currently active + @Published public private(set) var isActive: Bool = false + + /// Whether motion was detected recently + @Published public private(set) var motionDetected: Bool = false + + /// Current motion level (0.0 - 1.0) + @Published public private(set) var motionLevel: Float = 0 + + /// Camera authorization status + @Published public private(set) var authorizationStatus: AVAuthorizationStatus = .notDetermined + + /// Error message if detection failed + @Published public private(set) var errorMessage: String? + + // MARK: - Callbacks + + /// Called when motion is detected + public var onMotionDetected: (() -> Void)? + + /// Called when motion level changes (for debugging/visualization) + public var onMotionLevelChanged: ((Float) -> Void)? + + // MARK: - Private + + private var settings: KioskSettings { KioskModeManager.shared.settings } + private var captureSession: AVCaptureSession? + private var videoOutput: AVCaptureVideoDataOutput? + private let processingQueue = DispatchQueue(label: "com.haframe.motion", qos: .userInitiated) + + private var previousFrame: CIImage? + private var motionThreshold: Float = 0.02 // Adjustable based on sensitivity + private var cooldownTimer: Timer? + private var isInCooldown: Bool = false + + // MARK: - Initialization + + private override init() { + super.init() + checkAuthorizationStatus() + } + + deinit { + captureSession?.stopRunning() + captureSession = nil + cooldownTimer?.invalidate() + cooldownTimer = nil + } + + // MARK: - Public Methods + + /// Start motion detection + public func start() { + guard !isActive else { return } + + // Re-check authorization status before starting + checkAuthorizationStatus() + + guard authorizationStatus == .authorized else { + Current.Log.warning("Camera not authorized for motion detection (status: \(authorizationStatus.rawValue))") + return + } + + Current.Log.info("Starting camera motion detection") + + updateSensitivity() + setupCaptureSession() + + processingQueue.async { [weak self] in + self?.captureSession?.startRunning() + DispatchQueue.main.async { + self?.isActive = true + self?.errorMessage = nil + } + } + } + + /// Stop motion detection + public func stop() { + guard isActive else { return } + + Current.Log.info("Stopping camera motion detection") + + processingQueue.async { [weak self] in + self?.captureSession?.stopRunning() + DispatchQueue.main.async { + self?.isActive = false + self?.motionDetected = false + self?.motionLevel = 0 + self?.previousFrame = nil + } + } + + cooldownTimer?.invalidate() + cooldownTimer = nil + } + + /// Request camera authorization + public func requestAuthorization() async -> Bool { + let status = await AVCaptureDevice.requestAccess(for: .video) + authorizationStatus = AVCaptureDevice.authorizationStatus(for: .video) + return status + } + + /// Update sensitivity from settings + public func updateSensitivity() { + switch settings.cameraMotionSensitivity { + case .low: + motionThreshold = 0.05 + case .medium: + motionThreshold = 0.02 + case .high: + motionThreshold = 0.008 + } + + Current.Log.info("Motion sensitivity set to \(settings.cameraMotionSensitivity.rawValue), threshold: \(motionThreshold)") + } + + // MARK: - Private Methods + + private func checkAuthorizationStatus() { + authorizationStatus = AVCaptureDevice.authorizationStatus(for: .video) + } + + private func setupCaptureSession() { + let session = AVCaptureSession() + session.sessionPreset = .low // Use low resolution for efficiency + + // Get front camera (facing user for wall-mounted display) + guard let camera = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .front) else { + errorMessage = "Front camera not available" + Current.Log.error("Front camera not available for motion detection") + return + } + + do { + let input = try AVCaptureDeviceInput(device: camera) + if session.canAddInput(input) { + session.addInput(input) + } + + // Configure low frame rate to save power + try camera.lockForConfiguration() + camera.activeVideoMinFrameDuration = CMTime(value: 1, timescale: 5) // 5 fps + camera.activeVideoMaxFrameDuration = CMTime(value: 1, timescale: 5) + camera.unlockForConfiguration() + } catch { + errorMessage = "Failed to configure camera: \(error.localizedDescription)" + Current.Log.error("Camera configuration error: \(error)") + return + } + + // Setup video output + let output = AVCaptureVideoDataOutput() + output.videoSettings = [ + kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA, + ] + output.alwaysDiscardsLateVideoFrames = true + output.setSampleBufferDelegate(self, queue: processingQueue) + + if session.canAddOutput(output) { + session.addOutput(output) + } + + captureSession = session + videoOutput = output + } + + private func processFrame(_ pixelBuffer: CVPixelBuffer) { + let ciImage = CIImage(cvPixelBuffer: pixelBuffer) + + guard let previous = previousFrame else { + previousFrame = ciImage + return + } + + // Calculate difference between frames + let difference = calculateDifference(current: ciImage, previous: previous) + + DispatchQueue.main.async { [weak self] in + guard let self else { return } + + self.motionLevel = difference + self.onMotionLevelChanged?(difference) + + if difference > self.motionThreshold && !self.isInCooldown { + self.handleMotionDetected() + } + } + + previousFrame = ciImage + } + + private func calculateDifference(current: CIImage, previous: CIImage) -> Float { + // Create difference image + let differenceFilter = CIFilter(name: "CIDifferenceBlendMode") + differenceFilter?.setValue(current, forKey: kCIInputImageKey) + differenceFilter?.setValue(previous, forKey: kCIInputBackgroundImageKey) + + guard let differenceImage = differenceFilter?.outputImage else { return 0 } + + // Calculate average luminance of difference + let extentVector = CIVector( + x: differenceImage.extent.origin.x, + y: differenceImage.extent.origin.y, + z: differenceImage.extent.size.width, + w: differenceImage.extent.size.height + ) + + let averageFilter = CIFilter(name: "CIAreaAverage") + averageFilter?.setValue(differenceImage, forKey: kCIInputImageKey) + averageFilter?.setValue(extentVector, forKey: kCIInputExtentKey) + + guard let outputImage = averageFilter?.outputImage else { return 0 } + + // Get average color + var bitmap = [UInt8](repeating: 0, count: 4) + let context = CIContext(options: [.workingColorSpace: kCFNull as Any]) + context.render( + outputImage, + toBitmap: &bitmap, + rowBytes: 4, + bounds: CGRect(x: 0, y: 0, width: 1, height: 1), + format: .RGBA8, + colorSpace: nil + ) + + // Calculate luminance from RGB + let r = Float(bitmap[0]) / 255.0 + let g = Float(bitmap[1]) / 255.0 + let b = Float(bitmap[2]) / 255.0 + + return (r + g + b) / 3.0 + } + + private func handleMotionDetected() { + motionDetected = true + isInCooldown = true + + Current.Log.info("Motion detected (level: \(motionLevel))") + onMotionDetected?() + + // Start cooldown to prevent rapid re-triggering + cooldownTimer?.invalidate() + cooldownTimer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: false) { [weak self] _ in + DispatchQueue.main.async { + self?.isInCooldown = false + self?.motionDetected = false + } + } + } +} + +// MARK: - AVCaptureVideoDataOutputSampleBufferDelegate + +extension CameraMotionDetector: AVCaptureVideoDataOutputSampleBufferDelegate { + nonisolated public func captureOutput( + _ output: AVCaptureOutput, + didOutput sampleBuffer: CMSampleBuffer, + from connection: AVCaptureConnection + ) { + guard let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { return } + + Task { @MainActor in + processFrame(pixelBuffer) + } + } +} diff --git a/Sources/App/Kiosk/Camera/CameraOverlayView.swift b/Sources/App/Kiosk/Camera/CameraOverlayView.swift new file mode 100644 index 0000000000..b5fbf5b39a --- /dev/null +++ b/Sources/App/Kiosk/Camera/CameraOverlayView.swift @@ -0,0 +1,790 @@ +import AudioToolbox +import AVKit +import Combine +import Shared +import SwiftUI + +// MARK: - Camera Overlay View + +/// A Picture-in-Picture style camera overlay for doorbell/security camera events +public struct CameraOverlayView: View { + @ObservedObject private var manager = CameraOverlayManager.shared + + public init() {} + + public var body: some View { + GeometryReader { geometry in + if manager.isVisible, let stream = manager.currentStream { + // Semi-transparent background that dismisses on tap + Color.black.opacity(0.5) + .ignoresSafeArea() + .onTapGesture { + manager.dismiss() + } + + cameraContent(stream: stream, in: geometry) + .position(overlayPosition(in: geometry)) + .transition(.scale.combined(with: .opacity)) + .animation(.spring(), value: manager.isVisible) + } + } + .allowsHitTesting(manager.isVisible) + } + + @ViewBuilder + private func cameraContent(stream: CameraStream, in geometry: GeometryProxy) -> some View { + let size = overlaySize(in: geometry) + + VStack(spacing: 0) { + // Header with camera name and dismiss + headerView(stream: stream) + + // Camera stream - fills width, fixed height based on aspect ratio + CameraStreamView(stream: stream) + .frame(height: size.height) + .clipped() + + // Action buttons + if stream.showActions { + actionButtons(stream: stream) + } + } + .frame(width: size.width) // Constrain entire container width + .background(Color.black) + .cornerRadius(12) + .shadow(color: .black.opacity(0.4), radius: 8, x: 0, y: 4) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(Color.white.opacity(0.2), lineWidth: 1) + ) + } + + private func headerView(stream: CameraStream) -> some View { + HStack { + // Camera icon + Image(systemName: stream.type == .doorbell ? "video.doorbell" : "video") + .font(.caption) + .foregroundColor(.white) + .accessibilityHidden(true) // Name is read separately + + // Camera name + Text(stream.name) + .font(.caption.weight(.medium)) + .foregroundColor(.white) + .lineLimit(1) + .accessibilityLabel("Camera: \(stream.name)") + + Spacer() + + // Expand button + Button { + manager.expandToFullScreen() + } label: { + Image(systemName: "arrow.up.left.and.arrow.down.right") + .font(.caption) + .foregroundColor(.white.opacity(0.8)) + } + .accessibilityLabel("Expand to full screen") + .padding(.trailing, 4) + + // Dismiss button + Button { + manager.dismiss() + } label: { + Image(systemName: "xmark.circle.fill") + .font(.callout) + .foregroundColor(.white.opacity(0.8)) + } + .accessibilityLabel("Dismiss camera") + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(Color.black.opacity(0.6)) + } + + private func actionButtons(stream: CameraStream) -> some View { + VStack(spacing: 8) { + // Standard action buttons row + HStack(spacing: 12) { + if stream.type == .doorbell { + // Answer/talk button for doorbell + Button { + manager.answerDoorbell() + } label: { + Label("Talk", systemImage: "mic.fill") + .font(.caption.weight(.medium)) + .foregroundColor(.white) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Color.green) + .cornerRadius(6) + } + + // Unlock button (if configured) + if stream.unlockEntityId != nil { + Button { + manager.unlock() + } label: { + Label("Unlock", systemImage: "lock.open.fill") + .font(.caption.weight(.medium)) + .foregroundColor(.white) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Color.blue) + .cornerRadius(6) + } + } + } + + // Snapshot button + Button { + manager.takeSnapshot() + } label: { + Image(systemName: "camera.fill") + .font(.caption) + .foregroundColor(.white) + .padding(8) + .background(Color.white.opacity(0.2)) + .cornerRadius(6) + } + .accessibilityLabel("Take snapshot") + } + + // Custom action buttons (if any) + if let customActions = stream.customActions, !customActions.isEmpty { + customActionButtons(actions: customActions) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(Color.black.opacity(0.6)) + } + + private func customActionButtons(actions: [CameraStream.CameraAction]) -> some View { + HStack(spacing: 8) { + ForEach(actions) { action in + Button { + manager.executeAction(action) + } label: { + Label(action.label, systemImage: action.sfSymbol) + .font(.caption.weight(.medium)) + .foregroundColor(.white) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(Color.orange) + .cornerRadius(6) + } + } + } + } + + // MARK: - Layout + + private func overlaySize(in geometry: GeometryProxy) -> CGSize { + let sizeSettings = KioskModeManager.shared.settings.cameraPopupSize.sizeParameters + let maxWidth = geometry.size.width * sizeSettings.widthPercent + let maxHeight = geometry.size.height * sizeSettings.heightPercent + let aspectRatio: CGFloat = 16 / 9 + + var width = min(maxWidth, sizeSettings.maxWidth) + var height = width / aspectRatio + + if height > maxHeight { + height = maxHeight + width = height * aspectRatio + } + + return CGSize(width: width, height: height) + } + + private func overlayPosition(in geometry: GeometryProxy) -> CGPoint { + let position = KioskModeManager.shared.settings.cameraPopupPosition + let size = overlaySize(in: geometry) + let padding: CGFloat = 20 + + switch position { + case .center: + return CGPoint(x: geometry.size.width / 2, y: geometry.size.height / 2) + case .topLeft: + return CGPoint(x: size.width / 2 + padding, y: size.height / 2 + padding + 60) // 60 for header + case .topRight: + return CGPoint(x: geometry.size.width - size.width / 2 - padding, y: size.height / 2 + padding + 60) + case .bottomLeft: + return CGPoint(x: size.width / 2 + padding, y: geometry.size.height - size.height / 2 - padding - 40) // 40 for actions + case .bottomRight: + return CGPoint(x: geometry.size.width - size.width / 2 - padding, y: geometry.size.height - size.height / 2 - padding - 40) + } + } + +} + +// MARK: - Camera Stream View + +/// Displays a camera stream using HLS or MJPEG with proper Home Assistant authentication +struct CameraStreamView: UIViewRepresentable { + let stream: CameraStream + + func makeUIView(context: Context) -> UIView { + let containerView = UIView() + containerView.backgroundColor = .black + + // Create image view for displaying stream frames + let imageView = UIImageView(frame: containerView.bounds) + imageView.contentMode = .scaleAspectFit + imageView.clipsToBounds = true + imageView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + containerView.addSubview(imageView) + context.coordinator.imageView = imageView + + // Add loading indicator + let activityIndicator = UIActivityIndicatorView(style: .large) + activityIndicator.color = .white + activityIndicator.center = CGPoint(x: containerView.bounds.midX, y: containerView.bounds.midY) + activityIndicator.autoresizingMask = [.flexibleLeftMargin, .flexibleRightMargin, .flexibleTopMargin, .flexibleBottomMargin] + activityIndicator.startAnimating() + containerView.addSubview(activityIndicator) + context.coordinator.activityIndicator = activityIndicator + + // Start the appropriate stream + if stream.streamType == .hls, let hlsURL = stream.hlsURL { + // Use AVPlayer for HLS + let player = AVPlayer(url: hlsURL) + let playerLayer = AVPlayerLayer(player: player) + playerLayer.frame = containerView.bounds + playerLayer.videoGravity = .resizeAspect + containerView.layer.insertSublayer(playerLayer, at: 0) + player.play() + + context.coordinator.player = player + context.coordinator.playerLayer = playerLayer + activityIndicator.stopAnimating() + } else if let mjpegPath = stream.mjpegPath { + // Use MJPEGStreamer with proper authentication + startMJPEGStream(path: mjpegPath, coordinator: context.coordinator) + } + + return containerView + } + + private func startMJPEGStream(path: String, coordinator: Coordinator) { + guard let server = Current.servers.all.first, + let api = Current.api(for: server), + let url = api.server.info.connection.activeURL()?.appendingPathComponent(path) else { + Current.Log.error("Cannot start MJPEG stream: no server connection or invalid path") + coordinator.activityIndicator?.stopAnimating() + return + } + + // Create MJPEGStreamer with proper authentication via the API + let streamer = api.VideoStreamer() + coordinator.streamer = streamer + + Current.Log.info("Starting MJPEG stream from: \(url.absoluteString)") + + streamer.streamImages(fromURL: url) { [weak coordinator] image, error in + guard let coordinator = coordinator else { return } + + coordinator.activityIndicator?.stopAnimating() + + if let error = error { + Current.Log.error("MJPEG stream error: \(error.localizedDescription)") + return + } + + if let image = image { + coordinator.imageView?.image = image + } + } + } + + func updateUIView(_ uiView: UIView, context: Context) { + context.coordinator.playerLayer?.frame = uiView.bounds + context.coordinator.imageView?.frame = uiView.bounds + } + + func makeCoordinator() -> Coordinator { + Coordinator() + } + + class Coordinator { + var player: AVPlayer? + var playerLayer: AVPlayerLayer? + var imageView: UIImageView? + var streamer: MJPEGStreamer? + var activityIndicator: UIActivityIndicatorView? + + deinit { + player?.pause() + streamer?.cancel() + } + } +} + +// MARK: - Camera Stream Model + +public struct CameraStream: Identifiable { + public let id: String + public let name: String + public let entityId: String + public let type: CameraType + public let streamType: StreamType + public let hlsURL: URL? + /// The MJPEG stream path (relative to HA base URL), e.g. "/api/camera_proxy_stream/camera.front_door" + public let mjpegPath: String? + public let showActions: Bool + public let unlockEntityId: String? + public let autoDismissSeconds: TimeInterval? + /// Alert sound to play when popup appears + public let alertSound: AlertSound? + /// Volume for alert sound (0.0 - 1.0) + public let alertVolume: Float? + /// Custom action buttons to display + public let customActions: [CameraAction]? + + public enum CameraType { + case doorbell + case security + case generic + } + + public enum StreamType { + case hls + case mjpeg + } + + /// Predefined alert sounds for camera popup + public enum AlertSound: String, CaseIterable { + case none + case doorbellClassic = "doorbell_classic" + case doorbellChime = "doorbell_chime" + case doorbellMelody = "doorbell_melody" + case motionSubtle = "motion_subtle" + case motionAlert = "motion_alert" + case securityUrgent = "security_urgent" + + /// System sound ID for playback + var systemSoundID: UInt32? { + switch self { + case .none: return nil + case .doorbellClassic: return 1315 // Mail sent + case .doorbellChime: return 1314 // Tweet + case .doorbellMelody: return 1309 // Anticipate + case .motionSubtle: return 1057 // Tink + case .motionAlert: return 1007 // SMS received + case .securityUrgent: return 1005 // Alarm + } + } + + var displayName: String { + switch self { + case .none: return "None" + case .doorbellClassic: return "Classic Doorbell" + case .doorbellChime: return "Chime" + case .doorbellMelody: return "Melody" + case .motionSubtle: return "Subtle" + case .motionAlert: return "Alert" + case .securityUrgent: return "Urgent" + } + } + } + + /// Custom action button for camera popup + public struct CameraAction: Identifiable { + public let id: String + public let label: String + public let icon: String? + public let service: String + public let target: [String: Any]? + public let serviceData: [String: Any]? + public let confirmRequired: Bool + + public init( + id: String = UUID().uuidString, + label: String, + icon: String? = nil, + service: String, + target: [String: Any]? = nil, + serviceData: [String: Any]? = nil, + confirmRequired: Bool = false + ) { + self.id = id + self.label = label + self.icon = icon + self.service = service + self.target = target + self.serviceData = serviceData + self.confirmRequired = confirmRequired + } + + /// SF Symbol name for common icons + var sfSymbol: String { + guard let icon = icon else { return "questionmark.circle" } + + // Map common MDI icons to SF Symbols + switch icon.lowercased().replacingOccurrences(of: "mdi:", with: "") { + case "lightbulb", "lightbulb-on": return "lightbulb.fill" + case "lightbulb-off": return "lightbulb" + case "lock": return "lock.fill" + case "lock-open", "lock-open-variant": return "lock.open.fill" + case "bullhorn", "megaphone": return "megaphone.fill" + case "message", "chat": return "message.fill" + case "bell", "bell-ring": return "bell.fill" + case "door", "door-open": return "door.left.hand.open" + case "garage", "garage-open": return "rectangle.split.3x1" + case "fan": return "fan.fill" + case "thermostat": return "thermometer" + case "camera": return "camera.fill" + case "motion-sensor": return "figure.walk" + case "power": return "power" + case "play": return "play.fill" + case "stop": return "stop.fill" + default: return "questionmark.circle" + } + } + } + + public init( + id: String = UUID().uuidString, + name: String, + entityId: String, + type: CameraType = .generic, + streamType: StreamType = .mjpeg, + hlsURL: URL? = nil, + mjpegPath: String? = nil, + showActions: Bool = false, + unlockEntityId: String? = nil, + autoDismissSeconds: TimeInterval? = nil, + alertSound: AlertSound? = nil, + alertVolume: Float? = nil, + customActions: [CameraAction]? = nil + ) { + self.id = id + self.name = name + self.entityId = entityId + self.type = type + self.streamType = streamType + self.hlsURL = hlsURL + self.mjpegPath = mjpegPath + self.showActions = showActions + self.unlockEntityId = unlockEntityId + self.autoDismissSeconds = autoDismissSeconds + self.alertSound = alertSound + self.alertVolume = alertVolume + self.customActions = customActions + } +} + +// MARK: - Camera Overlay Manager + +@MainActor +public final class CameraOverlayManager: ObservableObject { + public static let shared = CameraOverlayManager() + + @Published public private(set) var isVisible: Bool = false + @Published public private(set) var currentStream: CameraStream? + + public var onExpandToFullScreen: ((CameraStream) -> Void)? + public var onDismiss: (() -> Void)? + + private var autoDismissTask: Task? + + private init() {} + + public func show(stream: CameraStream) { + currentStream = stream + isVisible = true + + // Play alert sound if configured + playAlertSound(stream: stream) + + // Auto-dismiss if configured + if let seconds = stream.autoDismissSeconds { + autoDismissTask?.cancel() + autoDismissTask = Task { [weak self] in + try? await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) + guard !Task.isCancelled else { return } + self?.dismiss() + } + } + + Current.Log.info("Showing camera overlay: \(stream.name)") + } + + private func playAlertSound(stream: CameraStream) { + guard let sound = stream.alertSound, sound != .none else { return } + guard let soundID = sound.systemSoundID else { return } + + // Set volume if specified + if let volume = stream.alertVolume { + // Note: System sounds respect device volume, can't set directly + // but we log it for debugging + Current.Log.verbose("Playing alert sound \(sound.rawValue) (volume hint: \(volume))") + } + + AudioServicesPlaySystemSound(SystemSoundID(soundID)) + Current.Log.info("Playing alert sound: \(sound.displayName)") + } + + public func dismiss() { + autoDismissTask?.cancel() + autoDismissTask = nil + isVisible = false + currentStream = nil + onDismiss?() + + Current.Log.info("Camera overlay dismissed") + } + + public func expandToFullScreen() { + guard let stream = currentStream else { return } + dismiss() + onExpandToFullScreen?(stream) + } + + public func answerDoorbell() { + guard let stream = currentStream else { return } + Current.Log.info("Answer doorbell: \(stream.name)") + + // Start two-way audio session + // This requires a configured media player entity for audio output + // and microphone access for input + Task { + await startTwoWayAudio(for: stream) + } + } + + private func startTwoWayAudio(for stream: CameraStream) async { + guard let server = Current.servers.all.first, + let api = Current.api(for: server) else { + Current.Log.error("No HA connection for two-way audio") + return + } + + // If stream has an associated media player for audio output, use it + // Otherwise, attempt to use the camera's built-in speaker if supported + let targetEntity = stream.unlockEntityId?.replacingOccurrences(of: "lock.", with: "media_player.") + ?? stream.entityId.replacingOccurrences(of: "camera.", with: "media_player.") + + // For cameras that support two-way audio (like some Nest/Ring cameras), + // we would need WebRTC. For now, we provide TTS feedback through a media player. + // Full WebRTC two-way audio would require additional native implementation. + + Current.Log.info("Two-way audio initiated for: \(stream.name)") + + // Notify that doorbell was answered (for automations) + _ = try? await api.connection.send(.init( + type: "fire_event", + data: [ + "event_type": "haframe_doorbell_answered", + "event_data": [ + "camera_entity_id": stream.entityId, + "stream_name": stream.name, + ], + ] + )).promise.value + + // Play a notification sound/message if media player is available + _ = try? await api.connection.send(.init( + type: "call_service", + data: [ + "domain": "tts", + "service": "speak", + "target": ["entity_id": targetEntity], + "service_data": [ + "message": "Someone is at the door and viewing the camera.", + ], + ] + )).promise.value + } + + public func unlock() { + guard let stream = currentStream, + let unlockEntityId = stream.unlockEntityId else { return } + + Current.Log.info("Unlock: \(unlockEntityId)") + + // Call HA service to unlock + Task { + guard let server = Current.servers.all.first, + let api = Current.api(for: server) else { return } + + let domain = unlockEntityId.split(separator: ".").first ?? "lock" + _ = try? await api.connection.send(.init( + type: "call_service", + data: [ + "domain": String(domain), + "service": "unlock", + "target": ["entity_id": unlockEntityId], + ] + )).promise.value + } + } + + /// Execute a custom action defined in the camera popup + public func executeAction(_ action: CameraStream.CameraAction) { + Current.Log.info("Executing custom action: \(action.label) -> \(action.service)") + + Task { + await callService(action: action) + } + } + + private func callService(action: CameraStream.CameraAction) async { + guard let server = Current.servers.all.first, + let api = Current.api(for: server) else { + Current.Log.error("No HA connection for custom action") + return + } + + // Parse domain and service from "domain.service" format + let parts = action.service.split(separator: ".") + guard parts.count == 2 else { + Current.Log.error("Invalid service format: \(action.service). Expected 'domain.service'") + return + } + + let domain = String(parts[0]) + let service = String(parts[1]) + + var data: [String: Any] = [ + "domain": domain, + "service": service, + ] + + // Add target if specified + if let target = action.target { + data["target"] = target + } + + // Add service data if specified + if let serviceData = action.serviceData { + data["service_data"] = serviceData + } + + do { + _ = try await api.connection.send(.init( + type: "call_service", + data: data + )).promise.value + Current.Log.info("Custom action completed: \(action.label)") + } catch { + Current.Log.error("Custom action failed: \(error.localizedDescription)") + } + } + + public func takeSnapshot() { + guard let stream = currentStream else { return } + Current.Log.info("Take snapshot: \(stream.name)") + + Task { + await captureSnapshot(for: stream) + } + } + + private func captureSnapshot(for stream: CameraStream) async { + guard let server = Current.servers.all.first, + let api = Current.api(for: server) else { + Current.Log.error("No HA connection for snapshot") + return + } + + // Generate unique filename with timestamp + let timestamp = Int(Date().timeIntervalSince1970) + let filename = "/config/www/snapshots/\(stream.id)_\(timestamp).jpg" + + do { + _ = try await api.connection.send(.init( + type: "call_service", + data: [ + "domain": "camera", + "service": "snapshot", + "target": ["entity_id": stream.entityId], + "service_data": ["filename": filename], + ] + )).promise.value + + Current.Log.info("Snapshot saved: \(filename)") + + // Fire event for automations + _ = try? await api.connection.send(.init( + type: "fire_event", + data: [ + "event_type": "haframe_snapshot_taken", + "event_data": [ + "camera_entity_id": stream.entityId, + "filename": filename, + ], + ] + )).promise.value + } catch { + Current.Log.error("Failed to take snapshot: \(error.localizedDescription)") + } + } +} + +// MARK: - Camera Overlay Passthrough View + +/// Custom UIView that only intercepts touches when the camera overlay is visible +public final class CameraOverlayPassthroughView: UIView { + public override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + // Only intercept touches when the overlay is visible + guard CameraOverlayManager.shared.isVisible else { + return nil + } + return super.hitTest(point, with: event) + } +} + +/// UIViewController that hosts CameraOverlayView with proper touch passthrough +public final class CameraOverlayViewController: UIViewController { + private var hostingController: UIHostingController? + + public override func loadView() { + view = CameraOverlayPassthroughView() + view.backgroundColor = .clear + } + + public override func viewDidLoad() { + super.viewDidLoad() + + let overlayView = CameraOverlayView() + let hosting = UIHostingController(rootView: overlayView) + hosting.view.backgroundColor = .clear + + addChild(hosting) + view.addSubview(hosting.view) + hosting.view.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + hosting.view.topAnchor.constraint(equalTo: view.topAnchor), + hosting.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), + hosting.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + hosting.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), + ]) + + hosting.didMove(toParent: self) + hostingController = hosting + } +} + +// MARK: - Preview + +#Preview { + ZStack { + Color.gray.edgesIgnoringSafeArea(.all) + + CameraOverlayView() + .onAppear { + CameraOverlayManager.shared.show(stream: CameraStream( + name: "Front Door", + entityId: "camera.front_door", + type: .doorbell, + streamType: .mjpeg, + mjpegPath: "/api/camera_proxy_stream/camera.front_door", + showActions: true, + unlockEntityId: "lock.front_door", + autoDismissSeconds: 30 + )) + } + } +} diff --git a/Sources/App/Kiosk/Camera/CameraStreamViewController.swift b/Sources/App/Kiosk/Camera/CameraStreamViewController.swift new file mode 100755 index 0000000000..71a02f169d --- /dev/null +++ b/Sources/App/Kiosk/Camera/CameraStreamViewController.swift @@ -0,0 +1,515 @@ +import AVKit +import Shared +import SwiftUI +import UIKit +import WebKit + +// MARK: - Camera Stream View Controller + +/// Full-screen camera view with auto-dismiss capability +@MainActor +public final class CameraStreamViewController: UIViewController { + // MARK: - Properties + + private let stream: CameraStream + private let autoDismissInterval: TimeInterval? + + private var player: AVPlayer? + private var playerLayer: AVPlayerLayer? + private var webView: WKWebView? + private var autoDismissTimer: Timer? + private var countdownLabel: UILabel? + private var remainingSeconds: Int = 0 + + public var onDismiss: (() -> Void)? + + // MARK: - Initialization + + public init(stream: CameraStream, autoDismiss: TimeInterval? = nil) { + self.stream = stream + self.autoDismissInterval = autoDismiss ?? stream.autoDismissSeconds + super.init(nibName: nil, bundle: nil) + modalPresentationStyle = .fullScreen + modalTransitionStyle = .crossDissolve + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Lifecycle + + override public func viewDidLoad() { + super.viewDidLoad() + setupUI() + setupStream() + setupAutoDismiss() + } + + override public func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + playerLayer?.frame = view.bounds + webView?.frame = view.bounds + } + + override public func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + cleanup() + } + + override public var prefersStatusBarHidden: Bool { + true + } + + override public var prefersHomeIndicatorAutoHidden: Bool { + true + } + + // MARK: - Setup + + private func setupUI() { + view.backgroundColor = .black + + // Header overlay + let headerView = createHeaderView() + view.addSubview(headerView) + headerView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + headerView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + headerView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + headerView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + headerView.heightAnchor.constraint(equalToConstant: 60), + ]) + + // Action buttons (for doorbell) + if stream.type == .doorbell { + let actionsView = createActionsView() + view.addSubview(actionsView) + actionsView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + actionsView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -20), + actionsView.centerXAnchor.constraint(equalTo: view.centerXAnchor), + ]) + } + + // Tap to dismiss gesture + let tapGesture = UITapGestureRecognizer(target: self, action: #selector(handleTap)) + tapGesture.numberOfTapsRequired = 1 + view.addGestureRecognizer(tapGesture) + } + + private func createHeaderView() -> UIView { + let container = UIVisualEffectView(effect: UIBlurEffect(style: .dark)) + + let stack = UIStackView() + stack.axis = .horizontal + stack.spacing = 12 + stack.alignment = .center + + // Camera icon + let iconView = UIImageView(image: UIImage(systemName: stream.type == .doorbell ? "video.doorbell.fill" : "video.fill")) + iconView.tintColor = .white + iconView.contentMode = .scaleAspectFit + iconView.widthAnchor.constraint(equalToConstant: 24).isActive = true + + // Camera name + let nameLabel = UILabel() + nameLabel.text = stream.name + nameLabel.textColor = .white + nameLabel.font = .systemFont(ofSize: 18, weight: .semibold) + + // Countdown label + let countdown = UILabel() + countdown.textColor = .white.withAlphaComponent(0.7) + countdown.font = .monospacedDigitSystemFont(ofSize: 14, weight: .regular) + countdownLabel = countdown + + // Close button + let closeButton = UIButton(type: .system) + closeButton.setImage(UIImage(systemName: "xmark.circle.fill"), for: .normal) + closeButton.tintColor = .white + closeButton.addTarget(self, action: #selector(dismissCamera), for: .touchUpInside) + + stack.addArrangedSubview(iconView) + stack.addArrangedSubview(nameLabel) + stack.addArrangedSubview(UIView()) // Spacer + stack.addArrangedSubview(countdown) + stack.addArrangedSubview(closeButton) + + container.contentView.addSubview(stack) + stack.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + stack.leadingAnchor.constraint(equalTo: container.contentView.leadingAnchor, constant: 16), + stack.trailingAnchor.constraint(equalTo: container.contentView.trailingAnchor, constant: -16), + stack.topAnchor.constraint(equalTo: container.contentView.topAnchor), + stack.bottomAnchor.constraint(equalTo: container.contentView.bottomAnchor), + ]) + + return container + } + + private func createActionsView() -> UIView { + let stack = UIStackView() + stack.axis = .horizontal + stack.spacing = 20 + stack.alignment = .center + + // Talk button + let talkButton = createActionButton( + icon: "mic.fill", + title: "Talk", + color: .systemGreen, + action: #selector(handleTalk) + ) + stack.addArrangedSubview(talkButton) + + // Unlock button (if available) + if stream.unlockEntityId != nil { + let unlockButton = createActionButton( + icon: "lock.open.fill", + title: "Unlock", + color: .systemBlue, + action: #selector(handleUnlock) + ) + stack.addArrangedSubview(unlockButton) + } + + // Snapshot button + let snapshotButton = createActionButton( + icon: "camera.fill", + title: "Snapshot", + color: .systemGray, + action: #selector(handleSnapshot) + ) + stack.addArrangedSubview(snapshotButton) + + return stack + } + + private func createActionButton(icon: String, title: String, color: UIColor, action: Selector) -> UIButton { + var config = UIButton.Configuration.filled() + config.image = UIImage(systemName: icon) + config.title = title + config.imagePadding = 8 + config.baseBackgroundColor = color + config.baseForegroundColor = .white + config.cornerStyle = .capsule + config.contentInsets = NSDirectionalEdgeInsets(top: 12, leading: 20, bottom: 12, trailing: 20) + + let button = UIButton(configuration: config) + button.addTarget(self, action: action, for: .touchUpInside) + return button + } + + private func setupStream() { + switch stream.streamType { + case .hls: + setupHLSPlayer() + case .mjpeg: + setupMJPEGWebView() + } + } + + private func setupHLSPlayer() { + guard let url = stream.hlsURL else { + Current.Log.warning("No HLS URL for stream: \(stream.name)") + return + } + + player = AVPlayer(url: url) + playerLayer = AVPlayerLayer(player: player) + playerLayer?.frame = view.bounds + playerLayer?.videoGravity = .resizeAspectFill + + if let layer = playerLayer { + view.layer.insertSublayer(layer, at: 0) + } + + player?.play() + Current.Log.info("Started HLS stream: \(url)") + } + + private func setupMJPEGWebView() { + guard let mjpegPath = stream.mjpegPath, + let server = Current.servers.all.first, + let api = Current.api(for: server), + let url = api.server.info.connection.activeURL()?.appendingPathComponent(mjpegPath) else { + Current.Log.warning("No MJPEG URL for stream: \(stream.name)") + return + } + + let config = WKWebViewConfiguration() + config.allowsInlineMediaPlayback = true + + webView = WKWebView(frame: view.bounds, configuration: config) + webView?.backgroundColor = .black + webView?.isOpaque = false + webView?.scrollView.isScrollEnabled = false + + if let web = webView { + view.insertSubview(web, at: 0) + } + + // Create HTML that displays the MJPEG stream centered + let html = """ + + + + + + + + + + + """ + + webView?.loadHTMLString(html, baseURL: nil) + Current.Log.info("Started MJPEG stream: \(url)") + } + + private func setupAutoDismiss() { + guard let interval = autoDismissInterval, interval > 0 else { return } + + remainingSeconds = Int(interval) + updateCountdown() + + autoDismissTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in + guard let self else { return } + + self.remainingSeconds -= 1 + self.updateCountdown() + + if self.remainingSeconds <= 0 { + self.dismissCamera() + } + } + } + + private func updateCountdown() { + let minutes = remainingSeconds / 60 + let seconds = remainingSeconds % 60 + countdownLabel?.text = String(format: "%d:%02d", minutes, seconds) + } + + private func cleanup() { + autoDismissTimer?.invalidate() + autoDismissTimer = nil + player?.pause() + player = nil + playerLayer?.removeFromSuperlayer() + playerLayer = nil + webView?.stopLoading() + webView?.removeFromSuperview() + webView = nil + } + + // MARK: - Actions + + @objc private func handleTap(_ gesture: UITapGestureRecognizer) { + // Reset auto-dismiss timer on tap + if let interval = autoDismissInterval, interval > 0 { + remainingSeconds = Int(interval) + updateCountdown() + } + } + + @objc private func dismissCamera() { + cleanup() + dismiss(animated: true) { [weak self] in + self?.onDismiss?() + } + } + + @objc private func handleTalk() { + Current.Log.info("Talk button pressed for: \(stream.name)") + + Task { + await startTwoWayAudio() + } + } + + private func startTwoWayAudio() async { + guard let server = Current.servers.all.first, + let api = Current.api(for: server) else { + Current.Log.error("No HA connection for two-way audio") + await MainActor.run { showFeedback("No connection") } + return + } + + // Determine target media player for audio output + // Convention: media_player with same name as camera (e.g., camera.front_door -> media_player.front_door) + let targetEntity = stream.unlockEntityId?.replacingOccurrences(of: "lock.", with: "media_player.") + ?? stream.entityId.replacingOccurrences(of: "camera.", with: "media_player.") + + Current.Log.info("Two-way audio initiated for: \(stream.name)") + + // Fire event so automations can handle the talk request + _ = try? await api.connection.send(.init( + type: "fire_event", + data: [ + "event_type": "haframe_doorbell_answered", + "event_data": [ + "camera_entity_id": stream.entityId, + "stream_name": stream.name, + ], + ] + )).promise.value + + // Attempt TTS announcement through associated media player + _ = try? await api.connection.send(.init( + type: "call_service", + data: [ + "domain": "tts", + "service": "speak", + "target": ["entity_id": targetEntity], + "service_data": [ + "message": "Someone is at the door and viewing the camera.", + ], + ] + )).promise.value + + await MainActor.run { + showFeedback("Talk initiated") + } + } + + @objc private func handleUnlock() { + guard let entityId = stream.unlockEntityId else { return } + + Current.Log.info("Unlock requested: \(entityId)") + + Task { + guard let server = Current.servers.all.first, + let api = Current.api(for: server) else { return } + + let domain = entityId.split(separator: ".").first ?? "lock" + _ = try? await api.connection.send(.init( + type: "call_service", + data: [ + "domain": String(domain), + "service": "unlock", + "target": ["entity_id": entityId], + ] + )).promise.value + + // Show brief feedback + await MainActor.run { + showFeedback("Unlocked") + } + } + } + + @objc private func handleSnapshot() { + Current.Log.info("Snapshot requested for: \(stream.entityId)") + + Task { + guard let server = Current.servers.all.first, + let api = Current.api(for: server) else { return } + + _ = try? await api.connection.send(.init( + type: "call_service", + data: [ + "domain": "camera", + "service": "snapshot", + "target": ["entity_id": stream.entityId], + "service_data": ["filename": "/config/www/snapshots/\(stream.id)_\(Date().timeIntervalSince1970).jpg"], + ] + )).promise.value + + await MainActor.run { + showFeedback("Snapshot saved") + } + } + } + + private func showFeedback(_ message: String) { + let label = UILabel() + label.text = message + label.textColor = .white + label.font = .systemFont(ofSize: 16, weight: .medium) + label.backgroundColor = UIColor.black.withAlphaComponent(0.7) + label.textAlignment = .center + label.layer.cornerRadius = 8 + label.clipsToBounds = true + + view.addSubview(label) + label.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + label.centerXAnchor.constraint(equalTo: view.centerXAnchor), + label.centerYAnchor.constraint(equalTo: view.centerYAnchor), + label.widthAnchor.constraint(greaterThanOrEqualToConstant: 150), + label.heightAnchor.constraint(equalToConstant: 44), + ]) + + UIView.animate(withDuration: 0.3, delay: 1.5, options: []) { + label.alpha = 0 + } completion: { _ in + label.removeFromSuperview() + } + } +} + +// MARK: - Camera Takeover Manager + +@MainActor +public final class CameraTakeoverManager { + public static let shared = CameraTakeoverManager() + + private weak var presentingViewController: UIViewController? + private var currentStreamController: CameraStreamViewController? + + public var onDismiss: (() -> Void)? + + private init() {} + + /// Show full-screen camera with optional auto-dismiss + public func showCamera( + stream: CameraStream, + from presenter: UIViewController, + autoDismiss: TimeInterval? = nil + ) { + // Dismiss any existing stream + dismissCamera() + + let controller = CameraStreamViewController(stream: stream, autoDismiss: autoDismiss) + controller.onDismiss = { [weak self] in + self?.currentStreamController = nil + self?.onDismiss?() + } + + presentingViewController = presenter + currentStreamController = controller + + presenter.present(controller, animated: true) + Current.Log.info("Showing full-screen camera: \(stream.name)") + } + + /// Dismiss current camera view + public func dismissCamera() { + currentStreamController?.dismiss(animated: true) + currentStreamController = nil + } + + /// Check if camera is currently showing + public var isShowingCamera: Bool { + currentStreamController != nil + } +} diff --git a/Sources/App/Kiosk/Camera/PresenceDetector.swift b/Sources/App/Kiosk/Camera/PresenceDetector.swift new file mode 100644 index 0000000000..5e322f662f --- /dev/null +++ b/Sources/App/Kiosk/Camera/PresenceDetector.swift @@ -0,0 +1,345 @@ +import AVFoundation +import Combine +import Shared +import UIKit +import Vision + +// MARK: - Presence Detector + +/// Detects human presence and faces using Apple's Vision framework +@MainActor +public final class PresenceDetector: NSObject, ObservableObject { + // MARK: - Singleton + + public static let shared = PresenceDetector() + + // MARK: - Published State + + /// Whether presence detection is currently active + @Published public private(set) var isActive: Bool = false + + /// Whether a person is currently detected + @Published public private(set) var personDetected: Bool = false + + /// Whether a face is currently detected + @Published public private(set) var faceDetected: Bool = false + + /// Number of faces detected + @Published public private(set) var faceCount: Int = 0 + + /// Last detection timestamp + @Published public private(set) var lastDetectionTime: Date? + + /// Camera authorization status + @Published public private(set) var authorizationStatus: AVAuthorizationStatus = .notDetermined + + /// Error message if detection failed + @Published public private(set) var errorMessage: String? + + // MARK: - Callbacks + + /// Called when presence state changes + public var onPresenceChanged: ((Bool) -> Void)? + + /// Called when face detection state changes + public var onFaceDetectionChanged: ((Bool, Int) -> Void)? + + // MARK: - Private + + private var settings: KioskSettings { KioskModeManager.shared.settings } + private var captureSession: AVCaptureSession? + private var videoOutput: AVCaptureVideoDataOutput? + private let processingQueue = DispatchQueue(label: "com.haframe.presence", qos: .userInitiated) + + // Vision requests + private var personDetectionRequest: VNDetectHumanRectanglesRequest? + private var faceDetectionRequest: VNDetectFaceRectanglesRequest? + + // State tracking + private var consecutiveDetections: Int = 0 + private var consecutiveMisses: Int = 0 + private let detectionThreshold: Int = 2 // Frames needed to confirm detection + private let missThreshold: Int = 5 // Frames needed to confirm absence + + private var presenceTimeout: Timer? + private let presenceTimeoutInterval: TimeInterval = 10 // Seconds before marking as absent + + // MARK: - Initialization + + private override init() { + super.init() + checkAuthorizationStatus() + setupVisionRequests() + } + + deinit { + captureSession?.stopRunning() + captureSession = nil + presenceTimeout?.invalidate() + presenceTimeout = nil + } + + // MARK: - Public Methods + + /// Start presence detection + public func start() { + guard !isActive else { return } + + // Re-check authorization status before starting + checkAuthorizationStatus() + + guard authorizationStatus == .authorized else { + Current.Log.warning("Camera not authorized for presence detection (status: \(authorizationStatus.rawValue))") + return + } + + Current.Log.info("Starting presence detection") + + setupCaptureSession() + + processingQueue.async { [weak self] in + self?.captureSession?.startRunning() + DispatchQueue.main.async { + self?.isActive = true + self?.errorMessage = nil + } + } + } + + /// Stop presence detection + public func stop() { + guard isActive else { return } + + Current.Log.info("Stopping presence detection") + + processingQueue.async { [weak self] in + self?.captureSession?.stopRunning() + DispatchQueue.main.async { + self?.isActive = false + self?.personDetected = false + self?.faceDetected = false + self?.faceCount = 0 + } + } + + presenceTimeout?.invalidate() + presenceTimeout = nil + } + + /// Request camera authorization + public func requestAuthorization() async -> Bool { + let status = await AVCaptureDevice.requestAccess(for: .video) + authorizationStatus = AVCaptureDevice.authorizationStatus(for: .video) + return status + } + + // MARK: - Private Methods + + private func checkAuthorizationStatus() { + authorizationStatus = AVCaptureDevice.authorizationStatus(for: .video) + } + + private func setupVisionRequests() { + // Person detection request + personDetectionRequest = VNDetectHumanRectanglesRequest { [weak self] request, error in + if let error { + Current.Log.error("Person detection error: \(error)") + return + } + self?.handlePersonDetectionResults(request.results as? [VNHumanObservation]) + } + personDetectionRequest?.upperBodyOnly = true // More efficient, good for wall-mounted + + // Face detection request + faceDetectionRequest = VNDetectFaceRectanglesRequest { [weak self] request, error in + if let error { + Current.Log.error("Face detection error: \(error)") + return + } + self?.handleFaceDetectionResults(request.results as? [VNFaceObservation]) + } + } + + private func setupCaptureSession() { + let session = AVCaptureSession() + session.sessionPreset = .medium // Balance quality and performance + + // Get front camera + guard let camera = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .front) else { + errorMessage = "Front camera not available" + Current.Log.error("Front camera not available for presence detection") + return + } + + do { + let input = try AVCaptureDeviceInput(device: camera) + if session.canAddInput(input) { + session.addInput(input) + } + + // Configure frame rate based on whether we need face detection + try camera.lockForConfiguration() + if settings.cameraFaceDetectionEnabled { + camera.activeVideoMinFrameDuration = CMTime(value: 1, timescale: 10) // 10 fps for face + } else { + camera.activeVideoMinFrameDuration = CMTime(value: 1, timescale: 3) // 3 fps for person only + } + camera.unlockForConfiguration() + } catch { + errorMessage = "Failed to configure camera: \(error.localizedDescription)" + Current.Log.error("Camera configuration error: \(error)") + return + } + + // Setup video output + let output = AVCaptureVideoDataOutput() + output.videoSettings = [ + kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA, + ] + output.alwaysDiscardsLateVideoFrames = true + output.setSampleBufferDelegate(self, queue: processingQueue) + + if session.canAddOutput(output) { + session.addOutput(output) + } + + captureSession = session + videoOutput = output + } + + private func processFrame(_ pixelBuffer: CVPixelBuffer) { + let handler = VNImageRequestHandler(cvPixelBuffer: pixelBuffer, orientation: .up, options: [:]) + + do { + var requests: [VNRequest] = [] + + // Always run person detection if enabled + if settings.cameraPresenceEnabled, let personRequest = personDetectionRequest { + requests.append(personRequest) + } + + // Run face detection if enabled + if settings.cameraFaceDetectionEnabled, let faceRequest = faceDetectionRequest { + requests.append(faceRequest) + } + + if !requests.isEmpty { + try handler.perform(requests) + } + } catch { + Current.Log.error("Vision request error: \(error)") + } + } + + private func handlePersonDetectionResults(_ results: [VNHumanObservation]?) { + let detected = !(results?.isEmpty ?? true) + + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.updatePresenceState(detected: detected) + } + } + + private func handleFaceDetectionResults(_ results: [VNFaceObservation]?) { + let faces = results ?? [] + let detected = !faces.isEmpty + + DispatchQueue.main.async { [weak self] in + guard let self else { return } + + let previouslyDetected = self.faceDetected + let previousCount = self.faceCount + + self.faceCount = faces.count + self.faceDetected = detected + + if detected != previouslyDetected || faces.count != previousCount { + self.onFaceDetectionChanged?(detected, faces.count) + + if detected { + self.lastDetectionTime = Date() + Current.Log.info("Face detected (count: \(faces.count))") + } + } + } + } + + private func updatePresenceState(detected: Bool) { + if detected { + consecutiveDetections += 1 + consecutiveMisses = 0 + + // Reset timeout + presenceTimeout?.invalidate() + presenceTimeout = Timer.scheduledTimer( + withTimeInterval: presenceTimeoutInterval, + repeats: false + ) { [weak self] _ in + Task { @MainActor [weak self] in + self?.handlePresenceTimeout() + } + } + + if consecutiveDetections >= detectionThreshold && !personDetected { + personDetected = true + lastDetectionTime = Date() + onPresenceChanged?(true) + Current.Log.info("Person presence detected") + } + } else { + consecutiveMisses += 1 + consecutiveDetections = 0 + + if consecutiveMisses >= missThreshold && personDetected { + // Don't immediately mark as absent - wait for timeout + // This prevents flickering when person moves slightly + } + } + } + + private func handlePresenceTimeout() { + if personDetected { + personDetected = false + faceDetected = false + faceCount = 0 + onPresenceChanged?(false) + Current.Log.info("Person presence timeout - marking as absent") + } + } +} + +// MARK: - AVCaptureVideoDataOutputSampleBufferDelegate + +extension PresenceDetector: AVCaptureVideoDataOutputSampleBufferDelegate { + nonisolated public func captureOutput( + _ output: AVCaptureOutput, + didOutput sampleBuffer: CMSampleBuffer, + from connection: AVCaptureConnection + ) { + guard let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { return } + + Task { @MainActor in + processFrame(pixelBuffer) + } + } +} + +// MARK: - Privacy Helpers + +extension PresenceDetector { + /// Check if detection is allowed based on privacy settings + public var isDetectionAllowed: Bool { + settings.cameraPresenceEnabled || settings.cameraFaceDetectionEnabled + } + + /// Get privacy-safe description of current state + public var privacySafeStatus: String { + if !isActive { + return "Inactive" + } else if personDetected { + return faceDetected ? "Face detected" : "Person detected" + } else { + return "Monitoring" + } + } +} diff --git a/Sources/App/Kiosk/Commands/KioskCommandHandlers.swift b/Sources/App/Kiosk/Commands/KioskCommandHandlers.swift new file mode 100644 index 0000000000..8244dcfa73 --- /dev/null +++ b/Sources/App/Kiosk/Commands/KioskCommandHandlers.swift @@ -0,0 +1,421 @@ +import Foundation +import PromiseKit +import Shared + +// MARK: - Kiosk Notification Command Handlers + +/// Handles `command_screen_on` - Wake the screen and exit screensaver +struct HandlerScreenOn: NotificationCommandHandler { + func handle(_ payload: [String: Any]) -> Promise { + Current.Log.info("Received command_screen_on") + + DispatchQueue.main.async { + KioskModeManager.shared.wakeScreen(source: "command") + } + + return .value(()) + } +} + +/// Handles `command_screen_off` - Start screensaver or blank screen +struct HandlerScreenOff: NotificationCommandHandler { + func handle(_ payload: [String: Any]) -> Promise { + Current.Log.info("Received command_screen_off") + + let modeString = payload["mode"] as? String + let mode = modeString.flatMap { ScreensaverMode(rawValue: $0) } + + DispatchQueue.main.async { + KioskModeManager.shared.sleepScreen(mode: mode) + } + + return .value(()) + } +} + +/// Handles `command_brightness` - Set screen brightness level +struct HandlerBrightness: NotificationCommandHandler { + func handle(_ payload: [String: Any]) -> Promise { + guard let level = payload["level"] as? Int else { + Current.Log.warning("command_brightness missing 'level' parameter") + return .value(()) + } + + Current.Log.info("Received command_brightness: \(level)") + + DispatchQueue.main.async { + KioskModeManager.shared.setBrightness(level) + } + + return .value(()) + } +} + +/// Handles `command_navigate` - Navigate to a URL or HA path +struct HandlerNavigate: NotificationCommandHandler { + func handle(_ payload: [String: Any]) -> Promise { + // Support both 'path' and 'url' keys + guard let path = (payload["path"] as? String) ?? (payload["url"] as? String) else { + Current.Log.warning("command_navigate missing 'path' or 'url' parameter") + return .value(()) + } + + Current.Log.info("Received command_navigate: \(path)") + + DispatchQueue.main.async { + KioskModeManager.shared.navigate(to: path) + } + + return .value(()) + } +} + +/// Handles `command_refresh` - Reload the current page +struct HandlerRefresh: NotificationCommandHandler { + func handle(_ payload: [String: Any]) -> Promise { + Current.Log.info("Received command_refresh") + + DispatchQueue.main.async { + KioskModeManager.shared.refresh() + } + + return .value(()) + } +} + +/// Handles `command_screensaver` - Control screensaver state +struct HandlerScreensaver: NotificationCommandHandler { + func handle(_ payload: [String: Any]) -> Promise { + let action = payload["action"] as? String ?? "toggle" + let modeString = payload["mode"] as? String + let mode = modeString.flatMap { ScreensaverMode(rawValue: $0) } + + Current.Log.info("Received command_screensaver: action=\(action), mode=\(mode?.rawValue ?? "default")") + + DispatchQueue.main.async { + let manager = KioskModeManager.shared + + switch action { + case "start", "on": + manager.sleepScreen(mode: mode) + case "stop", "off": + manager.wakeScreen(source: "command") + case "toggle": + if manager.screenState == .on { + manager.sleepScreen(mode: mode) + } else { + manager.wakeScreen(source: "command") + } + default: + Current.Log.warning("Unknown screensaver action: \(action)") + } + } + + return .value(()) + } +} + +/// Handles `command_kiosk_mode` - Enable/disable kiosk mode +struct HandlerKioskMode: NotificationCommandHandler { + func handle(_ payload: [String: Any]) -> Promise { + // Parse enabled parameter outside MainActor context + let explicitEnabled: Bool? + if let enabledParam = payload["enabled"] as? Bool { + explicitEnabled = enabledParam + } else if let enabledString = payload["enabled"] as? String { + explicitEnabled = enabledString.lowercased() == "true" || enabledString == "1" + } else { + explicitEnabled = nil // Will toggle + } + + DispatchQueue.main.async { + let enabled = explicitEnabled ?? !KioskModeManager.shared.isKioskModeActive + Current.Log.info("Received command_kiosk_mode: enabled=\(enabled)") + + if enabled { + KioskModeManager.shared.enableKioskMode() + } else { + KioskModeManager.shared.disableKioskMode() + } + } + + return .value(()) + } +} + +/// Handles `command_volume` - Set device volume +struct HandlerVolume: NotificationCommandHandler { + func handle(_ payload: [String: Any]) -> Promise { + guard let level = payload["level"] as? Int else { + Current.Log.warning("command_volume missing 'level' parameter") + return .value(()) + } + + Current.Log.info("Received command_volume: \(level)") + + // Convert 0-100 level to 0.0-1.0 range for AudioManager + let normalizedLevel = Float(max(0, min(100, level))) / 100.0 + + DispatchQueue.main.async { + AudioManager.shared.setVolume(normalizedLevel) + } + + return .value(()) + } +} + +/// Handles `command_tts` - Text-to-speech announcement +struct HandlerTTS: NotificationCommandHandler { + func handle(_ payload: [String: Any]) -> Promise { + guard let message = payload["message"] as? String else { + Current.Log.warning("command_tts missing 'message' parameter") + return .value(()) + } + + let volume = payload["volume"] as? Float + + Current.Log.info("Received command_tts: \(message)") + + DispatchQueue.main.async { + if let vol = volume { + AudioManager.shared.setVolume(vol) + } + AudioManager.shared.speak(message, priority: .high) + } + + return .value(()) + } +} + +/// Handles `command_launch_app` - Launch an external app +struct HandlerLaunchApp: NotificationCommandHandler { + func handle(_ payload: [String: Any]) -> Promise { + guard let scheme = payload["scheme"] as? String else { + Current.Log.warning("command_launch_app missing 'scheme' parameter") + return .value(()) + } + + let name = payload["name"] as? String + + Current.Log.info("Received command_launch_app: \(scheme)") + + DispatchQueue.main.async { + KioskModeManager.shared.launchApp(scheme: scheme, name: name) + } + + return .value(()) + } +} + +/// Handles `command_return` - Bring HAFrame back to foreground +struct HandlerReturn: NotificationCommandHandler { + func handle(_ payload: [String: Any]) -> Promise { + Current.Log.info("Received command_return") + + // Send a local notification that will bring the user back + let content = UNMutableNotificationContent() + content.title = "HAFrame" + content.body = "Tap to return to your dashboard" + content.sound = .default + + let request = UNNotificationRequest( + identifier: "haframe_return", + content: content, + trigger: nil + ) + + UNUserNotificationCenter.current().add(request) + + return .value(()) + } +} + +/// Handles `command_show_camera` - Show camera overlay/popup +struct HandlerShowCamera: NotificationCommandHandler { + func handle(_ payload: [String: Any]) -> Promise { + guard let entityId = payload["entity_id"] as? String else { + Current.Log.warning("command_show_camera missing 'entity_id' parameter") + return .value(()) + } + + let name = payload["name"] as? String ?? entityId.replacingOccurrences(of: "camera.", with: "").replacingOccurrences(of: "_", with: " ").capitalized + let typeString = payload["type"] as? String ?? "generic" + let streamTypeString = payload["stream_type"] as? String ?? "mjpeg" + let unlockEntityId = payload["unlock_entity_id"] as? String + let autoDismiss: TimeInterval? = (payload["auto_dismiss"] as? TimeInterval) ?? (payload["auto_dismiss"] as? Int).map { TimeInterval($0) } + let showActions = payload["show_actions"] as? Bool ?? (typeString == "doorbell") + + // Alert sound parameters + let soundString = payload["sound"] as? String + let alertSound: CameraStream.AlertSound? = soundString.flatMap { CameraStream.AlertSound(rawValue: $0) } + let alertVolume: Float? = (payload["sound_volume"] as? Double).map { Float($0) } ?? + (payload["sound_volume"] as? Float) + + // Parse custom actions + let customActions: [CameraStream.CameraAction]? = parseCustomActions(from: payload) + + // Determine camera type + let cameraType: CameraStream.CameraType + switch typeString.lowercased() { + case "doorbell": cameraType = .doorbell + case "security": cameraType = .security + default: cameraType = .generic + } + + // Determine stream type preference + let preferHLS = streamTypeString.lowercased() == "hls" + + Current.Log.info("Received command_show_camera: \(entityId)") + + // Wake the screen first so the camera popup is visible + DispatchQueue.main.async { + KioskModeManager.shared.wakeScreen(source: "camera_popup") + } + + // Get proper stream paths from Home Assistant via StreamCamera API + guard let server = Current.servers.all.first, + let api = Current.api(for: server) else { + Current.Log.error("No HA connection for camera stream") + return .value(()) + } + + return firstly { + api.StreamCamera(entityId: entityId) + }.recover { error -> Promise in + // Fall back to hardcoded path if StreamCamera fails (older HA versions) + Current.Log.info("StreamCamera failed, falling back to default path: \(error.localizedDescription)") + return .value(StreamCameraResponse(fallbackEntityID: entityId)) + }.done { response in + // Determine actual stream type based on what's available + let streamType: CameraStream.StreamType + let hlsURL: URL? + let mjpegPath: String? + + if preferHLS, let hlsPath = response.hlsPath, + let url = api.server.info.connection.activeURL()?.appendingPathComponent(hlsPath) { + streamType = .hls + hlsURL = url + mjpegPath = response.mjpegPath + } else if let path = response.mjpegPath { + streamType = .mjpeg + hlsURL = nil + mjpegPath = path + } else { + Current.Log.error("No stream path available for camera: \(entityId)") + return + } + + let stream = CameraStream( + name: name, + entityId: entityId, + type: cameraType, + streamType: streamType, + hlsURL: hlsURL, + mjpegPath: mjpegPath, + showActions: showActions, + unlockEntityId: unlockEntityId, + autoDismissSeconds: autoDismiss, + alertSound: alertSound, + alertVolume: alertVolume, + customActions: customActions + ) + + DispatchQueue.main.async { + CameraOverlayManager.shared.show(stream: stream) + } + } + } + + /// Parse custom actions from payload + private func parseCustomActions(from payload: [String: Any]) -> [CameraStream.CameraAction]? { + guard let actionsArray = payload["actions"] as? [[String: Any]], !actionsArray.isEmpty else { + return nil + } + + return actionsArray.compactMap { actionDict -> CameraStream.CameraAction? in + guard let label = actionDict["label"] as? String, + let service = actionDict["service"] as? String else { + return nil + } + + return CameraStream.CameraAction( + id: actionDict["id"] as? String ?? UUID().uuidString, + label: label, + icon: actionDict["icon"] as? String, + service: service, + target: actionDict["target"] as? [String: Any], + serviceData: actionDict["data"] as? [String: Any], + confirmRequired: actionDict["confirm"] as? Bool ?? false + ) + } + } +} + +/// Handles `command_dismiss_camera` - Dismiss camera overlay +struct HandlerDismissCamera: NotificationCommandHandler { + func handle(_ payload: [String: Any]) -> Promise { + Current.Log.info("Received command_dismiss_camera") + + DispatchQueue.main.async { + CameraOverlayManager.shared.dismiss() + } + + return .value(()) + } +} + +/// Handles `command_play_audio` - Play an audio file +struct HandlerPlayAudio: NotificationCommandHandler { + func handle(_ payload: [String: Any]) -> Promise { + guard let url = payload["url"] as? String else { + Current.Log.warning("command_play_audio missing 'url' parameter") + return .value(()) + } + + let volume = payload["volume"] as? Float + + Current.Log.info("Received command_play_audio: \(url)") + + DispatchQueue.main.async { + AudioManager.shared.playAudio(from: url, volume: volume) + } + + return .value(()) + } +} + +// MARK: - Registration Extension + +extension NotificationCommandManager { + /// Register all HAFrame kiosk command handlers + /// Call this during app initialization + public func registerKioskCommands() { + // Screen control + register(command: "command_screen_on", handler: HandlerScreenOn()) + register(command: "command_screen_off", handler: HandlerScreenOff()) + register(command: "command_brightness", handler: HandlerBrightness()) + register(command: "command_screensaver", handler: HandlerScreensaver()) + + // Navigation + register(command: "command_navigate", handler: HandlerNavigate()) + register(command: "command_refresh", handler: HandlerRefresh()) + + // Kiosk mode + register(command: "command_kiosk_mode", handler: HandlerKioskMode()) + + // Audio + register(command: "command_volume", handler: HandlerVolume()) + register(command: "command_tts", handler: HandlerTTS()) + register(command: "command_play_audio", handler: HandlerPlayAudio()) + + // App launcher + register(command: "command_launch_app", handler: HandlerLaunchApp()) + register(command: "command_return", handler: HandlerReturn()) + + // Camera + register(command: "command_show_camera", handler: HandlerShowCamera()) + register(command: "command_dismiss_camera", handler: HandlerDismissCamera()) + + Current.Log.info("Registered \(14) kiosk notification commands") + } +} diff --git a/Sources/App/Kiosk/Dashboard/DashboardManager.swift b/Sources/App/Kiosk/Dashboard/DashboardManager.swift new file mode 100644 index 0000000000..26be995bc2 --- /dev/null +++ b/Sources/App/Kiosk/Dashboard/DashboardManager.swift @@ -0,0 +1,360 @@ +import Combine +import Foundation +import Shared + +// MARK: - Dashboard Manager + +/// Manages multiple dashboards with rotation, scheduling, and conditional display +@MainActor +public final class DashboardManager: ObservableObject { + // MARK: - Singleton + + public static let shared = DashboardManager() + + // MARK: - Published State + + /// Currently active dashboard + @Published public private(set) var currentDashboard: DashboardConfig? + + /// Index in rotation sequence + @Published public private(set) var currentRotationIndex: Int = 0 + + /// Whether rotation is currently paused (e.g., user touch) + @Published public private(set) var isRotationPaused: Bool = false + + /// Dashboards available for rotation + @Published public private(set) var rotationDashboards: [DashboardConfig] = [] + + // MARK: - Callbacks + + /// Called when dashboard should change + public var onNavigate: ((String) -> Void)? + + // MARK: - Private + + private var settings: KioskSettings { KioskModeManager.shared.settings } + private var rotationTimer: Timer? + private var pauseResumeTimer: Timer? + private var scheduleTimer: Timer? + private var entityObservation: AnyCancellable? + private var lastActivityTime: Date = Date() + + // MARK: - Initialization + + private init() { + // Don't start timers in init - wait for start() + } + + deinit { + rotationTimer?.invalidate() + scheduleTimer?.invalidate() + entityObservation?.cancel() + } + + // MARK: - Public Methods + + /// Start dashboard management + public func start() { + updateRotationDashboards() + + // Check if a schedule entry should override, otherwise navigate to primary + let scheduledDashboard = checkScheduleAndNavigate() + + // If no schedule matched, navigate to the primary dashboard + if !scheduledDashboard { + navigateToPrimary() + } + + startRotationIfEnabled() + setupEntityObservation() + setupScheduleTimer() // Start schedule timer when manager starts + } + + /// Stop dashboard management + public func stop() { + stopRotation() + scheduleTimer?.invalidate() + scheduleTimer = nil + entityObservation?.cancel() + entityObservation = nil + } + + /// Navigate to a specific dashboard + public func navigateTo(_ dashboard: DashboardConfig) { + currentDashboard = dashboard + let url = applyKioskParameter(to: dashboard.url) + onNavigate?(url) + Current.Log.info("Navigated to dashboard: \(dashboard.name)") + } + + /// Navigate to dashboard by ID + public func navigateTo(dashboardId: String) { + guard let dashboard = settings.dashboards.first(where: { $0.id == dashboardId }) else { + Current.Log.warning("Dashboard not found: \(dashboardId)") + return + } + navigateTo(dashboard) + } + + /// Navigate to dashboard by URL + public func navigateTo(url: String) { + if let dashboard = settings.dashboards.first(where: { $0.url == url }) { + navigateTo(dashboard) + } else { + // Create an ad-hoc dashboard config + let adhoc = DashboardConfig(name: "Custom", url: url) + currentDashboard = adhoc + let finalURL = applyKioskParameter(to: url) + onNavigate?(finalURL) + } + } + + /// Move to next dashboard in rotation + public func nextDashboard() { + guard !rotationDashboards.isEmpty else { return } + currentRotationIndex = (currentRotationIndex + 1) % rotationDashboards.count + navigateTo(rotationDashboards[currentRotationIndex]) + } + + /// Move to previous dashboard in rotation + public func previousDashboard() { + guard !rotationDashboards.isEmpty else { return } + currentRotationIndex = (currentRotationIndex - 1 + rotationDashboards.count) % rotationDashboards.count + navigateTo(rotationDashboards[currentRotationIndex]) + } + + /// Called when user interacts with screen + public func userActivity() { + lastActivityTime = Date() + + if settings.pauseRotationOnTouch && settings.rotationEnabled { + pauseRotation() + } + } + + /// Pause rotation temporarily + public func pauseRotation() { + guard !isRotationPaused else { return } + + isRotationPaused = true + rotationTimer?.invalidate() + rotationTimer = nil + + Current.Log.info("Dashboard rotation paused") + + // Schedule resume after idle timeout + pauseResumeTimer?.invalidate() + pauseResumeTimer = Timer.scheduledTimer( + withTimeInterval: settings.resumeRotationAfterIdle, + repeats: false + ) { [weak self] _ in + Task { @MainActor [weak self] in + self?.resumeRotation() + } + } + } + + /// Resume rotation + public func resumeRotation() { + guard isRotationPaused else { return } + + isRotationPaused = false + pauseResumeTimer?.invalidate() + pauseResumeTimer = nil + + if settings.rotationEnabled { + startRotationTimer() + Current.Log.info("Dashboard rotation resumed") + } + } + + /// Reload dashboard configuration + public func reloadConfiguration() { + updateRotationDashboards() + + // If current dashboard was removed, navigate to first available + if let current = currentDashboard, + !settings.dashboards.contains(where: { $0.id == current.id }) { + if let first = rotationDashboards.first { + navigateTo(first) + } + } + } + + // MARK: - Private Methods + + private func updateRotationDashboards() { + rotationDashboards = settings.dashboards.filter { $0.includeInRotation } + + // Reset index if out of bounds + if currentRotationIndex >= rotationDashboards.count { + currentRotationIndex = 0 + } + } + + private func startRotationIfEnabled() { + guard settings.rotationEnabled else { return } + startRotationTimer() + } + + private func startRotationTimer() { + rotationTimer?.invalidate() + + guard settings.rotationInterval > 0 else { return } + + rotationTimer = Timer.scheduledTimer( + withTimeInterval: settings.rotationInterval, + repeats: true + ) { [weak self] _ in + Task { @MainActor [weak self] in + self?.nextDashboard() + } + } + } + + private func stopRotation() { + rotationTimer?.invalidate() + rotationTimer = nil + pauseResumeTimer?.invalidate() + pauseResumeTimer = nil + isRotationPaused = false + } + + // MARK: - Schedule Management + + private func setupScheduleTimer() { + // Check schedule every minute + scheduleTimer = Timer.scheduledTimer( + withTimeInterval: 60, + repeats: true + ) { [weak self] _ in + Task { @MainActor [weak self] in + self?.checkSchedule() + } + } + } + + /// Check schedule and navigate if a matching entry is found + /// - Returns: true if a scheduled dashboard was navigated to, false otherwise + @discardableResult + private func checkScheduleAndNavigate() -> Bool { + let now = Date() + let calendar = Calendar.current + let weekday = calendar.component(.weekday, from: now) + let hour = calendar.component(.hour, from: now) + let minute = calendar.component(.minute, from: now) + + // Find matching schedule entry + for entry in settings.dashboardSchedule { + guard entry.daysOfWeek.contains(weekday) else { continue } + + let currentTime = TimeOfDay(hour: hour, minute: minute) + + if isTimeInRange(currentTime, start: entry.startTime, end: entry.endTime) { + // Found a matching schedule - navigate to that dashboard + if currentDashboard?.id != entry.dashboardId { + navigateTo(dashboardId: entry.dashboardId) + } + return true + } + } + return false + } + + /// Check schedule (for timer-based periodic checks) + private func checkSchedule() { + _ = checkScheduleAndNavigate() + } + + private func isTimeInRange(_ time: TimeOfDay, start: TimeOfDay, end: TimeOfDay) -> Bool { + if start.isBefore(end) { + // Normal range (e.g., 08:00 to 17:00) + return !time.isBefore(start) && time.isBefore(end) + } else { + // Overnight range (e.g., 22:00 to 06:00) + return !time.isBefore(start) || time.isBefore(end) + } + } + + // MARK: - Entity-Based Dashboard Switching + + private func setupEntityObservation() { + // Watch entities that can trigger dashboard changes + let entityProvider = EntityStateProvider.shared + + // Collect all entity IDs that affect dashboard selection + let triggerEntityIds = settings.entityTriggers + .filter { trigger in + if case .navigate = trigger.action { + return true + } + return false + } + .map(\.entityId) + + guard !triggerEntityIds.isEmpty else { return } + + entityProvider.watchEntities(triggerEntityIds) + + entityObservation = entityProvider.$entityStates + .sink { [weak self] states in + self?.evaluateEntityConditions(states) + } + } + + private func evaluateEntityConditions(_ states: [String: EntityState]) { + for trigger in settings.entityTriggers where trigger.enabled { + guard let entityState = states[trigger.entityId] else { continue } + + if entityState.state == trigger.triggerState { + if case let .navigate(url) = trigger.action { + navigateTo(url: url) + } + } + } + } +} + +// MARK: - Dashboard Helpers + +extension DashboardManager { + /// Get the primary dashboard URL + public var primaryDashboardURL: String? { + if !settings.primaryDashboardURL.isEmpty { + return settings.primaryDashboardURL + } + return settings.dashboards.first?.url + } + + /// Navigate to the primary dashboard + public func navigateToPrimary() { + if let url = primaryDashboardURL { + navigateTo(url: url) + } + } + + /// Check if a dashboard exists + public func dashboardExists(id: String) -> Bool { + settings.dashboards.contains { $0.id == id } + } + + /// Get dashboard by ID + public func getDashboard(id: String) -> DashboardConfig? { + settings.dashboards.first { $0.id == id } + } + + /// Apply kiosk parameter to URL if enabled + /// Appends ?kiosk or &kiosk to the URL for the kiosk-mode HACS integration + public func applyKioskParameter(to url: String) -> String { + guard settings.appendHACSKioskParameter else { return url } + + // Don't add if already present + if url.contains("kiosk") { return url } + + if url.contains("?") { + return "\(url)&kiosk" + } else { + return "\(url)?kiosk" + } + } +} diff --git a/Sources/App/Kiosk/KioskConstants.swift b/Sources/App/Kiosk/KioskConstants.swift new file mode 100755 index 0000000000..9b78652350 --- /dev/null +++ b/Sources/App/Kiosk/KioskConstants.swift @@ -0,0 +1,162 @@ +import CoreGraphics +import Foundation + +// MARK: - Kiosk Constants + +/// Centralized constants for the kiosk mode +public enum KioskConstants { + // MARK: - Animation Durations + + public enum Animation { + /// Standard transition animation duration + public static let standard: TimeInterval = 0.3 + /// Quick animation for subtle transitions + public static let quick: TimeInterval = 0.2 + /// Slow animation for screensaver transitions + public static let slow: TimeInterval = 0.5 + /// Pixel shift animation duration + public static let pixelShift: TimeInterval = 1.0 + /// Spring animation response + public static let springResponse: Double = 0.4 + } + + // MARK: - Timing Intervals + + public enum Timing { + /// Motion detection cooldown period + public static let motionCooldown: TimeInterval = 2.0 + /// Recently fired trigger display duration (nanoseconds) + public static let recentlyFiredDuration: UInt64 = 3_000_000_000 + /// Network reconnect delay before refresh + public static let networkReconnectDelay: TimeInterval = 2.0 + /// Panel dismiss delay before app launch + public static let panelDismissDelay: TimeInterval = 0.2 + /// Feedback display duration + public static let feedbackDuration: TimeInterval = 1.5 + /// Schedule check interval + public static let scheduleCheckInterval: TimeInterval = 60 + } + + // MARK: - Motion Detection + + public enum Motion { + /// Low sensitivity threshold + public static let lowThreshold: Float = 0.05 + /// Medium sensitivity threshold + public static let mediumThreshold: Float = 0.02 + /// High sensitivity threshold + public static let highThreshold: Float = 0.008 + /// Frame rate for motion detection (fps) + public static let frameRate: Int32 = 5 + } + + // MARK: - Audio Detection + + public enum Audio { + /// Default loud audio threshold in dB + public static let loudThresholdDB: Float = -20 + /// Default quiet threshold in dB + public static let quietThresholdDB: Float = -50 + /// Sample interval for metering + public static let sampleInterval: TimeInterval = 0.1 + /// Consecutive samples needed to confirm loud audio + public static let loudSampleThreshold: Int = 3 + } + + // MARK: - Battery + + public enum Battery { + /// Critical battery level (20%) + public static let criticalLevel: Float = 0.20 + /// Low battery level (25%) + public static let lowLevel: Float = 0.25 + /// Medium battery level (50%) + public static let mediumLevel: Float = 0.50 + /// High battery level (75%) + public static let highLevel: Float = 0.75 + } + + // MARK: - UI Dimensions + + public enum UI { + /// Edge gesture detection size + public static let edgeGestureSize: CGFloat = 30 + /// Swipe gesture threshold + public static let swipeThreshold: CGFloat = 50 + /// Standard corner radius + public static let cornerRadius: CGFloat = 12 + /// Small corner radius + public static let smallCornerRadius: CGFloat = 8 + /// Standard padding + public static let standardPadding: CGFloat = 16 + /// Small padding + public static let smallPadding: CGFloat = 8 + /// Large clock font size + public static let largeClockFontSize: CGFloat = 120 + /// Minimal clock font size + public static let minimalClockFontSize: CGFloat = 80 + /// Digital clock font size + public static let digitalClockFontSize: CGFloat = 100 + /// Analog clock size + public static let analogClockSize: CGFloat = 300 + /// Header height + public static let headerHeight: CGFloat = 60 + /// Icon size for app shortcuts + public static let appIconSize: CGFloat = 50 + /// Camera pip size + public static let cameraPipWidth: CGFloat = 300 + /// Camera pip height + public static let cameraPipHeight: CGFloat = 225 + } + + // MARK: - Panel Sizes + + public enum Panel { + /// Maximum panel width ratio + public static let maxWidthRatio: CGFloat = 0.9 + /// Maximum panel width absolute + public static let maxWidth: CGFloat = 400 + /// Maximum panel height ratio + public static let maxHeightRatio: CGFloat = 0.6 + /// Maximum panel height absolute + public static let maxHeight: CGFloat = 500 + /// Minimum shortcuts before showing search + public static let searchThreshold: Int = 6 + } + + // MARK: - Shadows + + public enum Shadow { + /// Standard shadow opacity + public static let opacity: Double = 0.2 + /// Standard shadow radius + public static let radius: CGFloat = 4 + /// Panel shadow opacity + public static let panelOpacity: Double = 0.3 + /// Panel shadow radius + public static let panelRadius: CGFloat = 10 + } + + // MARK: - Accessibility + + public enum Accessibility { + /// Connection status label + public static let connectionStatus = "Connection Status" + /// Connected hint + public static let connectedHint = "Connected to Home Assistant" + /// Disconnected hint + public static let disconnectedHint = "Disconnected from Home Assistant" + /// Battery status label + public static let batteryStatus = "Battery Status" + /// Time display label + public static let timeDisplay = "Current Time" + /// Close button label + public static let closeButton = "Close" + /// Search field label + public static let searchField = "Search apps" + /// App shortcut label format + public static func appShortcut(_ name: String) -> String { + "Launch \(name)" + } + } +} diff --git a/Sources/App/Kiosk/KioskModeManager.swift b/Sources/App/Kiosk/KioskModeManager.swift new file mode 100644 index 0000000000..f168e32f51 --- /dev/null +++ b/Sources/App/Kiosk/KioskModeManager.swift @@ -0,0 +1,997 @@ +import AVFoundation +import AVFAudio +import Combine +import Foundation +import HAKit +import PromiseKit +import Shared +import UIKit + +// MARK: - Kiosk Mode Manager + +/// Central singleton managing all kiosk functionality +/// Coordinates screen state, screensaver, brightness, entity triggers, and HA communication +@MainActor +public final class KioskModeManager: ObservableObject { + + // MARK: - Singleton + + public static let shared = KioskModeManager() + + // MARK: - Published State + + /// Current kiosk settings (persisted) + @Published public private(set) var settings: KioskSettings { + didSet { + saveSettings() + settingsDidChange(from: oldValue, to: settings) + } + } + + /// Whether kiosk mode is currently active + @Published public private(set) var isKioskModeActive: Bool = false + + /// Current screen state + @Published public private(set) var screenState: ScreenState = .on + + /// Current screensaver mode (when active) + @Published public private(set) var activeScreensaverMode: ScreensaverMode? + + /// Current brightness level (0.0 - 1.0) + @Published public private(set) var currentBrightness: Float = 0.8 + + /// Current dashboard URL/path + @Published public private(set) var currentDashboard: String = "" + + /// App state (active, away, background) + @Published public private(set) var appState: AppState = .active + + /// Last wake source + @Published public private(set) var lastWakeSource: String = "launch" + + /// Last user activity timestamp + @Published public private(set) var lastActivityTime: Date = Date() + + /// Whether connected to Home Assistant + @Published public private(set) var isConnectedToHA: Bool = false + + /// Device orientation + @Published public private(set) var currentOrientation: OrientationLock = .current + + /// Tamper detected (unexpected orientation change) + @Published public private(set) var tamperDetected: Bool = false + + /// Launched app name (when away) + @Published public private(set) var launchedAppName: String? + + // MARK: - Private Properties + + private var cancellables = Set() + private var idleTimer: Timer? + private var refreshTimer: Timer? + private var brightnessTimer: Timer? + private var pixelShiftTimer: Timer? + + private var entitySubscriptions: [HACancellable] = [] + private var haConnection: HAConnection? + + private var originalBrightness: Float? + + // Audio playback + private var audioPlayer: AVAudioPlayer? + private let speechSynthesizer = AVSpeechSynthesizer() + + // MARK: - Callbacks + + /// Called when kiosk mode wants to navigate to a URL + public var onNavigate: ((String) -> Void)? + + /// Called when kiosk mode wants to refresh the current page + public var onRefresh: (() -> Void)? + + /// Called when screensaver should be shown + public var onShowScreensaver: ((ScreensaverMode) -> Void)? + + /// Called when screensaver should be hidden + public var onHideScreensaver: (() -> Void)? + + /// Called when status overlay visibility changes + public var onStatusOverlayChange: ((Bool) -> Void)? + + /// Called when kiosk mode is enabled/disabled (for UI lockdown) + public var onKioskModeChange: ((Bool) -> Void)? + + // MARK: - Notifications + + public static let settingsDidChangeNotification = Notification.Name("KioskModeSettingsDidChange") + public static let screenStateDidChangeNotification = Notification.Name("KioskModeScreenStateDidChange") + public static let kioskModeDidChangeNotification = Notification.Name("KioskModeDidChange") + + // MARK: - Initialization + + private init() { + self.settings = Self.loadSettings() + setupObservers() + Current.Log.info("KioskModeManager initialized") + } + + deinit { + // Clean up timers + idleTimer?.invalidate() + refreshTimer?.invalidate() + brightnessTimer?.invalidate() + pixelShiftTimer?.invalidate() + + // Clean up cancellables + cancellables.forEach { $0.cancel() } + cancellables.removeAll() + + // Clean up entity subscriptions + entitySubscriptions.forEach { $0.cancel() } + entitySubscriptions.removeAll() + + // Remove notification observers + NotificationCenter.default.removeObserver(self) + } + + // MARK: - Public Methods + + /// Enable kiosk mode + public func enableKioskMode() { + guard !isKioskModeActive else { return } + + Current.Log.info("Enabling kiosk mode") + isKioskModeActive = true + + // Store original brightness to restore later + originalBrightness = Float(UIScreen.main.brightness) + + // Prevent iOS from auto-locking the screen + if settings.preventAutoLock { + UIApplication.shared.isIdleTimerDisabled = true + Current.Log.info("Screen auto-lock disabled") + } + + // Apply settings + applyBrightnessSchedule() + startIdleTimer() + startRefreshTimer() + subscribeToEntityTriggers() + startCameraDetection() + + // Enable HA frontend kiosk mode (requires HA 2026.1+) + setFrontendKioskMode(enabled: true) + + onKioskModeChange?(true) + NotificationCenter.default.post(name: Self.kioskModeDidChangeNotification, object: nil) + + reportSensorUpdate() + } + + /// Disable kiosk mode (requires PIN if configured) + public func disableKioskMode() { + guard isKioskModeActive else { return } + + Current.Log.info("Disabling kiosk mode") + isKioskModeActive = false + + // Restore original brightness + if let original = originalBrightness { + UIScreen.main.brightness = CGFloat(original) + } + + // Re-enable iOS auto-lock + UIApplication.shared.isIdleTimerDisabled = false + Current.Log.info("Screen auto-lock restored") + + // Stop timers + stopIdleTimer() + stopRefreshTimer() + stopBrightnessTimer() + unsubscribeFromEntityTriggers() + stopCameraDetection() + + // Hide screensaver if active + hideScreensaver(source: "kiosk_disabled") + + // Disable HA frontend kiosk mode + setFrontendKioskMode(enabled: false) + + onKioskModeChange?(false) + NotificationCenter.default.post(name: Self.kioskModeDidChangeNotification, object: nil) + + reportSensorUpdate() + } + + /// Update settings + public func updateSettings(_ newSettings: KioskSettings) { + settings = newSettings + } + + /// Update a single setting using a closure + public func updateSettings(_ update: (inout KioskSettings) -> Void) { + var newSettings = settings + update(&newSettings) + settings = newSettings + } + + /// Record user activity (touch, motion, etc.) + public func recordActivity(source: String = "touch") { + lastActivityTime = Date() + lastWakeSource = source + + // Reset idle timer + if isKioskModeActive { + startIdleTimer() + } + + // If screensaver is active and this is a wake trigger, hide it + if screenState != .on && shouldWakeForSource(source) { + wakeScreen(source: source) + } + } + + /// Set current dashboard URL + public func setCurrentDashboard(_ url: String) { + currentDashboard = url + reportSensorUpdate() + } + + /// Set HA connection status + public func setConnectionStatus(_ connected: Bool) { + isConnectedToHA = connected + reportSensorUpdate() + } + + // MARK: - Screen Control + + /// Wake the screen (exit screensaver, restore brightness) + public func wakeScreen(source: String) { + guard screenState != .on else { return } + + Current.Log.info("Waking screen from source: \(source)") + lastWakeSource = source + lastActivityTime = Date() + + hideScreensaver(source: source) + applyBrightnessSchedule() + + screenState = .on + NotificationCenter.default.post(name: Self.screenStateDidChangeNotification, object: nil) + + // Refresh if configured + if settings.refreshOnWake { + onRefresh?() + } + + startIdleTimer() + reportSensorUpdate() + } + + /// Put screen to sleep (start screensaver) + public func sleepScreen(mode: ScreensaverMode? = nil) { + let screensaverMode = mode ?? settings.screensaverMode + + Current.Log.info("Sleeping screen with mode: \(screensaverMode)") + stopIdleTimer() + + showScreensaver(mode: screensaverMode) + reportSensorUpdate() + } + + /// Set brightness level (0-100) + public func setBrightness(_ level: Int) { + let clampedLevel = max(0, min(100, level)) + let brightness = Float(clampedLevel) / 100.0 + + Current.Log.info("Setting brightness to \(clampedLevel)%") + currentBrightness = brightness + UIScreen.main.brightness = CGFloat(brightness) + + reportSensorUpdate() + } + + /// Navigate to a URL/path + public func navigate(to path: String) { + Current.Log.info("Navigating to: \(path)") + currentDashboard = path + + // Apply kiosk parameter if enabled + var finalPath = path + if settings.appendHACSKioskParameter && !path.contains("kiosk") { + if path.contains("?") { + finalPath = "\(path)&kiosk" + } else { + finalPath = "\(path)?kiosk" + } + } + + onNavigate?(finalPath) + recordActivity(source: "navigate") + reportSensorUpdate() + } + + /// Refresh current page + public func refresh() { + Current.Log.info("Refreshing current page") + onRefresh?() + recordActivity(source: "refresh") + } + + /// Launch external app + public func launchApp(scheme: String, name: String? = nil) { + // Create a temporary shortcut if name provided + let shortcut: AppShortcut? = name.map { + AppShortcut(name: $0, urlScheme: scheme) + } + + // Delegate to AppLauncherManager + let success = AppLauncherManager.shared.launchApp(urlScheme: scheme, shortcut: shortcut) + + if success { + // Sync state from AppLauncherManager + syncAppLauncherState() + } + } + + /// Sync app state from AppLauncherManager + private func syncAppLauncherState() { + let launcher = AppLauncherManager.shared + appState = launcher.appState + launchedAppName = launcher.launchedApp?.name + reportSensorUpdate() + } + + /// Called when app returns to foreground + public func appDidBecomeActive() { + // AppLauncherManager handles the return logic + AppLauncherManager.shared.handleReturn() + + // Sync state + syncAppLauncherState() + + // Refresh if needed and was away + if settings.refreshOnWake && appState == .active { + onRefresh?() + } + } + + /// Called when app enters background + public func appDidEnterBackground() { + // Only set background if not launching an app (which sets away) + if appState == .active { + appState = .background + reportSensorUpdate() + } + } + + // MARK: - Screensaver + + private func showScreensaver(mode: ScreensaverMode) { + activeScreensaverMode = mode + + let dimLevel = screensaverDimLevel() + + switch mode { + case .blank: + screenState = .off + UIScreen.main.brightness = 0 + + case .dim: + screenState = .dimmed + UIScreen.main.brightness = CGFloat(dimLevel) + + case .clock, .clockWithEntities, .photos, .photosWithClock, .customURL: + screenState = .screensaver + if dimLevel < currentBrightness { + UIScreen.main.brightness = CGFloat(dimLevel) + } + } + + if settings.pixelShiftEnabled { + startPixelShiftTimer() + } + + onShowScreensaver?(mode) + NotificationCenter.default.post(name: Self.screenStateDidChangeNotification, object: nil) + } + + /// Returns the appropriate screensaver dim level based on schedule settings + private func screensaverDimLevel() -> Float { + if settings.screensaverBrightnessScheduleEnabled { + return isNightTime() ? settings.screensaverNightDimLevel : settings.screensaverDayDimLevel + } + return settings.screensaverDimLevel + } + + private func hideScreensaver(source: String) { + guard activeScreensaverMode != nil else { return } + + activeScreensaverMode = nil + stopPixelShiftTimer() + + onHideScreensaver?() + } + + // MARK: - Brightness Control + + private func applyBrightnessSchedule() { + guard settings.brightnessControlEnabled else { return } + + let brightness: Float + if settings.brightnessScheduleEnabled { + brightness = isNightTime() ? settings.nightBrightness : settings.dayBrightness + } else { + brightness = settings.manualBrightness + } + + currentBrightness = brightness + UIScreen.main.brightness = CGFloat(brightness) + + // Schedule next brightness change if using schedule + if settings.brightnessScheduleEnabled { + scheduleBrightnessChange() + } + } + + private func isNightTime() -> Bool { + let now = Date() + let calendar = Calendar.current + let hour = calendar.component(.hour, from: now) + let minute = calendar.component(.minute, from: now) + let currentTime = TimeOfDay(hour: hour, minute: minute) + + // Night time if current time is after nightStart OR before dayStart + if settings.nightStartTime.isBefore(settings.dayStartTime) { + // Normal case: night is e.g., 22:00 - 07:00 + return !currentTime.isBefore(settings.nightStartTime) || currentTime.isBefore(settings.dayStartTime) + } else { + // Edge case: night is e.g., 07:00 - 22:00 (inverted) + return !currentTime.isBefore(settings.nightStartTime) && currentTime.isBefore(settings.dayStartTime) + } + } + + private func scheduleBrightnessChange() { + stopBrightnessTimer() + + let now = Date() + let calendar = Calendar.current + + // Find next transition time + var nextTransition: Date? + let dayComponents = settings.dayStartTime.asDateComponents + let nightComponents = settings.nightStartTime.asDateComponents + + if let dayTime = calendar.nextDate(after: now, matching: dayComponents, matchingPolicy: .nextTime), + let nightTime = calendar.nextDate(after: now, matching: nightComponents, matchingPolicy: .nextTime) { + nextTransition = dayTime < nightTime ? dayTime : nightTime + } + + guard let transitionTime = nextTransition else { return } + + let interval = transitionTime.timeIntervalSince(now) + brightnessTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: false) { [weak self] _ in + self?.applyBrightnessSchedule() + } + } + + private func stopBrightnessTimer() { + brightnessTimer?.invalidate() + brightnessTimer = nil + } + + // MARK: - Timers + + private func startIdleTimer() { + stopIdleTimer() + + guard settings.screensaverEnabled, settings.screensaverTimeout > 0 else { return } + + idleTimer = Timer.scheduledTimer(withTimeInterval: settings.screensaverTimeout, repeats: false) { [weak self] _ in + guard let self, self.isKioskModeActive else { return } + + // Don't sleep if presence is currently detected (safeguard) + if self.settings.wakeOnCameraPresence && CameraDetectionManager.shared.presenceDetected { + Current.Log.info("Idle timer fired but presence detected - restarting timer") + self.startIdleTimer() + return + } + + self.sleepScreen() + } + } + + private func stopIdleTimer() { + idleTimer?.invalidate() + idleTimer = nil + } + + private func startRefreshTimer() { + stopRefreshTimer() + + guard settings.autoRefreshInterval > 0 else { return } + + refreshTimer = Timer.scheduledTimer(withTimeInterval: settings.autoRefreshInterval, repeats: true) { [weak self] _ in + guard let self, self.isKioskModeActive, self.screenState == .on else { return } + self.refresh() + } + } + + private func stopRefreshTimer() { + refreshTimer?.invalidate() + refreshTimer = nil + } + + // Note: Return reminder timer is now handled by AppLauncherManager + + private func startPixelShiftTimer() { + stopPixelShiftTimer() + + guard settings.pixelShiftEnabled, settings.pixelShiftInterval > 0 else { return } + + pixelShiftTimer = Timer.scheduledTimer( + withTimeInterval: settings.pixelShiftInterval, + repeats: true + ) { [weak self] _ in + // Post notification for screensaver view to handle + NotificationCenter.default.post(name: .kioskPixelShiftTick, object: nil) + } + } + + private func stopPixelShiftTimer() { + pixelShiftTimer?.invalidate() + pixelShiftTimer = nil + } + + // MARK: - Camera Detection + + private func startCameraDetection() { + let cameraManager = CameraDetectionManager.shared + + // Configure callbacks for wake/activity + cameraManager.onMotionDetected = { [weak self] in + self?.recordActivity(source: "camera_motion") + } + + cameraManager.onPresenceChanged = { [weak self] detected in + if detected { + self?.recordActivity(source: "camera_presence") + } + } + + // Start detection (will request permission if needed) + cameraManager.start() + + // If camera features are enabled but not authorized, request authorization + if settings.cameraMotionEnabled || settings.cameraPresenceEnabled || settings.cameraFaceDetectionEnabled { + if cameraManager.authorizationStatus == .notDetermined { + Task { + _ = await cameraManager.requestAuthorization() + } + } + } + } + + private func stopCameraDetection() { + // Clear callbacks to prevent memory leaks and stale references + let cameraManager = CameraDetectionManager.shared + cameraManager.onMotionDetected = nil + cameraManager.onPresenceChanged = nil + cameraManager.stop() + } + + // MARK: - Entity Triggers + + private func subscribeToEntityTriggers() { + unsubscribeFromEntityTriggers() + + // Get the HA connection + guard let server = Current.servers.all.first, + let api = Current.api(for: server) else { + Current.Log.warning("No HA server available for entity subscriptions") + return + } + + // Collect all entities we need to watch + var entitiesToWatch = Set() + + for trigger in settings.wakeEntities where trigger.enabled { + entitiesToWatch.insert(trigger.entityId) + } + + for trigger in settings.sleepEntities where trigger.enabled { + entitiesToWatch.insert(trigger.entityId) + } + + for trigger in settings.entityTriggers where trigger.enabled { + entitiesToWatch.insert(trigger.entityId) + } + + guard !entitiesToWatch.isEmpty else { return } + + Current.Log.info("Subscribing to \(entitiesToWatch.count) entities for triggers") + + // Subscribe to state changes via HAKit + // Note: This is a simplified version - actual implementation would use HAKit's subscription API + for entityId in entitiesToWatch { + let cancellable = api.connection.caches.states().subscribe { [weak self] _, states in + guard let state = states.all.first(where: { $0.entityId == entityId }) else { return } + // Dispatch to main actor for UI updates + Task { @MainActor [weak self] in + self?.handleEntityStateChange(entityId: entityId, state: state.state) + } + } + entitySubscriptions.append(cancellable) + } + } + + private func unsubscribeFromEntityTriggers() { + entitySubscriptions.forEach { $0.cancel() } + entitySubscriptions.removeAll() + } + + private func handleEntityStateChange(entityId: String, state: String) { + // Check wake triggers + for trigger in settings.wakeEntities where trigger.enabled && trigger.entityId == entityId { + if state == trigger.triggerState { + DispatchQueue.main.asyncAfter(deadline: .now() + trigger.delay) { [weak self] in + self?.wakeScreen(source: "entity:\(entityId)") + } + } + } + + // Check sleep triggers + for trigger in settings.sleepEntities where trigger.enabled && trigger.entityId == entityId { + if state == trigger.triggerState { + DispatchQueue.main.asyncAfter(deadline: .now() + trigger.delay) { [weak self] in + self?.sleepScreen() + } + } + } + + // Check action triggers + for trigger in settings.entityTriggers where trigger.enabled && trigger.entityId == entityId { + if state == trigger.triggerState { + executeAction(trigger.action, duration: trigger.duration) + } + } + } + + private func executeAction(_ action: TriggerAction, duration: TimeInterval?) { + switch action { + case .navigate(let url): + navigate(to: url) + + // If duration is set, navigate back after timeout + if let duration { + DispatchQueue.main.asyncAfter(deadline: .now() + duration) { [weak self] in + if let primary = self?.settings.primaryDashboardURL, !primary.isEmpty { + self?.navigate(to: primary) + } + } + } + + case .setBrightness(let level): + setBrightness(Int(level * 100)) + + case .startScreensaver(let mode): + sleepScreen(mode: mode) + + case .stopScreensaver: + wakeScreen(source: "trigger") + + case .refresh: + refresh() + + case .playSound(let url): + playSound(from: url) + + case .tts(let message): + speakText(message) + } + } + + // MARK: - Audio Playback + + private func playSound(from urlString: String) { + guard let url = URL(string: urlString) else { + Current.Log.warning("Invalid sound URL: \(urlString)") + return + } + + Current.Log.info("Playing sound: \(urlString)") + + // Handle local vs remote URLs + if url.isFileURL { + playLocalSound(url: url) + } else { + Task { + await playRemoteSound(url: url) + } + } + } + + private func playLocalSound(url: URL) { + do { + audioPlayer?.stop() + audioPlayer = try AVAudioPlayer(contentsOf: url) + audioPlayer?.play() + } catch { + Current.Log.error("Failed to play local sound: \(error.localizedDescription)") + } + } + + private func playRemoteSound(url: URL) async { + do { + let (data, response) = try await URLSession.shared.data(from: url) + + // Validate HTTP response + if let httpResponse = response as? HTTPURLResponse, + !(200...299).contains(httpResponse.statusCode) { + Current.Log.error("Failed to download sound: HTTP \(httpResponse.statusCode)") + return + } + + guard !data.isEmpty else { + Current.Log.error("Downloaded sound data is empty") + return + } + + audioPlayer?.stop() + audioPlayer = try AVAudioPlayer(data: data) + audioPlayer?.play() + } catch { + Current.Log.error("Failed to download/play sound: \(error.localizedDescription)") + } + } + + // MARK: - Text-to-Speech + + private func speakText(_ text: String) { + guard !text.isEmpty else { + Current.Log.warning("TTS: Empty text provided") + return + } + + Current.Log.info("TTS: \(text)") + + // Stop any current speech + if speechSynthesizer.isSpeaking { + speechSynthesizer.stopSpeaking(at: .immediate) + } + + let utterance = AVSpeechUtterance(string: text) + utterance.rate = AVSpeechUtteranceDefaultSpeechRate + utterance.pitchMultiplier = 1.0 + utterance.volume = settings.ttsVolume + + // Use device language or fallback to English + if let preferredLanguage = Locale.preferredLanguages.first { + utterance.voice = AVSpeechSynthesisVoice(language: preferredLanguage) + } else { + utterance.voice = AVSpeechSynthesisVoice(language: "en-US") + } + + speechSynthesizer.speak(utterance) + } + + // MARK: - HA Frontend Commands + + /// Send kiosk_mode/set command to Home Assistant frontend (requires HA 2026.1+) + /// This enables/disables HA's built-in frontend kiosk mode (hides sidebar, header, etc.) + private func setFrontendKioskMode(enabled: Bool) { + guard let server = Current.servers.all.first, + let api = Current.api(for: server) else { + Current.Log.warning("No HA server available for frontend kiosk mode command") + return + } + + Task { + do { + // Send the kiosk_mode/set command via WebSocket + _ = try await api.connection.send( + .init(type: "kiosk_mode/set", data: ["enable": enabled]) + ).promise.value + + Current.Log.info("Frontend kiosk mode \(enabled ? "enabled" : "disabled")") + } catch { + Current.Log.warning("Failed to set frontend kiosk mode: \(error.localizedDescription)") + } + } + } + + // MARK: - Settings Persistence + + private static let settingsKey = "kiosk_mode_settings" + + /// App group UserDefaults for settings persistence across app updates + private static var defaults: UserDefaults { + UserDefaults(suiteName: AppConstants.AppGroupID) ?? .standard + } + + private static func loadSettings() -> KioskSettings { + // First try app group (new location) + if let data = defaults.data(forKey: settingsKey) { + do { + return try JSONDecoder().decode(KioskSettings.self, from: data) + } catch { + Current.Log.error("Failed to decode kiosk settings: \(error). Using defaults.") + return KioskSettings() + } + } + + // Migration: check standard UserDefaults (old location) and migrate if found + if let legacyData = UserDefaults.standard.data(forKey: settingsKey) { + Current.Log.info("Migrating kiosk settings from standard UserDefaults to app group") + do { + let settings = try JSONDecoder().decode(KioskSettings.self, from: legacyData) + // Save to new location + defaults.set(legacyData, forKey: settingsKey) + // Remove from old location + UserDefaults.standard.removeObject(forKey: settingsKey) + return settings + } catch { + Current.Log.error("Failed to migrate kiosk settings: \(error). Using defaults.") + } + } + + return KioskSettings() + } + + public func saveSettings() { + do { + let data = try JSONEncoder().encode(settings) + Self.defaults.set(data, forKey: Self.settingsKey) + } catch { + Current.Log.error("Failed to encode kiosk settings: \(error)") + } + } + + /// Update a single setting using a key path + /// - Parameters: + /// - keyPath: The key path to the setting to update + /// - value: The new value + public func updateSetting(_ keyPath: WritableKeyPath, to value: T) { + var newSettings = settings + newSettings[keyPath: keyPath] = value + settings = newSettings + } + + private func settingsDidChange(from oldSettings: KioskSettings, to newSettings: KioskSettings) { + // Re-apply relevant settings if kiosk mode is active + if isKioskModeActive { + if oldSettings.brightnessControlEnabled != newSettings.brightnessControlEnabled || + oldSettings.brightnessScheduleEnabled != newSettings.brightnessScheduleEnabled || + oldSettings.dayBrightness != newSettings.dayBrightness || + oldSettings.nightBrightness != newSettings.nightBrightness || + oldSettings.manualBrightness != newSettings.manualBrightness { + applyBrightnessSchedule() + } + + if oldSettings.autoRefreshInterval != newSettings.autoRefreshInterval { + startRefreshTimer() + } + + if oldSettings.screensaverEnabled != newSettings.screensaverEnabled || + oldSettings.screensaverTimeout != newSettings.screensaverTimeout { + startIdleTimer() + } + + // Re-subscribe if entity triggers changed + if oldSettings.wakeEntities != newSettings.wakeEntities || + oldSettings.sleepEntities != newSettings.sleepEntities || + oldSettings.entityTriggers != newSettings.entityTriggers { + subscribeToEntityTriggers() + } + } + + NotificationCenter.default.post(name: Self.settingsDidChangeNotification, object: nil) + } + + // MARK: - Observers + + private func setupObservers() { + // App lifecycle + NotificationCenter.default.addObserver( + self, + selector: #selector(handleAppDidBecomeActive), + name: UIApplication.didBecomeActiveNotification, + object: nil + ) + + NotificationCenter.default.addObserver( + self, + selector: #selector(handleAppDidEnterBackground), + name: UIApplication.didEnterBackgroundNotification, + object: nil + ) + + // Orientation changes + NotificationCenter.default.addObserver( + self, + selector: #selector(handleOrientationChange), + name: UIDevice.orientationDidChangeNotification, + object: nil + ) + + // Network changes (for refresh on reconnect) + NotificationCenter.default.addObserver( + self, + selector: #selector(handleNetworkChange), + name: .init("NetworkReachabilityDidChange"), + object: nil + ) + } + + @objc private func handleAppDidBecomeActive() { + appDidBecomeActive() + } + + @objc private func handleAppDidEnterBackground() { + appDidEnterBackground() + } + + @objc private func handleOrientationChange() { + let orientation = UIDevice.current.orientation + let newLock: OrientationLock + + switch orientation { + case .portrait: newLock = .portrait + case .portraitUpsideDown: newLock = .portraitUpsideDown + case .landscapeLeft: newLock = .landscapeLeft + case .landscapeRight: newLock = .landscapeRight + default: return + } + + // Check for tamper + if settings.tamperDetectionEnabled && currentOrientation != .current && currentOrientation != newLock { + tamperDetected = true + reportSensorUpdate() + } + + currentOrientation = newLock + } + + @objc private func handleNetworkChange() { + if isKioskModeActive && settings.refreshOnNetworkReconnect { + // Small delay to let connection stabilize + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in + self?.refresh() + } + } + } + + // MARK: - Sensor Reporting + + private func reportSensorUpdate() { + // Trigger sensor update to HA + // This will be picked up by the KioskSensorProvider + NotificationCenter.default.post(name: .kioskSensorUpdate, object: nil) + } + + // MARK: - Helpers + + private func shouldWakeForSource(_ source: String) -> Bool { + switch source { + case "touch": + return settings.wakeOnTouch + case "motion", "camera_motion": + return settings.cameraMotionEnabled && settings.wakeOnCameraMotion + case "camera_presence", "camera_face": + return settings.cameraPresenceEnabled && settings.wakeOnCameraPresence + case _ where source.starts(with: "entity:"): + return true // Entity triggers always wake + case "command": + return true // Commands always wake + default: + return true + } + } +} + +// MARK: - Notification Names + +extension Notification.Name { + static let kioskSensorUpdate = Notification.Name("KioskSensorUpdate") + static let kioskPixelShiftTick = Notification.Name("KioskPixelShiftTick") +} diff --git a/Sources/App/Kiosk/KioskSettings.swift b/Sources/App/Kiosk/KioskSettings.swift new file mode 100644 index 0000000000..3d9fcc7fb6 --- /dev/null +++ b/Sources/App/Kiosk/KioskSettings.swift @@ -0,0 +1,794 @@ +import Foundation +import UIKit + +// MARK: - Main Settings Container + +/// Complete settings model for kiosk mode +/// All settings are Codable for persistence and HA integration sync +public struct KioskSettings: Codable, Equatable { + + // MARK: - Core Kiosk Mode + + /// Whether kiosk mode is currently enabled + var isKioskModeEnabled: Bool = false + + /// Whether biometric (Face ID/Touch ID) can be used to exit kiosk mode + var allowBiometricExit: Bool = true + + /// Whether device passcode can be used as fallback to exit + var allowDevicePasscodeExit: Bool = true + + /// Lock navigation (disable back gestures, pull-to-refresh, etc.) + var navigationLockdown: Bool = true + + /// Hide iOS status bar for full immersion + var hideStatusBar: Bool = true + + /// Prevent iOS from auto-locking the screen + var preventAutoLock: Bool = true + + /// Prevent accidental edge touches + var edgeProtection: Bool = false + + /// Edge protection inset in points + var edgeProtectionInset: CGFloat = 20 + + // MARK: - Dashboard Configuration + + /// Primary dashboard URL/path + var primaryDashboardURL: String = "" + + /// Enable native HA kiosk mode (hides sidebar/header, requires HA 2026.1+) + var nativeDashboardKioskMode: Bool = false + + /// Append ?kiosk to dashboard URLs (for kiosk-mode HACS integration) + var appendHACSKioskParameter: Bool = false + + /// All configured dashboards + var dashboards: [DashboardConfig] = [] + + /// Dashboard schedule entries + var dashboardSchedule: [DashboardScheduleEntry] = [] + + /// Enable dashboard rotation + var rotationEnabled: Bool = false + + /// Rotation interval in seconds + var rotationInterval: TimeInterval = 60 + + /// Pause rotation when user touches screen + var pauseRotationOnTouch: Bool = true + + /// Resume rotation after this many seconds of idle + var resumeRotationAfterIdle: TimeInterval = 30 + + // MARK: - Auto Refresh + + /// Legacy: Enable automatic refresh (kept for backwards compatibility) + /// Now controlled by autoRefreshInterval > 0 + var autoRefreshEnabled: Bool = true + + /// Periodic refresh interval in seconds (0 = never) + var autoRefreshInterval: TimeInterval = 0 + + /// Refresh when waking from screensaver/dim + var refreshOnWake: Bool = true + + /// Refresh when network reconnects + var refreshOnNetworkReconnect: Bool = true + + /// Refresh when HA WebSocket reconnects + var refreshOnHAReconnect: Bool = true + + // MARK: - Brightness Control + + /// Enable brightness management + var brightnessControlEnabled: Bool = true + + /// Manual brightness level (0.0 - 1.0) + var manualBrightness: Float = 0.8 + + /// Enable day/night brightness schedule + var brightnessScheduleEnabled: Bool = false + + /// Daytime brightness level (0.0 - 1.0) + var dayBrightness: Float = 0.8 + + /// Nighttime brightness level (0.0 - 1.0) + var nightBrightness: Float = 0.3 + + /// Time when day brightness starts (hour, minute) + var dayStartTime: TimeOfDay = TimeOfDay(hour: 7, minute: 0) + + /// Time when night brightness starts (hour, minute) + var nightStartTime: TimeOfDay = TimeOfDay(hour: 22, minute: 0) + + // MARK: - Screensaver + + /// Enable screensaver + var screensaverEnabled: Bool = true + + /// Screensaver mode + var screensaverMode: ScreensaverMode = .clock + + /// Seconds of idle before screensaver activates + var screensaverTimeout: TimeInterval = 300 // 5 minutes + + /// Brightness level when dimmed (0.0 - 1.0) - used when schedule is disabled + var screensaverDimLevel: Float = 0.1 + + /// Enable day/night screensaver brightness schedule (uses same times as main brightness) + var screensaverBrightnessScheduleEnabled: Bool = false + + /// Daytime screensaver brightness level (0.0 - 1.0) + var screensaverDayDimLevel: Float = 0.15 + + /// Nighttime screensaver brightness level (0.0 - 1.0) + var screensaverNightDimLevel: Float = 0.05 + + /// Enable pixel shifting for OLED burn-in prevention + var pixelShiftEnabled: Bool = true + + /// Pixel shift amount in points + var pixelShiftAmount: CGFloat = 10 + + /// Pixel shift interval in seconds + var pixelShiftInterval: TimeInterval = 60 + + // MARK: - Screensaver Clock Options + + /// Show seconds on clock + var clockShowSeconds: Bool = false + + /// Show date on clock + var clockShowDate: Bool = true + + /// Use 24-hour time format (false = 12-hour with AM/PM) + var clockUse24HourFormat: Bool = true + + /// Clock style + var clockStyle: ClockStyle = .large + + /// HA entities to display on clock screensaver + var clockEntities: [ClockEntityConfig] = [] + + // MARK: - Screensaver Weather + + /// Show weather on screensaver + var clockShowWeather: Bool = false + + /// Weather entity to display (e.g., weather.home) + var clockWeatherEntity: String = "" + + /// Temperature entity for more accurate temp display (optional, e.g., sensor.outdoor_temperature) + var clockTemperatureEntity: String = "" + + // MARK: - Screensaver Photos + + /// Photo source for screensaver + var photoSource: PhotoSource = .local + + /// Selected local photo album identifiers + var localPhotoAlbums: [String] = [] + + /// iCloud shared album identifiers + var iCloudAlbums: [String] = [] + + /// HA Media Browser path for photos + var haMediaPath: String = "" + + /// Photo display interval in seconds + var photoInterval: TimeInterval = 30 + + /// Photo transition style + var photoTransition: PhotoTransition = .fade + + /// Photo fit mode (fill or fit) + var photoFitMode: PhotoFitMode = .fill + + /// Show clock overlay on photos + var photoShowClockOverlay: Bool = true + + /// Show entity data overlay on photos + var photoShowEntityOverlay: Bool = false + + // MARK: - Screensaver Custom URL + + /// Custom URL to load as screensaver (e.g., a minimal HA dashboard) + var screensaverCustomURL: String = "" + + // MARK: - Wake/Sleep Triggers + + /// Wake screen on touch + var wakeOnTouch: Bool = true + + /// Wake screen on camera motion detection + var wakeOnCameraMotion: Bool = false + + /// Wake screen on camera presence/face detection + var wakeOnCameraPresence: Bool = false + + /// External HA entities that trigger wake + var wakeEntities: [EntityTrigger] = [] + + /// External HA entities that trigger sleep + var sleepEntities: [EntityTrigger] = [] + + /// Wake schedule entries + var wakeSchedule: [ScheduleEntry] = [] + + /// Sleep schedule entries + var sleepSchedule: [ScheduleEntry] = [] + + // MARK: - Entity Action Triggers + + /// Entity state changes that trigger actions + var entityTriggers: [EntityActionTrigger] = [] + + // MARK: - Camera & Presence Detection + + /// Enable camera-based motion detection + var cameraMotionEnabled: Bool = false + + /// Motion detection sensitivity + var cameraMotionSensitivity: MotionSensitivity = .medium + + /// Enable person presence detection (Vision framework) + var cameraPresenceEnabled: Bool = false + + /// Enable face detection (more accurate than presence) + var cameraFaceDetectionEnabled: Bool = false + + /// Report motion to HA as sensor + var reportMotionToHA: Bool = true + + /// Report presence to HA as sensor + var reportPresenceToHA: Bool = true + + // MARK: - Camera Popup + + /// Camera popup size when showing doorbell/security cameras + var cameraPopupSize: CameraPopupSize = .large + + /// Camera popup position on screen + var cameraPopupPosition: CameraPopupPosition = .center + + // MARK: - Audio + + /// Enable TTS announcements + var ttsEnabled: Bool = true + + /// TTS volume (0.0 - 1.0) + var ttsVolume: Float = 0.7 + + /// Enable audio alerts for critical events + var audioAlertsEnabled: Bool = true + + /// Enable ambient audio level detection + var ambientAudioDetectionEnabled: Bool = false + + // MARK: - App Launcher + + /// Configured app shortcuts + var appShortcuts: [AppShortcut] = [] + + /// Show quick launch panel + var quickLaunchEnabled: Bool = false + + /// Quick launch gesture + var quickLaunchGesture: QuickLaunchGesture = .swipeFromBottom + + /// Return reminder timeout in seconds (0 = disabled) + var appLaunchReturnTimeout: TimeInterval = 300 // 5 minutes + + // MARK: - Status Overlay + + /// Show status overlay bar + var statusOverlayEnabled: Bool = true + + /// Status overlay position + var statusOverlayPosition: OverlayPosition = .top + + /// Show connection status indicator + var showConnectionStatus: Bool = true + + /// Show current time + var showTime: Bool = false + + /// Show battery indicator + var showBattery: Bool = true + + /// HA entities to show in status overlay + var statusOverlayEntities: [String] = [] + + /// Auto-hide overlay after seconds (0 = always visible) + var statusOverlayAutoHide: TimeInterval = 5 + + // MARK: - Quick Actions + + /// Enable quick actions bar + var quickActionsEnabled: Bool = false + + /// Quick action gesture to reveal + var quickActionsGesture: QuickLaunchGesture = .swipeFromRight + + /// Configured quick actions + var quickActions: [QuickAction] = [] + + // MARK: - Device & Security + + /// Orientation lock setting + var orientationLock: OrientationLock = .current + + /// Enable tamper detection (orientation change alerts) + var tamperDetectionEnabled: Bool = false + + /// Enable touch feedback sounds + var touchSoundEnabled: Bool = false + + /// Enable touch haptic feedback + var touchHapticEnabled: Bool = true + + /// Auto-restart app on crash + var autoRestartOnCrash: Bool = true + + /// Low battery alert threshold (0-100, 0 = disabled) + var lowBatteryAlertThreshold: Int = 20 + + /// Report thermal state to HA + var reportThermalState: Bool = true + + // MARK: - Secret Exit Gesture + + /// Enable secret gesture to access kiosk settings (escape hatch) + var secretExitGestureEnabled: Bool = true + + /// Corner for secret exit gesture + var secretExitGestureCorner: ScreenCorner = .topRight + + /// Number of taps required for secret exit gesture + var secretExitGestureTaps: Int = 3 + + // MARK: - Enhanced Security (Sprint 9) + + /// Enable Guided Access integration + var guidedAccessEnabled: Bool = false + + /// Allow remote lock/unlock from HA + var remoteLockEnabled: Bool = true + + /// Current remote lock state (managed by HA) + var isRemotelyLocked: Bool = false + + /// Maximum charging level (battery health - 0 = disabled) + var maxChargingLevel: Int = 0 + + /// Enable thermal throttling warnings + var thermalThrottlingWarnings: Bool = true + + /// Report battery health metrics to HA + var reportBatteryHealth: Bool = true + + /// Locked orientation (used for tamper detection) + var lockedOrientation: DeviceOrientation? + + /// Expected orientation for tamper detection + var expectedOrientation: DeviceOrientation = .landscape + + /// Enable settings export capability + var allowSettingsExport: Bool = true +} + +// MARK: - Supporting Types + +public struct TimeOfDay: Codable, Equatable { + var hour: Int + var minute: Int + + var asDateComponents: DateComponents { + DateComponents(hour: hour, minute: minute) + } + + func isBefore(_ other: TimeOfDay) -> Bool { + if hour != other.hour { + return hour < other.hour + } + return minute < other.minute + } +} + +public struct DashboardConfig: Codable, Equatable, Identifiable { + public var id: String = UUID().uuidString + var name: String + var url: String + var icon: String = "mdi:view-dashboard" + + /// Whether this is included in rotation + var includeInRotation: Bool = true +} + +public struct DashboardScheduleEntry: Codable, Equatable, Identifiable { + public var id: String = UUID().uuidString + var dashboardId: String + var startTime: TimeOfDay + var endTime: TimeOfDay + var daysOfWeek: [Int] = [1, 2, 3, 4, 5, 6, 7] // 1 = Sunday +} + +public struct ScheduleEntry: Codable, Equatable, Identifiable { + public var id: String = UUID().uuidString + var time: TimeOfDay + var daysOfWeek: [Int] = [1, 2, 3, 4, 5, 6, 7] + var enabled: Bool = true +} + +public struct EntityTrigger: Codable, Equatable, Identifiable { + public var id: String = UUID().uuidString + var entityId: String + var triggerState: String // "on", "off", "home", etc. + var delay: TimeInterval = 0 // Debounce delay + var enabled: Bool = true +} + +public struct EntityActionTrigger: Codable, Equatable, Identifiable { + public var id: String = UUID().uuidString + var entityId: String + var triggerState: String + var action: TriggerAction + var delay: TimeInterval = 0 // Debounce delay before action fires + var duration: TimeInterval? // Auto-revert after this time (nil = permanent) + var enabled: Bool = true +} + +public enum TriggerAction: Codable, Equatable { + case navigate(url: String) + case setBrightness(level: Float) + case startScreensaver(mode: ScreensaverMode?) + case stopScreensaver + case refresh + case playSound(url: String) + case tts(message: String) +} + +public struct ClockEntityConfig: Codable, Equatable, Identifiable { + public var id: String = UUID().uuidString + var entityId: String + var label: String? // Custom label (nil = use friendly name) + var icon: String? // Custom icon (nil = use entity icon) + var showUnit: Bool = true + var displayFormat: EntityDisplayFormat = .auto + var decimalPlaces: Int? // nil = auto, 0 = integer, 1-3 = decimal places + var prefix: String? // Text before value (e.g., "$") + var suffix: String? // Text after value (replaces unit if set) +} + +public enum EntityDisplayFormat: String, Codable, CaseIterable { + case auto = "auto" // Automatic based on entity type + case value = "value" // Just the value + case valueWithUnit = "value_unit" // Value + unit (e.g., "72°F") + case valueSpaceUnit = "value_space_unit" // Value space unit (e.g., "72 °F") + case integer = "integer" // Round to integer + case percentage = "percentage" // Show as percentage + case compact = "compact" // Compact number (e.g., 1.2K) + case time = "time" // Format as time duration + + var displayName: String { + switch self { + case .auto: return "Auto" + case .value: return "Value Only" + case .valueWithUnit: return "Value + Unit" + case .valueSpaceUnit: return "Value (space) Unit" + case .integer: return "Integer" + case .percentage: return "Percentage" + case .compact: return "Compact" + case .time: return "Duration" + } + } +} + +public struct AppShortcut: Codable, Equatable, Identifiable { + public var id: String = UUID().uuidString + var name: String + var urlScheme: String + var icon: String = "mdi:application" + var systemImage: String? // SF Symbol name (preferred over MDI) +} + +public struct QuickAction: Codable, Equatable, Identifiable { + public var id: String = UUID().uuidString + var name: String + var icon: String + var actionType: QuickActionType +} + +public enum QuickActionType: Codable, Equatable { + case haService(domain: String, service: String, data: [String: String]) + case navigate(url: String) + case toggleEntity(entityId: String) + case script(entityId: String) + case scene(entityId: String) +} + +// MARK: - Enums + +public enum ScreensaverMode: String, Codable, CaseIterable { + case blank = "blank" + case dim = "dim" + case clock = "clock" + case clockWithEntities = "clock_entities" + case photos = "photos" + case photosWithClock = "photos_clock" + case customURL = "custom_url" + + var displayName: String { + switch self { + case .blank: return "Blank (Black Screen)" + case .dim: return "Dim Dashboard" + case .clock: return "Clock" + case .clockWithEntities: return "Clock + Sensors" + case .photos: return "Photo Frame" + case .photosWithClock: return "Photos + Clock" + case .customURL: return "Custom Dashboard" + } + } +} + +public enum ClockStyle: String, Codable, CaseIterable { + case large = "large" + case minimal = "minimal" + case analog = "analog" + case digital = "digital" + + var displayName: String { + switch self { + case .large: return "Large" + case .minimal: return "Minimal" + case .analog: return "Analog" + case .digital: return "Digital" + } + } +} + +public enum PhotoSource: String, Codable, CaseIterable { + case local = "local" + case iCloud = "icloud" + case haMedia = "ha_media" + case all = "all" + + var displayName: String { + switch self { + case .local: return "On This Device" + case .iCloud: return "iCloud Photos" + case .haMedia: return "Home Assistant Media" + case .all: return "All Sources" + } + } +} + +public enum PhotoTransition: String, Codable, CaseIterable { + case fade = "fade" + case slide = "slide" + case none = "none" + + var displayName: String { + switch self { + case .fade: return "Fade" + case .slide: return "Slide" + case .none: return "None" + } + } +} + +public enum PhotoFitMode: String, Codable, CaseIterable { + case fill = "fill" + case fit = "fit" + + var displayName: String { + switch self { + case .fill: return "Fill Screen" + case .fit: return "Fit to Screen" + } + } +} + +public enum MotionSensitivity: String, Codable, CaseIterable { + case low = "low" + case medium = "medium" + case high = "high" + + var displayName: String { + switch self { + case .low: return "Low" + case .medium: return "Medium" + case .high: return "High" + } + } +} + +public enum QuickLaunchGesture: String, Codable, CaseIterable { + case swipeFromBottom = "swipe_bottom" + case swipeFromTop = "swipe_top" + case swipeFromLeft = "swipe_left" + case swipeFromRight = "swipe_right" + case doubleTap = "double_tap" + case longPress = "long_press" + + var displayName: String { + switch self { + case .swipeFromBottom: return "Swipe from Bottom" + case .swipeFromTop: return "Swipe from Top" + case .swipeFromLeft: return "Swipe from Left" + case .swipeFromRight: return "Swipe from Right" + case .doubleTap: return "Double Tap" + case .longPress: return "Long Press" + } + } +} + +public enum OverlayPosition: String, Codable, CaseIterable { + case top = "top" + case bottom = "bottom" + + var displayName: String { + switch self { + case .top: return "Top" + case .bottom: return "Bottom" + } + } +} + +public enum OrientationLock: String, Codable, CaseIterable { + case current = "current" + case portrait = "portrait" + case portraitUpsideDown = "portrait_upside_down" + case landscape = "landscape" + case landscapeLeft = "landscape_left" + case landscapeRight = "landscape_right" + + var displayName: String { + switch self { + case .current: return "Current Orientation" + case .portrait: return "Portrait" + case .portraitUpsideDown: return "Portrait (Upside Down)" + case .landscape: return "Landscape (Any)" + case .landscapeLeft: return "Landscape Left" + case .landscapeRight: return "Landscape Right" + } + } +} + +// MARK: - Screen State (for sensors) + +public enum ScreenState: String, Codable { + case on = "on" + case dimmed = "dimmed" + case screensaver = "screensaver" + case off = "off" +} + +public enum AppState: String, Codable { + case active = "active" + case away = "away" + case background = "background" +} + +public enum ScreenCorner: String, Codable, CaseIterable { + case topLeft = "top_left" + case topRight = "top_right" + case bottomLeft = "bottom_left" + case bottomRight = "bottom_right" + + var displayName: String { + switch self { + case .topLeft: return "Top Left" + case .topRight: return "Top Right" + case .bottomLeft: return "Bottom Left" + case .bottomRight: return "Bottom Right" + } + } +} + +public enum CameraPopupSize: String, Codable, CaseIterable { + case small = "small" + case medium = "medium" + case large = "large" + case fullScreen = "full_screen" + + var displayName: String { + switch self { + case .small: return "Small" + case .medium: return "Medium" + case .large: return "Large" + case .fullScreen: return "Full Screen" + } + } + + /// Returns width percentage and max width in points + var sizeParameters: (widthPercent: CGFloat, maxWidth: CGFloat, heightPercent: CGFloat) { + switch self { + case .small: return (0.4, 320, 0.4) + case .medium: return (0.55, 450, 0.5) + case .large: return (0.7, 600, 0.6) + case .fullScreen: return (0.95, 1200, 0.9) + } + } +} + +public enum CameraPopupPosition: String, Codable, CaseIterable { + case center = "center" + case topLeft = "top_left" + case topRight = "top_right" + case bottomLeft = "bottom_left" + case bottomRight = "bottom_right" + + var displayName: String { + switch self { + case .center: return "Center" + case .topLeft: return "Top Left" + case .topRight: return "Top Right" + case .bottomLeft: return "Bottom Left" + case .bottomRight: return "Bottom Right" + } + } +} + +public enum DeviceOrientation: String, Codable, CaseIterable { + case portrait = "portrait" + case portraitUpsideDown = "portrait_upside_down" + case landscapeLeft = "landscape_left" + case landscapeRight = "landscape_right" + case landscape = "landscape" + case faceUp = "face_up" + case faceDown = "face_down" + case unknown = "unknown" + + var displayName: String { + switch self { + case .portrait: return "Portrait" + case .portraitUpsideDown: return "Portrait (Upside Down)" + case .landscapeLeft: return "Landscape Left" + case .landscapeRight: return "Landscape Right" + case .landscape: return "Landscape" + case .faceUp: return "Face Up" + case .faceDown: return "Face Down" + case .unknown: return "Unknown" + } + } + + static func from(_ uiOrientation: UIDeviceOrientation) -> DeviceOrientation { + switch uiOrientation { + case .portrait: return .portrait + case .portraitUpsideDown: return .portraitUpsideDown + case .landscapeLeft: return .landscapeLeft + case .landscapeRight: return .landscapeRight + case .faceUp: return .faceUp + case .faceDown: return .faceDown + default: return .unknown + } + } + + func matches(_ other: DeviceOrientation) -> Bool { + if self == other { return true } + // Landscape matches both left and right + if self == .landscape && (other == .landscapeLeft || other == .landscapeRight) { + return true + } + if other == .landscape && (self == .landscapeLeft || self == .landscapeRight) { + return true + } + return false + } +} + +// MARK: - Default App Shortcuts + +extension AppShortcut { + static let defaults: [AppShortcut] = [ + AppShortcut(name: "Safari", urlScheme: "x-web-search://", icon: "mdi:safari", systemImage: "safari"), + AppShortcut(name: "Music", urlScheme: "music://", icon: "mdi:music", systemImage: "music.note"), + AppShortcut(name: "Spotify", urlScheme: "spotify://", icon: "mdi:spotify", systemImage: nil), + AppShortcut(name: "Settings", urlScheme: "App-prefs://", icon: "mdi:cog", systemImage: "gear"), + AppShortcut(name: "Camera", urlScheme: "camera://", icon: "mdi:camera", systemImage: "camera"), + AppShortcut(name: "UniFi Protect", urlScheme: "uiprotect://", icon: "mdi:shield-home", systemImage: nil), + ] +} diff --git a/Sources/App/Kiosk/Overlay/EdgeProtectionView.swift b/Sources/App/Kiosk/Overlay/EdgeProtectionView.swift new file mode 100755 index 0000000000..62d697ab6e --- /dev/null +++ b/Sources/App/Kiosk/Overlay/EdgeProtectionView.swift @@ -0,0 +1,199 @@ +import Shared +import SwiftUI +import UIKit + +// MARK: - Edge Protection View + +/// An overlay that blocks accidental touches near screen edges +public struct EdgeProtectionView: View { + @ObservedObject private var manager = KioskModeManager.shared + + @State private var blockedTouch: CGPoint? + @State private var showBlockedIndicator = false + + public init() {} + + private var isEnabled: Bool { + manager.isKioskModeActive && manager.settings.edgeProtection + } + + private var inset: CGFloat { + manager.settings.edgeProtectionInset + } + + public var body: some View { + GeometryReader { geometry in + ZStack { + // Edge protection zones (invisible hit areas) + if isEnabled { + edgeProtectionOverlay(in: geometry) + } + + // Blocked touch indicator + if showBlockedIndicator, let point = blockedTouch { + blockedTouchIndicator + .position(point) + .transition(.scale.combined(with: .opacity)) + } + } + } + .animation(.easeOut(duration: KioskConstants.Animation.quick), value: showBlockedIndicator) + } + + // MARK: - Edge Protection Overlay + + @ViewBuilder + private func edgeProtectionOverlay(in geometry: GeometryProxy) -> some View { + // Top edge + Rectangle() + .fill(Color.clear) + .frame(height: inset) + .frame(maxWidth: .infinity) + .position(x: geometry.size.width / 2, y: inset / 2) + .contentShape(Rectangle()) + .onTapGesture { + handleBlockedTouch(at: CGPoint(x: geometry.size.width / 2, y: inset / 2)) + } + + // Bottom edge + Rectangle() + .fill(Color.clear) + .frame(height: inset) + .frame(maxWidth: .infinity) + .position(x: geometry.size.width / 2, y: geometry.size.height - inset / 2) + .contentShape(Rectangle()) + .onTapGesture { + handleBlockedTouch(at: CGPoint(x: geometry.size.width / 2, y: geometry.size.height - inset / 2)) + } + + // Left edge + Rectangle() + .fill(Color.clear) + .frame(width: inset) + .frame(maxHeight: .infinity) + .position(x: inset / 2, y: geometry.size.height / 2) + .contentShape(Rectangle()) + .onTapGesture { + handleBlockedTouch(at: CGPoint(x: inset / 2, y: geometry.size.height / 2)) + } + + // Right edge + Rectangle() + .fill(Color.clear) + .frame(width: inset) + .frame(maxHeight: .infinity) + .position(x: geometry.size.width - inset / 2, y: geometry.size.height / 2) + .contentShape(Rectangle()) + .onTapGesture { + handleBlockedTouch(at: CGPoint(x: geometry.size.width - inset / 2, y: geometry.size.height / 2)) + } + } + + // MARK: - Blocked Touch Indicator + + private var blockedTouchIndicator: some View { + Circle() + .fill(Color.red.opacity(0.3)) + .frame(width: 44, height: 44) + .overlay( + Image(systemName: "hand.raised.slash") + .foregroundColor(.red) + ) + } + + // MARK: - Touch Handling + + private func handleBlockedTouch(at point: CGPoint) { + blockedTouch = point + showBlockedIndicator = true + + // Play warning feedback + TouchFeedbackManager.shared.playFeedback(for: .warning) + + // Hide indicator after a short delay + Task { + try? await Task.sleep(nanoseconds: 500_000_000) // 0.5 seconds + await MainActor.run { + withAnimation { + showBlockedIndicator = false + } + } + } + + Current.Log.verbose("Edge protection blocked touch at: \(point)") + } +} + +// MARK: - Edge Protection UIKit View Controller + +/// UIKit view controller for edge protection overlay +public final class EdgeProtectionViewController: UIViewController { + private var hostingController: UIHostingController? + + public override func viewDidLoad() { + super.viewDidLoad() + + view.backgroundColor = .clear + view.isUserInteractionEnabled = true + + let edgeView = EdgeProtectionView() + hostingController = UIHostingController(rootView: edgeView) + hostingController?.view.backgroundColor = .clear + + guard let hostingController, let hostingView = hostingController.view else { return } + + addChild(hostingController) + view.addSubview(hostingView) + hostingView.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + hostingView.topAnchor.constraint(equalTo: view.topAnchor), + hostingView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + hostingView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + hostingView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + ]) + + hostingController.didMove(toParent: self) + } + +} + +// MARK: - Edge Protection Passthrough View + +/// Custom view that passes through touches in the safe zone +final class EdgeProtectionPassthroughView: UIView { + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + let manager = KioskModeManager.shared + + // Only intercept if edge protection is enabled + guard manager.isKioskModeActive && manager.settings.edgeProtection else { + return nil + } + + let inset = manager.settings.edgeProtectionInset + + // Check if touch is in edge zone + if point.x < inset || point.x > bounds.width - inset || + point.y < inset || point.y > bounds.height - inset { + // Return self to capture the touch + return self + } + + // Pass through touches in the safe zone + return nil + } +} + +// MARK: - Preview + +@available(iOS 17.0, *) +#Preview { + ZStack { + Color.blue.opacity(0.3) + .ignoresSafeArea() + + Text("Content Area") + + EdgeProtectionView() + } +} diff --git a/Sources/App/Kiosk/Overlay/QuickActionsView.swift b/Sources/App/Kiosk/Overlay/QuickActionsView.swift new file mode 100755 index 0000000000..8cd001977d --- /dev/null +++ b/Sources/App/Kiosk/Overlay/QuickActionsView.swift @@ -0,0 +1,417 @@ +import Combine +import HAKit +import Shared +import SwiftUI + +// MARK: - Quick Actions View + +/// A slide-out panel showing configurable quick actions for HA control +public struct QuickActionsView: View { + @ObservedObject private var kioskManager = KioskModeManager.shared + @Binding var isPresented: Bool + + @State private var executingActionId: String? + @State private var feedbackMessage: String? + + public init(isPresented: Binding) { + _isPresented = isPresented + } + + public var body: some View { + VStack(spacing: 0) { + // Header + headerView + + // Actions grid + ScrollView { + LazyVGrid(columns: gridColumns, spacing: KioskConstants.UI.standardPadding) { + ForEach(kioskManager.settings.quickActions) { action in + QuickActionButton( + action: action, + isExecuting: executingActionId == action.id + ) { + executeAction(action) + } + } + } + .padding() + } + + // Feedback message + if let message = feedbackMessage { + feedbackView(message: message) + } + } + .background(Color(.systemBackground).opacity(0.95)) + .cornerRadius(KioskConstants.UI.cornerRadius) + .shadow(color: .black.opacity(KioskConstants.Shadow.panelOpacity), + radius: KioskConstants.Shadow.panelRadius, x: 0, y: 5) + } + + private var gridColumns: [GridItem] { + [GridItem(.adaptive(minimum: 90, maximum: 110), spacing: KioskConstants.UI.standardPadding)] + } + + private var headerView: some View { + HStack { + Text("Quick Actions") + .font(.headline) + .foregroundColor(.primary) + + Spacer() + + Button { + isPresented = false + } label: { + Image(systemName: "xmark.circle.fill") + .font(.title2) + .foregroundColor(.secondary) + } + .accessibilityLabel(KioskConstants.Accessibility.closeButton) + } + .padding() + .background(Color(.secondarySystemBackground)) + } + + private func feedbackView(message: String) -> some View { + HStack { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.green) + Text(message) + .font(.caption) + } + .padding() + .background(Color.green.opacity(0.1)) + .transition(.move(edge: .bottom).combined(with: .opacity)) + } + + // MARK: - Action Execution + + private func executeAction(_ action: QuickAction) { + executingActionId = action.id + TouchFeedbackManager.shared.playFeedback(for: .action) + + Task { + let success = await QuickActionsManager.shared.executeAction(action) + + await MainActor.run { + executingActionId = nil + + if success { + showFeedback("Done") + } else { + showFeedback("Failed") + } + } + } + } + + private func showFeedback(_ message: String) { + withAnimation { + feedbackMessage = message + } + + Task { + try? await Task.sleep(nanoseconds: UInt64(KioskConstants.Timing.feedbackDuration * 1_000_000_000)) + await MainActor.run { + withAnimation { + feedbackMessage = nil + } + } + } + } +} + +// MARK: - Quick Action Button + +struct QuickActionButton: View { + let action: QuickAction + let isExecuting: Bool + let onTap: () -> Void + + var body: some View { + Button(action: onTap) { + VStack(spacing: 8) { + ZStack { + // Icon + Image(systemName: IconMapper.sfSymbol(from: action.icon, default: "star.fill")) + .font(.title2) + .foregroundColor(.accentColor) + .opacity(isExecuting ? 0.3 : 1) + + // Loading indicator + if isExecuting { + ProgressView() + .progressViewStyle(CircularProgressViewStyle()) + } + } + .frame(width: KioskConstants.UI.appIconSize, height: KioskConstants.UI.appIconSize) + .background(Color(.tertiarySystemBackground)) + .cornerRadius(KioskConstants.UI.cornerRadius) + .overlay( + RoundedRectangle(cornerRadius: KioskConstants.UI.cornerRadius) + .stroke(Color(.separator), lineWidth: 0.5) + ) + + // Name + Text(action.name) + .font(.caption) + .lineLimit(2) + .multilineTextAlignment(.center) + .foregroundColor(.primary) + } + } + .disabled(isExecuting) + .accessibilityLabel(action.name) + .accessibilityHint("Double tap to execute") + } +} + +// MARK: - Quick Actions Container + +/// Container view that handles gesture-based presentation of the quick actions panel +public struct QuickActionsContainerView: View { + @ObservedObject private var kioskManager = KioskModeManager.shared + @State private var isPanelPresented = false + @State private var dragOffset: CGFloat = 0 + + private var gesture: QuickLaunchGesture { + kioskManager.settings.quickActionsGesture + } + + private var isEnabled: Bool { + kioskManager.isKioskModeActive && kioskManager.settings.quickActionsEnabled + } + + public init() {} + + public var body: some View { + GeometryReader { geometry in + ZStack { + // Invisible gesture detector + if isEnabled { + Color.clear + .contentShape(Rectangle()) + .gesture(edgeGesture(in: geometry)) + } + + // Panel overlay + if isPanelPresented { + Color.black.opacity(0.3) + .ignoresSafeArea() + .onTapGesture { + withAnimation(.spring()) { + isPanelPresented = false + } + } + + panelView(in: geometry) + .transition(.move(edge: panelEdge).combined(with: .opacity)) + } + } + } + .animation(.spring(), value: isPanelPresented) + } + + private var panelEdge: Edge { + switch gesture { + case .swipeFromBottom: return .bottom + case .swipeFromTop: return .top + case .swipeFromLeft: return .leading + case .swipeFromRight: return .trailing + case .doubleTap, .longPress: return .trailing + } + } + + @ViewBuilder + private func panelView(in geometry: GeometryProxy) -> some View { + let panelSize = panelSize(in: geometry) + + QuickActionsView(isPresented: $isPanelPresented) + .frame(width: panelSize.width, height: panelSize.height) + .position(panelPosition(in: geometry, size: panelSize)) + } + + private func panelSize(in geometry: GeometryProxy) -> CGSize { + let maxWidth = min(geometry.size.width * KioskConstants.Panel.maxWidthRatio, KioskConstants.Panel.maxWidth) + let maxHeight = min(geometry.size.height * KioskConstants.Panel.maxHeightRatio, KioskConstants.Panel.maxHeight) + return CGSize(width: maxWidth, height: maxHeight) + } + + private func panelPosition(in geometry: GeometryProxy, size: CGSize) -> CGPoint { + let centerX = geometry.size.width / 2 + let centerY = geometry.size.height / 2 + let padding: CGFloat = 20 + + switch gesture { + case .swipeFromBottom: + return CGPoint(x: centerX, y: geometry.size.height - size.height / 2 - padding) + case .swipeFromTop: + return CGPoint(x: centerX, y: size.height / 2 + padding) + case .swipeFromLeft: + return CGPoint(x: size.width / 2 + padding, y: centerY) + case .swipeFromRight: + return CGPoint(x: geometry.size.width - size.width / 2 - padding, y: centerY) + case .doubleTap, .longPress: + return CGPoint(x: centerX, y: centerY) + } + } + + private func edgeGesture(in geometry: GeometryProxy) -> some Gesture { + let edgeSize = KioskConstants.UI.edgeGestureSize + let threshold = KioskConstants.UI.swipeThreshold + + return DragGesture(minimumDistance: 20) + .onChanged { value in + let startLocation = value.startLocation + + switch gesture { + case .swipeFromBottom: + if startLocation.y > geometry.size.height - edgeSize { + dragOffset = value.translation.height + } + case .swipeFromTop: + if startLocation.y < edgeSize { + dragOffset = value.translation.height + } + case .swipeFromLeft: + if startLocation.x < edgeSize { + dragOffset = value.translation.width + } + case .swipeFromRight: + if startLocation.x > geometry.size.width - edgeSize { + dragOffset = value.translation.width + } + default: + break + } + } + .onEnded { value in + switch gesture { + case .swipeFromBottom: + if value.startLocation.y > geometry.size.height - edgeSize, + value.translation.height < -threshold { + isPanelPresented = true + } + case .swipeFromTop: + if value.startLocation.y < edgeSize, + value.translation.height > threshold { + isPanelPresented = true + } + case .swipeFromLeft: + if value.startLocation.x < edgeSize, + value.translation.width > threshold { + isPanelPresented = true + } + case .swipeFromRight: + if value.startLocation.x > geometry.size.width - edgeSize, + value.translation.width < -threshold { + isPanelPresented = true + } + default: + break + } + + dragOffset = 0 + } + } +} + +// MARK: - Quick Actions Manager + +/// Manages execution of quick actions +@MainActor +public final class QuickActionsManager: ObservableObject { + public static let shared = QuickActionsManager() + + private init() {} + + /// Execute a quick action + public func executeAction(_ action: QuickAction) async -> Bool { + Current.Log.info("Executing quick action: \(action.name)") + + switch action.actionType { + case let .haService(domain, service, data): + return await callHAService(domain: domain, service: service, data: data) + + case let .navigate(url): + KioskModeManager.shared.navigate(to: url) + return true + + case let .toggleEntity(entityId): + return await toggleEntity(entityId) + + case let .script(entityId): + return await callHAService(domain: "script", service: "turn_on", data: ["entity_id": entityId]) + + case let .scene(entityId): + return await callHAService(domain: "scene", service: "turn_on", data: ["entity_id": entityId]) + } + } + + private func callHAService(domain: String, service: String, data: [String: String]) async -> Bool { + guard let server = Current.servers.all.first, + let api = Current.api(for: server) else { + Current.Log.error("No HA server available for service call") + return false + } + + do { + let serviceData: [String: Any] = data + _ = try await api.connection.send(.callService( + domain: .init(stringLiteral: domain), + service: .init(stringLiteral: service), + data: serviceData + )).promise.value + + Current.Log.info("Service call successful: \(domain).\(service)") + return true + } catch { + Current.Log.error("Service call failed: \(error.localizedDescription)") + return false + } + } + + private func toggleEntity(_ entityId: String) async -> Bool { + // Determine domain from entity_id + let domain = entityId.components(separatedBy: ".").first ?? "homeassistant" + + // Use appropriate toggle service based on domain + let toggleDomain: String + let toggleService: String + + switch domain { + case "light", "switch", "fan", "input_boolean", "automation", "script": + toggleDomain = domain + toggleService = "toggle" + case "cover": + toggleDomain = "cover" + toggleService = "toggle" + case "lock": + // Locks don't have toggle - need to check state first + toggleDomain = "lock" + toggleService = "toggle" + case "media_player": + toggleDomain = "media_player" + toggleService = "toggle" + default: + toggleDomain = "homeassistant" + toggleService = "toggle" + } + + return await callHAService( + domain: toggleDomain, + service: toggleService, + data: ["entity_id": entityId] + ) + } +} + +// MARK: - Preview + +#Preview { + QuickActionsView(isPresented: .constant(true)) + .frame(width: 350, height: 400) + .padding() + .background(Color.gray) +} diff --git a/Sources/App/Kiosk/Overlay/SecretExitGestureView.swift b/Sources/App/Kiosk/Overlay/SecretExitGestureView.swift new file mode 100644 index 0000000000..f99e9697cd --- /dev/null +++ b/Sources/App/Kiosk/Overlay/SecretExitGestureView.swift @@ -0,0 +1,263 @@ +import Combine +import SwiftUI +import UIKit + +// MARK: - Secret Exit Gesture View + +/// An invisible overlay that detects multi-tap gestures in a corner to access kiosk settings +/// This provides an escape hatch when navigation is locked down +public struct SecretExitGestureView: View { + @ObservedObject private var kioskManager = KioskModeManager.shared + @Binding var showSettings: Bool + + /// Size of the tap target area in the corner + private let cornerTapSize: CGFloat = 80 + + public init(showSettings: Binding) { + _showSettings = showSettings + } + + public var body: some View { + Group { + if kioskManager.isKioskModeActive && kioskManager.settings.secretExitGestureEnabled { + cornerTapArea + } + } + } + + @ViewBuilder + private var cornerTapArea: some View { + let corner = kioskManager.settings.secretExitGestureCorner + let requiredTaps = kioskManager.settings.secretExitGestureTaps + let alignment = alignmentForCorner(corner) + + // Use a frame with alignment to position the tap area in the corner + // The tap area is the only thing that receives touches + VStack { + if corner == .bottomLeft || corner == .bottomRight { + Spacer(minLength: 0) + } + HStack { + if corner == .topRight || corner == .bottomRight { + Spacer(minLength: 0) + } + SecretTapArea(requiredTaps: requiredTaps) { + showSettings = true + } + .frame(width: cornerTapSize, height: cornerTapSize) + if corner == .topLeft || corner == .bottomLeft { + Spacer(minLength: 0) + } + } + if corner == .topLeft || corner == .topRight { + Spacer(minLength: 0) + } + } + .allowsHitTesting(true) + } + + private func alignmentForCorner(_ corner: ScreenCorner) -> Alignment { + switch corner { + case .topLeft: return .topLeading + case .topRight: return .topTrailing + case .bottomLeft: return .bottomLeading + case .bottomRight: return .bottomTrailing + } + } +} + +// MARK: - Secret Tap Area + +/// A view that detects multiple rapid taps and triggers an action +private struct SecretTapArea: View { + let requiredTaps: Int + let onTriggered: () -> Void + + @State private var tapCount = 0 + @State private var resetTimer: Timer? + + /// Time window for completing all taps (seconds) + private let tapWindow: TimeInterval = 2.0 + + var body: some View { + Color.clear + .contentShape(Rectangle()) + .onTapGesture { + handleTap() + } + } + + private func handleTap() { + // Cancel existing timer + resetTimer?.invalidate() + + tapCount += 1 + + if tapCount >= requiredTaps { + // Success - trigger action + tapCount = 0 + resetTimer = nil + + // Provide haptic feedback + let generator = UIImpactFeedbackGenerator(style: .medium) + generator.impactOccurred() + + onTriggered() + } else { + // Provide subtle feedback for each tap + let generator = UIImpactFeedbackGenerator(style: .light) + generator.impactOccurred() + + // Reset tap count if no more taps within window + resetTimer = Timer.scheduledTimer(withTimeInterval: tapWindow, repeats: false) { _ in + tapCount = 0 + } + } + } +} + +// MARK: - UIKit Integration + +/// A passthrough view that only intercepts touches in corner regions +private class CornerTapPassthroughView: UIView { + /// Size of the corner tap region + var cornerSize: CGFloat = 80 + /// Which corner is active + var activeCorner: ScreenCorner = .topLeft + /// Whether the gesture is enabled + var isGestureEnabled: Bool = true + + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + // If gesture is disabled, pass through everything + guard isGestureEnabled else { return nil } + + // Check if the point is in the active corner + let cornerRect = rectForCorner(activeCorner) + if cornerRect.contains(point) { + // Let the subview (SwiftUI) handle this tap + return super.hitTest(point, with: event) + } + + // Pass through touches outside the corner + return nil + } + + private func rectForCorner(_ corner: ScreenCorner) -> CGRect { + switch corner { + case .topLeft: + return CGRect(x: 0, y: 0, width: cornerSize, height: cornerSize) + case .topRight: + return CGRect(x: bounds.width - cornerSize, y: 0, width: cornerSize, height: cornerSize) + case .bottomLeft: + return CGRect(x: 0, y: bounds.height - cornerSize, width: cornerSize, height: cornerSize) + case .bottomRight: + return CGRect(x: bounds.width - cornerSize, y: bounds.height - cornerSize, width: cornerSize, height: cornerSize) + } + } +} + +/// A UIView wrapper for the secret exit gesture that can be added to UIKit view controllers +public class SecretExitGestureViewController: UIViewController { + private var hostingController: UIHostingController? + private var cancellables = Set() + private var passthroughView: CornerTapPassthroughView? + + /// Callback when settings should be shown + public var onShowSettings: (() -> Void)? + + public override func loadView() { + let passthrough = CornerTapPassthroughView() + passthrough.backgroundColor = .clear + self.view = passthrough + self.passthroughView = passthrough + } + + public override func viewDidLoad() { + super.viewDidLoad() + setupGestureView() + setupSettingsObserver() + } + + private func setupGestureView() { + let wrapper = SecretExitGestureWrapper { [weak self] in + self?.onShowSettings?() + } + + let hosting = UIHostingController(rootView: wrapper) + hosting.view.backgroundColor = .clear + + addChild(hosting) + view.addSubview(hosting.view) + hosting.view.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + hosting.view.topAnchor.constraint(equalTo: view.topAnchor), + hosting.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), + hosting.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + hosting.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), + ]) + + hosting.didMove(toParent: self) + hostingController = hosting + + // Initial settings sync + updatePassthroughSettings() + } + + private func setupSettingsObserver() { + // Observe kiosk settings changes + NotificationCenter.default.addObserver( + self, + selector: #selector(settingsDidChange), + name: KioskModeManager.settingsDidChangeNotification, + object: nil + ) + + // Also observe kiosk mode state changes + NotificationCenter.default.addObserver( + self, + selector: #selector(settingsDidChange), + name: KioskModeManager.kioskModeDidChangeNotification, + object: nil + ) + } + + @objc private func settingsDidChange() { + updatePassthroughSettings() + } + + private func updatePassthroughSettings() { + let settings = KioskModeManager.shared.settings + passthroughView?.isGestureEnabled = settings.secretExitGestureEnabled && KioskModeManager.shared.isKioskModeActive + passthroughView?.activeCorner = settings.secretExitGestureCorner + passthroughView?.cornerSize = 80 + } +} + +/// Internal wrapper that converts the binding-based API to a closure-based one +private struct SecretExitGestureWrapper: View { + let onShowSettings: () -> Void + @State private var showSettings = false + + var body: some View { + SecretExitGestureView(showSettings: $showSettings) + .onChange(of: showSettings) { newValue in + if newValue { + showSettings = false + onShowSettings() + } + } + } +} + +// MARK: - Preview + +@available(iOS 17.0, *) +#Preview { + ZStack { + Color.blue.opacity(0.3) + Text("Tap top-left corner 3 times") + + SecretExitGestureView(showSettings: .constant(false)) + } +} diff --git a/Sources/App/Kiosk/Overlay/StatusOverlayView.swift b/Sources/App/Kiosk/Overlay/StatusOverlayView.swift new file mode 100755 index 0000000000..62f2e7ada9 --- /dev/null +++ b/Sources/App/Kiosk/Overlay/StatusOverlayView.swift @@ -0,0 +1,335 @@ +import Combine +import Shared +import SwiftUI +import UIKit + +// MARK: - Status Overlay View + +/// A floating overlay bar showing connection status, time, battery, and HA entities +public struct StatusOverlayView: View { + @ObservedObject private var manager = KioskModeManager.shared + @State private var isVisible = true + @State private var hideTimer: Timer? + @State private var batteryLevel: Float = 0 + @State private var batteryState: UIDevice.BatteryState = .unknown + @State private var currentTime = Date() + + private let timeTimer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() + + public init() {} + + public var body: some View { + if shouldShow { + overlayContent + .transition(.opacity.combined(with: .move(edge: position == .top ? .top : .bottom))) + .animation(.easeInOut(duration: 0.3), value: isVisible) + .onAppear { + UIDevice.current.isBatteryMonitoringEnabled = true + } + .onDisappear { + hideTimer?.invalidate() + hideTimer = nil + UIDevice.current.isBatteryMonitoringEnabled = false + } + } + } + + private var shouldShow: Bool { + manager.isKioskModeActive && + manager.settings.statusOverlayEnabled && + isVisible + } + + private var position: OverlayPosition { + manager.settings.statusOverlayPosition + } + + @ViewBuilder + private var overlayContent: some View { + HStack(spacing: 12) { + // Connection Status + if manager.settings.showConnectionStatus { + connectionStatusView + } + + Spacer() + + // Time + if manager.settings.showTime { + timeView + } + + // Battery + if manager.settings.showBattery { + batteryView + } + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background(overlayBackground) + .onAppear { + updateBattery() + startAutoHideTimerIfNeeded() + } + .onReceive(timeTimer) { _ in + currentTime = Date() + } + .onReceive(NotificationCenter.default.publisher(for: UIDevice.batteryLevelDidChangeNotification)) { _ in + updateBattery() + } + .onReceive(NotificationCenter.default.publisher(for: UIDevice.batteryStateDidChangeNotification)) { _ in + updateBattery() + } + } + + private var overlayBackground: some View { + RoundedRectangle(cornerRadius: 12) + .fill(.ultraThinMaterial) + .shadow(color: .black.opacity(0.2), radius: 4, x: 0, y: 2) + } + + // MARK: - Connection Status + + @ViewBuilder + private var connectionStatusView: some View { + HStack(spacing: 6) { + Circle() + .fill(connectionColor) + .frame(width: 8, height: 8) + + Text(connectionText) + .font(.caption) + .foregroundColor(.primary) + } + .accessibilityElement(children: .combine) + .accessibilityLabel(KioskConstants.Accessibility.connectionStatus) + .accessibilityValue(connectionText) + } + + private var connectionColor: Color { + manager.isConnectedToHA ? .green : .red + } + + private var connectionText: String { + manager.isConnectedToHA ? "Connected" : "Disconnected" + } + + // MARK: - Time View + + @ViewBuilder + private var timeView: some View { + Text(timeString) + .font(.caption.monospacedDigit()) + .foregroundColor(.primary) + .accessibilityLabel(KioskConstants.Accessibility.timeDisplay) + .accessibilityValue(timeString) + } + + private var timeString: String { + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm" + return formatter.string(from: currentTime) + } + + // MARK: - Battery View + + @ViewBuilder + private var batteryView: some View { + HStack(spacing: 4) { + Image(systemName: batteryIcon) + .foregroundColor(batteryColor) + + Text("\(Int(batteryLevel * 100))%") + .font(.caption.monospacedDigit()) + .foregroundColor(.primary) + } + .accessibilityElement(children: .combine) + .accessibilityLabel(KioskConstants.Accessibility.batteryStatus) + .accessibilityValue("\(Int(batteryLevel * 100)) percent\(batteryState == .charging ? ", charging" : "")") + } + + private var batteryIcon: String { + switch batteryState { + case .charging, .full: + return "battery.100.bolt" + case .unplugged: + if batteryLevel > 0.75 { + return "battery.100" + } else if batteryLevel > 0.50 { + return "battery.75" + } else if batteryLevel > 0.25 { + return "battery.50" + } else { + return "battery.25" + } + case .unknown: + return "battery.0" + @unknown default: + return "battery.0" + } + } + + private var batteryColor: Color { + if batteryState == .charging || batteryState == .full { + return .green + } else if batteryLevel <= 0.20 { + return .red + } else { + return .primary + } + } + + private func updateBattery() { + batteryLevel = UIDevice.current.batteryLevel + batteryState = UIDevice.current.batteryState + } + + // MARK: - Auto-Hide Timer + + private func startAutoHideTimerIfNeeded() { + guard manager.settings.statusOverlayAutoHide > 0 else { return } + resetAutoHideTimer() + } + + private func resetAutoHideTimer() { + hideTimer?.invalidate() + + let timeout = manager.settings.statusOverlayAutoHide + guard timeout > 0 else { + isVisible = true + return + } + + isVisible = true + + hideTimer = Timer.scheduledTimer(withTimeInterval: timeout, repeats: false) { _ in + withAnimation { + isVisible = false + } + } + } +} + +// MARK: - Status Overlay Container View + +/// A container view that positions the status overlay at the top or bottom +public struct StatusOverlayContainerView: View { + @ObservedObject private var manager = KioskModeManager.shared + + public init() {} + + public var body: some View { + GeometryReader { geometry in + VStack { + if manager.settings.statusOverlayPosition == .top { + StatusOverlayView() + .padding(.top, geometry.safeAreaInsets.top + 8) + .padding(.horizontal, 16) + Spacer() + } else { + Spacer() + StatusOverlayView() + .padding(.bottom, geometry.safeAreaInsets.bottom + 8) + .padding(.horizontal, 16) + } + } + } + .edgesIgnoringSafeArea(.all) + .allowsHitTesting(false) + } +} + +// MARK: - UIKit Hosting Controller + +/// A UIViewController that hosts the status overlay +public final class StatusOverlayViewController: UIViewController { + private var hostingController: UIHostingController? + private var cancellables = Set() + + public override func viewDidLoad() { + super.viewDidLoad() + + view.backgroundColor = .clear + // Don't intercept touches - let them pass through to web view + view.isUserInteractionEnabled = false + + let overlayView = StatusOverlayContainerView() + hostingController = UIHostingController(rootView: overlayView) + hostingController?.view.backgroundColor = .clear + hostingController?.view.isUserInteractionEnabled = false + + guard let hostingController, let hostingView = hostingController.view else { return } + + addChild(hostingController) + view.addSubview(hostingView) + hostingView.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + hostingView.topAnchor.constraint(equalTo: view.topAnchor), + hostingView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + hostingView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + hostingView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + ]) + + hostingController.didMove(toParent: self) + + // Listen for kiosk mode changes + setupObservers() + } + + private func setupObservers() { + NotificationCenter.default.addObserver( + self, + selector: #selector(updateVisibility), + name: KioskModeManager.kioskModeDidChangeNotification, + object: nil + ) + + NotificationCenter.default.addObserver( + self, + selector: #selector(updateVisibility), + name: KioskModeManager.settingsDidChangeNotification, + object: nil + ) + + // Initial visibility + updateVisibility() + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + @objc private func updateVisibility() { + let manager = KioskModeManager.shared + let shouldShow = manager.isKioskModeActive && manager.settings.statusOverlayEnabled + + UIView.animate(withDuration: 0.3) { + self.view.alpha = shouldShow ? 1 : 0 + } + } + +} + +// MARK: - Status Overlay Passthrough View + +/// Custom view that passes through touches outside of content +final class StatusOverlayPassthroughView: UIView { + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + // Only intercept touches on the actual overlay content + let hitView = super.hitTest(point, with: event) + + // Pass through touches that don't hit our content + if hitView === self { + return nil + } + + return hitView + } +} + +// MARK: - Preview + +#Preview { + StatusOverlayContainerView() +} diff --git a/Sources/App/Kiosk/Screensaver/ClockScreensaverView.swift b/Sources/App/Kiosk/Screensaver/ClockScreensaverView.swift new file mode 100644 index 0000000000..82cdb32555 --- /dev/null +++ b/Sources/App/Kiosk/Screensaver/ClockScreensaverView.swift @@ -0,0 +1,330 @@ +import Combine +import Shared +import SwiftUI + +// MARK: - Clock Screensaver View + +/// A screensaver view displaying time with optional date and HA entity data +public struct ClockScreensaverView: View { + @ObservedObject private var manager = KioskModeManager.shared + @ObservedObject private var entityProvider = EntityStateProvider.shared + @State private var currentTime = Date() + @State private var pixelShiftOffset: CGSize = .zero + + private let showEntities: Bool + private let timeTimer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() + + public init(showEntities: Bool = false) { + self.showEntities = showEntities + } + + public var body: some View { + GeometryReader { _ in + ZStack { + // Background + Color.black + .edgesIgnoringSafeArea(.all) + + // Clock content + VStack(spacing: 20) { + Spacer() + + clockDisplay + .offset(pixelShiftOffset) + + if manager.settings.clockShowDate { + dateDisplay + .offset(pixelShiftOffset) + } + + if showEntities && !manager.settings.clockEntities.isEmpty { + entityDisplay + .offset(pixelShiftOffset) + .padding(.top, 40) + } + + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + .onAppear { + if showEntities { + let entityIds = manager.settings.clockEntities.map(\.entityId) + entityProvider.watchEntities(entityIds) + } + } + .onDisappear { + if showEntities { + entityProvider.stopWatching() + } + } + .onReceive(timeTimer) { _ in + currentTime = Date() + } + .onReceive(NotificationCenter.default.publisher(for: .kioskPixelShiftTick)) { _ in + applyPixelShift() + } + } + + // MARK: - Clock Display + + @ViewBuilder + private var clockDisplay: some View { + switch manager.settings.clockStyle { + case .large: + largeClockDisplay + + case .minimal: + minimalClockDisplay + + case .analog: + analogClockDisplay + + case .digital: + digitalClockDisplay + } + } + + private var largeClockDisplay: some View { + Text(timeString) + .font(.system(size: 120, weight: .thin, design: .rounded)) + .foregroundColor(.white) + .monospacedDigit() + .accessibilityLabel("Current time: \(accessibleTimeString)") + } + + private var minimalClockDisplay: some View { + Text(timeString) + .font(.system(size: 80, weight: .ultraLight, design: .default)) + .foregroundColor(.white.opacity(0.9)) + .monospacedDigit() + .accessibilityLabel("Current time: \(accessibleTimeString)") + } + + private var digitalClockDisplay: some View { + Text(timeString) + .font(.system(size: 100, weight: .medium, design: .monospaced)) + .foregroundColor(.green) + .monospacedDigit() + .accessibilityLabel("Current time: \(accessibleTimeString)") + } + + private var analogClockDisplay: some View { + // Analog clock face + AnalogClockView(date: currentTime) + .frame(width: 300, height: 300) + .accessibilityLabel("Analog clock showing \(accessibleTimeString)") + } + + private var timeString: String { + let formatter = DateFormatter() + let use24Hour = manager.settings.clockUse24HourFormat + let showSeconds = manager.settings.clockShowSeconds + + if use24Hour { + formatter.dateFormat = showSeconds ? "HH:mm:ss" : "HH:mm" + } else { + formatter.dateFormat = showSeconds ? "h:mm:ss a" : "h:mm a" + } + return formatter.string(from: currentTime) + } + + private var accessibleTimeString: String { + let formatter = DateFormatter() + formatter.timeStyle = .short + return formatter.string(from: currentTime) + } + + // MARK: - Date Display + + private var dateDisplay: some View { + Text(dateString) + .font(.system(size: 28, weight: .light, design: .rounded)) + .foregroundColor(.white.opacity(0.7)) + .accessibilityLabel("Date: \(dateString)") + } + + private var dateString: String { + let formatter = DateFormatter() + formatter.dateFormat = "EEEE, MMMM d" + return formatter.string(from: currentTime) + } + + // MARK: - Entity Display + + private var entityDisplay: some View { + HStack(spacing: 40) { + ForEach(manager.settings.clockEntities.prefix(4)) { entity in + EntityValueView(config: entity) + } + } + } + + // MARK: - Pixel Shift + + private func applyPixelShift() { + guard manager.settings.pixelShiftEnabled else { return } + + let amount = manager.settings.pixelShiftAmount + + withAnimation(.easeInOut(duration: 1.0)) { + pixelShiftOffset = CGSize( + width: CGFloat.random(in: -amount...amount), + height: CGFloat.random(in: -amount...amount) + ) + } + } +} + +// MARK: - Analog Clock View + +struct AnalogClockView: View { + let date: Date + + private var calendar: Calendar { Calendar.current } + + private var hours: Int { + calendar.component(.hour, from: date) % 12 + } + + private var minutes: Int { + calendar.component(.minute, from: date) + } + + private var seconds: Int { + calendar.component(.second, from: date) + } + + var body: some View { + ZStack { + // Clock face + Circle() + .stroke(Color.white.opacity(0.3), lineWidth: 2) + .accessibilityHidden(true) + + // Hour markers + ForEach(0..<12) { hour in + Rectangle() + .fill(Color.white.opacity(0.6)) + .frame(width: hour % 3 == 0 ? 3 : 1, height: hour % 3 == 0 ? 15 : 8) + .offset(y: -130) + .rotationEffect(.degrees(Double(hour) * 30)) + .accessibilityHidden(true) + } + + // Hour hand + Rectangle() + .fill(Color.white) + .frame(width: 4, height: 70) + .offset(y: -35) + .rotationEffect(.degrees(Double(hours) * 30 + Double(minutes) * 0.5)) + .accessibilityHidden(true) + + // Minute hand + Rectangle() + .fill(Color.white) + .frame(width: 3, height: 100) + .offset(y: -50) + .rotationEffect(.degrees(Double(minutes) * 6)) + .accessibilityHidden(true) + + // Second hand + Rectangle() + .fill(Color.red) + .frame(width: 1, height: 110) + .offset(y: -55) + .rotationEffect(.degrees(Double(seconds) * 6)) + .accessibilityHidden(true) + + // Center dot + Circle() + .fill(Color.white) + .frame(width: 10, height: 10) + .accessibilityHidden(true) + } + .accessibilityElement(children: .ignore) + } +} + +// MARK: - Entity Value View + +struct EntityValueView: View { + let config: ClockEntityConfig + @ObservedObject private var entityProvider = EntityStateProvider.shared + + private var entityState: EntityState? { + entityProvider.state(for: config.entityId) + } + + var body: some View { + VStack(spacing: 8) { + // Icon + iconView + .font(.system(size: 24)) + .foregroundColor(.white.opacity(0.7)) + .accessibilityHidden(true) + + // Value + Text(displayValue) + .font(.system(size: 32, weight: .medium, design: .rounded)) + .foregroundColor(.white) + .monospacedDigit() + .accessibilityHidden(true) + + // Label + Text(displayLabel) + .font(.caption) + .foregroundColor(.white.opacity(0.5)) + .lineLimit(1) + .accessibilityHidden(true) + + // Unit (if separate from value) + if config.showUnit, let unit = entityState?.unitOfMeasurement, !unit.isEmpty { + Text(unit) + .font(.caption2) + .foregroundColor(.white.opacity(0.4)) + .accessibilityHidden(true) + } + } + .accessibilityElement(children: .combine) + .accessibilityLabel(accessibilityDescription) + } + + private var accessibilityDescription: String { + var description = displayLabel + description += ": \(displayValue)" + if config.showUnit, let unit = entityState?.unitOfMeasurement, !unit.isEmpty { + description += " \(unit)" + } + return description + } + + @ViewBuilder + private var iconView: some View { + let iconName = config.icon ?? entityState?.icon + if let iconName { + Image(systemName: IconMapper.sfSymbol(from: iconName, default: "sensor.fill")) + } else { + Image(systemName: "sensor.fill") + } + } + + private var displayValue: String { + entityState?.value ?? "--" + } + + private var displayLabel: String { + config.label ?? entityState?.friendlyName ?? config.entityId + } +} + +// MARK: - Preview + +#Preview("Large Clock") { + ClockScreensaverView(showEntities: false) +} + +#Preview("Clock with Entities") { + ClockScreensaverView(showEntities: true) +} diff --git a/Sources/App/Kiosk/Screensaver/CustomURLScreensaverView.swift b/Sources/App/Kiosk/Screensaver/CustomURLScreensaverView.swift new file mode 100755 index 0000000000..749b0ac926 --- /dev/null +++ b/Sources/App/Kiosk/Screensaver/CustomURLScreensaverView.swift @@ -0,0 +1,218 @@ +import Combine +import Shared +import SwiftUI +import WebKit + +// MARK: - Custom URL Screensaver View + +/// A screensaver that displays a custom URL (e.g., a custom HA dashboard) in a WebView +public struct CustomURLScreensaverView: View { + @ObservedObject private var manager = KioskModeManager.shared + @State private var isLoading = true + @State private var loadError: String? + @State private var pixelShiftOffset: CGSize = .zero + + public init() {} + + public var body: some View { + GeometryReader { _ in + ZStack { + // Background + Color.black + .edgesIgnoringSafeArea(.all) + + // WebView content + if let url = screensaverURL { + ScreensaverWebView( + url: url, + isLoading: $isLoading, + loadError: $loadError + ) + .offset(pixelShiftOffset) + .edgesIgnoringSafeArea(.all) + } else { + noURLConfiguredView + } + + // Loading overlay + if isLoading { + loadingOverlay + } + + // Error overlay + if let error = loadError { + errorOverlay(error) + } + } + } + .onReceive(NotificationCenter.default.publisher(for: .kioskPixelShiftTick)) { _ in + applyPixelShift() + } + } + + private var screensaverURL: URL? { + let urlString = manager.settings.screensaverCustomURL + guard !urlString.isEmpty else { return nil } + + // If it's a relative path, construct full URL from server + if urlString.hasPrefix("/") { + if let server = Current.servers.all.first, + let baseURL = server.info.connection.activeURL() { + return URL(string: baseURL.absoluteString + urlString) + } + } + + return URL(string: urlString) + } + + private var noURLConfiguredView: some View { + VStack(spacing: 16) { + Image(systemName: "globe") + .font(.system(size: 48)) + .foregroundColor(.white.opacity(0.6)) + + Text("No URL Configured") + .font(.headline) + .foregroundColor(.white.opacity(0.8)) + + Text("Set a custom URL in HAFrame screensaver settings") + .font(.caption) + .foregroundColor(.white.opacity(0.5)) + .multilineTextAlignment(.center) + } + } + + private var loadingOverlay: some View { + ZStack { + Color.black.opacity(0.7) + .edgesIgnoringSafeArea(.all) + + VStack(spacing: 16) { + ProgressView() + .scaleEffect(1.5) + .tint(.white) + + Text("Loading...") + .font(.headline) + .foregroundColor(.white.opacity(0.8)) + } + } + } + + private func errorOverlay(_ message: String) -> some View { + VStack(spacing: 16) { + Image(systemName: "exclamationmark.triangle") + .font(.system(size: 48)) + .foregroundColor(.orange) + + Text("Failed to Load") + .font(.headline) + .foregroundColor(.white) + + Text(message) + .font(.caption) + .foregroundColor(.white.opacity(0.7)) + .multilineTextAlignment(.center) + .padding(.horizontal, 40) + } + } + + private func applyPixelShift() { + guard manager.settings.pixelShiftEnabled else { return } + + let amount = manager.settings.pixelShiftAmount + + withAnimation(.easeInOut(duration: 1.0)) { + pixelShiftOffset = CGSize( + width: CGFloat.random(in: -amount...amount), + height: CGFloat.random(in: -amount...amount) + ) + } + } +} + +// MARK: - Screensaver WebView + +/// A UIViewRepresentable wrapper for WKWebView optimized for screensaver display +struct ScreensaverWebView: UIViewRepresentable { + let url: URL + @Binding var isLoading: Bool + @Binding var loadError: String? + + func makeUIView(context: Context) -> WKWebView { + let config = WKWebViewConfiguration() + + // Disable user interaction for screensaver mode + config.allowsInlineMediaPlayback = true + config.mediaTypesRequiringUserActionForPlayback = [] + + let webView = WKWebView(frame: .zero, configuration: config) + webView.navigationDelegate = context.coordinator + webView.isOpaque = false + webView.backgroundColor = .black + webView.scrollView.backgroundColor = .black + + // Disable scrolling and interactions + webView.scrollView.isScrollEnabled = false + webView.isUserInteractionEnabled = false + + // Hide scrollbars + webView.scrollView.showsHorizontalScrollIndicator = false + webView.scrollView.showsVerticalScrollIndicator = false + + return webView + } + + func updateUIView(_ webView: WKWebView, context: Context) { + // Only load if URL changed + if webView.url != url { + let request = URLRequest(url: url) + webView.load(request) + } + } + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + class Coordinator: NSObject, WKNavigationDelegate { + let parent: ScreensaverWebView + + init(_ parent: ScreensaverWebView) { + self.parent = parent + } + + func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) { + DispatchQueue.main.async { + self.parent.isLoading = true + self.parent.loadError = nil + } + } + + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + DispatchQueue.main.async { + self.parent.isLoading = false + } + } + + func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { + DispatchQueue.main.async { + self.parent.isLoading = false + self.parent.loadError = error.localizedDescription + } + } + + func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { + DispatchQueue.main.async { + self.parent.isLoading = false + self.parent.loadError = error.localizedDescription + } + } + } +} + +// MARK: - Preview + +#Preview { + CustomURLScreensaverView() +} diff --git a/Sources/App/Kiosk/Screensaver/EntityStateProvider.swift b/Sources/App/Kiosk/Screensaver/EntityStateProvider.swift new file mode 100755 index 0000000000..006982942e --- /dev/null +++ b/Sources/App/Kiosk/Screensaver/EntityStateProvider.swift @@ -0,0 +1,169 @@ +import Combine +import Foundation +import HAKit +import Shared + +// MARK: - Entity State Provider + +/// Provides real-time entity state data from Home Assistant for screensaver display +@MainActor +public final class EntityStateProvider: ObservableObject { + // MARK: - Singleton + + public static let shared = EntityStateProvider() + + // MARK: - Published State + + /// Current entity states keyed by entity ID + @Published public private(set) var entityStates: [String: EntityState] = [:] + + /// Whether we're currently connected to HA + @Published public private(set) var isConnected: Bool = false + + // MARK: - Private + + private var subscriptionToken: HACancellable? + private var watchedEntityIds: Set = [] + + // MARK: - Initialization + + private init() {} + + // MARK: - Public Methods + + /// Start watching specific entities for state changes + public func watchEntities(_ entityIds: [String]) { + let newIds = Set(entityIds) + guard newIds != watchedEntityIds else { return } + + watchedEntityIds = newIds + subscribeToEntities() + } + + /// Get the current state for an entity + public func state(for entityId: String) -> EntityState? { + entityStates[entityId] + } + + /// Stop watching all entities + public func stopWatching() { + subscriptionToken?.cancel() + subscriptionToken = nil + watchedEntityIds.removeAll() + entityStates.removeAll() + isConnected = false + } + + // MARK: - Private Methods + + private func subscribeToEntities() { + subscriptionToken?.cancel() + + guard let server = Current.servers.all.first, + let api = Current.api(for: server) else { + Current.Log.warning("No HA server available for entity subscription") + isConnected = false + return + } + + Current.Log.info("Subscribing to \(watchedEntityIds.count) entities for screensaver") + + subscriptionToken = api.connection.caches.states().subscribe { [weak self] _, states in + Task { @MainActor [weak self] in + guard let self else { return } + + self.isConnected = true + + // Update only the entities we're watching + var newStates: [String: EntityState] = [:] + for entityId in self.watchedEntityIds { + if let haEntity = states.all.first(where: { $0.entityId == entityId }) { + newStates[entityId] = EntityState(from: haEntity) + } + } + self.entityStates = newStates + } + } + } +} + +// MARK: - Entity State Model + +/// Simplified entity state for display purposes +public struct EntityState: Identifiable, Equatable { + public var id: String { entityId } + + public let entityId: String + public let state: String + public let friendlyName: String + public let icon: String? + public let unitOfMeasurement: String? + public let lastUpdated: Date? + + /// Formatted display value including unit + public var displayValue: String { + if let unit = unitOfMeasurement, !unit.isEmpty { + return "\(state) \(unit)" + } + return state + } + + /// Just the value without unit + public var value: String { + state + } + + init(from haEntity: HAEntity) { + self.entityId = haEntity.entityId + self.state = haEntity.state + self.friendlyName = haEntity.attributes["friendly_name"] as? String ?? haEntity.entityId + self.icon = haEntity.attributes["icon"] as? String + self.unitOfMeasurement = haEntity.attributes["unit_of_measurement"] as? String + self.lastUpdated = haEntity.lastUpdated + } + + // For previews + init(entityId: String, state: String, friendlyName: String, icon: String? = nil, unit: String? = nil) { + self.entityId = entityId + self.state = state + self.friendlyName = friendlyName + self.icon = icon + self.unitOfMeasurement = unit + self.lastUpdated = Date() + } +} + +// MARK: - Preview Helpers + +#if DEBUG +extension EntityStateProvider { + static var preview: EntityStateProvider { + let provider = EntityStateProvider() + provider.entityStates = [ + "sensor.temperature": EntityState( + entityId: "sensor.temperature", + state: "21.5", + friendlyName: "Temperature", + icon: "mdi:thermometer", + unit: "°C" + ), + "sensor.humidity": EntityState( + entityId: "sensor.humidity", + state: "45", + friendlyName: "Humidity", + icon: "mdi:water-percent", + unit: "%" + ), + "weather.home": EntityState( + entityId: "weather.home", + state: "sunny", + friendlyName: "Weather", + icon: "mdi:weather-sunny", + unit: nil + ), + ] + provider.isConnected = true + return provider + } +} +#endif diff --git a/Sources/App/Kiosk/Screensaver/PhotoManager.swift b/Sources/App/Kiosk/Screensaver/PhotoManager.swift new file mode 100755 index 0000000000..12cc088392 --- /dev/null +++ b/Sources/App/Kiosk/Screensaver/PhotoManager.swift @@ -0,0 +1,390 @@ +import Combine +import Foundation +import HAKit +import Photos +import Shared +import UIKit + +// MARK: - Photo Manager + +/// Manages fetching photos from multiple sources for the screensaver +@MainActor +public final class PhotoManager: ObservableObject { + // MARK: - Singleton + + public static let shared = PhotoManager() + + // MARK: - Published State + + /// Currently available photos + @Published public private(set) var photos: [ScreensaverPhoto] = [] + + /// Current photo index + @Published public private(set) var currentIndex: Int = 0 + + /// Whether we're currently loading photos + @Published public private(set) var isLoading: Bool = false + + /// Error message if loading failed + @Published public private(set) var errorMessage: String? + + /// Photos authorization status + @Published public private(set) var authorizationStatus: PHAuthorizationStatus = .notDetermined + + // MARK: - Private + + private var loadTask: Task? + private var rotationTimer: Timer? + private var settings: KioskSettings { KioskModeManager.shared.settings } + + // MARK: - Initialization + + private init() { + checkAuthorizationStatus() + } + + // MARK: - Public Methods + + /// Load photos from configured sources + public func loadPhotos() { + loadTask?.cancel() + + loadTask = Task { + await loadPhotosAsync() + } + } + + /// Start automatic photo rotation + public func startRotation() { + stopRotation() + + let interval = settings.photoInterval + guard interval > 0 else { return } + + rotationTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in + Task { @MainActor [weak self] in + self?.nextPhoto() + } + } + } + + /// Stop automatic photo rotation + public func stopRotation() { + rotationTimer?.invalidate() + rotationTimer = nil + } + + /// Move to next photo + public func nextPhoto() { + guard !photos.isEmpty else { return } + currentIndex = (currentIndex + 1) % photos.count + } + + /// Move to previous photo + public func previousPhoto() { + guard !photos.isEmpty else { return } + currentIndex = (currentIndex - 1 + photos.count) % photos.count + } + + /// Get current photo + public var currentPhoto: ScreensaverPhoto? { + guard currentIndex < photos.count else { return nil } + return photos[currentIndex] + } + + /// Request photo library access + public func requestAccess() async -> Bool { + let status = await PHPhotoLibrary.requestAuthorization(for: .readWrite) + authorizationStatus = status + return status == .authorized || status == .limited + } + + // MARK: - Private Methods + + private func checkAuthorizationStatus() { + authorizationStatus = PHPhotoLibrary.authorizationStatus(for: .readWrite) + } + + private func loadPhotosAsync() async { + isLoading = true + errorMessage = nil + + var allPhotos: [ScreensaverPhoto] = [] + + // Load from each configured source + switch settings.photoSource { + case .local: + allPhotos = await loadLocalPhotos() + + case .iCloud: + allPhotos = await loadiCloudPhotos() + + case .haMedia: + allPhotos = await loadHAMediaPhotos() + + case .all: + async let local = loadLocalPhotos() + async let iCloud = loadiCloudPhotos() + async let haMedia = loadHAMediaPhotos() + + let results = await [local, iCloud, haMedia] + allPhotos = results.flatMap { $0 } + } + + // Shuffle photos for variety + photos = allPhotos.shuffled() + currentIndex = 0 + isLoading = false + + if photos.isEmpty { + errorMessage = "No photos found" + } + } + + private func loadLocalPhotos() async -> [ScreensaverPhoto] { + guard authorizationStatus == .authorized || authorizationStatus == .limited else { + Current.Log.warning("Photo library access not authorized") + return [] + } + + return await withCheckedContinuation { continuation in + var photos: [ScreensaverPhoto] = [] + + let fetchOptions = PHFetchOptions() + fetchOptions.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: false)] + fetchOptions.fetchLimit = 100 // Limit to prevent memory issues + + let fetchResult: PHFetchResult + + // If specific albums are configured, fetch from those + if !settings.localPhotoAlbums.isEmpty { + let albumsFetch = PHAssetCollection.fetchAssetCollections( + withLocalIdentifiers: settings.localPhotoAlbums, + options: nil + ) + + var assets: [PHAsset] = [] + albumsFetch.enumerateObjects { collection, _, _ in + let assetsFetch = PHAsset.fetchAssets(in: collection, options: fetchOptions) + assetsFetch.enumerateObjects { asset, _, _ in + assets.append(asset) + } + } + + for asset in assets.prefix(100) { + photos.append(ScreensaverPhoto(asset: asset)) + } + } else { + // Fetch from all photos + fetchResult = PHAsset.fetchAssets(with: .image, options: fetchOptions) + fetchResult.enumerateObjects { asset, _, _ in + photos.append(ScreensaverPhoto(asset: asset)) + } + } + + Current.Log.info("Loaded \(photos.count) local photos") + continuation.resume(returning: photos) + } + } + + private func loadiCloudPhotos() async -> [ScreensaverPhoto] { + guard authorizationStatus == .authorized || authorizationStatus == .limited else { + return [] + } + + return await withCheckedContinuation { continuation in + var photos: [ScreensaverPhoto] = [] + + let fetchOptions = PHFetchOptions() + fetchOptions.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: false)] + fetchOptions.fetchLimit = 100 + + // Fetch from shared albums if configured + if !settings.iCloudAlbums.isEmpty { + let sharedAlbumsFetch = PHAssetCollection.fetchAssetCollections( + withLocalIdentifiers: settings.iCloudAlbums, + options: nil + ) + + sharedAlbumsFetch.enumerateObjects { collection, _, _ in + let assetsFetch = PHAsset.fetchAssets(in: collection, options: fetchOptions) + assetsFetch.enumerateObjects { asset, _, _ in + photos.append(ScreensaverPhoto(asset: asset)) + } + } + } else { + // Fetch from all shared albums + let sharedAlbums = PHAssetCollection.fetchAssetCollections( + with: .album, + subtype: .albumCloudShared, + options: nil + ) + + sharedAlbums.enumerateObjects { collection, _, stop in + let assetsFetch = PHAsset.fetchAssets(in: collection, options: fetchOptions) + assetsFetch.enumerateObjects { asset, _, _ in + photos.append(ScreensaverPhoto(asset: asset)) + } + + if photos.count >= 100 { + stop.pointee = true + } + } + } + + Current.Log.info("Loaded \(photos.count) iCloud photos") + continuation.resume(returning: photos) + } + } + + private func loadHAMediaPhotos() async -> [ScreensaverPhoto] { + guard let server = Current.servers.all.first, + let api = Current.api(for: server), + !settings.haMediaPath.isEmpty else { + return [] + } + + // Fetch media from Home Assistant Media Browser + // This is a simplified implementation - full implementation would use HA's media_source API + do { + let request = HATypedRequest(request: .init( + type: "media_source/browse_media", + data: ["media_content_id": settings.haMediaPath] + )) + + guard let response = try await api.connection.send(request).promise.value else { + Current.Log.error("No response from HA media browser") + return [] + } + let photos = response.children.compactMap { item -> ScreensaverPhoto? in + guard item.mediaClass == "image", + let urlString = item.mediaContentId, + let url = URL(string: urlString) else { + return nil + } + return ScreensaverPhoto(url: url, title: item.title) + } + + Current.Log.info("Loaded \(photos.count) HA media photos") + return Array(photos.prefix(100)) + } catch { + Current.Log.error("Failed to load HA media photos: \(error)") + return [] + } + } +} + +// MARK: - Screensaver Photo + +/// Represents a photo for the screensaver +public struct ScreensaverPhoto: Identifiable, Equatable { + public let id: String + public let source: PhotoSourceType + + /// The PHAsset for local/iCloud photos + public let asset: PHAsset? + + /// URL for remote photos (HA media) + public let url: URL? + + /// Optional title/caption + public let title: String? + + public static func == (lhs: ScreensaverPhoto, rhs: ScreensaverPhoto) -> Bool { + lhs.id == rhs.id + } + + init(asset: PHAsset) { + self.id = asset.localIdentifier + self.source = .local + self.asset = asset + self.url = nil + self.title = nil + } + + init(url: URL, title: String? = nil) { + self.id = url.absoluteString + self.source = .remote + self.asset = nil + self.url = url + self.title = title + } + + /// Load the image for display + @MainActor + public func loadImage(targetSize: CGSize) async -> UIImage? { + switch source { + case .local: + return await loadFromAsset(targetSize: targetSize) + case .remote: + return await loadFromURL() + } + } + + private func loadFromAsset(targetSize: CGSize) async -> UIImage? { + guard let asset else { return nil } + + return await withCheckedContinuation { continuation in + let options = PHImageRequestOptions() + options.deliveryMode = .highQualityFormat + options.isNetworkAccessAllowed = true + options.resizeMode = .exact + + PHImageManager.default().requestImage( + for: asset, + targetSize: targetSize, + contentMode: .aspectFill, + options: options + ) { image, _ in + continuation.resume(returning: image) + } + } + } + + private func loadFromURL() async -> UIImage? { + guard let url else { return nil } + + do { + let (data, _) = try await URLSession.shared.data(from: url) + return UIImage(data: data) + } catch { + Current.Log.error("Failed to load image from URL: \(error)") + return nil + } + } +} + +public enum PhotoSourceType { + case local + case remote +} + +// MARK: - HA Media Items Response + +struct HAMediaItems: HADataDecodable { + let title: String + let mediaClass: String? + let mediaContentId: String? + let children: [HAMediaItem] + + init(data: HAData) throws { + self.title = try data.decode("title") + self.mediaClass = try? data.decode("media_class") + self.mediaContentId = try? data.decode("media_content_id") + self.children = (try? data.decode("children")) ?? [] + } +} + +struct HAMediaItem: HADataDecodable { + let title: String + let mediaClass: String? + let mediaContentId: String? + let thumbnail: String? + + init(data: HAData) throws { + self.title = try data.decode("title") + self.mediaClass = try? data.decode("media_class") + self.mediaContentId = try? data.decode("media_content_id") + self.thumbnail = try? data.decode("thumbnail") + } +} diff --git a/Sources/App/Kiosk/Screensaver/PhotoScreensaverView.swift b/Sources/App/Kiosk/Screensaver/PhotoScreensaverView.swift new file mode 100755 index 0000000000..512eac1ebf --- /dev/null +++ b/Sources/App/Kiosk/Screensaver/PhotoScreensaverView.swift @@ -0,0 +1,272 @@ +import Combine +import Photos +import Shared +import SwiftUI + +// MARK: - Photo Screensaver View + +/// A screensaver view that displays photos with smooth transitions +public struct PhotoScreensaverView: View { + @ObservedObject private var manager = KioskModeManager.shared + @ObservedObject private var photoManager = PhotoManager.shared + + @State private var currentImage: UIImage? + @State private var nextImage: UIImage? + @State private var showingNext: Bool = false + @State private var pixelShiftOffset: CGSize = .zero + + private let showClock: Bool + private let screenSize = UIScreen.main.bounds.size + + public init(showClock: Bool = false) { + self.showClock = showClock + } + + public var body: some View { + GeometryReader { geometry in + ZStack { + // Background + Color.black + .edgesIgnoringSafeArea(.all) + + // Photo display with crossfade transition + photoContent(size: geometry.size) + .offset(pixelShiftOffset) + + // Optional clock overlay + if showClock { + clockOverlay + .offset(pixelShiftOffset) + } + + // Loading indicator + if photoManager.isLoading && currentImage == nil { + loadingView + } + + // Error message + if let error = photoManager.errorMessage, currentImage == nil { + errorView(error) + } + + // Photo access request + if photoManager.authorizationStatus == .notDetermined { + accessRequestView + } + } + } + .onAppear { + startPhotoDisplay() + } + .onDisappear { + stopPhotoDisplay() + } + .onReceive(photoManager.$currentIndex) { _ in + loadCurrentPhoto() + } + .onReceive(NotificationCenter.default.publisher(for: .kioskPixelShiftTick)) { _ in + applyPixelShift() + } + } + + // MARK: - Photo Content + + @ViewBuilder + private func photoContent(size: CGSize) -> some View { + ZStack { + // Current image + if let image = currentImage { + Image(uiImage: image) + .resizable() + .aspectRatio(contentMode: manager.settings.photoFitMode == .fill ? .fill : .fit) + .frame(width: size.width, height: size.height) + .clipped() + .opacity(showingNext ? 0 : 1) + .animation(.easeInOut(duration: 1.0), value: showingNext) + } + + // Next image (for crossfade) + if let image = nextImage { + Image(uiImage: image) + .resizable() + .aspectRatio(contentMode: manager.settings.photoFitMode == .fill ? .fill : .fit) + .frame(width: size.width, height: size.height) + .clipped() + .opacity(showingNext ? 1 : 0) + .animation(.easeInOut(duration: 1.0), value: showingNext) + } + } + } + + // MARK: - Clock Overlay + + private var clockOverlay: some View { + VStack { + Spacer() + + HStack { + Spacer() + + VStack(alignment: .trailing, spacing: 4) { + Text(timeString) + .font(.system(size: 48, weight: .light, design: .rounded)) + .foregroundColor(.white) + .shadow(color: .black.opacity(0.5), radius: 4, x: 0, y: 2) + + if manager.settings.clockShowDate { + Text(dateString) + .font(.system(size: 18, weight: .light, design: .rounded)) + .foregroundColor(.white.opacity(0.9)) + .shadow(color: .black.opacity(0.5), radius: 2, x: 0, y: 1) + } + } + .padding(24) + .background( + RoundedRectangle(cornerRadius: 16) + .fill(.ultraThinMaterial.opacity(0.3)) + ) + .padding(32) + } + } + } + + private var timeString: String { + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm" + return formatter.string(from: Date()) + } + + private var dateString: String { + let formatter = DateFormatter() + formatter.dateFormat = "EEEE, MMMM d" + return formatter.string(from: Date()) + } + + // MARK: - Loading View + + private var loadingView: some View { + VStack(spacing: 16) { + ProgressView() + .scaleEffect(1.5) + .tint(.white) + + Text("Loading photos...") + .font(.headline) + .foregroundColor(.white.opacity(0.8)) + } + } + + // MARK: - Error View + + private func errorView(_ message: String) -> some View { + VStack(spacing: 16) { + Image(systemName: "photo.on.rectangle.angled") + .font(.system(size: 48)) + .foregroundColor(.white.opacity(0.6)) + + Text(message) + .font(.headline) + .foregroundColor(.white.opacity(0.8)) + + Text("Configure photo sources in HAFrame settings") + .font(.caption) + .foregroundColor(.white.opacity(0.5)) + } + } + + // MARK: - Access Request View + + private var accessRequestView: some View { + VStack(spacing: 20) { + Image(systemName: "photo.badge.plus") + .font(.system(size: 64)) + .foregroundColor(.white.opacity(0.8)) + + Text("Photo Access Required") + .font(.title2.weight(.semibold)) + .foregroundColor(.white) + + Text("HAFrame needs access to your photos to display them as a screensaver.") + .font(.body) + .foregroundColor(.white.opacity(0.7)) + .multilineTextAlignment(.center) + .padding(.horizontal, 40) + + Button { + Task { + if await photoManager.requestAccess() { + photoManager.loadPhotos() + } + } + } label: { + Text("Grant Access") + .font(.headline) + .foregroundColor(.black) + .padding(.horizontal, 32) + .padding(.vertical, 12) + .background(Color.white) + .cornerRadius(10) + } + } + } + + // MARK: - Photo Management + + private func startPhotoDisplay() { + photoManager.loadPhotos() + photoManager.startRotation() + } + + private func stopPhotoDisplay() { + photoManager.stopRotation() + } + + private func loadCurrentPhoto() { + guard let photo = photoManager.currentPhoto else { return } + + Task { + let image = await photo.loadImage(targetSize: screenSize) + + // Crossfade transition + if currentImage != nil { + nextImage = image + withAnimation { + showingNext = true + } + + // After animation completes, swap images + try? await Task.sleep(nanoseconds: 1_100_000_000) // 1.1 seconds + currentImage = image + nextImage = nil + showingNext = false + } else { + currentImage = image + } + } + } + + // MARK: - Pixel Shift + + private func applyPixelShift() { + guard manager.settings.pixelShiftEnabled else { return } + + let amount = manager.settings.pixelShiftAmount + + withAnimation(.easeInOut(duration: 1.0)) { + pixelShiftOffset = CGSize( + width: CGFloat.random(in: -amount...amount), + height: CGFloat.random(in: -amount...amount) + ) + } + } +} + +// MARK: - Preview + +#Preview("Photo Screensaver") { + PhotoScreensaverView(showClock: false) +} + +#Preview("Photo with Clock") { + PhotoScreensaverView(showClock: true) +} diff --git a/Sources/App/Kiosk/Screensaver/ScreensaverViewController.swift b/Sources/App/Kiosk/Screensaver/ScreensaverViewController.swift new file mode 100644 index 0000000000..b8bf3ea0e4 --- /dev/null +++ b/Sources/App/Kiosk/Screensaver/ScreensaverViewController.swift @@ -0,0 +1,295 @@ +import Combine +import Shared +import SwiftUI +import UIKit + +// MARK: - Screensaver View Controller + +/// Main view controller that hosts and manages screensaver views +/// Supports multiple screensaver modes: blank, dim, clock, photos, custom URL +public final class ScreensaverViewController: UIViewController, UIGestureRecognizerDelegate { + // MARK: - Properties + + private var currentMode: ScreensaverMode? + private var hostingController: UIHostingController? + private var secretExitGestureController: SecretExitGestureViewController? + private var cancellables = Set() + private var pixelShiftOffset: CGPoint = .zero + private var wakeGesture: UITapGestureRecognizer? + + /// Callback when secret exit gesture wants to show settings + public var onShowSettings: (() -> Void)? + + // MARK: - Lifecycle + + public override func viewDidLoad() { + super.viewDidLoad() + + view.backgroundColor = .black + view.isUserInteractionEnabled = true + + // Add tap gesture to wake screen + let tapGesture = UITapGestureRecognizer(target: self, action: #selector(handleTap)) + tapGesture.delegate = self + wakeGesture = tapGesture + view.addGestureRecognizer(tapGesture) + + // Add swipe gesture for additional wake trigger + let swipeGesture = UISwipeGestureRecognizer(target: self, action: #selector(handleSwipe)) + swipeGesture.direction = [.up, .down, .left, .right] + view.addGestureRecognizer(swipeGesture) + + setupObservers() + setupSecretExitGesture() + } + + // MARK: - Gesture Recognizer Delegate + + public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { + // Don't let the wake gesture intercept taps in the secret exit corner + guard gestureRecognizer === wakeGesture else { return true } + + let settings = KioskModeManager.shared.settings + guard settings.secretExitGestureEnabled && KioskModeManager.shared.isKioskModeActive else { + return true + } + + let location = touch.location(in: view) + let cornerSize: CGFloat = 80 + let corner = settings.secretExitGestureCorner + + let cornerRect: CGRect + switch corner { + case .topLeft: + cornerRect = CGRect(x: 0, y: 0, width: cornerSize, height: cornerSize) + case .topRight: + cornerRect = CGRect(x: view.bounds.width - cornerSize, y: 0, width: cornerSize, height: cornerSize) + case .bottomLeft: + cornerRect = CGRect(x: 0, y: view.bounds.height - cornerSize, width: cornerSize, height: cornerSize) + case .bottomRight: + cornerRect = CGRect(x: view.bounds.width - cornerSize, y: view.bounds.height - cornerSize, width: cornerSize, height: cornerSize) + } + + // If touch is in the corner, don't let wake gesture receive it + return !cornerRect.contains(location) + } + + // MARK: - Secret Exit Gesture + + private func setupSecretExitGesture() { + let controller = SecretExitGestureViewController() + secretExitGestureController = controller + + // Forward the callback + controller.onShowSettings = { [weak self] in + self?.onShowSettings?() + } + + addChild(controller) + view.addSubview(controller.view) + controller.view.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + controller.view.topAnchor.constraint(equalTo: view.topAnchor), + controller.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), + controller.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + controller.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), + ]) + + controller.didMove(toParent: self) + + // Bring to front so it can receive taps + view.bringSubviewToFront(controller.view) + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + public override var prefersStatusBarHidden: Bool { + true + } + + public override var prefersHomeIndicatorAutoHidden: Bool { + true + } + + // MARK: - Public Methods + + /// Show the screensaver with the specified mode + public func show(mode: ScreensaverMode) { + guard currentMode != mode else { return } + + Current.Log.info("Showing screensaver: \(mode.rawValue)") + currentMode = mode + + // Remove existing content + hostingController?.view.removeFromSuperview() + hostingController?.removeFromParent() + hostingController = nil + + // Configure view based on mode + switch mode { + case .blank: + showBlankScreensaver() + + case .dim: + showDimScreensaver() + + case .clock: + showClockScreensaver(showEntities: false) + + case .clockWithEntities: + showClockScreensaver(showEntities: true) + + case .photos: + showPhotosScreensaver(withClock: false) + + case .photosWithClock: + showPhotosScreensaver(withClock: true) + + case .customURL: + showCustomURLScreensaver() + } + + // Fade in + view.alpha = 0 + UIView.animate(withDuration: 0.5) { + self.view.alpha = 1 + } + } + + /// Hide the screensaver + public func hide() { + Current.Log.info("Hiding screensaver") + + UIView.animate(withDuration: 0.3) { + self.view.alpha = 0 + } completion: { _ in + self.currentMode = nil + self.hostingController?.view.removeFromSuperview() + self.hostingController?.removeFromParent() + self.hostingController = nil + } + } + + /// Apply pixel shift offset + public func applyPixelShift() { + let manager = KioskModeManager.shared + guard manager.settings.pixelShiftEnabled else { return } + + let amount = manager.settings.pixelShiftAmount + + // Random offset within range + let newOffset = CGPoint( + x: CGFloat.random(in: -amount...amount), + y: CGFloat.random(in: -amount...amount) + ) + + pixelShiftOffset = newOffset + + // Apply transform to hosting controller view + UIView.animate(withDuration: 1.0) { + self.hostingController?.view.transform = CGAffineTransform( + translationX: newOffset.x, + y: newOffset.y + ) + } + } + + // MARK: - Private Methods + + private func setupObservers() { + // Listen for pixel shift ticks + NotificationCenter.default.addObserver( + self, + selector: #selector(handlePixelShiftTick), + name: .kioskPixelShiftTick, + object: nil + ) + } + + @objc private func handlePixelShiftTick() { + applyPixelShift() + } + + @objc private func handleTap() { + let manager = KioskModeManager.shared + if manager.settings.wakeOnTouch { + manager.wakeScreen(source: "touch") + } + } + + @objc private func handleSwipe() { + let manager = KioskModeManager.shared + if manager.settings.wakeOnTouch { + manager.wakeScreen(source: "swipe") + } + } + + // MARK: - Screensaver Mode Views + + private func showBlankScreensaver() { + // Just black screen - view.backgroundColor is already black + view.backgroundColor = .black + } + + private func showDimScreensaver() { + // Dim overlay on top of existing content + // The actual dimming is handled by brightness control in KioskModeManager + view.backgroundColor = UIColor.black.withAlphaComponent(0.7) + } + + private func showClockScreensaver(showEntities: Bool) { + let clockView = ClockScreensaverView(showEntities: showEntities) + embedSwiftUIView(AnyView(clockView)) + } + + private func showPhotosScreensaver(withClock: Bool) { + let photoView = PhotoScreensaverView(showClock: withClock) + embedSwiftUIView(AnyView(photoView)) + } + + private func showCustomURLScreensaver() { + let manager = KioskModeManager.shared + guard !manager.settings.screensaverCustomURL.isEmpty else { + // Fallback to clock if no URL configured + showClockScreensaver(showEntities: false) + return + } + + let urlView = CustomURLScreensaverView() + embedSwiftUIView(AnyView(urlView)) + } + + private func embedSwiftUIView(_ content: AnyView) { + let hosting = UIHostingController(rootView: content) + hosting.view.backgroundColor = .clear + + addChild(hosting) + view.addSubview(hosting.view) + hosting.view.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + hosting.view.topAnchor.constraint(equalTo: view.topAnchor), + hosting.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), + hosting.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + hosting.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), + ]) + + hosting.didMove(toParent: self) + hostingController = hosting + + // Bring secret exit gesture back to front so it can receive taps + if let secretView = secretExitGestureController?.view { + view.bringSubviewToFront(secretView) + } + } +} + +// MARK: - Preview + +@available(iOS 17.0, *) +#Preview { + ScreensaverViewController() +} diff --git a/Sources/App/Kiosk/Security/BatteryManager.swift b/Sources/App/Kiosk/Security/BatteryManager.swift new file mode 100755 index 0000000000..2b571bf986 --- /dev/null +++ b/Sources/App/Kiosk/Security/BatteryManager.swift @@ -0,0 +1,449 @@ +import Combine +import Shared +import UIKit + +// MARK: - Battery Manager + +/// Monitors battery status and thermal state for wall-mounted displays +@MainActor +public final class BatteryManager: ObservableObject { + // MARK: - Singleton + + public static let shared = BatteryManager() + + // MARK: - Published Properties + + @Published public private(set) var batteryLevel: Float = 1.0 + @Published public private(set) var batteryState: UIDevice.BatteryState = .unknown + @Published public private(set) var isLowPowerModeEnabled = false + @Published public private(set) var thermalState: ProcessInfo.ThermalState = .nominal + @Published public private(set) var isCharging = false + @Published public private(set) var lastLowBatteryWarning: Date? + @Published public private(set) var lastThermalWarning: Date? + + // MARK: - Private Properties + + private var settings: KioskSettings { KioskModeManager.shared.settings } + private var cancellables = Set() + + // MARK: - Constants + + private let lowBatteryDebounceInterval: TimeInterval = 300 // 5 minutes + private let thermalThrottlingDebounceInterval: TimeInterval = 60 // 1 minute + private let batteryHealthDegradationThreshold: Float = 0.2 + + // MARK: - Initialization + + private init() { + setupBatteryMonitoring() + setupThermalMonitoring() + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + // MARK: - Battery Monitoring Setup + + private func setupBatteryMonitoring() { + // Enable battery monitoring + UIDevice.current.isBatteryMonitoringEnabled = true + + // Get initial values + updateBatteryStatus() + + // Observe battery level changes + NotificationCenter.default.addObserver( + self, + selector: #selector(batteryLevelDidChange), + name: UIDevice.batteryLevelDidChangeNotification, + object: nil + ) + + // Observe battery state changes + NotificationCenter.default.addObserver( + self, + selector: #selector(batteryStateDidChange), + name: UIDevice.batteryStateDidChangeNotification, + object: nil + ) + + // Observe low power mode changes + NotificationCenter.default.addObserver( + self, + selector: #selector(lowPowerModeDidChange), + name: .NSProcessInfoPowerStateDidChange, + object: nil + ) + } + + private func setupThermalMonitoring() { + // Get initial thermal state + updateThermalState() + + // Observe thermal state changes + NotificationCenter.default.addObserver( + self, + selector: #selector(thermalStateDidChange), + name: ProcessInfo.thermalStateDidChangeNotification, + object: nil + ) + } + + // MARK: - Battery Status + + @objc private func batteryLevelDidChange() { + updateBatteryStatus() + checkLowBatteryWarning() + } + + @objc private func batteryStateDidChange() { + updateBatteryStatus() + } + + @objc private func lowPowerModeDidChange() { + isLowPowerModeEnabled = ProcessInfo.processInfo.isLowPowerModeEnabled + Current.Log.info("Low Power Mode: \(isLowPowerModeEnabled ? "enabled" : "disabled")") + } + + private func updateBatteryStatus() { + batteryLevel = UIDevice.current.batteryLevel + batteryState = UIDevice.current.batteryState + isCharging = batteryState == .charging || batteryState == .full + + // Notify sensor provider + NotificationCenter.default.post(name: .batteryStatusChanged, object: nil) + } + + private func checkLowBatteryWarning() { + let threshold = settings.lowBatteryAlertThreshold + guard threshold > 0 else { return } + + let levelPercent = Int(batteryLevel * 100) + + if levelPercent <= threshold && !isCharging { + // Debounce warnings + if let lastWarning = lastLowBatteryWarning, + Date().timeIntervalSince(lastWarning) < lowBatteryDebounceInterval { + return + } + + lastLowBatteryWarning = Date() + triggerLowBatteryWarning(level: levelPercent) + } + } + + private func triggerLowBatteryWarning(level: Int) { + Current.Log.warning("Low battery warning: \(level)%") + + // Play warning sound + if settings.audioAlertsEnabled { + TouchFeedbackManager.shared.playFeedback(for: .warning) + } + + // Notify HA + NotificationCenter.default.post( + name: .lowBatteryWarning, + object: nil, + userInfo: ["level": level] + ) + } + + // MARK: - Thermal Monitoring + + @objc private func thermalStateDidChange() { + updateThermalState() + } + + private func updateThermalState() { + thermalState = ProcessInfo.processInfo.thermalState + + // Log state change + Current.Log.info("Thermal state changed to: \(thermalStateName)") + + // Notify sensor provider + NotificationCenter.default.post(name: .thermalStateChanged, object: nil) + + // Check for throttling warnings + checkThermalWarning() + } + + private func checkThermalWarning() { + guard settings.thermalThrottlingWarnings else { return } + + switch thermalState { + case .serious, .critical: + // Debounce warnings + if let lastWarning = lastThermalWarning, + Date().timeIntervalSince(lastWarning) < thermalThrottlingDebounceInterval { + return + } + + lastThermalWarning = Date() + triggerThermalWarning() + + default: + break + } + } + + private func triggerThermalWarning() { + Current.Log.warning("Thermal throttling warning: \(thermalStateName)") + + // Show alert + let alert = UIAlertController( + title: "Device Overheating", + message: "The device temperature is high. Performance may be reduced. Consider improving ventilation or reducing screen brightness.", + preferredStyle: .alert + ) + + alert.addAction(UIAlertAction(title: "Reduce Brightness", style: .default) { _ in + // Reduce brightness to help cool down + KioskModeManager.shared.setBrightness(30) + }) + + alert.addAction(UIAlertAction(title: "Dismiss", style: .cancel)) + + // Present alert + if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let rootViewController = windowScene.windows.first?.rootViewController { + rootViewController.present(alert, animated: true) + } + + // Notify HA + NotificationCenter.default.post( + name: .thermalWarning, + object: nil, + userInfo: ["state": thermalStateName] + ) + } + + // MARK: - Public Properties + + /// Battery level as percentage (0-100) + public var batteryPercentage: Int { + Int(batteryLevel * 100) + } + + /// Human-readable battery state + public var batteryStateName: String { + switch batteryState { + case .unknown: return "Unknown" + case .unplugged: return "Unplugged" + case .charging: return "Charging" + case .full: return "Full" + @unknown default: return "Unknown" + } + } + + /// Human-readable thermal state + public var thermalStateName: String { + switch thermalState { + case .nominal: return "Normal" + case .fair: return "Warm" + case .serious: return "Hot" + case .critical: return "Critical" + @unknown default: return "Unknown" + } + } + + /// Icon for thermal state + public var thermalStateIcon: String { + switch thermalState { + case .nominal: return "thermometer" + case .fair: return "thermometer.medium" + case .serious: return "thermometer.high" + case .critical: return "flame.fill" + @unknown default: return "thermometer" + } + } + + /// Color for thermal state + public var thermalStateColor: UIColor { + switch thermalState { + case .nominal: return .systemGreen + case .fair: return .systemYellow + case .serious: return .systemOrange + case .critical: return .systemRed + @unknown default: return .systemGray + } + } + + // MARK: - Battery Health (Estimated) + + /// Estimate battery health based on available information + /// Note: iOS doesn't expose actual battery health, this is an approximation + public var estimatedBatteryHealth: BatteryHealth { + // If device is charging and battery level is stuck at a low percentage, + // it might indicate degraded battery + // This is a rough heuristic - real battery health requires private APIs + if isCharging && batteryLevel < batteryHealthDegradationThreshold { + return .degraded + } + return .good + } + + public enum BatteryHealth: String { + case good = "Good" + case degraded = "Degraded" + case unknown = "Unknown" + } +} + +// MARK: - Notification Names + +extension Notification.Name { + static let batteryStatusChanged = Notification.Name("batteryStatusChanged") + static let lowBatteryWarning = Notification.Name("lowBatteryWarning") + static let thermalStateChanged = Notification.Name("thermalStateChanged") + static let thermalWarning = Notification.Name("thermalWarning") +} + +// MARK: - Battery Settings View + +import SwiftUI + +public struct BatterySettingsView: View { + @ObservedObject private var manager = BatteryManager.shared + @ObservedObject private var kioskManager = KioskModeManager.shared + + public init() {} + + public var body: some View { + Form { + Section { + // Battery level + HStack { + Label("Battery Level", systemImage: batteryIcon) + .foregroundColor(batteryColor) + Spacer() + Text("\(manager.batteryPercentage)%") + .foregroundColor(.secondary) + } + + // Charging status + HStack { + Label("Status", systemImage: manager.isCharging ? "bolt.fill" : "battery.100") + Spacer() + Text(manager.batteryStateName) + .foregroundColor(.secondary) + } + + // Low power mode + if manager.isLowPowerModeEnabled { + HStack { + Label("Low Power Mode", systemImage: "leaf.fill") + .foregroundColor(.yellow) + Spacer() + Text("Enabled") + .foregroundColor(.yellow) + } + } + + } header: { + Text("Battery Status") + } + + Section { + // Thermal state + HStack { + Label("Temperature", systemImage: manager.thermalStateIcon) + .foregroundColor(Color(manager.thermalStateColor)) + Spacer() + Text(manager.thermalStateName) + .foregroundColor(Color(manager.thermalStateColor)) + } + + } header: { + Text("Thermal Status") + } + + Section { + // Low battery threshold + Stepper( + "Low Battery Warning: \(kioskManager.settings.lowBatteryAlertThreshold)%", + value: Binding( + get: { kioskManager.settings.lowBatteryAlertThreshold }, + set: { newValue in + kioskManager.updateSettings { $0.lowBatteryAlertThreshold = newValue } + } + ), + in: 0...50, + step: 5 + ) + + Toggle("Thermal Throttling Warnings", isOn: Binding( + get: { kioskManager.settings.thermalThrottlingWarnings }, + set: { newValue in + kioskManager.updateSettings { $0.thermalThrottlingWarnings = newValue } + } + )) + + Toggle("Report Battery Health", isOn: Binding( + get: { kioskManager.settings.reportBatteryHealth }, + set: { newValue in + kioskManager.updateSettings { $0.reportBatteryHealth = newValue } + } + )) + + Toggle("Report Thermal State", isOn: Binding( + get: { kioskManager.settings.reportThermalState }, + set: { newValue in + kioskManager.updateSettings { $0.reportThermalState = newValue } + } + )) + + } header: { + Text("Alerts & Reporting") + } footer: { + Text("Configure warnings for low battery and high temperature conditions. Reports are sent to Home Assistant.") + } + + Section { + VStack(alignment: .leading, spacing: 10) { + tipRow(icon: "sun.max", text: "Keep the device out of direct sunlight") + tipRow(icon: "wind", text: "Ensure adequate ventilation behind the mount") + tipRow(icon: "moon", text: "Lower brightness at night to reduce heat") + tipRow(icon: "bolt.slash", text: "Consider limiting charge to 80% for battery longevity") + } + .padding(.vertical, 5) + } header: { + Text("Tips for Wall-Mounted Displays") + } + } + .navigationTitle("Battery & Thermal") + } + + private var batteryIcon: String { + if manager.isCharging { + return "battery.100.bolt" + } + + switch manager.batteryPercentage { + case 0...25: return "battery.25" + case 26...50: return "battery.50" + case 51...75: return "battery.75" + default: return "battery.100" + } + } + + private var batteryColor: Color { + if manager.batteryPercentage <= 20 && !manager.isCharging { + return .red + } else if manager.batteryPercentage <= 40 && !manager.isCharging { + return .orange + } + return .green + } + + private func tipRow(icon: String, text: String) -> some View { + HStack(alignment: .top, spacing: 12) { + Image(systemName: icon) + .foregroundColor(.accentColor) + .frame(width: 20) + + Text(text) + .font(.subheadline) + } + } +} diff --git a/Sources/App/Kiosk/Security/CrashRecoveryManager.swift b/Sources/App/Kiosk/Security/CrashRecoveryManager.swift new file mode 100755 index 0000000000..31f8793626 --- /dev/null +++ b/Sources/App/Kiosk/Security/CrashRecoveryManager.swift @@ -0,0 +1,257 @@ +import Shared +import UIKit + +// MARK: - Crash Recovery Manager + +/// Manages crash detection and recovery for kiosk mode +@MainActor +public final class CrashRecoveryManager { + // MARK: - Singleton + + public static let shared = CrashRecoveryManager() + + // MARK: - Private Properties + + private let crashFlagKey = "HAFrame.didCrash" + private let crashCountKey = "HAFrame.crashCount" + private let lastCrashKey = "HAFrame.lastCrashDate" + private let launchTimeKey = "HAFrame.lastLaunchTime" + + private var settings: KioskSettings { KioskModeManager.shared.settings } + + // MARK: - Initialization + + private init() {} + + // MARK: - Crash Detection + + /// Call this at app launch to detect if previous session crashed + public func checkForPreviousCrash() -> Bool { + let didCrash = UserDefaults.standard.bool(forKey: crashFlagKey) + + if didCrash { + // Increment crash count + let crashCount = UserDefaults.standard.integer(forKey: crashCountKey) + 1 + UserDefaults.standard.set(crashCount, forKey: crashCountKey) + UserDefaults.standard.set(Date(), forKey: lastCrashKey) + + Current.Log.warning("Previous session crashed. Total crash count: \(crashCount)") + + // Clear crash flag + UserDefaults.standard.set(false, forKey: crashFlagKey) + + return true + } + + return false + } + + /// Call this when app launches normally (after any crash handling) + public func markAppLaunched() { + // Set crash flag - will be cleared on clean termination + UserDefaults.standard.set(true, forKey: crashFlagKey) + UserDefaults.standard.set(Date(), forKey: launchTimeKey) + + Current.Log.info("App launch recorded") + } + + /// Call this when app terminates cleanly + public func markCleanTermination() { + UserDefaults.standard.set(false, forKey: crashFlagKey) + + Current.Log.info("Clean termination recorded") + } + + /// Get crash statistics + public var crashCount: Int { + UserDefaults.standard.integer(forKey: crashCountKey) + } + + public var lastCrashDate: Date? { + UserDefaults.standard.object(forKey: lastCrashKey) as? Date + } + + public var lastLaunchDate: Date? { + UserDefaults.standard.object(forKey: launchTimeKey) as? Date + } + + /// Reset crash statistics + public func resetCrashStatistics() { + UserDefaults.standard.set(0, forKey: crashCountKey) + UserDefaults.standard.removeObject(forKey: lastCrashKey) + + Current.Log.info("Crash statistics reset") + } + + // MARK: - Recovery Actions + + /// Handle recovery after a crash + public func handleCrashRecovery() { + guard settings.autoRestartOnCrash else { + Current.Log.info("Auto-restart on crash disabled, skipping recovery") + return + } + + Current.Log.info("Executing crash recovery...") + + // Report crash to HA + NotificationCenter.default.post(name: .appCrashRecovered, object: nil) + + // Check if we're in a crash loop (multiple crashes in short time) + if isCrashLoop { + handleCrashLoop() + return + } + + // Restore kiosk mode if it was enabled + if settings.isKioskModeEnabled { + Current.Log.info("Restoring kiosk mode after crash") + KioskModeManager.shared.enableKioskMode() + } + } + + /// Check if we're in a crash loop + private var isCrashLoop: Bool { + // Consider it a crash loop if 3+ crashes in 5 minutes + let recentCrashThreshold = 3 + let timeWindow: TimeInterval = 300 // 5 minutes + + guard crashCount >= recentCrashThreshold else { return false } + + if let lastCrash = lastCrashDate { + return Date().timeIntervalSince(lastCrash) < timeWindow + } + + return false + } + + /// Handle crash loop scenario + private func handleCrashLoop() { + Current.Log.error("Crash loop detected! Disabling kiosk mode for safety.") + + // Disable kiosk mode to allow user to troubleshoot + KioskModeManager.shared.updateSetting(\.isKioskModeEnabled, to: false) + + // Show alert + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in + self?.showCrashLoopAlert() + } + } + + private func showCrashLoopAlert() { + let alert = UIAlertController( + title: "Crash Loop Detected", + message: "HAFrame has crashed multiple times. Kiosk mode has been temporarily disabled to allow troubleshooting.\n\nCrash count: \(crashCount)", + preferredStyle: .alert + ) + + alert.addAction(UIAlertAction(title: "Reset Crash Count", style: .destructive) { [weak self] _ in + self?.resetCrashStatistics() + }) + + alert.addAction(UIAlertAction(title: "OK", style: .default)) + + // Present alert + if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let rootViewController = windowScene.windows.first?.rootViewController { + rootViewController.present(alert, animated: true) + } + } + + // MARK: - Background State Handling + + /// Call when app enters background + public func handleEnterBackground() { + // Save current state for recovery + KioskModeManager.shared.saveSettings() + + Current.Log.info("App entering background, state saved") + } + + /// Call when app becomes active + public func handleBecomeActive() { + // Restore state if needed + if settings.isKioskModeEnabled && !KioskModeManager.shared.isKioskModeActive { + Current.Log.info("Restoring kiosk mode after becoming active") + KioskModeManager.shared.enableKioskMode() + } + } +} + +// MARK: - Notification Names + +extension Notification.Name { + static let appCrashRecovered = Notification.Name("appCrashRecovered") +} + +// MARK: - Crash Recovery Settings View + +import SwiftUI + +public struct CrashRecoverySettingsView: View { + @ObservedObject private var kioskManager = KioskModeManager.shared + private let crashManager = CrashRecoveryManager.shared + + public init() {} + + public var body: some View { + Form { + Section { + Toggle("Auto-Restart on Crash", isOn: Binding( + get: { kioskManager.settings.autoRestartOnCrash }, + set: { newValue in + kioskManager.updateSettings { $0.autoRestartOnCrash = newValue } + } + )) + } header: { + Text("Crash Recovery") + } footer: { + Text("Automatically restore kiosk mode if the app crashes. If multiple crashes occur in quick succession, kiosk mode will be temporarily disabled.") + } + + Section { + // Crash count + HStack { + Label("Total Crashes", systemImage: "exclamationmark.triangle") + Spacer() + Text("\(crashManager.crashCount)") + .foregroundColor(.secondary) + } + + // Last crash + if let lastCrash = crashManager.lastCrashDate { + HStack { + Label("Last Crash", systemImage: "clock") + Spacer() + Text(lastCrash, style: .relative) + .foregroundColor(.secondary) + } + } + + // Last launch + if let lastLaunch = crashManager.lastLaunchDate { + HStack { + Label("Current Session", systemImage: "play.circle") + Spacer() + Text(lastLaunch, style: .relative) + .foregroundColor(.secondary) + } + } + + } header: { + Text("Statistics") + } + + if crashManager.crashCount > 0 { + Section { + Button(role: .destructive) { + crashManager.resetCrashStatistics() + } label: { + Label("Reset Crash Statistics", systemImage: "trash") + } + } + } + } + .navigationTitle("Crash Recovery") + } +} diff --git a/Sources/App/Kiosk/Security/GuidedAccessManager.swift b/Sources/App/Kiosk/Security/GuidedAccessManager.swift new file mode 100755 index 0000000000..40868e9ff6 --- /dev/null +++ b/Sources/App/Kiosk/Security/GuidedAccessManager.swift @@ -0,0 +1,232 @@ +import Shared +import UIKit + +// MARK: - Guided Access Manager + +/// Manages iOS Guided Access integration for enhanced kiosk security +@MainActor +public final class GuidedAccessManager: ObservableObject { + // MARK: - Singleton + + public static let shared = GuidedAccessManager() + + // MARK: - Published Properties + + @Published public private(set) var isGuidedAccessEnabled = false + @Published public private(set) var isGuidedAccessActive = false + + // MARK: - Private Properties + + private var settings: KioskSettings { KioskModeManager.shared.settings } + + // MARK: - Initialization + + private init() { + setupNotifications() + checkGuidedAccessStatus() + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + // MARK: - Setup + + private func setupNotifications() { + NotificationCenter.default.addObserver( + self, + selector: #selector(guidedAccessStatusChanged), + name: UIAccessibility.guidedAccessStatusDidChangeNotification, + object: nil + ) + } + + @objc private func guidedAccessStatusChanged() { + checkGuidedAccessStatus() + } + + // MARK: - Status Check + + /// Check current Guided Access status + public func checkGuidedAccessStatus() { + isGuidedAccessEnabled = UIAccessibility.isGuidedAccessEnabled + + // Update sensor provider + NotificationCenter.default.post(name: .guidedAccessStatusChanged, object: nil) + + Current.Log.info("Guided Access status: \(isGuidedAccessEnabled ? "enabled" : "disabled")") + } + + // MARK: - Guided Access Control + + /// Request to enable Guided Access + /// Note: iOS does not provide a programmatic API to enable Guided Access + /// This provides guidance to users on how to enable it manually + public func requestEnableGuidedAccess() { + Current.Log.info("Guided Access must be enabled manually via Settings > Accessibility > Guided Access") + + // Show alert with instructions + let alert = UIAlertController( + title: "Enable Guided Access", + message: """ + To enable Guided Access for enhanced kiosk security: + + 1. Go to Settings > Accessibility > Guided Access + 2. Turn on Guided Access + 3. Set a Guided Access passcode + 4. Triple-click the side/home button to start a Guided Access session + + This will prevent users from exiting HAFrame. + """, + preferredStyle: .alert + ) + + alert.addAction(UIAlertAction(title: "Open Settings", style: .default) { _ in + if let url = URL(string: UIApplication.openSettingsURLString) { + UIApplication.shared.open(url) + } + }) + + alert.addAction(UIAlertAction(title: "OK", style: .cancel)) + + // Present alert + if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let rootViewController = windowScene.windows.first?.rootViewController { + rootViewController.present(alert, animated: true) + } + } + + /// Show instructions for starting Guided Access session + public func showStartSessionInstructions() { + let alert = UIAlertController( + title: "Start Guided Access Session", + message: """ + To lock HAFrame in kiosk mode: + + Triple-click the side button (or home button on older devices) to start Guided Access. + + Make sure Guided Access is enabled in Settings > Accessibility first. + """, + preferredStyle: .alert + ) + + alert.addAction(UIAlertAction(title: "OK", style: .default)) + + if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let rootViewController = windowScene.windows.first?.rootViewController { + rootViewController.present(alert, animated: true) + } + } + + /// Check if Guided Access should be recommended + public var shouldRecommendGuidedAccess: Bool { + // Recommend if kiosk mode is enabled but Guided Access is not + return settings.isKioskModeEnabled && + settings.guidedAccessEnabled && + !isGuidedAccessEnabled + } +} + +// MARK: - Notification Names + +extension Notification.Name { + static let guidedAccessStatusChanged = Notification.Name("guidedAccessStatusChanged") +} + +// MARK: - Guided Access Settings View + +import SwiftUI + +public struct GuidedAccessSettingsView: View { + @ObservedObject private var manager = GuidedAccessManager.shared + @ObservedObject private var kioskManager = KioskModeManager.shared + + public init() {} + + public var body: some View { + Form { + Section { + // Status + HStack { + Label("Guided Access", systemImage: "lock.shield") + Spacer() + Text(manager.isGuidedAccessEnabled ? "Active" : "Inactive") + .foregroundColor(manager.isGuidedAccessEnabled ? .green : .secondary) + } + + // Enable in settings toggle + Toggle("Use Guided Access", isOn: Binding( + get: { kioskManager.settings.guidedAccessEnabled }, + set: { newValue in + kioskManager.updateSettings { $0.guidedAccessEnabled = newValue } + } + )) + + } header: { + Text("Guided Access") + } footer: { + Text("Guided Access prevents users from leaving the app and disables hardware buttons.") + } + + if kioskManager.settings.guidedAccessEnabled { + Section { + if !manager.isGuidedAccessEnabled { + // Setup instructions + Button { + manager.requestEnableGuidedAccess() + } label: { + Label("Setup Guided Access", systemImage: "gear") + } + + Text("Guided Access needs to be configured in iOS Settings before use.") + .font(.caption) + .foregroundColor(.secondary) + } else { + // Start session button + Button { + manager.showStartSessionInstructions() + } label: { + Label("Start Session Instructions", systemImage: "play.circle") + } + + Text("Triple-click the side button to start or end a Guided Access session.") + .font(.caption) + .foregroundColor(.secondary) + } + } header: { + Text("Actions") + } + + Section { + VStack(alignment: .leading, spacing: 10) { + instructionRow(number: 1, text: "Go to Settings > Accessibility > Guided Access") + instructionRow(number: 2, text: "Turn on Guided Access") + instructionRow(number: 3, text: "Tap Passcode Settings and set a Guided Access passcode") + instructionRow(number: 4, text: "Return to HAFrame") + instructionRow(number: 5, text: "Triple-click the side button") + instructionRow(number: 6, text: "Tap Start in the top right") + } + .padding(.vertical, 5) + } header: { + Text("Setup Instructions") + } + } + } + .navigationTitle("Guided Access") + } + + private func instructionRow(number: Int, text: String) -> some View { + HStack(alignment: .top, spacing: 12) { + Text("\(number)") + .font(.caption) + .fontWeight(.bold) + .foregroundColor(.white) + .frame(width: 20, height: 20) + .background(Color.accentColor) + .clipShape(Circle()) + + Text(text) + .font(.subheadline) + } + } +} diff --git a/Sources/App/Kiosk/Security/SecurityManager.swift b/Sources/App/Kiosk/Security/SecurityManager.swift new file mode 100644 index 0000000000..d8c825c65c --- /dev/null +++ b/Sources/App/Kiosk/Security/SecurityManager.swift @@ -0,0 +1,302 @@ +import LocalAuthentication +import Shared +import UIKit + +// MARK: - Security Manager + +/// Manages biometric authentication and security features for kiosk mode +@MainActor +public final class SecurityManager: ObservableObject { + // MARK: - Singleton + + public static let shared = SecurityManager() + + // MARK: - Published Properties + + @Published public private(set) var isAuthenticated = false + @Published public private(set) var biometryType: LABiometryType = .none + @Published public private(set) var isBiometryAvailable = false + @Published public private(set) var isLocked = false + + // MARK: - Private Properties + + private let context = LAContext() + private var settings: KioskSettings { KioskModeManager.shared.settings } + + // MARK: - Initialization + + private init() { + checkBiometryAvailability() + } + + // MARK: - Biometry Check + + /// Check what biometric authentication is available + public func checkBiometryAvailability() { + var error: NSError? + let available = context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) + + isBiometryAvailable = available + biometryType = context.biometryType + + if let error { + Current.Log.warning("Biometry not available: \(error.localizedDescription)") + } + } + + /// Get human-readable name for biometry type + public var biometryName: String { + switch biometryType { + case .faceID: return "Face ID" + case .touchID: return "Touch ID" + case .opticID: return "Optic ID" + case .none: return "None" + @unknown default: return "Biometric" + } + } + + // MARK: - Authentication + + /// Authenticate user with biometrics or device passcode + /// - Parameter reason: The reason shown to user for authentication + /// - Returns: True if authentication succeeded + public func authenticate(reason: String = "Authenticate to exit kiosk mode") async -> Bool { + let context = LAContext() + + // Determine which policy to use based on settings + let policy: LAPolicy + if settings.allowBiometricExit && settings.allowDevicePasscodeExit { + // Allow biometrics with passcode fallback + policy = .deviceOwnerAuthentication + } else if settings.allowBiometricExit { + // Biometrics only + policy = .deviceOwnerAuthenticationWithBiometrics + } else if settings.allowDevicePasscodeExit { + // Device passcode only (rare case) + policy = .deviceOwnerAuthentication + } else { + // No authentication configured - allow exit + Current.Log.warning("No authentication method configured for kiosk exit") + isAuthenticated = true + return true + } + + do { + let success = try await context.evaluatePolicy(policy, localizedReason: reason) + if success { + isAuthenticated = true + Current.Log.info("Authentication successful") + return true + } + } catch { + Current.Log.warning("Authentication failed: \(error.localizedDescription)") + } + + return false + } + + /// Reset authentication state + public func resetAuthentication() { + isAuthenticated = false + } + + // MARK: - Remote Lock/Unlock + + /// Lock the device remotely (from HA command) + public func remoteLock() { + guard settings.remoteLockEnabled else { + Current.Log.warning("Remote lock is disabled") + return + } + + isLocked = true + KioskModeManager.shared.updateSetting(\.isRemotelyLocked, to: true) + Current.Log.info("Device remotely locked") + + // Notify sensor provider + NotificationCenter.default.post(name: .kioskLockStateChanged, object: nil) + } + + /// Unlock the device remotely (from HA command) + public func remoteUnlock() { + guard settings.remoteLockEnabled else { + Current.Log.warning("Remote unlock is disabled") + return + } + + isLocked = false + KioskModeManager.shared.updateSetting(\.isRemotelyLocked, to: false) + Current.Log.info("Device remotely unlocked") + + // Notify sensor provider + NotificationCenter.default.post(name: .kioskLockStateChanged, object: nil) + } + + /// Check if exit from kiosk mode should be blocked + public var shouldBlockExit: Bool { + if isLocked { return true } + if settings.isRemotelyLocked { return true } + return false + } + + // MARK: - Device Owner Authentication (PIN or Biometric) + + /// Check if device passcode is set + public var isDevicePasscodeSet: Bool { + let context = LAContext() + var error: NSError? + let canEvaluate = context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error) + return canEvaluate + } + + /// Authenticate with device passcode as fallback + /// - Returns: (success, errorMessage) - success if authenticated, errorMessage if failed + public func authenticateWithDevicePasscode(reason: String = "Enter device passcode") async -> (success: Bool, error: String?) { + let context = LAContext() + + // First check if passcode is even set + var checkError: NSError? + guard context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &checkError) else { + let message = checkError?.localizedDescription ?? "Device passcode is not set" + Current.Log.warning("Device passcode not available: \(message)") + return (false, "Device passcode is not set. Please set a passcode in iOS Settings, or use a different exit method.") + } + + do { + let success = try await context.evaluatePolicy( + .deviceOwnerAuthentication, + localizedReason: reason + ) + + if success { + isAuthenticated = true + Current.Log.info("Device passcode authentication successful") + return (true, nil) + } + } catch let error as LAError { + switch error.code { + case .userCancel: + return (false, nil) // User cancelled, no error message needed + case .passcodeNotSet: + return (false, "Device passcode is not set. Please set a passcode in iOS Settings, or use a different exit method.") + default: + Current.Log.warning("Device passcode authentication failed: \(error.localizedDescription)") + return (false, error.localizedDescription) + } + } catch { + Current.Log.warning("Device passcode authentication failed: \(error.localizedDescription)") + return (false, error.localizedDescription) + } + + return (false, nil) + } +} + +// MARK: - Notification Names + +extension Notification.Name { + static let kioskLockStateChanged = Notification.Name("kioskLockStateChanged") +} + +// MARK: - Authentication View + +import SwiftUI + +/// Simple authentication view that triggers device authentication (Face ID/Touch ID/Passcode) +public struct AuthenticationView: View { + @Binding var isPresented: Bool + let onAuthenticated: () -> Void + + @State private var isAuthenticating = false + @State private var authenticationFailed = false + + public init(isPresented: Binding, onAuthenticated: @escaping () -> Void) { + _isPresented = isPresented + self.onAuthenticated = onAuthenticated + } + + public var body: some View { + VStack(spacing: 30) { + // Icon + Image(systemName: authenticationIcon) + .font(.system(size: 60)) + .foregroundColor(.accentColor) + + // Title + Text("Authentication Required") + .font(.title2) + .fontWeight(.semibold) + + Text("Authenticate to exit kiosk mode") + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + + if authenticationFailed { + Text("Authentication failed. Please try again.") + .font(.caption) + .foregroundColor(.red) + } + + // Authenticate button + Button { + authenticate() + } label: { + HStack { + Image(systemName: authenticationIcon) + Text("Authenticate") + } + .font(.headline) + .foregroundColor(.white) + .padding() + .frame(maxWidth: .infinity) + .background(Color.accentColor) + .cornerRadius(12) + } + .disabled(isAuthenticating) + .padding(.horizontal) + + // Cancel button + Button("Cancel") { + isPresented = false + } + .foregroundColor(.secondary) + } + .padding(40) + .background(Color(.systemBackground)) + .cornerRadius(20) + .shadow(radius: 20) + .onAppear { + // Auto-trigger authentication on appear + authenticate() + } + } + + private var authenticationIcon: String { + switch SecurityManager.shared.biometryType { + case .faceID: return "faceid" + case .touchID: return "touchid" + case .opticID: return "opticid" + default: return "lock.fill" + } + } + + private func authenticate() { + isAuthenticating = true + authenticationFailed = false + + Task { + let success = await SecurityManager.shared.authenticate(reason: "Exit kiosk mode") + await MainActor.run { + isAuthenticating = false + if success { + isPresented = false + onAuthenticated() + } else { + authenticationFailed = true + TouchFeedbackManager.shared.playFeedback(for: .error) + } + } + } + } +} diff --git a/Sources/App/Kiosk/Security/SettingsManager.swift b/Sources/App/Kiosk/Security/SettingsManager.swift new file mode 100755 index 0000000000..3c58028407 --- /dev/null +++ b/Sources/App/Kiosk/Security/SettingsManager.swift @@ -0,0 +1,348 @@ +import Shared +import SwiftUI +import UniformTypeIdentifiers + +// MARK: - Settings Manager + +/// Manages settings export and import for kiosk mode +@MainActor +public final class SettingsManager: ObservableObject { + // MARK: - Singleton + + public static let shared = SettingsManager() + + // MARK: - Published Properties + + @Published public private(set) var lastExportDate: Date? + @Published public private(set) var lastImportDate: Date? + @Published public var exportError: String? + @Published public var importError: String? + + // MARK: - Private Properties + + private let exportFileType = UTType(filenameExtension: "kioskconfig") ?? .json + + // MARK: - Initialization + + private init() {} + + // MARK: - Export + + /// Export current settings to JSON data + public func exportSettings() -> Data? { + let settings = KioskModeManager.shared.settings + + do { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + encoder.dateEncodingStrategy = .iso8601 + + let exportData = SettingsExport( + version: Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0", + exportDate: Date(), + settings: settings + ) + + let data = try encoder.encode(exportData) + lastExportDate = Date() + + Current.Log.info("Settings exported successfully") + return data + + } catch { + exportError = "Failed to export settings: \(error.localizedDescription)" + Current.Log.error("Settings export failed: \(error)") + return nil + } + } + + /// Get settings export as a shareable file URL + public func exportSettingsFile() -> URL? { + guard let data = exportSettings() else { return nil } + + let fileName = "Kiosk_Settings_\(dateFormatter.string(from: Date())).kioskconfig" + let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(fileName) + + do { + try data.write(to: tempURL) + return tempURL + } catch { + exportError = "Failed to create export file: \(error.localizedDescription)" + Current.Log.error("Failed to write export file: \(error)") + return nil + } + } + + private lazy var dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd_HHmmss" + return formatter + }() + + // MARK: - Import + + /// Import settings from JSON data + public func importSettings(from data: Data) -> Bool { + do { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + + let importData = try decoder.decode(SettingsExport.self, from: data) + + // Validate version compatibility + guard isVersionCompatible(importData.version) else { + importError = "Settings file is from an incompatible version" + return false + } + + // Apply imported settings + KioskModeManager.shared.updateSettings(importData.settings) + + lastImportDate = Date() + Current.Log.info("Settings imported successfully from version \(importData.version)") + + return true + + } catch { + importError = "Failed to import settings: \(error.localizedDescription)" + Current.Log.error("Settings import failed: \(error)") + return false + } + } + + /// Import settings from a file URL + public func importSettings(from url: URL) -> Bool { + do { + let data = try Data(contentsOf: url) + return importSettings(from: data) + } catch { + importError = "Failed to read settings file: \(error.localizedDescription)" + Current.Log.error("Failed to read import file: \(error)") + return false + } + } + + // MARK: - Version Compatibility + + private func isVersionCompatible(_ version: String) -> Bool { + // Simple version check - could be more sophisticated + let currentVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0" + + // Extract major version + let importMajor = version.components(separatedBy: ".").first ?? "1" + let currentMajor = currentVersion.components(separatedBy: ".").first ?? "1" + + // Allow import from same major version + return importMajor == currentMajor + } + + // MARK: - Reset + + /// Reset all settings to defaults + public func resetToDefaults() { + KioskModeManager.shared.updateSettings(KioskSettings()) + + Current.Log.info("Settings reset to defaults") + } +} + +// MARK: - Settings Export Container + +/// Container for exported settings with metadata +public struct SettingsExport: Codable { + let version: String + let exportDate: Date + let settings: KioskSettings +} + +// MARK: - Settings Transfer View + +public struct SettingsTransferView: View { + @ObservedObject private var manager = SettingsManager.shared + @ObservedObject private var kioskManager = KioskModeManager.shared + + @State private var showExportSheet = false + @State private var showImportPicker = false + @State private var showResetConfirmation = false + @State private var showImportSuccess = false + @State private var showExportSuccess = false + + public init() {} + + public var body: some View { + Form { + Section { + Toggle("Allow Settings Export", isOn: Binding( + get: { kioskManager.settings.allowSettingsExport }, + set: { newValue in + kioskManager.updateSettings { $0.allowSettingsExport = newValue } + } + )) + } header: { + Text("Permissions") + } footer: { + Text("When enabled, settings can be exported to a file for backup or transfer to another device.") + } + + if kioskManager.settings.allowSettingsExport { + Section { + // Export button + Button { + exportSettings() + } label: { + Label("Export Settings", systemImage: "square.and.arrow.up") + } + + // Last export date + if let lastExport = manager.lastExportDate { + HStack { + Text("Last Export") + Spacer() + Text(lastExport, style: .relative) + .foregroundColor(.secondary) + } + .font(.caption) + } + + } header: { + Text("Export") + } footer: { + Text("Export your current settings to share with another device or for backup.") + } + + Section { + // Import button + Button { + showImportPicker = true + } label: { + Label("Import Settings", systemImage: "square.and.arrow.down") + } + + // Last import date + if let lastImport = manager.lastImportDate { + HStack { + Text("Last Import") + Spacer() + Text(lastImport, style: .relative) + .foregroundColor(.secondary) + } + .font(.caption) + } + + } header: { + Text("Import") + } footer: { + Text("Import settings from a previously exported file. This will replace your current settings.") + } + } + + Section { + Button(role: .destructive) { + showResetConfirmation = true + } label: { + Label("Reset to Defaults", systemImage: "arrow.counterclockwise") + } + } header: { + Text("Reset") + } footer: { + Text("Reset all kiosk settings to their default values. This cannot be undone.") + } + + // Error display + if let error = manager.exportError { + Section { + Text(error) + .foregroundColor(.red) + .font(.caption) + } + } + + if let error = manager.importError { + Section { + Text(error) + .foregroundColor(.red) + .font(.caption) + } + } + } + .navigationTitle("Settings Transfer") + .sheet(isPresented: $showExportSheet) { + if let url = manager.exportSettingsFile() { + ShareSheet(items: [url]) + } + } + .fileImporter( + isPresented: $showImportPicker, + allowedContentTypes: [.json, UTType(filenameExtension: "kioskconfig") ?? .json], + allowsMultipleSelection: false + ) { result in + handleImport(result) + } + .confirmationDialog( + "Reset Settings?", + isPresented: $showResetConfirmation, + titleVisibility: .visible + ) { + Button("Reset to Defaults", role: .destructive) { + manager.resetToDefaults() + } + Button("Cancel", role: .cancel) {} + } message: { + Text("This will reset all kiosk settings to their default values. This action cannot be undone.") + } + .alert("Import Successful", isPresented: $showImportSuccess) { + Button("OK") {} + } message: { + Text("Settings have been imported successfully.") + } + .alert("Export Successful", isPresented: $showExportSuccess) { + Button("OK") {} + } message: { + Text("Settings have been exported successfully.") + } + } + + private func exportSettings() { + manager.exportError = nil + showExportSheet = true + } + + private func handleImport(_ result: Result<[URL], Error>) { + manager.importError = nil + + switch result { + case .success(let urls): + guard let url = urls.first else { + manager.importError = "No file selected" + return + } + + // Start accessing security-scoped resource + guard url.startAccessingSecurityScopedResource() else { + manager.importError = "Unable to access file" + return + } + + defer { url.stopAccessingSecurityScopedResource() } + + if manager.importSettings(from: url) { + showImportSuccess = true + } + + case .failure(let error): + manager.importError = "Failed to select file: \(error.localizedDescription)" + } + } +} + +// MARK: - Share Sheet + +struct ShareSheet: UIViewControllerRepresentable { + let items: [Any] + + func makeUIViewController(context: Context) -> UIActivityViewController { + UIActivityViewController(activityItems: items, applicationActivities: nil) + } + + func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {} +} diff --git a/Sources/App/Kiosk/Security/TamperDetectionManager.swift b/Sources/App/Kiosk/Security/TamperDetectionManager.swift new file mode 100755 index 0000000000..590813910e --- /dev/null +++ b/Sources/App/Kiosk/Security/TamperDetectionManager.swift @@ -0,0 +1,340 @@ +import CoreMotion +import Shared +import UIKit + +// MARK: - Tamper Detection Manager + +/// Monitors device orientation and movement for tamper detection +@MainActor +public final class TamperDetectionManager: ObservableObject { + // MARK: - Singleton + + public static let shared = TamperDetectionManager() + + // MARK: - Published Properties + + @Published public private(set) var isTamperDetected = false + @Published public private(set) var currentOrientation: DeviceOrientation = .unknown + @Published public private(set) var lastTamperEvent: Date? + + // MARK: - Private Properties + + private var settings: KioskSettings { KioskModeManager.shared.settings } + private let motionManager = CMMotionManager() + private var isMonitoring = false + private var initialOrientation: DeviceOrientation? + + // Movement thresholds + private let accelerationThreshold: Double = 2.0 // G-force threshold + private let rotationThreshold: Double = 3.0 // Radians per second + + // MARK: - Initialization + + private init() { + setupOrientationMonitoring() + } + + // MARK: - Setup + + private func setupOrientationMonitoring() { + UIDevice.current.beginGeneratingDeviceOrientationNotifications() + + NotificationCenter.default.addObserver( + self, + selector: #selector(orientationChanged), + name: UIDevice.orientationDidChangeNotification, + object: nil + ) + + updateCurrentOrientation() + } + + @objc private func orientationChanged() { + let previousOrientation = currentOrientation + updateCurrentOrientation() + + // Check for tamper if monitoring is enabled + guard settings.tamperDetectionEnabled && settings.isKioskModeEnabled else { return } + + // Check if orientation changed unexpectedly + if let expected = settings.lockedOrientation ?? initialOrientation { + if !currentOrientation.matches(expected) && currentOrientation != .unknown { + triggerTamperAlert( + reason: "Orientation changed from \(expected.displayName) to \(currentOrientation.displayName)" + ) + } + } + } + + private func updateCurrentOrientation() { + let uiOrientation = UIDevice.current.orientation + + // Only update for valid orientations + if uiOrientation != .unknown && uiOrientation != .faceUp && uiOrientation != .faceDown { + currentOrientation = DeviceOrientation.from(uiOrientation) + } + } + + // MARK: - Monitoring Control + + /// Start tamper detection monitoring + public func startMonitoring() { + guard !isMonitoring else { return } + guard settings.tamperDetectionEnabled else { return } + + isMonitoring = true + + // Record initial orientation if not locked + if settings.lockedOrientation == nil { + initialOrientation = currentOrientation + } + + // Start motion monitoring for sudden movements + startMotionMonitoring() + + Current.Log.info("Tamper detection started") + } + + /// Stop tamper detection monitoring + public func stopMonitoring() { + isMonitoring = false + motionManager.stopDeviceMotionUpdates() + NotificationCenter.default.removeObserver( + self, + name: UIDevice.orientationDidChangeNotification, + object: nil + ) + UIDevice.current.endGeneratingDeviceOrientationNotifications() + + Current.Log.info("Tamper detection stopped") + } + + /// Reset tamper state + public func resetTamperState() { + isTamperDetected = false + lastTamperEvent = nil + + // Notify sensor provider + NotificationCenter.default.post(name: .tamperStateChanged, object: nil) + + Current.Log.info("Tamper state reset") + } + + /// Lock current orientation as expected orientation + public func lockCurrentOrientation() { + KioskModeManager.shared.updateSetting(\.lockedOrientation, to: currentOrientation) + initialOrientation = currentOrientation + + Current.Log.info("Orientation locked to: \(currentOrientation.displayName)") + } + + // MARK: - Motion Monitoring + + private func startMotionMonitoring() { + guard motionManager.isDeviceMotionAvailable else { + Current.Log.warning("Device motion not available") + return + } + + motionManager.deviceMotionUpdateInterval = 0.1 + + motionManager.startDeviceMotionUpdates(to: .main) { [weak self] motion, error in + guard let self, let motion else { return } + // Already on main queue, call directly + self.checkForSuddenMovement(motion: motion) + } + } + + private func checkForSuddenMovement(motion: CMDeviceMotion) { + let acceleration = motion.userAcceleration + let totalAcceleration = sqrt( + pow(acceleration.x, 2) + + pow(acceleration.y, 2) + + pow(acceleration.z, 2) + ) + + let rotation = motion.rotationRate + let totalRotation = sqrt( + pow(rotation.x, 2) + + pow(rotation.y, 2) + + pow(rotation.z, 2) + ) + + // Check for sudden movement + if totalAcceleration > accelerationThreshold { + triggerTamperAlert(reason: "Sudden movement detected (acceleration: \(String(format: "%.1f", totalAcceleration))g)") + } + + // Check for rapid rotation + if totalRotation > rotationThreshold { + triggerTamperAlert(reason: "Rapid rotation detected") + } + } + + // MARK: - Tamper Alert + + private func triggerTamperAlert(reason: String) { + // Debounce - don't trigger too frequently + if let lastEvent = lastTamperEvent, + Date().timeIntervalSince(lastEvent) < 5.0 { + return + } + + isTamperDetected = true + lastTamperEvent = Date() + + Current.Log.warning("Tamper detected: \(reason)") + + // Play warning feedback + TouchFeedbackManager.shared.playFeedback(for: .warning) + + // Notify sensor provider to report to HA + NotificationCenter.default.post( + name: .tamperStateChanged, + object: nil, + userInfo: ["reason": reason] + ) + + // Show alert if configured + showTamperAlert(reason: reason) + } + + private func showTamperAlert(reason: String) { + let alert = UIAlertController( + title: "Tamper Detected", + message: reason, + preferredStyle: .alert + ) + + alert.addAction(UIAlertAction(title: "Dismiss", style: .default) { [weak self] _ in + self?.resetTamperState() + }) + + // Present alert + if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let rootViewController = windowScene.windows.first?.rootViewController { + rootViewController.present(alert, animated: true) + } + } +} + +// MARK: - Notification Names + +extension Notification.Name { + static let tamperStateChanged = Notification.Name("tamperStateChanged") +} + +// MARK: - Tamper Detection Settings View + +import SwiftUI + +public struct TamperDetectionSettingsView: View { + @ObservedObject private var manager = TamperDetectionManager.shared + @ObservedObject private var kioskManager = KioskModeManager.shared + + public init() {} + + public var body: some View { + Form { + Section { + Toggle("Enable Tamper Detection", isOn: Binding( + get: { kioskManager.settings.tamperDetectionEnabled }, + set: { newValue in + kioskManager.updateSettings { $0.tamperDetectionEnabled = newValue } + } + )) + + } header: { + Text("Tamper Detection") + } footer: { + Text("Detect when the device is moved or rotated from its expected position.") + } + + if kioskManager.settings.tamperDetectionEnabled { + Section { + // Current status + HStack { + Label("Status", systemImage: manager.isTamperDetected ? "exclamationmark.triangle.fill" : "checkmark.shield") + .foregroundColor(manager.isTamperDetected ? .orange : .green) + Spacer() + Text(manager.isTamperDetected ? "Tamper Detected" : "Secure") + .foregroundColor(manager.isTamperDetected ? .orange : .green) + } + + // Current orientation + HStack { + Label("Current Orientation", systemImage: "rotate.3d") + Spacer() + Text(manager.currentOrientation.displayName) + .foregroundColor(.secondary) + } + + // Locked orientation + if let locked = kioskManager.settings.lockedOrientation { + HStack { + Label("Expected Orientation", systemImage: "lock") + Spacer() + Text(locked.displayName) + .foregroundColor(.secondary) + } + } + + } header: { + Text("Status") + } + + Section { + // Lock orientation button + Button { + manager.lockCurrentOrientation() + } label: { + Label("Lock Current Orientation", systemImage: "lock.rotation") + } + + // Reset tamper state + if manager.isTamperDetected { + Button { + manager.resetTamperState() + } label: { + Label("Reset Tamper Alert", systemImage: "arrow.counterclockwise") + } + .foregroundColor(.orange) + } + + // Clear locked orientation + if kioskManager.settings.lockedOrientation != nil { + Button { + kioskManager.updateSetting(\.lockedOrientation, to: nil) + } label: { + Label("Clear Locked Orientation", systemImage: "lock.open") + } + .foregroundColor(.red) + } + + } header: { + Text("Actions") + } + + Section { + // Picker for expected orientation + Picker("Expected Orientation", selection: Binding( + get: { kioskManager.settings.expectedOrientation }, + set: { newValue in + kioskManager.updateSettings { $0.expectedOrientation = newValue } + } + )) { + ForEach(DeviceOrientation.allCases.filter { $0 != .unknown && $0 != .faceUp && $0 != .faceDown }, id: \.self) { orientation in + Text(orientation.displayName).tag(orientation) + } + } + + } header: { + Text("Configuration") + } footer: { + Text("Set the expected orientation. Tamper alerts will trigger if the device orientation doesn't match.") + } + } + } + .navigationTitle("Tamper Detection") + } +} diff --git a/Sources/App/Kiosk/Settings/DashboardConfigurationView.swift b/Sources/App/Kiosk/Settings/DashboardConfigurationView.swift new file mode 100644 index 0000000000..cede54faa7 --- /dev/null +++ b/Sources/App/Kiosk/Settings/DashboardConfigurationView.swift @@ -0,0 +1,343 @@ +import HAKit +import Shared +import SwiftUI + +// MARK: - Dashboard Picker View + +/// View for selecting a dashboard from available HA dashboards +public struct DashboardPickerView: View { + @Environment(\.dismiss) private var dismiss + @Binding var selectedPath: String + + @State private var availableDashboards: [HAPanel] = [] + @State private var isLoading = true + @State private var loadError: String? + @State private var customPath = "" + + public init(selectedPath: Binding) { + _selectedPath = selectedPath + _customPath = State(initialValue: selectedPath.wrappedValue) + } + + public var body: some View { + Form { + // Available dashboards from HA + Section { + if isLoading { + HStack { + ProgressView() + .padding(.trailing, 8) + Text("Loading dashboards...") + .foregroundColor(.secondary) + } + } else if let error = loadError { + VStack(alignment: .leading, spacing: 4) { + Text("Could not load dashboards") + .foregroundColor(.red) + Text(error) + .font(.caption) + .foregroundColor(.secondary) + } + } else if availableDashboards.isEmpty { + Text("No dashboards found") + .foregroundColor(.secondary) + } else { + ForEach(availableDashboards, id: \.path) { dashboard in + Button { + selectedPath = "/\(dashboard.path)" + customPath = selectedPath + } label: { + HStack { + if let icon = dashboard.icon { + Image(systemName: IconMapper.sfSymbol(from: icon, default: "rectangle.grid.1x2")) + .foregroundColor(.accentColor) + .frame(width: 24) + } + + VStack(alignment: .leading, spacing: 2) { + Text(dashboard.title) + .foregroundColor(.primary) + Text("/\(dashboard.path)") + .font(.caption) + .foregroundColor(.secondary) + } + + Spacer() + + if selectedPath == "/\(dashboard.path)" { + Image(systemName: "checkmark") + .foregroundColor(.accentColor) + } + } + } + } + } + } header: { + Text("Available Dashboards") + } footer: { + Text("Dashboards are loaded from your Home Assistant instance.") + } + + // Custom path entry + Section { + TextField("Custom Path (e.g., /lovelace/kiosk)", text: $customPath) + .keyboardType(.URL) + .autocapitalization(.none) + .autocorrectionDisabled() + .onSubmit { + selectedPath = customPath + } + + if !customPath.isEmpty && customPath != selectedPath { + Button("Use Custom Path") { + selectedPath = customPath + } + } + } header: { + Text("Custom Path") + } footer: { + Text("Enter a custom dashboard path if it's not listed above.") + } + + // Clear selection + if !selectedPath.isEmpty { + Section { + Button(role: .destructive) { + selectedPath = "" + customPath = "" + } label: { + Label("Clear Selection", systemImage: "xmark.circle") + } + } + } + } + .navigationTitle("Select Dashboard") + .navigationBarTitleDisplayMode(.inline) + .onAppear { + loadDashboards() + } + .refreshable { + await refreshDashboards() + } + } + + private func loadDashboards() { + isLoading = true + loadError = nil + + // Get the first available server + guard let server = Current.servers.all.first, + let api = Current.api(for: server) else { + loadError = "No Home Assistant server configured" + isLoading = false + return + } + + // Fetch panels (dashboards) from HA + api.connection.caches.panels.once { panels in + Task { @MainActor in + // Filter to only lovelace dashboards + self.availableDashboards = panels.allPanels.filter { $0.component == "lovelace" } + self.isLoading = false + } + } + } + + private func refreshDashboards() async { + await withCheckedContinuation { continuation in + loadDashboards() + // Give it a moment to complete + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + continuation.resume() + } + } + } +} + +// MARK: - Dashboard Configuration View + +/// View for configuring dashboard URLs and rotation settings +public struct DashboardConfigurationView: View { + @Environment(\.dismiss) private var dismiss + @Binding var dashboards: [DashboardConfig] + @Binding var primaryURL: String + + @State private var newDashboardName = "" + @State private var newDashboardURL = "" + @State private var showAddSheet = false + @State private var editingDashboard: DashboardConfig? + + public init(dashboards: Binding<[DashboardConfig]>, primaryURL: Binding) { + _dashboards = dashboards + _primaryURL = primaryURL + } + + public var body: some View { + NavigationView { + Form { + Section { + TextField("Primary Dashboard URL", text: $primaryURL) + .keyboardType(.URL) + .autocapitalization(.none) + .autocorrectionDisabled() + } header: { + Text("Primary Dashboard") + } footer: { + Text("The main dashboard to display when kiosk mode starts.") + } + + Section { + ForEach($dashboards) { $dashboard in + DashboardRow(dashboard: $dashboard) { + editingDashboard = dashboard + } + } + .onDelete(perform: deleteDashboards) + .onMove(perform: moveDashboards) + + Button { + showAddSheet = true + } label: { + Label("Add Dashboard", systemImage: "plus.circle") + } + } header: { + Text("Dashboard Rotation") + } footer: { + Text("Add multiple dashboards for rotation. Reorder by dragging.") + } + } + .navigationTitle("Dashboard Configuration") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Done") { dismiss() } + } + ToolbarItem(placement: .primaryAction) { + EditButton() + } + } + .sheet(isPresented: $showAddSheet) { + DashboardEditSheet( + name: $newDashboardName, + url: $newDashboardURL, + isNew: true + ) { + if !newDashboardURL.isEmpty { + let dashboard = DashboardConfig( + name: newDashboardName.isEmpty ? "Dashboard" : newDashboardName, + url: newDashboardURL + ) + dashboards.append(dashboard) + } + newDashboardName = "" + newDashboardURL = "" + } + } + .sheet(item: $editingDashboard) { dashboard in + if let index = dashboards.firstIndex(where: { $0.id == dashboard.id }) { + DashboardEditSheet( + name: $dashboards[index].name, + url: $dashboards[index].url, + isNew: false + ) {} + } + } + } + } + + private func deleteDashboards(at offsets: IndexSet) { + dashboards.remove(atOffsets: offsets) + } + + private func moveDashboards(from source: IndexSet, to destination: Int) { + dashboards.move(fromOffsets: source, toOffset: destination) + } +} + +// MARK: - Dashboard Row + +struct DashboardRow: View { + @Binding var dashboard: DashboardConfig + let onEdit: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text(dashboard.name) + .font(.headline) + + Text(dashboard.url) + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(1) + } + .contentShape(Rectangle()) + .onTapGesture(perform: onEdit) + } +} + +// MARK: - Dashboard Edit Sheet + +struct DashboardEditSheet: View { + @Environment(\.dismiss) private var dismiss + @Binding var name: String + @Binding var url: String + let isNew: Bool + let onSave: () -> Void + + var body: some View { + NavigationView { + Form { + Section { + TextField("Name", text: $name) + } footer: { + Text("A friendly name for this dashboard.") + } + + Section { + NavigationLink { + DashboardPickerView(selectedPath: $url) + } label: { + HStack { + Text("Dashboard") + Spacer() + Text(url.isEmpty ? "Not Set" : url) + .foregroundColor(.secondary) + .lineLimit(1) + } + } + } footer: { + Text("Select a dashboard from Home Assistant or enter a custom path.") + } + } + .navigationTitle(isNew ? "Add Dashboard" : "Edit Dashboard") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + dismiss() + } + } + ToolbarItem(placement: .confirmationAction) { + Button("Save") { + onSave() + dismiss() + } + .disabled(url.isEmpty) + } + } + } + } +} + +// MARK: - Preview + +@available(iOS 17.0, *) +#Preview { + DashboardConfigurationView( + dashboards: .constant([ + DashboardConfig(name: "Home", url: "http://homeassistant.local:8123/lovelace/home"), + DashboardConfig(name: "Weather", url: "http://homeassistant.local:8123/lovelace/weather"), + ]), + primaryURL: .constant("http://homeassistant.local:8123") + ) +} diff --git a/Sources/App/Kiosk/Settings/EntityTriggersView.swift b/Sources/App/Kiosk/Settings/EntityTriggersView.swift new file mode 100755 index 0000000000..735b2cc70c --- /dev/null +++ b/Sources/App/Kiosk/Settings/EntityTriggersView.swift @@ -0,0 +1,369 @@ +import Shared +import SwiftUI + +// MARK: - Entity Triggers View + +/// View for configuring HA entity triggers for wake/sleep and actions +public struct EntityTriggersView: View { + @Environment(\.dismiss) private var dismiss + @Binding var wakeEntities: [EntityTrigger] + @Binding var sleepEntities: [EntityTrigger] + @Binding var actionTriggers: [EntityActionTrigger] + + @State private var showAddWake = false + @State private var showAddSleep = false + @State private var showAddAction = false + + public init( + wakeEntities: Binding<[EntityTrigger]>, + sleepEntities: Binding<[EntityTrigger]>, + actionTriggers: Binding<[EntityActionTrigger]> + ) { + _wakeEntities = wakeEntities + _sleepEntities = sleepEntities + _actionTriggers = actionTriggers + } + + public var body: some View { + NavigationView { + Form { + wakeSection + sleepSection + actionSection + } + .navigationTitle("Entity Triggers") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { dismiss() } + } + } + .sheet(isPresented: $showAddWake) { + EntityTriggerEditView( + title: "Add Wake Trigger", + onSave: { trigger in + wakeEntities.append(trigger) + } + ) + } + .sheet(isPresented: $showAddSleep) { + EntityTriggerEditView( + title: "Add Sleep Trigger", + onSave: { trigger in + sleepEntities.append(trigger) + } + ) + } + .sheet(isPresented: $showAddAction) { + EntityActionTriggerEditView( + onSave: { trigger in + actionTriggers.append(trigger) + } + ) + } + } + } + + // MARK: - Wake Section + + private var wakeSection: some View { + Section { + ForEach($wakeEntities) { $trigger in + EntityTriggerRow(trigger: $trigger) + } + .onDelete { offsets in + wakeEntities.remove(atOffsets: offsets) + } + + Button { + showAddWake = true + } label: { + Label("Add Wake Trigger", systemImage: "plus.circle") + } + } header: { + Text("Wake Triggers") + } footer: { + Text("Entities that will wake the display when their state changes to the trigger value.") + } + } + + // MARK: - Sleep Section + + private var sleepSection: some View { + Section { + ForEach($sleepEntities) { $trigger in + EntityTriggerRow(trigger: $trigger) + } + .onDelete { offsets in + sleepEntities.remove(atOffsets: offsets) + } + + Button { + showAddSleep = true + } label: { + Label("Add Sleep Trigger", systemImage: "plus.circle") + } + } header: { + Text("Sleep Triggers") + } footer: { + Text("Entities that will activate the screensaver when their state changes to the trigger value.") + } + } + + // MARK: - Action Section + + private var actionSection: some View { + Section { + ForEach($actionTriggers) { $trigger in + EntityActionTriggerRow(trigger: $trigger) + } + .onDelete { offsets in + actionTriggers.remove(atOffsets: offsets) + } + + Button { + showAddAction = true + } label: { + Label("Add Action Trigger", systemImage: "plus.circle") + } + } header: { + Text("Action Triggers") + } footer: { + Text("Entities that trigger specific actions like navigation or brightness changes.") + } + } +} + +// MARK: - Entity Trigger Row + +struct EntityTriggerRow: View { + @Binding var trigger: EntityTrigger + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text(trigger.entityId) + .font(.headline) + + HStack { + Text("State:") + .foregroundColor(.secondary) + Text(trigger.triggerState) + .foregroundColor(.accentColor) + } + .font(.caption) + } + } +} + +// MARK: - Entity Action Trigger Row + +struct EntityActionTriggerRow: View { + @Binding var trigger: EntityActionTrigger + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text(trigger.entityId) + .font(.headline) + + HStack { + Text("State:") + .foregroundColor(.secondary) + Text(trigger.triggerState) + + Spacer() + + Text("Action:") + .foregroundColor(.secondary) + Text(trigger.action.displayName) + } + .font(.caption) + } + } +} + +// MARK: - Entity Trigger Edit View + +struct EntityTriggerEditView: View { + @Environment(\.dismiss) private var dismiss + let title: String + let onSave: (EntityTrigger) -> Void + + @State private var entityId = "" + @State private var triggerState = "on" + + var body: some View { + NavigationView { + Form { + Section { + TextField("Entity ID", text: $entityId) + .autocapitalization(.none) + .autocorrectionDisabled() + + TextField("Trigger State", text: $triggerState) + .autocapitalization(.none) + .autocorrectionDisabled() + } footer: { + Text("Enter the entity ID (e.g., binary_sensor.motion) and the state that triggers the action (e.g., on, off, home).") + } + } + .navigationTitle(title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button("Save") { + let trigger = EntityTrigger( + entityId: entityId, + triggerState: triggerState + ) + onSave(trigger) + dismiss() + } + .disabled(entityId.isEmpty) + } + } + } + } +} + +// MARK: - Entity Action Trigger Edit View + +struct EntityActionTriggerEditView: View { + @Environment(\.dismiss) private var dismiss + let onSave: (EntityActionTrigger) -> Void + + @State private var entityId = "" + @State private var triggerState = "on" + @State private var selectedActionType = ActionType.navigate + @State private var urlValue = "" + @State private var brightnessValue: Float = 1.0 + @State private var messageValue = "" + + enum ActionType: String, CaseIterable { + case navigate = "Navigate" + case setBrightness = "Set Brightness" + case startScreensaver = "Start Screensaver" + case stopScreensaver = "Stop Screensaver" + case refresh = "Refresh" + case playSound = "Play Sound" + case tts = "TTS" + } + + var body: some View { + NavigationView { + Form { + Section { + TextField("Entity ID", text: $entityId) + .autocapitalization(.none) + .autocorrectionDisabled() + + TextField("Trigger State", text: $triggerState) + .autocapitalization(.none) + .autocorrectionDisabled() + } header: { + Text("Trigger") + } + + Section { + Picker("Action Type", selection: $selectedActionType) { + ForEach(ActionType.allCases, id: \.self) { type in + Text(type.rawValue).tag(type) + } + } + + switch selectedActionType { + case .navigate: + TextField("URL", text: $urlValue) + .autocapitalization(.none) + .autocorrectionDisabled() + case .setBrightness: + VStack(alignment: .leading) { + Text("Brightness: \(Int(brightnessValue * 100))%") + Slider(value: $brightnessValue, in: 0...1) + } + case .playSound: + TextField("Sound URL", text: $urlValue) + .autocapitalization(.none) + .autocorrectionDisabled() + case .tts: + TextField("Message", text: $messageValue) + default: + EmptyView() + } + } header: { + Text("Action") + } + } + .navigationTitle("Add Action Trigger") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button("Save") { + let action = buildAction() + let trigger = EntityActionTrigger( + entityId: entityId, + triggerState: triggerState, + action: action + ) + onSave(trigger) + dismiss() + } + .disabled(entityId.isEmpty) + } + } + } + } + + private func buildAction() -> TriggerAction { + switch selectedActionType { + case .navigate: + return .navigate(url: urlValue) + case .setBrightness: + return .setBrightness(level: brightnessValue) + case .startScreensaver: + return .startScreensaver(mode: nil) + case .stopScreensaver: + return .stopScreensaver + case .refresh: + return .refresh + case .playSound: + return .playSound(url: urlValue) + case .tts: + return .tts(message: messageValue) + } + } +} + +// MARK: - TriggerAction Extensions + +extension TriggerAction { + var displayName: String { + switch self { + case .navigate: return "Navigate" + case .setBrightness: return "Set Brightness" + case .startScreensaver: return "Start Screensaver" + case .stopScreensaver: return "Stop Screensaver" + case .refresh: return "Refresh" + case .playSound: return "Play Sound" + case .tts: return "TTS" + } + } +} + +// MARK: - Preview + +@available(iOS 17.0, *) +#Preview { + EntityTriggersView( + wakeEntities: .constant([ + EntityTrigger(entityId: "binary_sensor.motion", triggerState: "on") + ]), + sleepEntities: .constant([]), + actionTriggers: .constant([]) + ) +} diff --git a/Sources/App/Kiosk/Settings/KioskSettingsView.swift b/Sources/App/Kiosk/Settings/KioskSettingsView.swift new file mode 100644 index 0000000000..05e6bf5a17 --- /dev/null +++ b/Sources/App/Kiosk/Settings/KioskSettingsView.swift @@ -0,0 +1,566 @@ +import Shared +import SwiftUI + +// MARK: - Main Kiosk Settings View + +struct KioskSettingsView: View { + @ObservedObject private var manager = KioskModeManager.shared + @State private var settings: KioskSettings + @State private var showingAuthentication = false + @State private var showingDashboardConfig = false + @State private var showingScreensaverConfig = false + @State private var showingEntityTriggers = false + @State private var showingAppLauncher = false + @State private var showingAuthError = false + @State private var authErrorMessage = "" + + init() { + _settings = State(initialValue: KioskModeManager.shared.settings) + } + + var body: some View { + Form { + // Quick Enable Section + kioskModeSection + + if !manager.isKioskModeActive { + // Only show config when kiosk mode is disabled + coreSettingsSection + dashboardSection + autoRefreshSection + brightnessSection + screensaverSection + triggersSection + presenceSection + cameraPopupSection + audioSection + deviceSection + } + } + .navigationTitle("Kiosk Mode") + .navigationBarTitleDisplayMode(.large) + .onChange(of: settings) { newValue in + manager.updateSettings(newValue) + } + .sheet(isPresented: $showingAuthentication) { + AuthenticationView(isPresented: $showingAuthentication) { + manager.disableKioskMode() + } + } + .sheet(isPresented: $showingDashboardConfig) { + DashboardConfigurationView(dashboards: $settings.dashboards, primaryURL: $settings.primaryDashboardURL) + } + .sheet(isPresented: $showingScreensaverConfig) { + ScreensaverConfigView(settings: $settings) + } + .sheet(isPresented: $showingEntityTriggers) { + EntityTriggersView( + wakeEntities: $settings.wakeEntities, + sleepEntities: $settings.sleepEntities, + actionTriggers: $settings.entityTriggers + ) + } + .alert("Authentication Error", isPresented: $showingAuthError) { + Button("OK", role: .cancel) { } + } message: { + Text(authErrorMessage) + } + } + + // MARK: - Kiosk Mode Section + + private var kioskModeSection: some View { + Section { + if manager.isKioskModeActive { + VStack(alignment: .leading, spacing: 12) { + HStack { + Image(systemName: "lock.fill") + .foregroundColor(.green) + Text("Kiosk Mode Active") + .font(.headline) + } + + Text("Screen: \(manager.screenState.rawValue)") + .font(.caption) + .foregroundColor(.secondary) + + if let screensaver = manager.activeScreensaverMode { + Text("Screensaver: \(screensaver.displayName)") + .font(.caption) + .foregroundColor(.secondary) + } + + Button(role: .destructive) { + attemptKioskExit() + } label: { + Label("Exit Kiosk Mode", systemImage: "lock.open") + } + .accessibilityHint("Double-tap to exit kiosk mode. Authentication may be required.") + } + } else { + Toggle(isOn: Binding( + get: { manager.isKioskModeActive }, + set: { newValue in + if newValue { + manager.enableKioskMode() + } + } + )) { + Label("Enable Kiosk Mode", systemImage: "lock") + } + } + } header: { + Text("Kiosk Mode") + } footer: { + if !manager.isKioskModeActive { + Text("When enabled, the display will be locked to the dashboard. Use Face ID, Touch ID, or device passcode to exit.") + } + } + } + + // MARK: - Core Settings Section + + private var coreSettingsSection: some View { + Section { + Toggle(isOn: $settings.allowBiometricExit) { + Label("Allow Face ID / Touch ID", systemImage: "faceid") + } + + Toggle(isOn: $settings.allowDevicePasscodeExit) { + Label("Allow Device Passcode", systemImage: "lock.shield") + } + + Toggle(isOn: $settings.navigationLockdown) { + Label("Lock Navigation", systemImage: "hand.raised") + } + + Toggle(isOn: $settings.hideStatusBar) { + Label("Hide Status Bar", systemImage: "rectangle.expand.vertical") + } + + Toggle(isOn: $settings.preventAutoLock) { + Label("Prevent Auto-Lock", systemImage: "lock.open.display") + } + + Toggle(isOn: $settings.edgeProtection) { + Label("Edge Touch Protection", systemImage: "rectangle.dashed") + } + + // Secret Exit Gesture + Toggle(isOn: $settings.secretExitGestureEnabled) { + Label("Secret Exit Gesture", systemImage: "hand.tap") + } + + if settings.secretExitGestureEnabled { + Picker("Exit Gesture Corner", selection: $settings.secretExitGestureCorner) { + ForEach(ScreenCorner.allCases, id: \.self) { corner in + Text(corner.displayName).tag(corner) + } + } + + Stepper(value: $settings.secretExitGestureTaps, in: 2...5) { + Label("Taps Required: \(settings.secretExitGestureTaps)", systemImage: "number") + } + } + } header: { + Text("Security & Display") + } footer: { + if settings.secretExitGestureEnabled { + Text("Tap the \(settings.secretExitGestureCorner.displayName.lowercased()) corner \(settings.secretExitGestureTaps) times to access kiosk settings when locked. This is your escape hatch if navigation is locked down.") + } else { + Text("Navigation lockdown prevents back gestures and pull-to-refresh. Edge protection ignores touches near screen edges. Warning: Without the secret exit gesture, you can only exit kiosk mode via Home Assistant commands.") + } + } + } + + // MARK: - Dashboard Section + + private var dashboardSection: some View { + Section { + NavigationLink { + DashboardPickerView(selectedPath: $settings.primaryDashboardURL) + } label: { + HStack { + Label("Default Dashboard", systemImage: "house") + Spacer() + Text(settings.primaryDashboardURL.isEmpty ? "Not Set" : settings.primaryDashboardURL) + .foregroundColor(.secondary) + .lineLimit(1) + } + } + + Toggle(isOn: $settings.nativeDashboardKioskMode) { + Label("Dashboard Kiosk Mode", systemImage: "rectangle.on.rectangle.slash") + } + + Toggle(isOn: $settings.appendHACSKioskParameter) { + Label("HACS Kiosk Mode URL", systemImage: "link.badge.plus") + } + + Button { + showingDashboardConfig = true + } label: { + HStack { + Label("Configure Dashboards", systemImage: "rectangle.stack") + Spacer() + Text("\(settings.dashboards.count)") + .foregroundColor(.secondary) + } + } + + Toggle(isOn: $settings.rotationEnabled) { + Label("Rotate Dashboards", systemImage: "arrow.2.squarepath") + } + + if settings.rotationEnabled { + Stepper(value: $settings.rotationInterval, in: 10...3600, step: 10) { + Label("Rotation: \(Int(settings.rotationInterval))s", systemImage: "timer") + } + + Toggle(isOn: $settings.pauseRotationOnTouch) { + Label("Pause on Touch", systemImage: "hand.tap") + } + } + } header: { + Text("Dashboard") + } footer: { + if settings.nativeDashboardKioskMode { + Text("Dashboard Kiosk Mode hides the HA sidebar and header (requires HA 2026.1+).") + } else if settings.appendHACSKioskParameter { + Text("HACS Kiosk Mode URL appends ?kiosk to URLs (requires kiosk-mode HACS integration).") + } else { + Text("Select a dashboard from Home Assistant or enter a custom path.") + } + } + } + + // MARK: - Auto Refresh Section + + private var autoRefreshSection: some View { + Section { + Picker("Periodic Refresh", selection: $settings.autoRefreshInterval) { + Text("Never").tag(TimeInterval(0)) + Text("5 minutes").tag(TimeInterval(300)) + Text("15 minutes").tag(TimeInterval(900)) + Text("30 minutes").tag(TimeInterval(1800)) + Text("1 hour").tag(TimeInterval(3600)) + } + + Toggle(isOn: $settings.refreshOnWake) { + Label("Refresh on Wake", systemImage: "sunrise") + } + + Toggle(isOn: $settings.refreshOnNetworkReconnect) { + Label("Refresh on Network Change", systemImage: "wifi") + } + + Toggle(isOn: $settings.refreshOnHAReconnect) { + Label("Refresh on HA Reconnect", systemImage: "server.rack") + } + } header: { + Text("Refresh") + } footer: { + Text("Periodic refresh reloads the dashboard on a schedule. Other options refresh only when specific events occur.") + } + } + + // MARK: - Brightness Section + + private var brightnessSection: some View { + Section { + Toggle(isOn: $settings.brightnessControlEnabled) { + Label("Manage Brightness", systemImage: "sun.max") + } + + if settings.brightnessControlEnabled { + VStack(alignment: .leading) { + Text("Brightness: \(Int(settings.manualBrightness * 100))%") + Slider(value: $settings.manualBrightness, in: 0.05...1.0) + } + + Toggle(isOn: $settings.brightnessScheduleEnabled) { + Label("Use Schedule", systemImage: "clock") + } + + if settings.brightnessScheduleEnabled { + HStack { + Text("Day") + Spacer() + Text("\(Int(settings.dayBrightness * 100))%") + Slider(value: $settings.dayBrightness, in: 0.05...1.0) + .frame(width: 100) + } + + DatePicker("Day Starts", selection: Binding( + get: { dateFromTimeOfDay(settings.dayStartTime) }, + set: { settings.dayStartTime = timeOfDayFromDate($0) } + ), displayedComponents: .hourAndMinute) + + HStack { + Text("Night") + Spacer() + Text("\(Int(settings.nightBrightness * 100))%") + Slider(value: $settings.nightBrightness, in: 0.05...1.0) + .frame(width: 100) + } + + DatePicker("Night Starts", selection: Binding( + get: { dateFromTimeOfDay(settings.nightStartTime) }, + set: { settings.nightStartTime = timeOfDayFromDate($0) } + ), displayedComponents: .hourAndMinute) + } + } + } header: { + Text("Brightness") + } + } + + // MARK: - Screensaver Section + + private var screensaverSection: some View { + Section { + Toggle(isOn: $settings.screensaverEnabled) { + Label("Enable Screensaver", systemImage: "moon.stars") + } + + if settings.screensaverEnabled { + Picker("Mode", selection: $settings.screensaverMode) { + ForEach(ScreensaverMode.allCases, id: \.self) { mode in + Text(mode.displayName).tag(mode) + } + } + + Stepper(value: $settings.screensaverTimeout, in: 30...3600, step: 30) { + Label("Timeout: \(formatDuration(settings.screensaverTimeout))", systemImage: "timer") + } + + if settings.screensaverMode == .dim { + VStack(alignment: .leading) { + Text("Dim Level: \(Int(settings.screensaverDimLevel * 100))%") + Slider(value: $settings.screensaverDimLevel, in: 0.01...0.5) + } + } + + Button { + showingScreensaverConfig = true + } label: { + Label("Screensaver Options", systemImage: "slider.horizontal.3") + } + + Toggle(isOn: $settings.pixelShiftEnabled) { + Label("Pixel Shift (Burn-in Prevention)", systemImage: "arrow.up.left.and.arrow.down.right") + } + } + } header: { + Text("Screensaver") + } + } + + // MARK: - Triggers Section + + private var triggersSection: some View { + Section { + Toggle(isOn: $settings.wakeOnTouch) { + Label("Wake on Touch", systemImage: "hand.tap") + } + + Button { + showingEntityTriggers = true + } label: { + HStack { + Label("Entity Triggers", systemImage: "bolt") + Spacer() + let count = settings.wakeEntities.count + settings.sleepEntities.count + settings.entityTriggers.count + Text("\(count)") + .foregroundColor(.secondary) + } + } + } header: { + Text("Wake & Sleep Triggers") + } footer: { + Text("Configure external motion sensors or other HA entities to wake or sleep the display.") + } + } + + // MARK: - Presence Section + + private var presenceSection: some View { + Section { + Toggle(isOn: $settings.cameraMotionEnabled) { + Label("Camera Motion Detection", systemImage: "camera.viewfinder") + } + + if settings.cameraMotionEnabled { + Toggle(isOn: $settings.wakeOnCameraMotion) { + Label("Wake on Motion", systemImage: "sunrise") + } + + Picker("Sensitivity", selection: $settings.cameraMotionSensitivity) { + ForEach(MotionSensitivity.allCases, id: \.self) { sensitivity in + Text(sensitivity.displayName).tag(sensitivity) + } + } + + Toggle(isOn: $settings.reportMotionToHA) { + Label("Report to Home Assistant", systemImage: "arrow.up.circle") + } + } + + Toggle(isOn: $settings.cameraPresenceEnabled) { + Label("Person Detection", systemImage: "person.fill.viewfinder") + } + + if settings.cameraPresenceEnabled { + Toggle(isOn: $settings.wakeOnCameraPresence) { + Label("Wake on Presence", systemImage: "sunrise") + } + + Toggle(isOn: $settings.cameraFaceDetectionEnabled) { + Label("Use Face Detection", systemImage: "face.smiling") + } + + Toggle(isOn: $settings.reportPresenceToHA) { + Label("Report to Home Assistant", systemImage: "arrow.up.circle") + } + } + } header: { + Text("Camera & Presence") + } footer: { + Text("Motion and presence detection uses the front camera. All processing is done on-device.") + } + } + + // MARK: - Camera Popup Section + + private var cameraPopupSection: some View { + Section { + Picker("Popup Size", selection: $settings.cameraPopupSize) { + ForEach(CameraPopupSize.allCases, id: \.self) { size in + Text(size.displayName).tag(size) + } + } + + Picker("Popup Position", selection: $settings.cameraPopupPosition) { + ForEach(CameraPopupPosition.allCases, id: \.self) { position in + Text(position.displayName).tag(position) + } + } + } header: { + Text("Camera Popup") + } footer: { + Text("Configure how doorbell and security camera popups appear when triggered by notifications.") + } + } + + // MARK: - Audio Section + + private var audioSection: some View { + Section { + Toggle(isOn: $settings.ttsEnabled) { + Label("Text-to-Speech", systemImage: "speaker.wave.3") + } + + if settings.ttsEnabled { + VStack(alignment: .leading) { + Text("TTS Volume: \(Int(settings.ttsVolume * 100))%") + Slider(value: $settings.ttsVolume, in: 0...1) + } + } + + Toggle(isOn: $settings.audioAlertsEnabled) { + Label("Audio Alerts", systemImage: "bell.badge") + } + } header: { + Text("Audio") + } + } + + // MARK: - Device Section + + private var deviceSection: some View { + Section { + Picker("Orientation Lock", selection: $settings.orientationLock) { + ForEach(OrientationLock.allCases, id: \.self) { orientation in + Text(orientation.displayName).tag(orientation) + } + } + + Toggle(isOn: $settings.tamperDetectionEnabled) { + Label("Tamper Detection", systemImage: "shield.checkered") + } + + Toggle(isOn: $settings.touchHapticEnabled) { + Label("Haptic Feedback", systemImage: "hand.point.up.braille") + } + + Toggle(isOn: $settings.touchSoundEnabled) { + Label("Touch Sounds", systemImage: "speaker.wave.1") + } + + if settings.lowBatteryAlertThreshold > 0 { + Stepper(value: $settings.lowBatteryAlertThreshold, in: 0...50, step: 5) { + Label("Low Battery Alert: \(settings.lowBatteryAlertThreshold)%", systemImage: "battery.25percent") + } + } else { + Toggle(isOn: Binding( + get: { settings.lowBatteryAlertThreshold > 0 }, + set: { settings.lowBatteryAlertThreshold = $0 ? 20 : 0 } + )) { + Label("Low Battery Alerts", systemImage: "battery.25percent") + } + } + } header: { + Text("Device") + } + } + + // MARK: - Helpers + + private func formatDuration(_ seconds: TimeInterval) -> String { + if seconds < 60 { + return "\(Int(seconds))s" + } else if seconds < 3600 { + return "\(Int(seconds / 60))m" + } else { + return "\(Int(seconds / 3600))h" + } + } + + private func dateFromTimeOfDay(_ time: TimeOfDay) -> Date { + var components = DateComponents() + components.hour = time.hour + components.minute = time.minute + return Calendar.current.date(from: components) ?? Date() + } + + private func timeOfDayFromDate(_ date: Date) -> TimeOfDay { + let components = Calendar.current.dateComponents([.hour, .minute], from: date) + return TimeOfDay(hour: components.hour ?? 0, minute: components.minute ?? 0) + } + + // MARK: - Kiosk Exit Logic + + /// Attempt to exit kiosk mode with device authentication + private func attemptKioskExit() { + // Check if any authentication method is configured + let hasBiometric = settings.allowBiometricExit && SecurityManager.shared.isBiometryAvailable + let hasPasscode = settings.allowDevicePasscodeExit && SecurityManager.shared.isDevicePasscodeSet + + if hasBiometric || hasPasscode { + // Show authentication sheet + showingAuthentication = true + } else { + // No authentication configured - just exit + manager.disableKioskMode() + } + } +} + +// MARK: - Preview + +@available(iOS 17.0, *) +#Preview { + NavigationStack { + KioskSettingsView() + } +} diff --git a/Sources/App/Kiosk/Settings/PhotoAlbumPickerView.swift b/Sources/App/Kiosk/Settings/PhotoAlbumPickerView.swift new file mode 100644 index 0000000000..1e297705c7 --- /dev/null +++ b/Sources/App/Kiosk/Settings/PhotoAlbumPickerView.swift @@ -0,0 +1,389 @@ +import Photos +import PhotosUI +import SwiftUI + +// MARK: - Photo Album Picker View + +/// View for selecting photo albums from the device's photo library +public struct PhotoAlbumPickerView: View { + @Environment(\.dismiss) private var dismiss + @Binding var selectedAlbumIds: [String] + + /// Type of albums to show + let albumType: AlbumType + + /// Title for the view + let title: String + + @State private var albums: [AlbumInfo] = [] + @State private var isLoading = true + @State private var permissionStatus: PHAuthorizationStatus = .notDetermined + @State private var searchText = "" + + public enum AlbumType { + case local + case iCloud + + var subtitle: String { + switch self { + case .local: return "Select albums from this device" + case .iCloud: return "Select iCloud shared albums" + } + } + } + + public init(selectedAlbumIds: Binding<[String]>, albumType: AlbumType, title: String = "Select Albums") { + _selectedAlbumIds = selectedAlbumIds + self.albumType = albumType + self.title = title + } + + public var body: some View { + NavigationView { + Group { + switch permissionStatus { + case .authorized, .limited: + albumListContent + case .denied, .restricted: + permissionDeniedView + case .notDetermined: + requestPermissionView + @unknown default: + requestPermissionView + } + } + .navigationTitle(title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { dismiss() } + } + } + .onAppear { + checkPermission() + } + } + } + + // MARK: - Album List Content + + private var albumListContent: some View { + VStack(spacing: 0) { + if isLoading { + ProgressView("Loading albums...") + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if albums.isEmpty { + VStack(spacing: 16) { + Image(systemName: "photo.on.rectangle.angled") + .font(.system(size: 48)) + .foregroundColor(.secondary) + Text("No Albums Found") + .font(.headline) + Text(albumType == .iCloud ? "No iCloud shared albums available" : "No albums found on this device") + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + List { + if !searchText.isEmpty { + filteredAlbumsList + } else { + allAlbumsList + } + } + .searchable(text: $searchText, prompt: "Search albums") + } + } + } + + private var allAlbumsList: some View { + Group { + // Smart Albums section + let smartAlbums = albums.filter { $0.isSmartAlbum } + if !smartAlbums.isEmpty { + Section("Smart Albums") { + ForEach(smartAlbums) { album in + albumRow(album) + } + } + } + + // User Albums section + let userAlbums = albums.filter { !$0.isSmartAlbum } + if !userAlbums.isEmpty { + Section("My Albums") { + ForEach(userAlbums) { album in + albumRow(album) + } + } + } + } + } + + private var filteredAlbumsList: some View { + let filtered = albums.filter { $0.title.localizedCaseInsensitiveContains(searchText) } + return Section { + ForEach(filtered) { album in + albumRow(album) + } + } + } + + private func albumRow(_ album: AlbumInfo) -> some View { + HStack { + // Album thumbnail + if let thumbnail = album.thumbnail { + Image(uiImage: thumbnail) + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: 60, height: 60) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } else { + RoundedRectangle(cornerRadius: 8) + .fill(Color.secondary.opacity(0.2)) + .frame(width: 60, height: 60) + .overlay( + Image(systemName: "photo.on.rectangle") + .foregroundColor(.secondary) + ) + } + + VStack(alignment: .leading, spacing: 2) { + Text(album.title) + .font(.body) + Text("\(album.assetCount) photos") + .font(.caption) + .foregroundColor(.secondary) + } + + Spacer() + + // Selection indicator + if selectedAlbumIds.contains(album.identifier) { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.accentColor) + .font(.title2) + } else { + Image(systemName: "circle") + .foregroundColor(.secondary) + .font(.title2) + } + } + .contentShape(Rectangle()) + .onTapGesture { + toggleSelection(for: album) + } + } + + // MARK: - Permission Views + + private var requestPermissionView: some View { + VStack(spacing: 16) { + Image(systemName: "photo.on.rectangle") + .font(.system(size: 48)) + .foregroundColor(.secondary) + Text("Photo Access Required") + .font(.headline) + Text("Please grant access to your photo library to select albums for the screensaver.") + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) + Button("Grant Access") { + requestPermission() + } + .buttonStyle(.bordered) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private var permissionDeniedView: some View { + VStack(spacing: 16) { + Image(systemName: "exclamationmark.triangle") + .font(.system(size: 48)) + .foregroundColor(.orange) + Text("Photo Access Denied") + .font(.headline) + Text("Photo library access was denied. Please enable it in Settings to select albums.") + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) + Button("Open Settings") { + if let url = URL(string: UIApplication.openSettingsURLString) { + UIApplication.shared.open(url) + } + } + .buttonStyle(.bordered) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + // MARK: - Permission Handling + + private func checkPermission() { + permissionStatus = PHPhotoLibrary.authorizationStatus(for: .readWrite) + if permissionStatus == .authorized || permissionStatus == .limited { + loadAlbums() + } + } + + private func requestPermission() { + PHPhotoLibrary.requestAuthorization(for: .readWrite) { status in + DispatchQueue.main.async { + permissionStatus = status + if status == .authorized || status == .limited { + loadAlbums() + } + } + } + } + + // MARK: - Album Loading + + private func loadAlbums() { + isLoading = true + albums = [] + + DispatchQueue.global(qos: .userInitiated).async { + var loadedAlbums: [AlbumInfo] = [] + + // Fetch smart albums (like Favorites, Recent, etc.) + let smartAlbumTypes: [PHAssetCollectionSubtype] = [ + .smartAlbumFavorites, + .smartAlbumRecentlyAdded, + .smartAlbumUserLibrary, + .smartAlbumSelfPortraits, + .smartAlbumPanoramas, + .smartAlbumScreenshots, + .smartAlbumBursts, + .smartAlbumLivePhotos, + ] + + for subtype in smartAlbumTypes { + let fetchResult = PHAssetCollection.fetchAssetCollections( + with: .smartAlbum, + subtype: subtype, + options: nil + ) + + fetchResult.enumerateObjects { collection, _, _ in + if let albumInfo = createAlbumInfo(from: collection, isSmartAlbum: true) { + loadedAlbums.append(albumInfo) + } + } + } + + // Fetch user-created albums + let userAlbumsFetch = PHAssetCollection.fetchAssetCollections( + with: .album, + subtype: .any, + options: nil + ) + + userAlbumsFetch.enumerateObjects { collection, _, _ in + if let albumInfo = createAlbumInfo(from: collection, isSmartAlbum: false) { + loadedAlbums.append(albumInfo) + } + } + + // For iCloud, also fetch shared albums + if albumType == .iCloud { + let sharedAlbumsFetch = PHAssetCollection.fetchAssetCollections( + with: .album, + subtype: .albumCloudShared, + options: nil + ) + + sharedAlbumsFetch.enumerateObjects { collection, _, _ in + if let albumInfo = createAlbumInfo(from: collection, isSmartAlbum: false) { + loadedAlbums.append(albumInfo) + } + } + } + + // Sort: smart albums first, then by name + loadedAlbums.sort { lhs, rhs in + if lhs.isSmartAlbum != rhs.isSmartAlbum { + return lhs.isSmartAlbum + } + return lhs.title.localizedCaseInsensitiveCompare(rhs.title) == .orderedAscending + } + + DispatchQueue.main.async { + albums = loadedAlbums + isLoading = false + } + } + } + + private func createAlbumInfo(from collection: PHAssetCollection, isSmartAlbum: Bool) -> AlbumInfo? { + let fetchOptions = PHFetchOptions() + fetchOptions.predicate = NSPredicate(format: "mediaType == %d", PHAssetMediaType.image.rawValue) + fetchOptions.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: false)] + + let assets = PHAsset.fetchAssets(in: collection, options: fetchOptions) + + // Skip albums with no photos + guard assets.count > 0 else { return nil } + + // Get thumbnail from the first asset + var thumbnail: UIImage? + if let firstAsset = assets.firstObject { + let options = PHImageRequestOptions() + options.isSynchronous = true + options.deliveryMode = .fastFormat + options.resizeMode = .fast + + PHImageManager.default().requestImage( + for: firstAsset, + targetSize: CGSize(width: 120, height: 120), + contentMode: .aspectFill, + options: options + ) { image, _ in + thumbnail = image + } + } + + return AlbumInfo( + identifier: collection.localIdentifier, + title: collection.localizedTitle ?? "Untitled Album", + assetCount: assets.count, + thumbnail: thumbnail, + isSmartAlbum: isSmartAlbum + ) + } + + // MARK: - Selection + + private func toggleSelection(for album: AlbumInfo) { + if let index = selectedAlbumIds.firstIndex(of: album.identifier) { + selectedAlbumIds.remove(at: index) + } else { + selectedAlbumIds.append(album.identifier) + } + } +} + +// MARK: - Album Info Model + +struct AlbumInfo: Identifiable { + let id = UUID() + let identifier: String + let title: String + let assetCount: Int + let thumbnail: UIImage? + let isSmartAlbum: Bool +} + +// MARK: - Preview + +@available(iOS 17.0, *) +#Preview { + PhotoAlbumPickerView( + selectedAlbumIds: .constant(["test-album-1"]), + albumType: .local + ) +} diff --git a/Sources/App/Kiosk/Settings/ScreensaverConfigView.swift b/Sources/App/Kiosk/Settings/ScreensaverConfigView.swift new file mode 100755 index 0000000000..175b7df4b1 --- /dev/null +++ b/Sources/App/Kiosk/Settings/ScreensaverConfigView.swift @@ -0,0 +1,295 @@ +import Shared +import SwiftUI + +// MARK: - Screensaver Config View + +/// Detailed screensaver configuration view +public struct ScreensaverConfigView: View { + @Environment(\.dismiss) private var dismiss + @Binding var settings: KioskSettings + + public init(settings: Binding) { + _settings = settings + } + + public var body: some View { + NavigationView { + Form { + // Mode Selection + Section { + Picker("Screensaver Mode", selection: $settings.screensaverMode) { + ForEach(ScreensaverMode.allCases, id: \.self) { mode in + Text(mode.displayName).tag(mode) + } + } + .pickerStyle(.menu) + } header: { + Text("Mode") + } + + // Mode-specific settings + switch settings.screensaverMode { + case .clock, .clockWithEntities: + clockSettings + case .photos, .photosWithClock: + photoSettings + case .dim: + dimSettings + case .blank: + EmptyView() + case .customURL: + customURLSettings + } + + // Common settings + commonSettings + } + .navigationTitle("Screensaver Options") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { dismiss() } + } + } + } + } + + // MARK: - Clock Settings + + private var clockSettings: some View { + Section { + Toggle("Show Date", isOn: $settings.clockShowDate) + + Toggle("Show Seconds", isOn: $settings.clockShowSeconds) + + Toggle("Use 24-Hour Format", isOn: $settings.clockUse24HourFormat) + + Picker("Clock Style", selection: $settings.clockStyle) { + ForEach(ClockStyle.allCases, id: \.self) { style in + Text(style.displayName).tag(style) + } + } + + if settings.screensaverMode == .clockWithEntities { + clockEntitiesSection + } + } header: { + Text("Clock Options") + } + } + + private var clockEntitiesSection: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Display Entities") + .font(.subheadline) + .foregroundColor(.secondary) + + ForEach($settings.clockEntities) { $entity in + HStack { + TextField("Entity ID", text: $entity.entityId) + .autocapitalization(.none) + .autocorrectionDisabled() + + Button { + settings.clockEntities.removeAll { $0.id == entity.id } + } label: { + Image(systemName: "minus.circle.fill") + .foregroundColor(.red) + } + } + } + + Button { + settings.clockEntities.append(ClockEntityConfig(entityId: "")) + } label: { + Label("Add Entity", systemImage: "plus.circle") + } + } + } + + // MARK: - Photo Settings + + private var photoSettings: some View { + Section { + Picker("Photo Source", selection: $settings.photoSource) { + ForEach(PhotoSource.allCases, id: \.self) { source in + Text(source.displayName).tag(source) + } + } + + // Album selection based on source type + if settings.photoSource == .local || settings.photoSource == .all { + NavigationLink { + PhotoAlbumPickerView( + selectedAlbumIds: $settings.localPhotoAlbums, + albumType: .local, + title: "Device Albums" + ) + } label: { + HStack { + Text("Device Albums") + Spacer() + Text(albumSelectionText(for: settings.localPhotoAlbums)) + .foregroundColor(.secondary) + } + } + } + + if settings.photoSource == .iCloud || settings.photoSource == .all { + NavigationLink { + PhotoAlbumPickerView( + selectedAlbumIds: $settings.iCloudAlbums, + albumType: .iCloud, + title: "iCloud Albums" + ) + } label: { + HStack { + Text("iCloud Albums") + Spacer() + Text(albumSelectionText(for: settings.iCloudAlbums)) + .foregroundColor(.secondary) + } + } + } + + if settings.photoSource == .haMedia || settings.photoSource == .all { + TextField("HA Media Path", text: $settings.haMediaPath) + .autocapitalization(.none) + .autocorrectionDisabled() + } + + Stepper(value: $settings.photoInterval, in: 5...120, step: 5) { + Text("Interval: \(Int(settings.photoInterval))s") + } + + Picker("Transition Style", selection: $settings.photoTransition) { + ForEach(PhotoTransition.allCases, id: \.self) { transition in + Text(transition.displayName).tag(transition) + } + } + + Picker("Fit Mode", selection: $settings.photoFitMode) { + ForEach(PhotoFitMode.allCases, id: \.self) { mode in + Text(mode.displayName).tag(mode) + } + } + + if settings.screensaverMode == .photosWithClock { + Toggle("Show Clock Overlay", isOn: $settings.photoShowClockOverlay) + Toggle("Show Entity Overlay", isOn: $settings.photoShowEntityOverlay) + } + } header: { + Text("Photo Options") + } footer: { + if settings.photoSource == .local || settings.photoSource == .iCloud { + Text("Select specific albums to show photos from, or leave empty to use all photos.") + } + } + } + + private func albumSelectionText(for albums: [String]) -> String { + if albums.isEmpty { + return "All" + } else if albums.count == 1 { + return "1 album" + } else { + return "\(albums.count) albums" + } + } + + // MARK: - Dim Settings + + private var dimSettings: some View { + Section { + Text("The screen will be dimmed to the brightness level set below.") + .font(.subheadline) + .foregroundColor(.secondary) + } header: { + Text("Dim Mode") + } footer: { + Text("Dim mode shows a blank dimmed screen. Adjust brightness in the Brightness section below.") + } + } + + // MARK: - Custom URL Settings + + private var customURLSettings: some View { + Section { + TextField("Custom Dashboard URL", text: $settings.screensaverCustomURL) + .autocapitalization(.none) + .autocorrectionDisabled() + } header: { + Text("Custom URL") + } footer: { + Text("Enter a URL to a custom dashboard to display as the screensaver.") + } + } + + // MARK: - Common Settings + + private var commonSettings: some View { + Group { + brightnessSettings + burnInSettings + } + } + + private var brightnessSettings: some View { + Section { + VStack(alignment: .leading) { + Text("Dim Level: \(Int(settings.screensaverDimLevel * 100))%") + Slider(value: $settings.screensaverDimLevel, in: 0.01...0.5) + } + + Toggle("Day/Night Schedule", isOn: $settings.screensaverBrightnessScheduleEnabled) + + if settings.screensaverBrightnessScheduleEnabled { + VStack(alignment: .leading) { + Text("Day Brightness: \(Int(settings.screensaverDayDimLevel * 100))%") + Slider(value: $settings.screensaverDayDimLevel, in: 0.01...0.5) + } + + VStack(alignment: .leading) { + Text("Night Brightness: \(Int(settings.screensaverNightDimLevel * 100))%") + Slider(value: $settings.screensaverNightDimLevel, in: 0.01...0.3) + } + } + } header: { + Text("Brightness") + } footer: { + if settings.screensaverBrightnessScheduleEnabled { + Text("Uses the same day/night schedule times as main brightness settings.") + } else { + Text("Adjust how dim the screen gets during screensaver.") + } + } + } + + private var burnInSettings: some View { + Section { + Toggle("Pixel Shift", isOn: $settings.pixelShiftEnabled) + + if settings.pixelShiftEnabled { + Stepper(value: $settings.pixelShiftInterval, in: 30...300, step: 30) { + Text("Shift Interval: \(Int(settings.pixelShiftInterval))s") + } + + VStack(alignment: .leading) { + Text("Shift Amount: \(Int(settings.pixelShiftAmount))px") + Slider(value: $settings.pixelShiftAmount, in: 5...30) + } + } + } header: { + Text("Burn-in Prevention") + } footer: { + Text("Pixel shift periodically moves content to prevent screen burn-in.") + } + } +} + +// MARK: - Preview + +@available(iOS 17.0, *) +#Preview { + ScreensaverConfigView(settings: .constant(KioskSettings())) +} diff --git a/Sources/App/Kiosk/Utilities/AnimationUtilities.swift b/Sources/App/Kiosk/Utilities/AnimationUtilities.swift new file mode 100755 index 0000000000..dfebcd58ea --- /dev/null +++ b/Sources/App/Kiosk/Utilities/AnimationUtilities.swift @@ -0,0 +1,232 @@ +import SwiftUI + +// MARK: - Kiosk Animations + +/// Pre-defined animations for consistent UX across the kiosk mode +public enum KioskAnimation { + /// Quick fade animation for subtle transitions + public static let fade = Animation.easeInOut(duration: KioskConstants.Animation.quick) + + /// Standard animation for most transitions + public static let standard = Animation.easeInOut(duration: KioskConstants.Animation.standard) + + /// Slow animation for screensaver transitions + public static let slow = Animation.easeInOut(duration: KioskConstants.Animation.slow) + + /// Spring animation for interactive elements + public static let spring = Animation.spring(response: KioskConstants.Animation.springResponse, dampingFraction: 0.8) + + /// Bouncy spring for playful interactions + public static let bouncy = Animation.spring(response: 0.5, dampingFraction: 0.6) + + /// Smooth spring for panels and overlays + public static let panel = Animation.spring(response: 0.4, dampingFraction: 0.85) + + /// Gentle animation for pixel shift + public static let pixelShift = Animation.easeInOut(duration: KioskConstants.Animation.pixelShift) +} + +// MARK: - Kiosk Transitions + +/// Pre-defined transitions for consistent UX +public enum KioskTransition { + /// Fade in/out + public static let fade = AnyTransition.opacity + + /// Scale and fade + public static let scaleAndFade = AnyTransition.scale.combined(with: .opacity) + + /// Slide from bottom with fade + public static let slideFromBottom = AnyTransition.move(edge: .bottom).combined(with: .opacity) + + /// Slide from top with fade + public static let slideFromTop = AnyTransition.move(edge: .top).combined(with: .opacity) + + /// Slide from left with fade + public static let slideFromLeft = AnyTransition.move(edge: .leading).combined(with: .opacity) + + /// Slide from right with fade + public static let slideFromRight = AnyTransition.move(edge: .trailing).combined(with: .opacity) + + /// Asymmetric panel transition (slide in, fade out) + public static func panel(edge: Edge) -> AnyTransition { + .asymmetric( + insertion: .move(edge: edge).combined(with: .opacity), + removal: .opacity + ) + } + + /// Screensaver transition (slow fade) + public static let screensaver = AnyTransition.opacity.animation(KioskAnimation.slow) + + /// Alert transition (scale up, fade out) + public static let alert = AnyTransition.asymmetric( + insertion: .scale(scale: 0.9).combined(with: .opacity), + removal: .opacity + ) +} + +// MARK: - View Modifiers + +/// Applies a smooth appear animation +public struct AppearAnimationModifier: ViewModifier { + let animation: Animation + @State private var isVisible = false + + public init(animation: Animation = KioskAnimation.standard) { + self.animation = animation + } + + public func body(content: Content) -> some View { + content + .opacity(isVisible ? 1 : 0) + .scaleEffect(isVisible ? 1 : 0.95) + .onAppear { + withAnimation(animation) { + isVisible = true + } + } + } +} + +/// Applies a pulse animation (useful for drawing attention) +public struct PulseAnimationModifier: ViewModifier { + @State private var isPulsing = false + let duration: Double + + public init(duration: Double = 1.0) { + self.duration = duration + } + + public func body(content: Content) -> some View { + content + .scaleEffect(isPulsing ? 1.05 : 1.0) + .opacity(isPulsing ? 0.8 : 1.0) + .onAppear { + withAnimation(.easeInOut(duration: duration).repeatForever(autoreverses: true)) { + isPulsing = true + } + } + } +} + +/// Applies a shimmer loading animation +public struct ShimmerModifier: ViewModifier { + @State private var phase: CGFloat = 0 + + public func body(content: Content) -> some View { + content + .overlay( + GeometryReader { geometry in + LinearGradient( + gradient: Gradient(colors: [ + .clear, + .white.opacity(0.3), + .clear, + ]), + startPoint: .leading, + endPoint: .trailing + ) + .frame(width: geometry.size.width * 2) + .offset(x: -geometry.size.width + phase * geometry.size.width * 2) + } + ) + .mask(content) + .onAppear { + withAnimation(.linear(duration: 1.5).repeatForever(autoreverses: false)) { + phase = 1 + } + } + } +} + +// MARK: - View Extensions + +extension View { + /// Apply smooth appear animation + public func appearAnimation(_ animation: Animation = KioskAnimation.standard) -> some View { + modifier(AppearAnimationModifier(animation: animation)) + } + + /// Apply pulse animation + public func pulseAnimation(duration: Double = 1.0) -> some View { + modifier(PulseAnimationModifier(duration: duration)) + } + + /// Apply shimmer loading animation + public func shimmer() -> some View { + modifier(ShimmerModifier()) + } + + /// Apply a smooth scale effect on press + public func pressEffect() -> some View { + buttonStyle(PressEffectButtonStyle()) + } +} + +// MARK: - Button Styles + +/// Button style that scales down slightly on press +public struct PressEffectButtonStyle: ButtonStyle { + public func makeBody(configuration: Configuration) -> some View { + configuration.label + .scaleEffect(configuration.isPressed ? 0.95 : 1.0) + .opacity(configuration.isPressed ? 0.9 : 1.0) + .animation(.easeOut(duration: 0.1), value: configuration.isPressed) + } +} + +/// Button style with spring bounce effect +public struct BounceButtonStyle: ButtonStyle { + public func makeBody(configuration: Configuration) -> some View { + configuration.label + .scaleEffect(configuration.isPressed ? 0.9 : 1.0) + .animation(.spring(response: 0.3, dampingFraction: 0.5), value: configuration.isPressed) + } +} + +/// Button style that highlights on press +public struct HighlightButtonStyle: ButtonStyle { + public func makeBody(configuration: Configuration) -> some View { + configuration.label + .background( + RoundedRectangle(cornerRadius: KioskConstants.UI.smallCornerRadius) + .fill(configuration.isPressed ? Color.accentColor.opacity(0.1) : Color.clear) + ) + .animation(.easeOut(duration: 0.1), value: configuration.isPressed) + } +} + +// MARK: - Animated Property Wrappers + +/// Property wrapper that animates value changes +@propertyWrapper +public struct Animated: DynamicProperty { + @State private var value: Value + private let animation: Animation + + public init(wrappedValue: Value, animation: Animation = .default) { + _value = State(initialValue: wrappedValue) + self.animation = animation + } + + public var wrappedValue: Value { + get { value } + nonmutating set { + withAnimation(animation) { + value = newValue + } + } + } + + public var projectedValue: Binding { + Binding( + get: { value }, + set: { newValue in + withAnimation(animation) { + value = newValue + } + } + ) + } +} diff --git a/Sources/App/Kiosk/Utilities/IconMapper.swift b/Sources/App/Kiosk/Utilities/IconMapper.swift new file mode 100755 index 0000000000..c66bc75cf7 --- /dev/null +++ b/Sources/App/Kiosk/Utilities/IconMapper.swift @@ -0,0 +1,165 @@ +import Foundation + +// MARK: - Icon Mapper + +/// Utility for mapping Material Design Icons (MDI) to SF Symbols +public enum IconMapper { + /// Comprehensive mapping of MDI icons to SF Symbols + public static let mdiToSFSymbol: [String: String] = [ + // General + "mdi:application": "app.fill", + "mdi:cog": "gear", + "mdi:settings": "gear", + "mdi:power": "power", + "mdi:check": "checkmark", + "mdi:close": "xmark", + "mdi:plus": "plus", + "mdi:minus": "minus", + "mdi:search": "magnifyingglass", + "mdi:refresh": "arrow.clockwise", + + // Browser + "mdi:safari": "safari.fill", + "mdi:web": "globe", + "mdi:link": "link", + + // Media & Audio + "mdi:music": "music.note", + "mdi:spotify": "music.note.list", + "mdi:play": "play.fill", + "mdi:pause": "pause.fill", + "mdi:stop": "stop.fill", + "mdi:skip-next": "forward.fill", + "mdi:skip-previous": "backward.fill", + "mdi:volume-high": "speaker.wave.3.fill", + "mdi:volume-medium": "speaker.wave.2.fill", + "mdi:volume-low": "speaker.wave.1.fill", + "mdi:volume-off": "speaker.slash.fill", + "mdi:speaker": "speaker.wave.2", + + // Camera & Video + "mdi:camera": "camera.fill", + "mdi:video": "video.fill", + "mdi:cctv": "video", + "mdi:video-doorbell": "video.doorbell.fill", + + // Communication + "mdi:message": "message.fill", + "mdi:phone": "phone.fill", + "mdi:email": "envelope.fill", + "mdi:bell": "bell.fill", + "mdi:bell-outline": "bell", + + // Time & Calendar + "mdi:clock": "clock.fill", + "mdi:calendar": "calendar", + "mdi:alarm": "alarm", + "mdi:timer": "timer", + + // Weather + "mdi:weather-sunny": "sun.max", + "mdi:weather-cloudy": "cloud", + "mdi:weather-rainy": "cloud.rain", + "mdi:weather-partly-cloudy": "cloud.sun", + "mdi:weather-snowy": "cloud.snow", + "mdi:weather-windy": "wind", + "mdi:thermometer": "thermometer", + "mdi:water-percent": "humidity", + "mdi:humidity": "humidity", + + // Home & Security + "mdi:home": "house.fill", + "mdi:home-assistant": "house.fill", + "mdi:shield": "shield.fill", + "mdi:shield-home": "shield.fill", + "mdi:lock": "lock.fill", + "mdi:lock-open": "lock.open.fill", + "mdi:door": "door.left.hand.open", + "mdi:door-open": "door.left.hand.open", + "mdi:door-closed": "door.left.hand.closed", + "mdi:window-open": "window.vertical.open", + "mdi:window-closed": "window.vertical.closed", + "mdi:garage": "rectangle.split.3x1", + "mdi:motion-sensor": "figure.walk.motion", + "mdi:run": "figure.run", + + // Lighting + "mdi:lightbulb": "lightbulb.fill", + "mdi:lightbulb-on": "lightbulb.fill", + "mdi:lightbulb-off": "lightbulb.slash", + "mdi:lamp": "lamp.desk.fill", + "mdi:ceiling-light": "light.recessed", + "mdi:floor-lamp": "lamp.floor.fill", + + // HVAC & Climate + "mdi:thermostat": "thermostat", + "mdi:fan": "fan", + "mdi:air-conditioner": "air.conditioner.horizontal", + "mdi:snowflake": "snowflake", + "mdi:fire": "flame.fill", + + // Power & Energy + "mdi:flash": "bolt.fill", + "mdi:lightning-bolt": "bolt.fill", + "mdi:battery": "battery.100", + "mdi:battery-charging": "battery.100.bolt", + "mdi:solar-power": "sun.max.trianglebadge.exclamationmark", + "mdi:ev-station": "bolt.car", + "mdi:power-plug": "powerplug.fill", + "mdi:gas-station": "fuelpump", + + // Appliances + "mdi:washing-machine": "washer", + "mdi:dishwasher": "dishwasher", + "mdi:fridge": "refrigerator", + "mdi:stove": "stove", + "mdi:microwave": "microwave", + "mdi:coffee": "cup.and.saucer", + "mdi:vacuum": "humidifier.and.droplets", + + // Devices + "mdi:television": "tv", + "mdi:monitor": "display", + "mdi:laptop": "laptopcomputer", + "mdi:tablet": "ipad", + "mdi:cellphone": "iphone", + "mdi:wifi": "wifi", + "mdi:bluetooth": "antenna.radiowaves.left.and.right", + "mdi:printer": "printer", + "mdi:router": "wifi.router", + + // People & Location + "mdi:account": "person.fill", + "mdi:account-multiple": "person.2.fill", + "mdi:car": "car.fill", + "mdi:map": "map.fill", + "mdi:map-marker": "mappin", + "mdi:navigation": "location.north.fill", + + // Sensors & Gauges + "mdi:gauge": "gauge", + "mdi:speedometer": "speedometer", + "mdi:sensor": "sensor.fill", + "mdi:water": "drop.fill", + + // Dashboard + "mdi:view-dashboard": "rectangle.grid.2x2.fill", + "mdi:view-dashboard-outline": "rectangle.grid.2x2", + ] + + /// Convert an MDI icon name to an SF Symbol + /// - Parameter mdiName: The MDI icon name (e.g., "mdi:thermometer") + /// - Returns: The corresponding SF Symbol name, or "questionmark.circle" if not found + public static func sfSymbol(from mdiName: String) -> String { + mdiToSFSymbol[mdiName] ?? "questionmark.circle" + } + + /// Convert an MDI icon name to an SF Symbol, with a custom default + /// - Parameters: + /// - mdiName: The MDI icon name + /// - defaultSymbol: The default SF Symbol to use if no mapping exists + /// - Returns: The corresponding SF Symbol name + public static func sfSymbol(from mdiName: String, default defaultSymbol: String) -> String { + mdiToSFSymbol[mdiName] ?? defaultSymbol + } +} diff --git a/Sources/App/Kiosk/Utilities/TouchFeedbackManager.swift b/Sources/App/Kiosk/Utilities/TouchFeedbackManager.swift new file mode 100755 index 0000000000..a2c6b3b8dc --- /dev/null +++ b/Sources/App/Kiosk/Utilities/TouchFeedbackManager.swift @@ -0,0 +1,203 @@ +import AudioToolbox +import AVFoundation +import Shared +import UIKit + +// MARK: - Touch Feedback Manager + +/// Manages haptic and sound feedback for touch interactions +@MainActor +public final class TouchFeedbackManager { + // MARK: - Singleton + + public static let shared = TouchFeedbackManager() + + // MARK: - Feedback Types + + public enum FeedbackType { + /// Light tap feedback (button taps) + case tap + /// Medium impact feedback (selections, toggles) + case selection + /// Heavy impact feedback (important actions) + case action + /// Success feedback (completed actions) + case success + /// Warning feedback (alerts, confirmations) + case warning + /// Error feedback (failures) + case error + } + + // MARK: - Private Properties + + private var settings: KioskSettings { KioskModeManager.shared.settings } + + // Haptic generators (lazy initialized for performance) + private lazy var lightImpactGenerator = UIImpactFeedbackGenerator(style: .light) + private lazy var mediumImpactGenerator = UIImpactFeedbackGenerator(style: .medium) + private lazy var heavyImpactGenerator = UIImpactFeedbackGenerator(style: .heavy) + private lazy var selectionGenerator = UISelectionFeedbackGenerator() + private lazy var notificationGenerator = UINotificationFeedbackGenerator() + + // Sound player + private var audioPlayer: AVAudioPlayer? + + // MARK: - Initialization + + private init() { + prepareGenerators() + } + + // MARK: - Public Methods + + /// Play feedback for a given type + public func playFeedback(for type: FeedbackType) { + if settings.touchHapticEnabled { + playHaptic(for: type) + } + + if settings.touchSoundEnabled { + playSound(for: type) + } + } + + /// Play haptic feedback only + public func playHaptic(for type: FeedbackType) { + guard settings.touchHapticEnabled else { return } + + switch type { + case .tap: + lightImpactGenerator.impactOccurred() + + case .selection: + selectionGenerator.selectionChanged() + + case .action: + mediumImpactGenerator.impactOccurred() + + case .success: + notificationGenerator.notificationOccurred(.success) + + case .warning: + notificationGenerator.notificationOccurred(.warning) + + case .error: + notificationGenerator.notificationOccurred(.error) + } + } + + /// Play sound feedback only + public func playSound(for type: FeedbackType) { + guard settings.touchSoundEnabled else { return } + + let soundID: SystemSoundID + + switch type { + case .tap: + soundID = 1104 // Tock + case .selection: + soundID = 1105 // Tink + case .action: + soundID = 1306 // Key pressed + case .success: + soundID = 1025 // Payment success (Bloom) + case .warning: + soundID = 1255 // Tone + case .error: + soundID = 1257 // Negative tone + } + + AudioServicesPlaySystemSound(soundID) + } + + /// Prepare haptic generators for responsiveness + public func prepareGenerators() { + lightImpactGenerator.prepare() + mediumImpactGenerator.prepare() + heavyImpactGenerator.prepare() + selectionGenerator.prepare() + notificationGenerator.prepare() + } + + // MARK: - Convenience Methods + + /// Play tap feedback (for button touches) + public func tap() { + playFeedback(for: .tap) + } + + /// Play selection feedback (for toggles, selections) + public func selection() { + playFeedback(for: .selection) + } + + /// Play action feedback (for executing commands) + public func action() { + playFeedback(for: .action) + } + + /// Play success feedback + public func success() { + playFeedback(for: .success) + } + + /// Play warning feedback + public func warning() { + playFeedback(for: .warning) + } + + /// Play error feedback + public func error() { + playFeedback(for: .error) + } +} + +// MARK: - SwiftUI View Modifier + +/// View modifier that adds touch feedback to any view +public struct TouchFeedbackModifier: ViewModifier { + let feedbackType: TouchFeedbackManager.FeedbackType + + public func body(content: Content) -> some View { + content + .simultaneousGesture( + TapGesture() + .onEnded { _ in + TouchFeedbackManager.shared.playFeedback(for: feedbackType) + } + ) + } +} + +// MARK: - View Extension + +import SwiftUI + +extension View { + /// Add touch feedback to a view + public func touchFeedback(_ type: TouchFeedbackManager.FeedbackType = .tap) -> some View { + modifier(TouchFeedbackModifier(feedbackType: type)) + } +} + +// MARK: - UIKit Integration + +extension TouchFeedbackManager { + /// Add touch feedback to a UIButton + public func addFeedback(to button: UIButton, type: FeedbackType = .tap) { + button.addTarget(self, action: #selector(handleButtonTouch), for: .touchUpInside) + // Store the feedback type as associated object + objc_setAssociatedObject(button, &feedbackTypeKey, type, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + + @objc private func handleButtonTouch(_ sender: UIButton) { + if let type = objc_getAssociatedObject(sender, &feedbackTypeKey) as? FeedbackType { + playFeedback(for: type) + } else { + playFeedback(for: .tap) + } + } +} + +private var feedbackTypeKey: UInt8 = 0 diff --git a/Sources/App/WebView/Extensions/WebViewController+Kiosk.swift b/Sources/App/WebView/Extensions/WebViewController+Kiosk.swift new file mode 100644 index 0000000000..c585433ce7 --- /dev/null +++ b/Sources/App/WebView/Extensions/WebViewController+Kiosk.swift @@ -0,0 +1,551 @@ +import Combine +import Shared +import SwiftUI +import UIKit + +// MARK: - Kiosk Mode Extension + +private var statusOverlayKey: UInt8 = 0 +private var screensaverKey: UInt8 = 0 +private var cameraOverlayKey: UInt8 = 0 +private var quickLaunchKey: UInt8 = 0 +private var secretExitGestureKey: UInt8 = 0 +private var kioskCancellablesKey: UInt8 = 0 + +extension WebViewController { + /// The camera overlay view controller + private var cameraOverlayController: CameraOverlayViewController? { + get { objc_getAssociatedObject(self, &cameraOverlayKey) as? CameraOverlayViewController } + set { objc_setAssociatedObject(self, &cameraOverlayKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } + } + + /// The quick launch panel view controller + private var quickLaunchController: QuickLaunchViewController? { + get { objc_getAssociatedObject(self, &quickLaunchKey) as? QuickLaunchViewController } + set { objc_setAssociatedObject(self, &quickLaunchKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } + } + + /// The secret exit gesture view controller + private var secretExitGestureController: SecretExitGestureViewController? { + get { objc_getAssociatedObject(self, &secretExitGestureKey) as? SecretExitGestureViewController } + set { objc_setAssociatedObject(self, &secretExitGestureKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } + } + + /// The status overlay view controller + private var statusOverlayController: StatusOverlayViewController? { + get { objc_getAssociatedObject(self, &statusOverlayKey) as? StatusOverlayViewController } + set { objc_setAssociatedObject(self, &statusOverlayKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } + } + + /// The screensaver view controller + private var screensaverController: ScreensaverViewController? { + get { objc_getAssociatedObject(self, &screensaverKey) as? ScreensaverViewController } + set { objc_setAssociatedObject(self, &screensaverKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } + } + + /// Cancellables for kiosk mode observers - auto-cleanup on dealloc + private var kioskCancellables: Set { + get { + (objc_getAssociatedObject(self, &kioskCancellablesKey) as? Set) ?? Set() + } + set { + objc_setAssociatedObject(self, &kioskCancellablesKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + } + + /// Setup kiosk mode integration with KioskModeManager + /// Call this from viewDidLoad + func setupKioskMode() { + let manager = KioskModeManager.shared + + // Wire up callbacks from KioskModeManager + manager.onNavigate = { [weak self] path in + self?.navigateToKioskPath(path) + } + + manager.onRefresh = { [weak self] in + self?.refresh() + } + + manager.onKioskModeChange = { [weak self] enabled in + self?.updateKioskModeLockdown(enabled: enabled) + } + + manager.onStatusOverlayChange = { [weak self] visible in + self?.updateStatusOverlayVisibility(visible: visible) + } + + manager.onShowScreensaver = { [weak self] mode in + self?.showScreensaver(mode: mode) + } + + manager.onHideScreensaver = { [weak self] in + self?.hideScreensaver() + } + + // Observe kiosk mode and settings changes using Combine (auto-cleanup on dealloc) + var cancellables = Set() + + NotificationCenter.default.publisher(for: KioskModeManager.kioskModeDidChangeNotification) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.kioskModeDidChange() + } + .store(in: &cancellables) + + NotificationCenter.default.publisher(for: KioskModeManager.settingsDidChangeNotification) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.kioskSettingsDidChange() + } + .store(in: &cancellables) + + kioskCancellables = cancellables + + // Setup the status overlay and screensaver + setupStatusOverlay() + setupScreensaver() + setupCameraOverlay() + setupQuickLaunchPanel() + setupSecretExitGesture() + + // Setup dashboard manager + setupDashboardManager() + setupCameraTakeoverManager() + + // Apply initial state if already in kiosk mode + if manager.isKioskModeActive { + updateKioskModeLockdown(enabled: true) + // Send native kiosk mode command if enabled + if manager.settings.nativeDashboardKioskMode { + sendNativeKioskModeCommand(enabled: true) + } + } + + // Report current dashboard URL + if let url = webView?.url?.absoluteString { + manager.setCurrentDashboard(url) + } + + // Start managers if in kiosk mode + if manager.isKioskModeActive { + DashboardManager.shared.start() + CameraDetectionManager.shared.start() + if manager.settings.ambientAudioDetectionEnabled { + AmbientAudioDetector.shared.start() + } + } + } + + // MARK: - Status Overlay + + private func setupStatusOverlay() { + let overlayController = StatusOverlayViewController() + statusOverlayController = overlayController + + addChild(overlayController) + view.addSubview(overlayController.view) + overlayController.view.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + overlayController.view.topAnchor.constraint(equalTo: view.topAnchor), + overlayController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), + overlayController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + overlayController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), + ]) + + overlayController.didMove(toParent: self) + + // Initially hidden + let manager = KioskModeManager.shared + let shouldShow = manager.isKioskModeActive && manager.settings.statusOverlayEnabled + overlayController.view.alpha = shouldShow ? 1 : 0 + } + + private func updateStatusOverlayVisibility(visible: Bool) { + UIView.animate(withDuration: 0.3) { + self.statusOverlayController?.view.alpha = visible ? 1 : 0 + } + } + + // MARK: - Screensaver + + private func setupScreensaver() { + let controller = ScreensaverViewController() + screensaverController = controller + + // Wire up secret exit gesture from screensaver + controller.onShowSettings = { [weak self] in + self?.showKioskSettingsSheet() + } + + addChild(controller) + view.addSubview(controller.view) + controller.view.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + controller.view.topAnchor.constraint(equalTo: view.topAnchor), + controller.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), + controller.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + controller.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), + ]) + + controller.didMove(toParent: self) + + // Initially hidden + controller.view.alpha = 0 + controller.view.isHidden = true + } + + private func showScreensaver(mode: ScreensaverMode) { + guard let controller = screensaverController else { return } + + Current.Log.info("Showing screensaver: \(mode.rawValue)") + + // Bring screensaver to front (but behind status overlay) + if let statusView = statusOverlayController?.view { + view.insertSubview(controller.view, belowSubview: statusView) + } else { + view.bringSubviewToFront(controller.view) + } + + controller.view.isHidden = false + controller.show(mode: mode) + } + + private func hideScreensaver() { + guard let controller = screensaverController else { return } + + Current.Log.info("Hiding screensaver") + controller.hide() + + // Hide after animation completes + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + controller.view.isHidden = true + } + } + + /// Navigate to a path for kiosk mode + private func navigateToKioskPath(_ path: String) { + // Check if it's an absolute URL or relative path + if path.hasPrefix("http://") || path.hasPrefix("https://") { + if let url = URL(string: path) { + open(inline: url) + } + } else { + // Relative path - append to server URL + navigateToPath(path: path) + } + + // Update the manager with the new dashboard + KioskModeManager.shared.setCurrentDashboard(path) + } + + /// Update lockdown state based on kiosk mode + func updateKioskModeLockdown(enabled: Bool) { + let manager = KioskModeManager.shared + let lockNavigation = enabled && manager.settings.navigationLockdown + + // Disable/enable pull-to-refresh + updatePullToRefresh(enabled: !lockNavigation) + + // Disable/enable edge gestures + updateEdgeGestures(enabled: !lockNavigation) + + // Update scroll bouncing + webView?.scrollView.bounces = !lockNavigation + + Current.Log.info("Kiosk lockdown updated: enabled=\(enabled), navigationLockdown=\(lockNavigation)") + } + + /// Enable or disable pull-to-refresh + private func updatePullToRefresh(enabled: Bool) { + guard !Current.isCatalyst else { return } + + if enabled { + // Re-add refresh control if not present + if refreshControl.superview == nil { + webView?.scrollView.addSubview(refreshControl) + } + refreshControl.isEnabled = true + } else { + // Remove refresh control + refreshControl.removeFromSuperview() + refreshControl.isEnabled = false + } + } + + /// Enable or disable edge pan gestures + private func updateEdgeGestures(enabled: Bool) { + leftEdgePanGestureRecognizer.isEnabled = enabled + rightEdgeGestureRecognizer.isEnabled = enabled + } + + // MARK: - Notification Handlers + + @MainActor + private func kioskModeDidChange() { + let manager = KioskModeManager.shared + let enabled = manager.isKioskModeActive + updateKioskModeLockdown(enabled: enabled) + + // Send native kiosk mode command to HA frontend (requires HA 2026.1+) + if manager.settings.nativeDashboardKioskMode { + sendNativeKioskModeCommand(enabled: enabled) + } + + // Update status bar visibility + setNeedsStatusBarAppearanceUpdate() + + // Ensure secret exit gesture is on top when kiosk mode is active + if enabled, let gestureView = secretExitGestureController?.view { + view.bringSubviewToFront(gestureView) + } + + // Start or stop managers based on kiosk mode state + if enabled { + DashboardManager.shared.start() + CameraDetectionManager.shared.start() + if manager.settings.ambientAudioDetectionEnabled { + AmbientAudioDetector.shared.start() + } + } else { + // Stop managers in reverse order of dependency + CameraTakeoverManager.shared.dismissCamera() + CameraOverlayManager.shared.dismiss() + AmbientAudioDetector.shared.stop() + CameraDetectionManager.shared.stop() + DashboardManager.shared.stop() + + // Navigate back to device's default dashboard + if let defaultURL = server.info.connection.webviewURL() { + Current.Log.info("Kiosk mode disabled - navigating to default dashboard: \(defaultURL)") + load(request: URLRequest(url: defaultURL)) + } + } + } + + @MainActor + private func kioskSettingsDidChange() { + let manager = KioskModeManager.shared + let enabled = manager.isKioskModeActive + + // Re-apply lockdown in case navigationLockdown setting changed + updateKioskModeLockdown(enabled: enabled) + + // Update native kiosk mode state if setting changed while in kiosk mode + if enabled { + sendNativeKioskModeCommand(enabled: manager.settings.nativeDashboardKioskMode) + } + + // Update status bar visibility if hideStatusBar setting changed + setNeedsStatusBarAppearanceUpdate() + } + + // MARK: - Touch Handling for Kiosk Mode + + /// Record activity when the webview receives touches + @MainActor + func recordKioskActivity() { + if KioskModeManager.shared.isKioskModeActive { + KioskModeManager.shared.recordActivity(source: "touch") + + // Notify dashboard manager of user activity (for rotation pause) + DashboardManager.shared.userActivity() + } + } +} + +// MARK: - UIScrollViewDelegate Extension + +extension WebViewController { + /// Call this from scrollViewWillBeginDragging + func handleScrollViewDragging() { + // Already on main thread from scroll view delegate + DispatchQueue.main.async { [weak self] in + guard let self else { return } + if KioskModeManager.shared.isKioskModeActive { + KioskModeManager.shared.recordActivity(source: "touch") + DashboardManager.shared.userActivity() + } + } + } +} + +// MARK: - Dashboard Manager Integration + +extension WebViewController { + private func setupDashboardManager() { + let dashboardManager = DashboardManager.shared + + // Handle navigation requests from dashboard manager + dashboardManager.onNavigate = { [weak self] url in + self?.navigateToKioskPath(url) + } + } +} + +// MARK: - Camera Overlay Integration + +extension WebViewController { + private func setupCameraOverlay() { + let controller = CameraOverlayViewController() + cameraOverlayController = controller + + addChild(controller) + view.addSubview(controller.view) + controller.view.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + controller.view.topAnchor.constraint(equalTo: view.topAnchor), + controller.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), + controller.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + controller.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), + ]) + + controller.didMove(toParent: self) + + // Configure expand callback + CameraOverlayManager.shared.onExpandToFullScreen = { [weak self] stream in + self?.showFullScreenCamera(stream: stream) + } + } + + private func setupCameraTakeoverManager() { + CameraTakeoverManager.shared.onDismiss = { [weak self] in + // Return to previous state when camera is dismissed + Current.Log.info("Camera takeover dismissed") + } + } + + /// Show full-screen camera from PiP expansion or direct trigger + func showFullScreenCamera(stream: CameraStream) { + CameraTakeoverManager.shared.showCamera( + stream: stream, + from: self, + autoDismiss: stream.autoDismissSeconds + ) + } + + /// Show camera overlay for doorbell or security event + func showCameraOverlay(stream: CameraStream) { + CameraOverlayManager.shared.show(stream: stream) + } + + /// Dismiss camera overlay + func dismissCameraOverlay() { + CameraOverlayManager.shared.dismiss() + } +} + +// MARK: - Quick Launch Panel Integration + +extension WebViewController { + private func setupQuickLaunchPanel() { + let controller = QuickLaunchViewController() + quickLaunchController = controller + + addChild(controller) + view.addSubview(controller.view) + controller.view.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + controller.view.topAnchor.constraint(equalTo: view.topAnchor), + controller.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), + controller.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + controller.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), + ]) + + controller.didMove(toParent: self) + + // Update visibility based on kiosk mode and settings + updateQuickLaunchPanelVisibility() + } + + private func updateQuickLaunchPanelVisibility() { + let manager = KioskModeManager.shared + let shouldShow = manager.isKioskModeActive && manager.settings.quickLaunchEnabled + + quickLaunchController?.view.isHidden = !shouldShow + } +} + +// MARK: - Secret Exit Gesture Integration + +extension WebViewController { + private func setupSecretExitGesture() { + let controller = SecretExitGestureViewController() + secretExitGestureController = controller + + // Set up the callback to show kiosk settings + controller.onShowSettings = { [weak self] in + self?.showKioskSettingsSheet() + } + + addChild(controller) + view.addSubview(controller.view) + controller.view.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + controller.view.topAnchor.constraint(equalTo: view.topAnchor), + controller.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), + controller.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + controller.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), + ]) + + controller.didMove(toParent: self) + + // Bring to front so it can receive taps + view.bringSubviewToFront(controller.view) + } + + /// Show the kiosk settings sheet for exiting kiosk mode + private func showKioskSettingsSheet() { + Current.Log.info("Secret exit gesture triggered - showing kiosk settings") + + let settingsView = NavigationView { + KioskSettingsView() + } + + let hostingController = UIHostingController(rootView: settingsView) + hostingController.modalPresentationStyle = .formSheet + + present(hostingController, animated: true) + } +} + +// MARK: - Kiosk Mode State Changes + +extension WebViewController { + @objc private func handleKioskModeEnabled() { + DashboardManager.shared.start() + CameraDetectionManager.shared.start() + if KioskModeManager.shared.settings.ambientAudioDetectionEnabled { + AmbientAudioDetector.shared.start() + } + } + + @objc private func handleKioskModeDisabled() { + DashboardManager.shared.stop() + CameraDetectionManager.shared.stop() + AmbientAudioDetector.shared.stop() + AudioManager.shared.stopAudio() + AudioManager.shared.stopSpeaking() + CameraOverlayManager.shared.dismiss() + CameraTakeoverManager.shared.dismissCamera() + } +} + +// MARK: - Native Kiosk Mode (HA 2026.1+) + +extension WebViewController { + /// Send native kiosk mode command to HA frontend + /// This uses the kiosk_mode/set external bus command added in HA 2026.1 + func sendNativeKioskModeCommand(enabled: Bool) { + Current.Log.info("Sending native kiosk mode command: enable=\(enabled)") + webViewExternalMessageHandler.sendExternalBus(message: .init( + command: WebViewExternalBusOutgoingMessage.kioskModeSet.rawValue, + payload: ["enable": enabled] + )).cauterize() + } +} diff --git a/Sources/App/WebView/WebViewController.swift b/Sources/App/WebView/WebViewController.swift index 5a60c0ffdd..5b413a0d90 100644 --- a/Sources/App/WebView/WebViewController.swift +++ b/Sources/App/WebView/WebViewController.swift @@ -20,15 +20,15 @@ enum FrontEndConnectionState: String { } final class WebViewController: UIViewController, WKNavigationDelegate, WKUIDelegate { - private var webView: WKWebView! + var webView: WKWebView! let server: Server private var urlObserver: NSKeyValueObservation? private var tokens = [HACancellable]() - private let refreshControl = UIRefreshControl() - private let leftEdgePanGestureRecognizer: UIScreenEdgePanGestureRecognizer - private let rightEdgeGestureRecognizer: UIScreenEdgePanGestureRecognizer + let refreshControl = UIRefreshControl() + let leftEdgePanGestureRecognizer: UIScreenEdgePanGestureRecognizer + let rightEdgeGestureRecognizer: UIScreenEdgePanGestureRecognizer private var emptyStateView: UIView? private let emptyStateTransitionDuration: TimeInterval = 0.3 @@ -68,11 +68,14 @@ final class WebViewController: UIViewController, WKNavigationDelegate, WKUIDeleg private var underlyingPreferredStatusBarStyle: UIStatusBarStyle = .lightContent override var prefersStatusBarHidden: Bool { - Current.settingsStore.fullScreen + // Hide status bar if fullScreen is enabled OR if kiosk mode is active with hideStatusBar + let kioskHide = KioskModeManager.shared.isKioskModeActive && KioskModeManager.shared.settings.hideStatusBar + return Current.settingsStore.fullScreen || kioskHide } override var prefersHomeIndicatorAutoHidden: Bool { - Current.settingsStore.fullScreen + // Hide home indicator if fullScreen is enabled OR if kiosk mode is active + Current.settingsStore.fullScreen || KioskModeManager.shared.isKioskModeActive } override var preferredStatusBarStyle: UIStatusBarStyle { @@ -187,17 +190,23 @@ final class WebViewController: UIViewController, WKNavigationDelegate, WKUIDeleg let userContentController = setupUserContentController() - guard let wsBridgeJSPath = Bundle.main.path(forResource: "WebSocketBridge", ofType: "js"), - let wsBridgeJS = try? String(contentsOfFile: wsBridgeJSPath) else { - fatalError("Couldn't load WebSocketBridge.js for injection to WKWebView!") + if let wsBridgeJSPath = Bundle.main.path(forResource: "WebSocketBridge", ofType: "js"), + let wsBridgeJS = try? String(contentsOfFile: wsBridgeJSPath) { + userContentController.addUserScript(WKUserScript( + source: wsBridgeJS, + injectionTime: .atDocumentEnd, + forMainFrameOnly: false + )) + } else { + // Log error but continue - WebSocket bridge is important but not fatal + Current.Log.error("Couldn't load WebSocketBridge.js for injection to WKWebView. WebSocket functionality may be impaired.") + Current.crashReporter.logError(NSError( + domain: "WebViewController", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "WebSocketBridge.js not found in bundle"] + )) } - userContentController.addUserScript(WKUserScript( - source: wsBridgeJS, - injectionTime: .atDocumentEnd, - forMainFrameOnly: false - )) - userContentController.addUserScript(.init( source: """ window.addEventListener("error", (e) => { @@ -217,9 +226,9 @@ final class WebViewController: UIViewController, WKNavigationDelegate, WKUIDeleg config.applicationNameForUserAgent = HomeAssistantAPI.applicationNameForUserAgent config.defaultWebpagePreferences.preferredContentMode = Current.isCatalyst ? .desktop : .mobile - webView = WKWebView(frame: view!.frame, configuration: config) + webView = WKWebView(frame: view.frame, configuration: config) webView.isOpaque = false - view!.addSubview(webView) + view.addSubview(webView) setupGestures(numberOfTouchesRequired: 2) setupGestures(numberOfTouchesRequired: 3) @@ -255,6 +264,7 @@ final class WebViewController: UIViewController, WKNavigationDelegate, WKUIDeleg postOnboardingNotificationPermission() emptyStateObservations() checkForLocalSecurityLevelDecisionNeeded() + setupKioskMode() // #if DEBUG // if #available(iOS 26.0, *) { @@ -1441,6 +1451,10 @@ extension WebViewController: UIScrollViewDelegate { scrollView.contentOffset.y = scrollView.contentSize.height - scrollView.bounds.height } } + + func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { + handleScrollViewDragging() + } } extension WebViewController: UIGestureRecognizerDelegate { diff --git a/Sources/App/WebView/WebViewExternalBusMessage.swift b/Sources/App/WebView/WebViewExternalBusMessage.swift index 460fc1b796..af2ea9e9f5 100644 --- a/Sources/App/WebView/WebViewExternalBusMessage.swift +++ b/Sources/App/WebView/WebViewExternalBusMessage.swift @@ -49,4 +49,5 @@ enum WebViewExternalBusOutgoingMessage: String, CaseIterable { case improvDiscoveredDevice = "improv/discovered_device" case improvDiscoveredDeviceSetupDone = "improv/device_setup_done" case navigate = "navigate" + case kioskModeSet = "kiosk_mode/set" } diff --git a/Sources/Shared/Notifications/LocalPush/LocalPushEvent.swift b/Sources/Shared/Notifications/LocalPush/LocalPushEvent.swift index 2d346c224e..92879adec2 100644 --- a/Sources/Shared/Notifications/LocalPush/LocalPushEvent.swift +++ b/Sources/Shared/Notifications/LocalPush/LocalPushEvent.swift @@ -19,7 +19,8 @@ extension HATypedSubscription { return HATypedSubscription(request: .init( type: "mobile_app/push_notification_channel", - data: data + data: data, + retryDuration: nil )) } }