Skip to content
Merged
Show file tree
Hide file tree
Changes from 37 commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
61c9ff7
Add kiosk mode core infrastructure (PR 1/5)
nstefanelli Jan 13, 2026
dcfb29e
Use SFSafeSymbols instead of string-based system images
nstefanelli Jan 13, 2026
b9efbcf
Add unit tests for kiosk mode functionality
nstefanelli Jan 13, 2026
9817f51
Address PR review feedback
nstefanelli Jan 14, 2026
aa39b40
Add Kiosk Mode entry to app settings menu
nstefanelli Jan 14, 2026
b2ce91a
Fix MainActor isolation error in deinit - weak references handle clea…
nstefanelli Jan 14, 2026
db18fb5
Improve kiosk auth behavior
nstefanelli Jan 14, 2026
664b1b9
Fix: Use manager.settings for auth checks, not local settings copy
nstefanelli Jan 14, 2026
4329c1e
Fix status bar hiding and add 30-second screensaver timeout
nstefanelli Jan 14, 2026
7f25208
Merge upstream/main to resolve conflicts
nstefanelli Jan 14, 2026
4004e78
Fix SwiftFormat lint issues
nstefanelli Jan 14, 2026
a79e322
Fix SwiftFormat issues in test file (sort imports, remove test prefix)
nstefanelli Jan 14, 2026
e05279e
Merge branch 'main' into kiosk-pr1-core
nstefanelli Jan 14, 2026
39d5d5e
Fix kiosk settings UI issues from PR review
nstefanelli Jan 14, 2026
a71e530
Clarify night time logic comments per Copilot review
nstefanelli Jan 14, 2026
19f95f3
Merge branch 'main' into kiosk-pr1-core
nstefanelli Jan 14, 2026
806f28f
Address PR review feedback from bgoncal
nstefanelli Jan 15, 2026
fedb9ba
Merge branch 'main' into kiosk-pr1-core
nstefanelli Jan 15, 2026
a618cdc
Remove unused settings and add Done button to toolbar
nstefanelli Jan 15, 2026
a690539
Merge branch 'main' into kiosk-pr1-core
nstefanelli Jan 15, 2026
1649e29
Address PR #4218 review feedback
nstefanelli Jan 16, 2026
cf2b11a
Merge branch 'main' into kiosk-pr1-core
nstefanelli Jan 16, 2026
20f3679
Merge branch 'main' into kiosk-pr1-core
nstefanelli Jan 17, 2026
be5fb59
Address PR review feedback from bgoncal
nstefanelli Jan 21, 2026
913edc2
Merge upstream/main and adapt kiosk mode to new Frontend directory st…
nstefanelli Mar 9, 2026
57848ae
Regenerate SwiftGen Strings.swift with kiosk localization keys
nstefanelli Mar 9, 2026
f4896bb
Fix Xcode project file and update database table for upstream compati…
nstefanelli Mar 9, 2026
b5e5285
Update Xcode project and regenerate SwiftGen strings after upstream m…
nstefanelli Mar 9, 2026
89db14f
Replace deprecated edgesIgnoringSafeArea with ignoresSafeArea
nstefanelli Mar 9, 2026
83bbd2f
Address Copilot code review feedback
nstefanelli Mar 9, 2026
d89d94c
Remove extra trailing blank line in KioskSettings test file
nstefanelli Mar 9, 2026
3050279
Fix database table count tests for kiosk settings table
nstefanelli Mar 9, 2026
0a7f1c0
Merge remote-tracking branch 'upstream/main' into kiosk-pr1-core
nstefanelli Mar 9, 2026
58241d1
Rebuild pbxproj from upstream to fix corrupted merge
nstefanelli Mar 9, 2026
099edf2
Add KioskSettingsTable to Shared-watchOS build phase
nstefanelli Mar 9, 2026
75e8b72
Remove KioskSettingsTable from SharedTesting build phase
nstefanelli Mar 9, 2026
7786ec9
Address second round of Copilot review feedback
nstefanelli Mar 9, 2026
69b0743
Address maintainer review: handler pattern, fullscreen screensaver, G…
nstefanelli Mar 11, 2026
7a2cceb
Strip day/night brightness schedule for separate PR, hide clock optio…
nstefanelli Mar 11, 2026
d6a88b7
Merge upstream/main into kiosk-pr1-core
nstefanelli Mar 11, 2026
b1622f2
Address round 2 maintainer review and Copilot feedback
nstefanelli Mar 12, 2026
1963418
Address round 3 maintainer review feedback
nstefanelli Mar 12, 2026
ad2a89f
Merge branch 'main' into kiosk-pr1-core
nstefanelli Mar 12, 2026
7c17738
Gate kiosk settings behind TestFlight, add BETA label
nstefanelli Mar 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
136 changes: 101 additions & 35 deletions HomeAssistant.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

229 changes: 229 additions & 0 deletions Sources/App/Frontend/Extensions/WebViewController+Kiosk.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
import Combine
import Shared
import SwiftUI
import UIKit

// MARK: - Kiosk Mode Extension

extension WebViewController {
/// 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.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<AnyCancellable>()

manager.$isKioskModeActive
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.kioskModeDidChange()
}
.store(in: &cancellables)

manager.$settings
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.kioskSettingsDidChange()
}
.store(in: &cancellables)

kioskCancellables = cancellables

// Setup the screensaver
setupScreensaver()

// Setup secret exit gesture (only when not showing screensaver)
setupSecretExitGesture()

// Apply initial state if already in kiosk mode
if manager.isKioskModeActive {
updateKioskModeLockdown(enabled: true)
}
}

// MARK: - Screensaver

private func setupScreensaver() {
let controller = KioskScreensaverViewController()
screensaverController = controller

// Forward the callback for showing settings
controller.onShowSettings = { [weak self] in
self?.showKioskSettings()
}

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)
controller.view.isHidden = true
}

private func setupSecretExitGesture() {
let controller = KioskSecretExitGestureViewController()
secretExitGestureController = controller

controller.onShowSettings = { [weak self] in
self?.showKioskSettings()
}

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)
}

private func showScreensaver(mode: ScreensaverMode) {
guard let controller = screensaverController else { return }

Current.Log.info("Showing screensaver: \(mode.rawValue)")

controller.view.isHidden = false
view.bringSubviewToFront(controller.view)
controller.show(mode: mode)
}

private func hideScreensaver() {
guard let controller = screensaverController else { return }

Current.Log.info("Hiding screensaver")
controller.hide()

// Delay hiding the view until the animation completes
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
controller.view.isHidden = true
}
}

// MARK: - Navigation

private func navigateToKioskPath(_ path: String) {
Current.Log.info("Kiosk navigating to: \(path)")

// Use the existing navigateToPath method
navigateToPath(path: path)
}

// MARK: - UI Lockdown

private func updateKioskModeLockdown(enabled: Bool) {
let settings = KioskModeManager.shared.settings

// Update iOS system status bar and home indicator visibility
// The navigation controller must be updated first so it re-queries its child
if let navController = navigationController {
navController.setNeedsStatusBarAppearanceUpdate()
navController.setNeedsUpdateOfHomeIndicatorAutoHidden()
}
setNeedsStatusBarAppearanceUpdate()
setNeedsUpdateOfHomeIndicatorAutoHidden()

// Hide/show the custom status bar background view
if let statusBarView {
let shouldHide = enabled && settings.hideStatusBar
statusBarView.isHidden = shouldHide
}
}

// MARK: - Status Bar & Home Indicator

/// Override in WebViewController to check kiosk mode
var kioskPrefersStatusBarHidden: Bool {
let manager = KioskModeManager.shared
return manager.isKioskModeActive && manager.settings.hideStatusBar
}

/// Override in WebViewController to check kiosk mode
var kioskPrefersHomeIndicatorAutoHidden: Bool {
KioskModeManager.shared.isKioskModeActive
}

// MARK: - Settings

private func showKioskSettings() {
Current.Log.info("Showing kiosk settings")

// Use UINavigationController to avoid SwiftUI NavigationView dismissal bugs on iOS 15
// Pass an explicit dismiss closure since SwiftUI's @Environment(\.dismiss) doesn't work
// reliably when UIHostingController is embedded in UINavigationController presented via UIKit
let settingsView = KioskSettingsView(onDismiss: { [weak self] in
self?.dismiss(animated: true) { [weak self] in
self?.refreshStatusBarAppearance()
}
})
let hostingController = UIHostingController(rootView: settingsView)
let navController = UINavigationController(rootViewController: hostingController)
navController.modalPresentationStyle = .pageSheet
present(navController, animated: true)
}

/// Force a complete status bar appearance refresh after modal dismissal
private func refreshStatusBarAppearance() {
navigationController?.setNeedsStatusBarAppearanceUpdate()
setNeedsStatusBarAppearanceUpdate()
navigationController?.setNeedsUpdateOfHomeIndicatorAutoHidden()
setNeedsUpdateOfHomeIndicatorAutoHidden()
}

// MARK: - Observers

private func kioskModeDidChange() {
let manager = KioskModeManager.shared
Current.Log.info("Kiosk mode changed: \(manager.isKioskModeActive)")

updateKioskModeLockdown(enabled: manager.isKioskModeActive)
}

private func kioskSettingsDidChange() {
// Re-apply lockdown settings in case they changed
let manager = KioskModeManager.shared
if manager.isKioskModeActive {
updateKioskModeLockdown(enabled: true)
}
}

// MARK: - Touch Handling

/// Call this when user touches the screen to record activity
func recordKioskActivity() {
KioskModeManager.shared.recordActivity(source: "touch")
}
}
18 changes: 16 additions & 2 deletions Sources/App/Frontend/WebView/WebViewController.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import AVFoundation
import AVKit
import Combine
import CoreLocation
import HAKit
import Improv_iOS
Expand Down Expand Up @@ -66,14 +67,20 @@ final class WebViewController: UIViewController, WKNavigationDelegate, WKUIDeleg
/// Each navigation resets this to false so we can show the empty state
var isConnected = false

// MARK: - Kiosk Mode Properties

var screensaverController: KioskScreensaverViewController?
var secretExitGestureController: KioskSecretExitGestureViewController?
var kioskCancellables = Set<AnyCancellable>()

var underlyingPreferredStatusBarStyle: UIStatusBarStyle = .lightContent

override var prefersStatusBarHidden: Bool {
Current.settingsStore.fullScreen
Current.settingsStore.fullScreen || kioskPrefersStatusBarHidden
}

override var prefersHomeIndicatorAutoHidden: Bool {
Current.settingsStore.fullScreen
Current.settingsStore.fullScreen || kioskPrefersHomeIndicatorAutoHidden
}

override var preferredStatusBarStyle: UIStatusBarStyle {
Expand Down Expand Up @@ -263,6 +270,7 @@ final class WebViewController: UIViewController, WKNavigationDelegate, WKUIDeleg
postOnboardingNotificationPermission()
emptyStateObservations()
checkForLocalSecurityLevelDecisionNeeded()
setupKioskMode()
}

// Workaround for webview rotation issues: https://github.com/Telerik-Verified-Plugins/WKWebView/pull/263
Expand All @@ -282,6 +290,12 @@ final class WebViewController: UIViewController, WKNavigationDelegate, WKUIDeleg
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
updateDatabaseAndPanels()

// Refresh kiosk status bar state when view appears (e.g., after settings modal dismisses)
if KioskModeManager.shared.isKioskModeActive {
setNeedsStatusBarAppearanceUpdate()
navigationController?.setNeedsStatusBarAppearanceUpdate()
}
}

override func viewWillDisappear(_ animated: Bool) {
Expand Down
18 changes: 17 additions & 1 deletion Sources/App/Frontend/WebView/WebViewWindowController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,22 @@ import Shared
import SwiftUI
import UIKit

/// Navigation controller that forwards status bar and home indicator preferences to its top view controller.
/// This is needed for kiosk mode to properly hide the status bar when WebViewController is embedded.
final class StatusBarForwardingNavigationController: UINavigationController {
override var childForStatusBarHidden: UIViewController? {
topViewController
}

override var childForStatusBarStyle: UIViewController? {
topViewController
}

override var childForHomeIndicatorAutoHidden: UIViewController? {
topViewController
}
}

final class WebViewWindowController {
enum RootViewControllerType {
case onboarding
Expand Down Expand Up @@ -55,7 +71,7 @@ final class WebViewWindowController {
}

private func webViewNavigationController(rootViewController: UIViewController? = nil) -> UINavigationController {
let navigationController = UINavigationController()
let navigationController = StatusBarForwardingNavigationController()
navigationController.setNavigationBarHidden(true, animated: false)

if let rootViewController {
Expand Down
Loading
Loading