192 lines
7.5 KiB
Swift
192 lines
7.5 KiB
Swift
import Network
|
|
import UIKit
|
|
|
|
/// Bridges Swift host events to Kotlin (via UserDefaults + notifications).
|
|
/// Full bidirectional callback requires exporting Kotlin handlers through the ComposeApp framework.
|
|
final class HostBridge {
|
|
static let shared = HostBridge()
|
|
private var localNetworkBrowser: NWBrowser?
|
|
private var localNetworkTimeout: DispatchWorkItem?
|
|
private var qrScanner: QrScannerViewController?
|
|
|
|
/// iOS 没有显式的本地网络权限 API;开始 Bonjour 浏览会在首次进入传输页时触发系统授权。
|
|
func requestLocalNetworkPermission() {
|
|
guard localNetworkBrowser == nil else { return }
|
|
|
|
let parameters = NWParameters()
|
|
parameters.includePeerToPeer = true
|
|
let browser = NWBrowser(
|
|
for: .bonjour(type: "_llmp._tcp", domain: nil),
|
|
using: parameters
|
|
)
|
|
localNetworkBrowser = browser
|
|
browser.stateUpdateHandler = { [weak self] state in
|
|
switch state {
|
|
case .ready, .failed:
|
|
self?.stopLocalNetworkPermissionRequest()
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
browser.start(queue: .main)
|
|
|
|
let timeout = DispatchWorkItem { [weak self] in
|
|
self?.stopLocalNetworkPermissionRequest()
|
|
}
|
|
localNetworkTimeout = timeout
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 30, execute: timeout)
|
|
}
|
|
|
|
private func stopLocalNetworkPermissionRequest() {
|
|
localNetworkTimeout?.cancel()
|
|
localNetworkTimeout = nil
|
|
localNetworkBrowser?.stateUpdateHandler = nil
|
|
localNetworkBrowser?.cancel()
|
|
localNetworkBrowser = nil
|
|
}
|
|
|
|
func handleDeepLink(_ url: String) {
|
|
UserDefaults(suiteName: widgetGroupId)?.set(url, forKey: "pendingDeepLink")
|
|
NotificationCenter.default.post(
|
|
name: Notification.Name("llmpDeepLink"),
|
|
object: nil,
|
|
userInfo: ["url": url]
|
|
)
|
|
}
|
|
|
|
func handleWidgetAction(from userInfo: [AnyHashable: Any]?) {
|
|
guard let url = userInfo?["url"] as? String else { return }
|
|
if url.contains("toggle_play") {
|
|
NotificationCenter.default.post(name: Notification.Name("llmpHostTogglePlay"), object: nil)
|
|
} else if url.contains("previous") {
|
|
NotificationCenter.default.post(name: Notification.Name("llmpHostPrevious"), object: nil)
|
|
} else if url.contains("next") {
|
|
NotificationCenter.default.post(name: Notification.Name("llmpHostNext"), object: nil)
|
|
} else if url.contains("toggle_love") {
|
|
NotificationCenter.default.post(name: Notification.Name("llmpHostToggleLove"), object: nil)
|
|
}
|
|
}
|
|
|
|
func presentShare(from userInfo: [AnyHashable: Any]?) {
|
|
if (userInfo?["type"] as? String) == "qq" {
|
|
QqShareHost.shared.share(
|
|
title: userInfo?["title"] as? String ?? "",
|
|
text: userInfo?["text"] as? String ?? "",
|
|
url: userInfo?["url"] as? String ?? "",
|
|
imageUrl: userInfo?["imageUrl"] as? String,
|
|
imagePath: userInfo?["imagePath"] as? String
|
|
)
|
|
return
|
|
}
|
|
guard let root = topViewController() else { return }
|
|
var items: [Any] = []
|
|
if let text = userInfo?["text"] as? String, !text.isEmpty {
|
|
items.append(text)
|
|
}
|
|
if let urlString = userInfo?["url"] as? String, let url = URL(string: urlString) {
|
|
items.append(url)
|
|
}
|
|
guard !items.isEmpty else { return }
|
|
let controller = UIActivityViewController(activityItems: items, applicationActivities: nil)
|
|
root.present(controller, animated: true)
|
|
}
|
|
|
|
func showToast(_ message: String) {
|
|
presentToast(message, retry: true)
|
|
}
|
|
|
|
private func presentToast(_ message: String, retry: Bool) {
|
|
guard !message.isEmpty else { return }
|
|
guard let root = topViewController() else {
|
|
if retry {
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { [weak self] in
|
|
self?.presentToast(message, retry: false)
|
|
}
|
|
}
|
|
return
|
|
}
|
|
let label = PaddingLabel()
|
|
label.text = message
|
|
label.textColor = .white
|
|
label.backgroundColor = UIColor.black.withAlphaComponent(0.78)
|
|
label.textAlignment = .center
|
|
label.semanticContentAttribute = .forceLeftToRight
|
|
label.font = .systemFont(ofSize: 14)
|
|
label.numberOfLines = 0
|
|
label.layer.cornerRadius = 8
|
|
label.clipsToBounds = true
|
|
label.translatesAutoresizingMaskIntoConstraints = false
|
|
root.view.addSubview(label)
|
|
NSLayoutConstraint.activate([
|
|
label.centerXAnchor.constraint(equalTo: root.view.centerXAnchor),
|
|
label.bottomAnchor.constraint(equalTo: root.view.safeAreaLayoutGuide.bottomAnchor, constant: -24),
|
|
label.leadingAnchor.constraint(greaterThanOrEqualTo: root.view.leadingAnchor, constant: 32),
|
|
label.trailingAnchor.constraint(lessThanOrEqualTo: root.view.trailingAnchor, constant: -32),
|
|
])
|
|
UIView.animate(withDuration: 0.2, delay: 1.6, options: .curveEaseIn) {
|
|
label.alpha = 0
|
|
} completion: { _ in
|
|
label.removeFromSuperview()
|
|
}
|
|
}
|
|
|
|
func presentWebView(from userInfo: [AnyHashable: Any]?) {
|
|
guard let url = userInfo?["url"] as? String, !url.isEmpty else { return }
|
|
let title = userInfo?["title"] as? String
|
|
guard let root = topViewController() else { return }
|
|
let webVC = InAppWebViewController(url: url, title: title)
|
|
let nav = UINavigationController(rootViewController: webVC)
|
|
nav.modalPresentationStyle = .fullScreen
|
|
root.present(nav, animated: true)
|
|
}
|
|
|
|
func presentQrScanner(from userInfo: [AnyHashable: Any]?) {
|
|
guard let requestId = userInfo?["requestId"] as? String else { return }
|
|
let reply: (String) -> Void = { result in
|
|
NotificationCenter.default.post(
|
|
name: Notification.Name("llmpQrScanResult"),
|
|
object: nil,
|
|
userInfo: ["requestId": requestId, "result": result]
|
|
)
|
|
}
|
|
guard qrScanner == nil, let root = topViewController(), !root.isBeingDismissed else {
|
|
reply("")
|
|
return
|
|
}
|
|
let scanner = QrScannerViewController(labels: userInfo ?? [:]) { [weak self] result in
|
|
self?.qrScanner = nil
|
|
reply(result)
|
|
}
|
|
qrScanner = scanner
|
|
scanner.modalPresentationStyle = .fullScreen
|
|
root.present(scanner, animated: true)
|
|
}
|
|
|
|
private func topViewController() -> UIViewController? {
|
|
let scenes = UIApplication.shared.connectedScenes
|
|
.compactMap { $0 as? UIWindowScene }
|
|
let window = scenes.flatMap { $0.windows }.first { $0.isKeyWindow }
|
|
var top = window?.rootViewController
|
|
while let presented = top?.presentedViewController {
|
|
top = presented
|
|
}
|
|
return top
|
|
}
|
|
}
|
|
|
|
private final class PaddingLabel: UILabel {
|
|
private let inset = UIEdgeInsets(top: 8, left: 14, bottom: 8, right: 14)
|
|
|
|
override func drawText(in rect: CGRect) {
|
|
super.drawText(in: rect.inset(by: inset))
|
|
}
|
|
|
|
override var intrinsicContentSize: CGSize {
|
|
let size = super.intrinsicContentSize
|
|
return CGSize(
|
|
width: size.width + inset.left + inset.right,
|
|
height: size.height + inset.top + inset.bottom
|
|
)
|
|
}
|
|
}
|