Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
5fec665
fix(kiosk): update HAKit 0.4.19 and Starscream for HA 2026.10 (#6038)
jaipur2001 Oct 12, 2026
dae8fd6
fix(kiosk): recover root navigation on invalid URLs and HTTP 403/404/…
jaipur2001 Oct 12, 2026
5fdc60c
fix(kiosk): stop retrying permanently rejected refresh tokens (#6012)
jaipur2001 Oct 12, 2026
7e5f04f
fix(kiosk): defer essential frontend external bus messages while back…
jaipur2001 Oct 12, 2026
5bc441f
fix(kiosk): deliver deferred frontend messages on scene activation (#…
jaipur2001 Oct 12, 2026
8e81f0c
test(kiosk): exercise background message replay and scene activation …
jaipur2001 Oct 12, 2026
5ddd682
fix(kiosk): remove phantom iPad full-screen top inset and status stri…
jaipur2001 Oct 12, 2026
f9e0216
test(kiosk): cover iPad full-screen/window controls and kiosk status …
jaipur2001 Oct 12, 2026
fe26d7c
style(kiosk): format UIScene observers consistently with upstream
jaipur2001 Oct 12, 2026
5d947ce
ci(kiosk): retain signing shell commands, fix YAML whitespace and doc…
jaipur2001 Oct 12, 2026
10ad1a7
ci: show SwiftLint file locations and run SwiftFormat independently
jaipur2001 Oct 12, 2026
e903098
ci(kiosk): fix typed SF symbols and lint only changed Swift files on PR
jaipur2001 Oct 12, 2026
7e84f39
test(kiosk): avoid hanging async gate and enforce per-test timeout
jaipur2001 Oct 12, 2026
397e8c6
test(kiosk): bound async waits and centralize xcodebuild test timeout…
jaipur2001 Oct 12, 2026
0de359f
ci: retain original E2E time allowances; enforce per-test limits only…
jaipur2001 Oct 12, 2026
7a38217
ci(kiosk): skip optional WatchApp snapshots when target watchOS simul…
jaipur2001 Oct 12, 2026
426092e
style(ci): satisfy RuboCop conditional modifier for WatchApp skip sum…
jaipur2001 Oct 12, 2026
2c61d4f
test(kiosk): cover scene notifications, recovery timer and missing-UR…
jaipur2001 Oct 12, 2026
d703b6c
test(kiosk): cover unknown-domain navigation errors without redirect
jaipur2001 Oct 12, 2026
00d99bf
style(tests): use default SwiftFormat grouping for four-digit timer i…
jaipur2001 Oct 12, 2026
1c15166
test(kiosk): yield main actor for SwiftUI stand-by snapshot stabiliza…
jaipur2001 Oct 12, 2026
5337aa9
ci(kiosk): gate Xcode suite on successful lint and wrap snapshot test…
jaipur2001 Oct 12, 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
27 changes: 23 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 2
- uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1.321.0
with:
ruby-version: "3.1"
Expand All @@ -39,12 +41,27 @@ jobs:
run: |
docker run --rm -v `pwd`:`pwd` -w `pwd` \
ghcr.io/realm/swiftlint:0.54.0 \
swiftlint lint --strict --config .swiftlint.yml --reporter github-actions-logging
swiftlint lint --strict --config .swiftlint.yml --reporter xcode
- name: SwiftFormat
if: ${{ success() || failure() }}
shell: bash
run: |
docker run --rm -v `pwd`:`pwd` -w `pwd` \
ghcr.io/nicklockwood/swiftformat:0.53.1 \
--lint --config .swiftformat .
set -euo pipefail
if [[ "$GITHUB_EVENT_NAME" == "pull_request" ]]; then
# Check all changed Swift files without reformatting unrelated legacy code.
git diff --name-only --diff-filter=ACMR HEAD^1 HEAD -- '*.swift' > .swiftformat-changed-files
if [[ ! -s .swiftformat-changed-files ]]; then
echo "No changed Swift files in this pull request."
exit 0
fi
docker run --rm -v "$PWD:$PWD" -w "$PWD" \
ghcr.io/nicklockwood/swiftformat:0.53.1 \
--lint --config .swiftformat --filelist .swiftformat-changed-files
else
docker run --rm -v "$PWD:$PWD" -w "$PWD" \
ghcr.io/nicklockwood/swiftformat:0.53.1 \
--lint --config .swiftformat .
fi

check-swiftlint-disables:
needs: lint
Expand Down Expand Up @@ -146,6 +163,8 @@ jobs:
```

test:
# Never allocate an expensive Xcode runner for a pull request that already fails lint.
needs: lint
runs-on: xcode-27
timeout-minutes: 90
steps:
Expand Down
9 changes: 5 additions & 4 deletions .github/workflows/kiosk_ipa.yml
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
# Shell blocks contain long, intentionally unsplit signing/packaging commands.
# yamllint disable rule:line-length
name: Kiosk IPA Build

on:
Expand Down Expand Up @@ -100,15 +102,15 @@ jobs:

// Upstream uses ".HomeAssistant". Our registered App ID is lower-case.
PRODUCT_BUNDLE_IDENTIFIER = $(BUNDLE_ID_PREFIX).homeassistant$(BUNDLE_ID_SUFFIX)$(PROVISIONING_SUFFIX)

CODE_SIGN_STYLE_DEFAULT = Manual
CODE_SIGN_IDENTITY = Apple Development

// Profile names are read from the actual .mobileprovision files later
// and supplied to xcodebuild as KIOSK_* build settings.
PROVISIONING_PROFILE_SPECIFIER_QKSPJURFCD_App = $(KIOSK_MAIN_PROFILE_NAME)
PROVISIONING_PROFILE_SPECIFIER_QKSPJURFCD_Extensions_NotificationService = $(KIOSK_NSE_PROFILE_NAME)

ENABLE_CRITICAL_ALERTS_QKSPJURFCD = 0
ENABLE_PUSH_PROVIDER_QKSPJURFCD = 0
ENABLE_DEVICE_NAME_QKSPJURFCD = 0
Expand Down Expand Up @@ -1362,4 +1364,3 @@ jobs:
fi

rm -rf "$RUNNER_TEMP/kiosk-package-signing"

8 changes: 6 additions & 2 deletions HomeAssistant.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,7 @@
42F1C0113140B00011AABB11 /* HAAPITokenFetchFailure.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42F1C0123140B00011AABB12 /* HAAPITokenFetchFailure.test.swift */; };
42F1C0133140B00011AABB13 /* TokenManagerAccessTokenRejection.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42F1C0143140B00011AABB14 /* TokenManagerAccessTokenRejection.test.swift */; };
42F1C0153140B00011AABB15 /* TokenManagerRevocation.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42F1C0163140B00011AABB16 /* TokenManagerRevocation.test.swift */; };
42F1C0173140B00011AABB17 /* TokenManagerRefreshTokenRejection.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42F1C0183140B00011AABB18 /* TokenManagerRefreshTokenRejection.test.swift */; };
42F1C0323140B00011AACC02 /* ConnectivityWrapper.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42F1C0223140B00011AACC01 /* ConnectivityWrapper.test.swift */; };
42F384052FB49C9500390AFC /* DebugSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 42F384042FB49C9500390AFC /* DebugSwift */; };
42FA000000000000000C0002 /* WebRTC in Frameworks */ = {isa = PBXBuildFile; productRef = 42FA000000000000000C0001 /* WebRTC */; };
Expand Down Expand Up @@ -678,6 +679,7 @@
42F1C0123140B00011AABB12 /* HAAPITokenFetchFailure.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HAAPITokenFetchFailure.test.swift; sourceTree = "<group>"; };
42F1C0143140B00011AABB14 /* TokenManagerAccessTokenRejection.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenManagerAccessTokenRejection.test.swift; sourceTree = "<group>"; };
42F1C0163140B00011AABB16 /* TokenManagerRevocation.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenManagerRevocation.test.swift; sourceTree = "<group>"; };
42F1C0183140B00011AABB18 /* TokenManagerRefreshTokenRejection.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenManagerRefreshTokenRejection.test.swift; sourceTree = "<group>"; };
42F1C0223140B00011AACC01 /* ConnectivityWrapper.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectivityWrapper.test.swift; sourceTree = "<group>"; };
42F1DA732B4FF9F8002729BC /* MaterialDesignIcons+CarPlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MaterialDesignIcons+CarPlay.swift"; sourceTree = "<group>"; };
42FF5E962E22E5C100BDF5EF /* TodoListItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TodoListItem.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -3186,6 +3188,7 @@
42F1C0123140B00011AABB12 /* HAAPITokenFetchFailure.test.swift */,
42F1C0143140B00011AABB14 /* TokenManagerAccessTokenRejection.test.swift */,
42F1C0163140B00011AABB16 /* TokenManagerRevocation.test.swift */,
42F1C0183140B00011AABB18 /* TokenManagerRefreshTokenRejection.test.swift */,
11B38EDE275BE29F00205C7B /* ConnectionInfo.test.swift */,
42F1C0223140B00011AACC01 /* ConnectivityWrapper.test.swift */,
480E9A5D40714BBAA81B15F7 /* ClientCertificate.test.swift */,
Expand Down Expand Up @@ -4689,6 +4692,7 @@
426A2EFB30596E8B00006022 /* AppDatabaseSuspensionProtectedWork.test.swift in Sources */,
42F1C0133140B00011AABB13 /* TokenManagerAccessTokenRejection.test.swift in Sources */,
42F1C0153140B00011AABB15 /* TokenManagerRevocation.test.swift in Sources */,
42F1C0173140B00011AABB17 /* TokenManagerRefreshTokenRejection.test.swift in Sources */,
1104FCCF253275CF00B8BE34 /* WatchBackgroundRefreshScheduler.test.swift in Sources */,
110AA57B25B38C02005061A0 /* ServerAlerter.test.swift in Sources */,
1133F5F625F1DBF000AD776F /* CLLocation+Sanitize.test.swift in Sources */,
Expand Down Expand Up @@ -6196,7 +6200,7 @@
repositoryURL = "https://github.com/bgoncal/starscream";
requirement = {
kind = revision;
revision = aaaf609d07eb487b2fccbe77f6267cf0843e2b19;
revision = 9f793f7396d0def800c18be36769deb6d1313238;
};
};
42886D292FFE839A004EB33B /* XCRemoteSwiftPackageReference "PromiseKit" */ = {
Expand All @@ -6212,7 +6216,7 @@
repositoryURL = "https://github.com/home-assistant/HAKit.git";
requirement = {
kind = exactVersion;
version = 0.4.18;
version = 0.4.19;
};
};
42991E5F2FF4474500C2C76B /* XCRemoteSwiftPackageReference "sdk-iOS" */ = {
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 3 additions & 3 deletions Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

44 changes: 44 additions & 0 deletions Sources/App/Frontend/Extensions/WebViewScriptMessageHandler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,21 @@ enum WKUserContentControllerMessage: String, CaseIterable {
}

final class WebViewScriptMessageHandler: NSObject, WKScriptMessageHandler {
/// External bus messages held while the app is in the background and handed over once it is active,
/// rather than dropped. The frontend waits for its `config/get` reply before it asks for a token, so a
/// page that loads in the background never connects without it, and the connection reports are what
/// take the stand-by loader down.
private static let externalBusMessagesDeferredInBackground: Set<WebViewExternalBusMessage> = [
.configGet,
.connectionStatus,
.frontendLoaded,
]

weak var webView: WebViewControllerProtocol?
var isAppInBackground: @MainActor () -> Bool = { UIApplication.shared.applicationState == .background }

private var deferredExternalBusMessages: [[String: Any]] = []

@MainActor func userContentController(
_ userContentController: WKUserContentController,
didReceive message: WKScriptMessage
Expand All @@ -33,6 +45,13 @@ final class WebViewScriptMessageHandler: NSObject, WKScriptMessageHandler {

@MainActor func handle(messageName: String, messageBody: [String: Any]) {
guard !isAppInBackground() else {
if let type = Self.typeDeferredInBackground(messageName: messageName, messageBody: messageBody) {
Current.Log.verbose("Deferring external bus message \(type) until app is active")
// Only the latest of each matters: an older page is gone, and an older connection report is stale.
deferredExternalBusMessages.removeAll { ($0["type"] as? String) == type }
deferredExternalBusMessages.append(messageBody)
return
}
Current.Log.verbose("Ignoring WKUserContentController message \(messageName) because app is in background")
// The frontend caches the pending getExternalAuth promise and never asks again while it
// stays unsettled, so the callback must be rejected instead of dropped - otherwise the
Expand All @@ -47,6 +66,9 @@ final class WebViewScriptMessageHandler: NSObject, WKScriptMessageHandler {
return
}

// Anything held back while in the background was sent first, so it is handled first.
deliverDeferredMessages()

switch WKUserContentControllerMessage(rawValue: messageName) {
case .externalBus:
handleExternalBus(messageBody)
Expand Down Expand Up @@ -83,6 +105,28 @@ final class WebViewScriptMessageHandler: NSObject, WKScriptMessageHandler {
webView?.webViewExternalMessageHandler.handleExternalMessage(messageBody)
}

/// Hands over the external bus messages held back while the app was in the background, in the order the
/// frontend last sent each of them.
@MainActor func deliverDeferredMessages() {
guard !isAppInBackground(), !deferredExternalBusMessages.isEmpty else { return }
let messages = deferredExternalBusMessages
deferredExternalBusMessages = []
Current.Log.info("Delivering \(messages.count) external bus messages received in the background")
for message in messages {
handleExternalBus(message)
}
}

private static func typeDeferredInBackground(messageName: String, messageBody: [String: Any]) -> String? {
guard WKUserContentControllerMessage(rawValue: messageName) == .externalBus,
let type = messageBody["type"] as? String,
let message = WebViewExternalBusMessage(rawValue: type),
externalBusMessagesDeferredInBackground.contains(message) else {
return nil
}
return type
}

/// Updates the theme colors based on the message body.
private func handleUpdateThemeColors(_ messageBody: [String: Any]) {
handleThemeUpdate(messageBody)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,14 @@ final class HomeAssistantViewModel: ObservableObject {
self?.updateFullScreenLoaderVisibility(hasEmptyState: emptyState != nil)
}
.store(in: &cancellables)

// The watchdog fires once per load, and in the background it can spend that on a frontend whose
// reports are being held back. A loader that is still up when its scene comes back gets another one.
overlayState.sceneReturnedFromBackground
.sink { [weak self] in
self?.armLoaderWatchdog()
}
.store(in: &cancellables)
}

private func handleConnectionStateChange(_ connectionState: FrontEndConnectionState) {
Expand Down Expand Up @@ -347,12 +355,11 @@ final class HomeAssistantViewModel: ObservableObject {
}

/// The loader only ever comes down on a frontend connection state, and that report can go missing: the
/// frontend announces `frontend/loaded` exactly once per page load, external-bus messages that arrive
/// while the app is backgrounded are dropped, and a document restored from WebKit's page cache never
/// announces itself again (`handleFrontendRestoredFromPageCache` covers that, but only while the frontend
/// was connected when the page was cached). The frontend behind the loader is alive in those cases, so
/// once the navigation itself has finished, wait a bounded amount of time for a connection state and then
/// get out of the user's way rather than covering a working frontend indefinitely.
/// frontend announces `frontend/loaded` exactly once per page load, and a document restored from WebKit's
/// page cache never announces itself again (`handleFrontendRestoredFromPageCache` covers that, but only
/// while the frontend was connected when the page was cached). The frontend behind the loader is alive in
/// those cases, so once the navigation itself has finished, wait a bounded amount of time for a connection
/// state and then get out of the user's way rather than covering a working frontend indefinitely.
private func armLoaderWatchdog() {
guard isFullScreenLoaderMounted else { return }
let cycleID = loaderCycleID
Expand Down
3 changes: 3 additions & 0 deletions Sources/App/Frontend/WebView/WebFrontendOverlayState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ final class WebFrontendOverlayState: ObservableObject {
/// notifications, App Intents), so a host that has the frontend off screen can bring it back.
let externalNavigationRequests = PassthroughSubject<Void, Never>()

/// Re-arm frontend loader recovery after this scene returns from the background.
let sceneReturnedFromBackground = PassthroughSubject<Void, Never>()

/// Theme color for the top status-bar inset, drawn by `HomeAssistantView` over the (always edge-to-edge)
/// web view. Nil when there should be no themed bar — i.e. edge-to-edge / full-screen is enabled, or on
/// Catalyst (where the native status-bar view handles it).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,41 @@ import UIKit
// MARK: - Empty State

extension WebViewController {
/// Only this window's lifecycle should flush the bus. Without a hosting window (e.g. tests), accept any scene.
private func concernsFrontendScene(_ notification: Notification) -> Bool {
guard let scene = notification.object as? UIScene else { return false }
guard let frontendScene = view.window?.windowScene else { return true }
return scene === frontendScene
}

@objc func sceneDidEnterBackground(_ notification: Notification) {
guard concernsFrontendScene(notification) else { return }
handleSceneDidEnterBackground()
}

@objc func sceneDidActivate(_ notification: Notification) {
guard concernsFrontendScene(notification) else { return }
handleSceneDidActivate()
}

func handleSceneDidEnterBackground() {
didEnterBackgroundSinceLastActivation = true
}

/// Foreground activation is the reliable opportunity to answer config/get from a background-loaded page.
/// Re-arm the SwiftUI loader watchdog only when returning from an actual background transition.
func handleSceneDidActivate() {
webViewScriptMessageHandler.deliverDeferredMessages()
let returnedFromBackground = didEnterBackgroundSinceLastActivation
didEnterBackgroundSinceLastActivation = false
if returnedFromBackground {
overlayState?.sceneReturnedFromBackground.send()
if emptyStateTimer != nil, !connectionState.isReadyForDisplay {
scheduleEmptyStateAfterGracePeriod()
}
}
}

func emptyStateStyle(for connectionState: FrontEndConnectionState) -> WebViewEmptyStateStyle {
// A deliberate log out lands in the same authentication-less state as a revoked token, so it is
// resolved first: the copy has to read as "log back in", not "your session expired".
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,15 +72,15 @@ extension WebViewController {
}

/// Publishes the themed status-bar strip to SwiftUI. In compact-width layouts the web view runs truly
/// edge-to-edge (no strip) by default when the server core supports it (2026.8+) or full-screen is
/// enabled. The strip is drawn for older cores, in regular-width layouts (unless full-screen is
/// enabled), or when the developer "always below status bar" override is on.
/// edge-to-edge (no strip) by default when the server core supports it (2026.8+), and in every layout
/// once the status bar is hidden by full screen or kiosk mode. The strip is drawn for older cores, in
/// regular-width layouts, or when the developer "always below status bar" override is on.
func updateThemedStatusBar() {
let isCompactWidth = traitCollection.horizontalSizeClass == .compact
let coreSupportsEdgeToEdge = server.info.version >= .canDisplayEdgeToEdge
let belowStatusBarOverride = Current.settingsStore.webViewAlwaysBelowStatusBar
let edgeToEdge = (isCompactWidth && coreSupportsEdgeToEdge && !belowStatusBarOverride)
|| Current.settingsStore.fullScreen
|| WebViewChromeState.resolveStatusBarHidden()
DispatchQueue.main.async { [weak self] in
guard let self else { return }
overlayState?.statusBarColor = (edgeToEdge || Current.isCatalyst) ? nil : themedStatusBarColor()
Expand Down
Loading
Loading