Skip to content

Commit 3eca615

Browse files
authored
Add support for /invite home assistant deeplink (#3591)
<!-- Thank you for submitting a Pull Request and helping to improve Home Assistant. Please complete the following sections to help the processing and review of your changes. Please do not delete anything from this template. --> ## Summary <!-- Provide a brief summary of the changes you have made and most importantly what they aim to achieve --> Context: home-assistant/my.home-assistant.io#544 ## Screenshots <!-- If this is a user-facing change not in the frontend, please include screenshots in light and dark mode. --> ## Link to pull request in Documentation repository <!-- Pull requests that add, change or remove functionality must have a corresponding pull request in the Companion App Documentation repository (https://github.com/home-assistant/companion.home-assistant). Please add the number of this pull request after the "#" --> Documentation: home-assistant/companion.home-assistant# ## Any other notes <!-- If there is any other information of note, like if this Pull Request is part of a bigger change, please include it here. -->
1 parent 52e8a5e commit 3eca615

File tree

11 files changed

+182
-31
lines changed

11 files changed

+182
-31
lines changed

HomeAssistant.xcodeproj/project.pbxproj

+6
Original file line numberDiff line numberDiff line change
@@ -621,6 +621,8 @@
621621
4235075E2CDB756800A19902 /* HAServices.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4235075C2CDB756800A19902 /* HAServices.swift */; };
622622
42383F702D9576F700C745F2 /* AppTriggerSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42383F6F2D9576F700C745F2 /* AppTriggerSource.swift */; };
623623
42383F712D9576F700C745F2 /* AppTriggerSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42383F6F2D9576F700C745F2 /* AppTriggerSource.swift */; };
624+
4238DCA42DD1F1E300126434 /* AppSessionValues.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4238DCA32DD1F1E300126434 /* AppSessionValues.swift */; };
625+
4238DCA52DD1F1E300126434 /* AppSessionValues.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4238DCA32DD1F1E300126434 /* AppSessionValues.swift */; };
624626
4239D1832C4FFCCE003497FC /* WatchUserDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4239D1802C4FFB75003497FC /* WatchUserDefaults.swift */; };
625627
423B5E092D67781A0000CB95 /* WidgetBasicContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 115560E027010D8400A8F818 /* WidgetBasicContainerView.swift */; };
626628
423B5E0A2D6778370000CB95 /* WidgetBackground.swift in Sources */ = {isa = PBXBuildFile; fileRef = 424A7F452B188946008C8DF3 /* WidgetBackground.swift */; };
@@ -2055,6 +2057,7 @@
20552057
42333ADA2D0B1771001E8408 /* EntityRegistryListForDisplay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EntityRegistryListForDisplay.swift; sourceTree = "<group>"; };
20562058
4235075C2CDB756800A19902 /* HAServices.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HAServices.swift; sourceTree = "<group>"; };
20572059
42383F6F2D9576F700C745F2 /* AppTriggerSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppTriggerSource.swift; sourceTree = "<group>"; };
2060+
4238DCA32DD1F1E300126434 /* AppSessionValues.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSessionValues.swift; sourceTree = "<group>"; };
20582061
4239D1802C4FFB75003497FC /* WatchUserDefaults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchUserDefaults.swift; sourceTree = "<group>"; };
20592062
423F44EF2C17238200766A99 /* ChatBubbleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatBubbleView.swift; sourceTree = "<group>"; };
20602063
423F44FE2C186E4500766A99 /* WatchCommunicatorService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchCommunicatorService.swift; sourceTree = "<group>"; };
@@ -5739,6 +5742,7 @@
57395742
isa = PBXGroup;
57405743
children = (
57415744
D0C884792122A65800CCB501 /* SettingsStore.swift */,
5745+
4238DCA32DD1F1E300126434 /* AppSessionValues.swift */,
57425746
);
57435747
path = Settings;
57445748
sourceTree = "<group>";
@@ -7993,6 +7997,7 @@
79937997
11F20BFD274D5DA900DFB163 /* Server+Fakes.swift in Sources */,
79947998
11A3BD2E26192210005237E6 /* LocalPushManager.swift in Sources */,
79957999
4278C9C22C8F226500A7B5F4 /* GuaranteedMessages.swift in Sources */,
8000+
4238DCA42DD1F1E300126434 /* AppSessionValues.swift in Sources */,
79968001
11CFD785273662DF0082D557 /* Server.swift in Sources */,
79978002
116C0C30267EB90F00A992E4 /* UserDefaultsValueSync.swift in Sources */,
79988003
B67CE8B422200F220034C1D0 /* URL+Extensions.swift in Sources */,
@@ -8278,6 +8283,7 @@
82788283
D0EEF320214DE3B300D1D360 /* Strings.swift in Sources */,
82798284
11CFD784273662DF0082D557 /* Server.swift in Sources */,
82808285
116C0C2F267EB90F00A992E4 /* UserDefaultsValueSync.swift in Sources */,
8286+
4238DCA52DD1F1E300126434 /* AppSessionValues.swift in Sources */,
82818287
11A3BD2D26192210005237E6 /* LocalPushManager.swift in Sources */,
82828288
42FCD00C2B9B25D60057783F /* ThreadClientProtocol.swift in Sources */,
82838289
42D334282D105990008D8E78 /* AppPanel.swift in Sources */,

Sources/App/Onboarding/Container/OnboardingNavigationView.swift

+1-5
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,6 @@ enum OnboardingNavigation {
3232
}
3333

3434
struct OnboardingNavigationView: View {
35-
static func controller(onboardingStyle: OnboardingStyle) -> UIViewController {
36-
OnboardingNavigationView(onboardingStyle: onboardingStyle).embeddedInHostingController()
37-
}
38-
3935
@Environment(\.dismiss) private var dismiss
4036
@StateObject public var viewModel = OnboardingNavigationViewModel()
4137
public let onboardingStyle: OnboardingStyle
@@ -51,7 +47,7 @@ struct OnboardingNavigationView: View {
5147
case .initial:
5248
OnboardingWelcomeView(shouldDismissOnboarding: $viewModel.shouldDismiss)
5349
case .secondary:
54-
OnboardingServersListView(shouldDismissOnboarding: $viewModel.shouldDismiss)
50+
OnboardingServersListView()
5551
case let .required(type):
5652
switch type {
5753
case .full:

Sources/App/Onboarding/Screens/OnboardingServersList/OnboardingServersListView.swift

+69-9
Original file line numberDiff line numberDiff line change
@@ -7,27 +7,51 @@ struct OnboardingServersListView: View {
77
@Environment(\.horizontalSizeClass) private var sizeClass
88

99
@EnvironmentObject var hostingProvider: ViewControllerProvider
10-
@StateObject private var viewModel = OnboardingServersListViewModel()
10+
@StateObject private var viewModel: OnboardingServersListViewModel
1111

1212
@State private var showDocumentation = false
1313
@State private var showManualInput = false
1414
@State private var screenLoaded = false
1515

16-
@Binding var shouldDismissOnboarding: Bool
16+
let prefillURL: URL?
17+
18+
init(prefillURL: URL? = nil, shouldDismissOnSuccess: Bool = false) {
19+
self.prefillURL = prefillURL
20+
self
21+
._viewModel =
22+
.init(wrappedValue: OnboardingServersListViewModel(shouldDismissOnSuccess: shouldDismissOnSuccess))
23+
}
1724

1825
var body: some View {
1926
List {
20-
headerView
21-
list
22-
manualInputButton
27+
if let prefillURL {
28+
prefillURLHeader(url: prefillURL)
29+
} else {
30+
if let inviteURL = Current.appSessionValues.inviteURL {
31+
prefillURLHeader(url: inviteURL)
32+
Text("Other options")
33+
.frame(maxWidth: .infinity, alignment: .center)
34+
.multilineTextAlignment(.center)
35+
.foregroundStyle(.secondary)
36+
.listRowBackground(Color.clear)
37+
} else {
38+
headerView
39+
}
40+
list
41+
manualInputButton
42+
}
2343
}
2444
.animation(.easeInOut, value: viewModel.discoveredInstances.count)
25-
.navigationTitle(L10n.Onboarding.Scanning.title)
45+
.navigationTitle(prefillURL == nil ? L10n.Onboarding.Scanning.title : "")
2646
.navigationBarTitleDisplayMode(.inline)
2747
.toolbar(content: {
2848
ToolbarItem(placement: .topBarTrailing) {
29-
// Loading happens when URL is manually inputed by user
30-
if viewModel.isLoading {
49+
if prefillURL != nil {
50+
CloseButton {
51+
dismiss()
52+
}
53+
} else if viewModel.isLoading {
54+
// Loading happens when URL is manually inputed by user
3155
ProgressView()
3256
.progressViewStyle(.circular)
3357
} else {
@@ -51,6 +75,11 @@ struct OnboardingServersListView: View {
5175
.onDisappear {
5276
onDisappear()
5377
}
78+
.onChange(of: viewModel.shouldDismiss) { newValue in
79+
if newValue {
80+
dismiss()
81+
}
82+
}
5483
.sheet(isPresented: $viewModel.showError) {
5584
errorView
5685
}
@@ -75,7 +104,14 @@ struct OnboardingServersListView: View {
75104
private func onAppear() {
76105
if !screenLoaded {
77106
screenLoaded = true
78-
viewModel.startDiscovery()
107+
if let prefillURL {
108+
viewModel.selectInstance(
109+
.init(manualURL: prefillURL),
110+
controller: hostingProvider.viewController
111+
)
112+
} else {
113+
viewModel.startDiscovery()
114+
}
79115
}
80116
}
81117

@@ -84,6 +120,30 @@ struct OnboardingServersListView: View {
84120
viewModel.currentlyInstanceLoading = nil
85121
}
86122

123+
@ViewBuilder
124+
private func prefillURLHeader(url: URL) -> some View {
125+
AppleLikeListTopRowHeader(
126+
image: nil,
127+
headerImageAlternativeView: AnyView(
128+
Image(uiImage: Asset.logo.image)
129+
.resizable()
130+
.aspectRatio(contentMode: .fit)
131+
.frame(width: 80, height: 80)
132+
),
133+
title: "Home Assistant Invite",
134+
subtitle: url.absoluteString
135+
)
136+
Section {
137+
Button {
138+
viewModel.selectInstance(.init(manualURL: url), controller: hostingProvider.viewController)
139+
} label: {
140+
Text("Accept")
141+
}
142+
.buttonStyle(.primaryButton)
143+
.listRowBackground(Color.clear)
144+
}
145+
}
146+
87147
@ViewBuilder
88148
private var errorView: some View {
89149
if let error = viewModel.error {

Sources/App/Onboarding/Screens/OnboardingServersList/OnboardingServersListViewModel.swift

+13-1
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ final class OnboardingServersListViewModel: ObservableObject {
1717
@Published var error: Error?
1818

1919
@Published var showPermissionsFlow = false
20+
@Published var shouldDismiss = false
2021
@Published var onboardingServer: Server?
2122

2223
/// Indicator for manual input loading
@@ -25,10 +26,13 @@ final class OnboardingServersListViewModel: ObservableObject {
2526
private var webhookSensors: [WebhookSensor] = []
2627
private var discovery = Current.bonjour()
2728
private var cancellables = Set<AnyCancellable>()
29+
private let shouldDismissOnSuccess: Bool
2830

29-
init() {
31+
init(shouldDismissOnSuccess: Bool) {
32+
self.shouldDismissOnSuccess = shouldDismissOnSuccess
3033
discovery.observer = self
3134
Current.sensors.register(observer: self)
35+
Current.onboardingObservation.register(observer: self)
3236
}
3337

3438
func startDiscovery() {
@@ -160,3 +164,11 @@ extension OnboardingServersListViewModel: SensorObserver {
160164
}
161165
}
162166
}
167+
168+
extension OnboardingServersListViewModel: OnboardingStateObserver {
169+
func onboardingStateDidChange(to state: OnboardingState) {
170+
if state == .complete, shouldDismissOnSuccess {
171+
shouldDismiss = true
172+
}
173+
}
174+
}

Sources/App/Onboarding/Screens/OnboardingWelcomeView.swift

+1-1
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ struct OnboardingWelcomeView: View {
4343

4444
private var continueButton: some View {
4545
VStack {
46-
NavigationLink(destination: OnboardingServersListView(shouldDismissOnboarding: $shouldDismissOnboarding)) {
46+
NavigationLink(destination: OnboardingServersListView()) {
4747
Text(verbatim: L10n.continueLabel)
4848
}
4949
.buttonStyle(.primaryButton)

Sources/App/Settings/SettingsViewController.swift

+1-1
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ class SettingsViewController: HAFormViewController {
5959
$0.onCellSelection { _, row in
6060
row.deselect(animated: true)
6161
controller.present(
62-
OnboardingNavigationView.controller(onboardingStyle: .secondary),
62+
OnboardingNavigationView(onboardingStyle: .secondary).embeddedInHostingController(),
6363
animated: true,
6464
completion: nil
6565
)

Sources/App/WebView/IncomingURLHandler.swift

+19
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,11 @@ class IncomingURLHandler {
2121
case performAction = "perform_action"
2222
case assist
2323
case navigate
24+
case invite
2425
case createCustomWidget = "createcustomwidget"
2526
}
2627

28+
// swiftlint:disable cyclomatic_complexity
2729
@discardableResult
2830
func handle(url: URL) -> Bool {
2931
Current.Log.verbose("Received URL: \(url)")
@@ -155,6 +157,23 @@ class IncomingURLHandler {
155157
))
156158
webViewController.presentOverlayController(controller: controller, animated: true)
157159
}
160+
case .invite:
161+
// homeassistant://invite#url=http%3A%2F%2Fhomeassistant.local%3A8123
162+
Current.Log.verbose("Received Home Assistant invitation URL: \(url)")
163+
guard let fragment = url.fragment else {
164+
Current.Log.error("Home Assistant invitation does not contain a fragment (e.g. #url=...)")
165+
return false
166+
}
167+
168+
// Convert fragment into query items (#url=... -> ?url=...)
169+
let components = URLComponents(string: "?\(fragment)")
170+
let urlParam = components?.queryItems?.first(where: { $0.name == "url" })?.value
171+
172+
let inviteUrl = URL(string: urlParam.orEmpty)
173+
174+
Current.sceneManager.webViewWindowControllerPromise.done { windowController in
175+
windowController.presentInvitation(url: inviteUrl)
176+
}
158177
}
159178
} else {
160179
Current.Log.warning("Can't route incoming URL: \(url)")

Sources/App/WebView/WebViewWindowController.swift

+52-9
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,18 @@ import SwiftUI
66
import UIKit
77

88
final class WebViewWindowController {
9+
enum RootViewControllerType {
10+
case onboarding
11+
case webView
12+
}
13+
914
let window: UIWindow
1015
var restorationActivity: NSUserActivity?
1116

1217
var webViewControllerPromise: Guarantee<WebViewController>
1318

1419
private var cachedWebViewControllers = [Identifier<Server>: WebViewController]()
15-
20+
private var rootViewControllerType: RootViewControllerType?
1621
private var webViewControllerSeal: (WebViewController) -> Void
1722
private var onboardingPreloadWebViewController: WebViewController?
1823

@@ -29,7 +34,8 @@ final class WebViewWindowController {
2934
webViewControllerPromise.value?.userActivity
3035
}
3136

32-
private func updateRootViewController(to newValue: UIViewController) {
37+
private func updateRootViewController(to newValue: UIViewController, type: RootViewControllerType) {
38+
rootViewControllerType = type
3339
let newWebViewController = newValue.children.compactMap { $0 as? WebViewController }.first
3440

3541
// must be before the seal fires, or it may request during deinit of an old one
@@ -61,17 +67,48 @@ final class WebViewWindowController {
6167
func setup() {
6268
if let style = OnboardingNavigation.requiredOnboardingStyle {
6369
Current.Log.info("Showing onboarding \(style)")
64-
updateRootViewController(to: OnboardingNavigationView.controller(onboardingStyle: style))
70+
updateRootViewController(
71+
to: OnboardingNavigationView(onboardingStyle: style).embeddedInHostingController(),
72+
type: .onboarding
73+
)
6574
} else {
6675
if let webViewController = makeWebViewIfNotInCache(restorationType: .init(restorationActivity)) {
67-
updateRootViewController(to: webViewNavigationController(rootViewController: webViewController))
76+
updateRootViewController(
77+
to: webViewNavigationController(rootViewController: webViewController),
78+
type: .webView
79+
)
6880
} else {
69-
updateRootViewController(to: OnboardingNavigationView.controller(onboardingStyle: .initial))
81+
updateRootViewController(
82+
to: OnboardingNavigationView(onboardingStyle: .initial).embeddedInHostingController(),
83+
type: .onboarding
84+
)
7085
}
7186
restorationActivity = nil
7287
}
7388
}
7489

90+
func presentInvitation(url inviteURL: URL?) {
91+
guard let inviteURL else { return }
92+
93+
switch rootViewControllerType {
94+
case .onboarding:
95+
Current.appSessionValues.inviteURL = inviteURL
96+
case .webView:
97+
webViewControllerPromise.done { controller in
98+
let navigationView = NavigationView {
99+
OnboardingServersListView(prefillURL: inviteURL, shouldDismissOnSuccess: true)
100+
}.navigationViewStyle(.stack)
101+
controller.presentOverlayController(
102+
controller: navigationView.embeddedInHostingController(),
103+
animated: true
104+
)
105+
}
106+
case nil:
107+
Current.Log.error("No root view controller type set, presentInvitation failed")
108+
return
109+
}
110+
}
111+
75112
private func makeWebViewIfNotInCache(
76113
restorationType: WebViewController.RestorationType?,
77114
shouldLoadImmediately: Bool = false
@@ -142,7 +179,10 @@ final class WebViewWindowController {
142179
}
143180
}()
144181

145-
updateRootViewController(to: webViewNavigationController(rootViewController: newController))
182+
updateRootViewController(
183+
to: webViewNavigationController(rootViewController: newController),
184+
type: .webView
185+
)
146186
resolver(newController)
147187
}
148188

@@ -361,8 +401,8 @@ extension WebViewWindowController: OnboardingStateObserver {
361401
switch type {
362402
case .error, .logout:
363403
if Current.servers.all.isEmpty {
364-
let controller = OnboardingNavigationView.controller(onboardingStyle: .initial)
365-
updateRootViewController(to: controller)
404+
let controller = OnboardingNavigationView(onboardingStyle: .initial).embeddedInHostingController()
405+
updateRootViewController(to: controller, type: .onboarding)
366406

367407
if type.shouldShowError {
368408
let alert = UIAlertController(
@@ -410,7 +450,10 @@ extension WebViewWindowController: OnboardingStateObserver {
410450
}
411451

412452
if let controller {
413-
updateRootViewController(to: webViewNavigationController(rootViewController: controller))
453+
updateRootViewController(
454+
to: webViewNavigationController(rootViewController: controller),
455+
type: .webView
456+
)
414457
}
415458
}
416459
}

Sources/Shared/Environment/Environment.swift

+3
Original file line numberDiff line numberDiff line change
@@ -447,4 +447,7 @@ public class AppEnvironment {
447447
Bonjour()
448448
}
449449
#endif
450+
451+
/// Values stored for the given app session until terminated by the OS.
452+
public var appSessionValues: AppSessionValuesProtocol = AppSessionValues.shared
450453
}

0 commit comments

Comments
 (0)