mirror of
https://github.com/home-assistant/iOS.git
synced 2026-04-12 15:26:45 -05:00
## Summary Enables AssistView to open in a dedicated window on macOS while maintaining modal presentation on iOS/iPad. **Implementation:** - Added `.assist` case to `SceneActivity` enum with `ha.assist` identifier - Created `AssistSceneDelegate` extending `BasicSceneDelegate` to manage Assist windows - Updated `Info.plist` with Assist scene configuration - Added `SceneManager.activateAnyScene(for:with:)` to pass parameters via `NSUserActivity.userInfo` - Modified `WebViewExternalMessageHandler.showAssist()` to branch on `Current.isCatalyst`: - macOS: `SceneManager.activateAnyScene(for: .assist, with: userInfo)` - iOS/iPad: Existing modal presentation (unchanged) - Window centering: Assist window opens centered on screen (600x600) using `UIWindowScene.MacGeometryPreferences` (iOS 17+) - Close button: Removed toolbar close button in scene mode since window has native close button; modal presentation on iOS/iPad retains close button **Parameters passed via userInfo:** - `server`: Server identifier string - `pipelineId`: Pipeline ID string - `autoStartRecording`: Boolean ## Screenshots N/A - Scene system change, requires macOS build to screenshot ## Link to pull request in Documentation repository Documentation: home-assistant/companion.home-assistant# ## Any other notes Follows existing scene patterns from `SettingsSceneDelegate` and `AboutSceneDelegate`. No breaking changes to iOS/iPad behavior. The `showCloseButton` parameter in `AssistView` allows flexible control of close button visibility - `false` for scene mode (window has native controls), `true` for modal presentation. <!-- START COPILOT CODING AGENT SUFFIX --> <details> <summary>Original prompt</summary> > Turn AssistView into a scene, so it can be initiated in a new window on macOS </details> <!-- START COPILOT CODING AGENT TIPS --> --- 💡 You can make Copilot smarter by setting up custom instructions, customizing its development environment and configuring Model Context Protocol (MCP) servers. Learn more [Copilot coding agent tips](https://gh.io/copilot-coding-agent-tips) in the docs. --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: bgoncal <5808343+bgoncal@users.noreply.github.com>
234 lines
8.4 KiB
Swift
234 lines
8.4 KiB
Swift
import Foundation
|
|
import MBProgressHUD
|
|
import PromiseKit
|
|
import Shared
|
|
import UIKit
|
|
|
|
// TODO: can i combine this with the enum?
|
|
|
|
struct SceneQuery<DelegateType: UIWindowSceneDelegate> {
|
|
let activity: SceneActivity
|
|
}
|
|
|
|
extension UIWindowSceneDelegate {
|
|
func informManager(from connectionOptions: UIScene.ConnectionOptions) {
|
|
let pendingResolver: (Self) -> Void = Current.sceneManager
|
|
.pendingResolver(from: connectionOptions.userActivities)
|
|
|
|
pendingResolver(self)
|
|
}
|
|
}
|
|
|
|
final class SceneManager {
|
|
// types too hard here
|
|
fileprivate static let activityUserInfoKeyResolver = "resolver"
|
|
|
|
private struct PendingResolver {
|
|
private var handleBlock: (Any) -> Void
|
|
init<T>(resolver: @escaping (T) -> Void) {
|
|
self.handleBlock = { value in
|
|
if let value = value as? T {
|
|
resolver(value)
|
|
}
|
|
}
|
|
}
|
|
|
|
func resolve(with possible: some Any) {
|
|
handleBlock(possible)
|
|
}
|
|
}
|
|
|
|
private var pendingResolvers: [String: PendingResolver] = [:]
|
|
|
|
var webViewWindowControllerPromise: Guarantee<WebViewWindowController> {
|
|
firstly { () -> Guarantee<WebViewSceneDelegate> in
|
|
scene(for: .init(activity: .webView))
|
|
}.map { delegate in
|
|
delegate.windowController!
|
|
}
|
|
}
|
|
|
|
init() {
|
|
// swiftlint:disable prohibit_environment_assignment
|
|
Current.realmFatalPresentation = { [weak self] viewController in
|
|
guard let self else { return }
|
|
|
|
let under = UIViewController()
|
|
under.view.backgroundColor = .black
|
|
under.modalPresentationStyle = .fullScreen
|
|
|
|
webViewWindowControllerPromise.done { parent in
|
|
parent.present(under, animated: false, completion: {
|
|
under.present(viewController, animated: true, completion: nil)
|
|
})
|
|
}
|
|
}
|
|
// swiftlint:enable prohibit_environment_assignment
|
|
}
|
|
|
|
fileprivate func pendingResolver<T>(from activities: Set<NSUserActivity>) -> (T) -> Void {
|
|
let (promise, outerResolver) = Guarantee<T>.pending()
|
|
|
|
if supportsMultipleScenes {
|
|
activities.compactMap { activity in
|
|
activity.userInfo?[Self.activityUserInfoKeyResolver] as? String
|
|
}.compactMap { token in
|
|
pendingResolvers[token]
|
|
}.forEach { resolver in
|
|
promise.done { resolver.resolve(with: $0) }
|
|
}
|
|
} else {
|
|
pendingResolvers
|
|
.values
|
|
.forEach { resolver in promise.done { resolver.resolve(with: $0) } }
|
|
}
|
|
|
|
return outerResolver
|
|
}
|
|
|
|
private func existingScenes(for activity: SceneActivity) -> [UIScene] {
|
|
UIApplication.shared.connectedScenes.filter { scene in
|
|
// Filter out scenes that are in the background or unattached state
|
|
// as they may be in the process of being destroyed
|
|
guard scene.activationState != .unattached else {
|
|
return false
|
|
}
|
|
return scene.session.configuration.name.flatMap(SceneActivity.init(configurationName:)) == activity
|
|
}.sorted { a, b in
|
|
switch (a.activationState, b.activationState) {
|
|
case (.unattached, .unattached): return true
|
|
case (.unattached, _): return false
|
|
case (_, .unattached): return true
|
|
case (.foregroundActive, _): return true
|
|
case (_, .foregroundActive): return false
|
|
case (.foregroundInactive, _): return true
|
|
case (_, .foregroundInactive): return false
|
|
case (_, _): return true
|
|
}
|
|
}
|
|
}
|
|
|
|
public var supportsMultipleScenes: Bool {
|
|
UIApplication.shared.supportsMultipleScenes
|
|
}
|
|
|
|
public func activateAnyScene(for activity: SceneActivity) {
|
|
UIApplication.shared.requestSceneSessionActivation(
|
|
existingScenes(for: activity).first?.session,
|
|
userActivity: activity.activity,
|
|
options: nil
|
|
) { error in
|
|
Current.Log.error(error)
|
|
}
|
|
}
|
|
|
|
public func activateAnyScene(for activity: SceneActivity, with userInfo: [AnyHashable: Any]) {
|
|
UIApplication.shared.requestSceneSessionActivation(
|
|
existingScenes(for: activity).first?.session,
|
|
userActivity: activity.activity(with: userInfo),
|
|
options: nil
|
|
) { error in
|
|
Current.Log.error(error)
|
|
}
|
|
}
|
|
|
|
public func scene<DelegateType: UIWindowSceneDelegate>(
|
|
for query: SceneQuery<DelegateType>
|
|
) -> Guarantee<DelegateType> {
|
|
if let active = existingScenes(for: query.activity).first,
|
|
let delegate = active.delegate as? DelegateType {
|
|
Current.Log.verbose("Ready to activate scene \(active.session.persistentIdentifier)")
|
|
|
|
let options = UIScene.ActivationRequestOptions()
|
|
options.requestingScene = active
|
|
|
|
// Only activate scene if not activated already
|
|
guard active.activationState != .foregroundActive else {
|
|
Current.Log
|
|
.verbose("Did not activate scene \(active.session.persistentIdentifier), it was already active")
|
|
return .value(delegate)
|
|
}
|
|
|
|
// Only activate scene if the app is already in foreground or transitioning to foreground
|
|
// This prevents widgets, notifications, or background tasks from unexpectedly bringing the app to
|
|
// foreground
|
|
let shouldActivate = UIApplication.shared.applicationState == .active ||
|
|
active.activationState == .foregroundInactive
|
|
|
|
if shouldActivate {
|
|
Current.Log.verbose("Activating scene \(active.session.persistentIdentifier)")
|
|
|
|
// Guarantee it runs on main thread when coming from widgets
|
|
DispatchQueue.main.async {
|
|
if #available(iOS 17.0, *) {
|
|
UIApplication.shared.activateSceneSession(for: .init(session: active.session, options: options))
|
|
} else {
|
|
UIApplication.shared.requestSceneSessionActivation(
|
|
active.session,
|
|
userActivity: nil,
|
|
options: options,
|
|
errorHandler: nil
|
|
)
|
|
}
|
|
}
|
|
} else {
|
|
Current.Log
|
|
.verbose(
|
|
"Skipping scene activation for \(active.session.persistentIdentifier) - app is in background"
|
|
)
|
|
}
|
|
|
|
return .value(delegate)
|
|
}
|
|
|
|
assert(
|
|
supportsMultipleScenes || query.activity == .webView,
|
|
"if we don't support multiple scenes, how are we running without one besides at immediate startup?"
|
|
)
|
|
|
|
let (promise, resolver) = Guarantee<DelegateType>.pending()
|
|
|
|
let token = UUID().uuidString
|
|
pendingResolvers[token] = PendingResolver(resolver: resolver)
|
|
|
|
if supportsMultipleScenes {
|
|
Current.Log.verbose("Ready to request new scene activation for \(query.activity)")
|
|
|
|
let activity = query.activity.activity
|
|
activity.userInfo = [
|
|
Self.activityUserInfoKeyResolver: token,
|
|
]
|
|
|
|
UIApplication.shared.requestSceneSessionActivation(
|
|
nil,
|
|
userActivity: activity,
|
|
options: nil,
|
|
errorHandler: { error in
|
|
// error is called in most cases, even when no error occurs, so we silently swallow it
|
|
// TODO: does this actually happen in normal circumstances?
|
|
Current.Log.error("scene activation error: \(error)")
|
|
}
|
|
)
|
|
}
|
|
|
|
return promise
|
|
}
|
|
|
|
public func showFullScreenConfirm(
|
|
icon: MaterialDesignIcons,
|
|
text: String,
|
|
onto window: Promise<UIWindow>
|
|
) {
|
|
window.done { window in
|
|
let hud = MBProgressHUD.showAdded(to: window, animated: true)
|
|
hud.mode = .customView
|
|
hud.backgroundView.style = .blur
|
|
hud.customView = with(IconImageView(frame: .init(x: 0, y: 0, width: 64, height: 64))) {
|
|
$0.iconDrawable = icon
|
|
}
|
|
hud.label.text = text
|
|
hud.hide(animated: true, afterDelay: 3)
|
|
}.cauterize()
|
|
}
|
|
}
|