diff --git a/HomeAssistant.xcodeproj/project.pbxproj b/HomeAssistant.xcodeproj/project.pbxproj index f8b894175..c2251e86d 100644 --- a/HomeAssistant.xcodeproj/project.pbxproj +++ b/HomeAssistant.xcodeproj/project.pbxproj @@ -764,6 +764,8 @@ 42DF6B2D2CCF8A2200D7EC14 /* PermissionRequestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42DF6B2C2CCF8A2200D7EC14 /* PermissionRequestView.swift */; }; 42DF6B2F2CCF918D00D7EC14 /* BluetoothPermissionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42DF6B2E2CCF918D00D7EC14 /* BluetoothPermissionView.swift */; }; 42E65F082C8079FE00C4A6F2 /* ControlAssistValueProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42E65F072C8079FE00C4A6F2 /* ControlAssistValueProvider.swift */; }; + 42E6C08A2CE4F4FA007CA622 /* DownloadManagerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42E6C0892CE4F4FA007CA622 /* DownloadManagerView.swift */; }; + 42E6C08C2CE4F7A8007CA622 /* DownloadManagerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42E6C08B2CE4F7A8007CA622 /* DownloadManagerViewModel.swift */; }; 42E95C552CA44FC90010ECE3 /* SafariWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42E95C542CA44FC90010ECE3 /* SafariWebView.swift */; }; 42E95C572CA45EFA0010ECE3 /* OnboardingErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42E95C562CA45EFA0010ECE3 /* OnboardingErrorView.swift */; }; 42E95C592CA46AD50010ECE3 /* ActivityView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42E95C582CA46AD50010ECE3 /* ActivityView.swift */; }; @@ -2052,6 +2054,8 @@ 42DF6B2C2CCF8A2200D7EC14 /* PermissionRequestView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionRequestView.swift; sourceTree = ""; }; 42DF6B2E2CCF918D00D7EC14 /* BluetoothPermissionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BluetoothPermissionView.swift; sourceTree = ""; }; 42E65F072C8079FE00C4A6F2 /* ControlAssistValueProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlAssistValueProvider.swift; sourceTree = ""; }; + 42E6C0892CE4F4FA007CA622 /* DownloadManagerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadManagerView.swift; sourceTree = ""; }; + 42E6C08B2CE4F7A8007CA622 /* DownloadManagerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadManagerViewModel.swift; sourceTree = ""; }; 42E95C542CA44FC90010ECE3 /* SafariWebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SafariWebView.swift; sourceTree = ""; }; 42E95C562CA45EFA0010ECE3 /* OnboardingErrorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingErrorView.swift; sourceTree = ""; }; 42E95C582CA46AD50010ECE3 /* ActivityView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityView.swift; sourceTree = ""; }; @@ -3068,6 +3072,7 @@ 11AD2E2A2528FDB700FBC437 /* WebView */ = { isa = PBXGroup; children = ( + 42E6C0882CE4F4EC007CA622 /* DownloadManager */, 42B942F42CAA1E4400E36E02 /* Payload */, 42BE698B2C4691E000745ECA /* Views */, 11DE822F24FAE66F00E636B8 /* UIWindow+Additions.swift */, @@ -4141,6 +4146,15 @@ path = Add; sourceTree = ""; }; + 42E6C0882CE4F4EC007CA622 /* DownloadManager */ = { + isa = PBXGroup; + children = ( + 42E6C0892CE4F4FA007CA622 /* DownloadManagerView.swift */, + 42E6C08B2CE4F7A8007CA622 /* DownloadManagerViewModel.swift */, + ); + path = DownloadManager; + sourceTree = ""; + }; 42EFFAEA2C8882CC002F10FC /* CarPlay */ = { isa = PBXGroup; children = ( @@ -6728,6 +6742,7 @@ 425573CC2B5574AD00145217 /* CarPlayAreasZonesTemplate+Build.swift in Sources */, 42A47A8C2C4547B800C9B43D /* WebViewExternalMessageHandler+Build.swift in Sources */, B626AAF11D8F972800A0D225 /* SettingsDetailViewController.swift in Sources */, + 42E6C08C2CE4F7A8007CA622 /* DownloadManagerViewModel.swift in Sources */, 1127381C2622B6F300F5E312 /* DebugSettingsViewController.swift in Sources */, 42266B252B7A4BA900E94A71 /* BarcodeScannerViewModel.swift in Sources */, 11DE823024FAE66F00E636B8 /* UIWindow+Additions.swift in Sources */, @@ -6846,6 +6861,7 @@ B661FC88226D478300E541DD /* OnboardingScanningViewController.swift in Sources */, 11A48D8124CA8ADB0021BDD9 /* NotificationCategory+Observation.swift in Sources */, 1100D51D2496AECE00B1073C /* PermissionStatusRow.swift in Sources */, + 42E6C08A2CE4F4FA007CA622 /* DownloadManagerView.swift in Sources */, D0B25BD221323CA600678C2C /* ClientEventPayloadViewController.swift in Sources */, 42E95C572CA45EFA0010ECE3 /* OnboardingErrorView.swift in Sources */, B641BC1F1E2097EF002CCBC1 /* AboutViewController.swift in Sources */, diff --git a/Sources/App/Resources/en.lproj/Localizable.strings b/Sources/App/Resources/en.lproj/Localizable.strings index 24fa9d448..482a743a0 100644 --- a/Sources/App/Resources/en.lproj/Localizable.strings +++ b/Sources/App/Resources/en.lproj/Localizable.strings @@ -1040,4 +1040,7 @@ Home Assistant is free and open source home automation software with a focus on "widgets.sensors.description" = "Display state of sensors"; "widgets.sensors.not_configured" = "No Sensors Configured"; "widgets.sensors.title" = "Sensors"; -"yes_label" = "Yes"; \ No newline at end of file +"yes_label" = "Yes"; +"download_manager.downloading.title" = "Downloading"; +"download_manager.finished.title" = "Download finished"; +"download_manager.failed.title" = "Failed to download file, error: %@"; diff --git a/Sources/App/WebView/DownloadManager/DownloadManagerView.swift b/Sources/App/WebView/DownloadManager/DownloadManagerView.swift new file mode 100644 index 000000000..8e4651918 --- /dev/null +++ b/Sources/App/WebView/DownloadManager/DownloadManagerView.swift @@ -0,0 +1,100 @@ +import Shared +import SwiftUI + +@available(iOS 17.0, *) +struct DownloadManagerView: View { + @Environment(\.dismiss) private var dismiss + + @StateObject var viewModel: DownloadManagerViewModel + + init(viewModel: DownloadManagerViewModel) { + self._viewModel = .init(wrappedValue: viewModel) + } + + var body: some View { + VStack(spacing: .zero) { + HStack { + Button(action: { + viewModel.deleteFile() + dismiss() + }, label: { + Image(systemSymbol: .xmarkCircleFill) + .font(.title) + .foregroundStyle( + Color(uiColor: .systemBackground), + .gray.opacity(0.5) + ) + }) + .frame(maxWidth: .infinity, alignment: .trailing) + .padding() + } + if viewModel.finished { + successView + } else if viewModel.failed { + fileCard + failedCard + } else { + ProgressView() + .progressViewStyle(.circular) + .scaleEffect(2) + .padding(Spaces.four) + Text(L10n.DownloadManager.Downloading.title) + .font(.title.bold()) + fileCard + } + Spacer() + } + } + + private var successView: some View { + VStack(spacing: Spaces.three) { + Image(systemSymbol: .checkmark) + .foregroundStyle(.green) + .font(.system(size: 100)) + .symbolEffect( + .bounce, + options: .nonRepeating + ) + Text(L10n.DownloadManager.Finished.title) + .font(.title.bold()) + if let url = viewModel.lastURLCreated { + ShareLink(viewModel.fileName, item: url) + .padding() + .foregroundStyle(.white) + .background(Color.asset(Asset.Colors.haPrimary)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .padding() + } + } + } + + private var fileCard: some View { + HStack { + Image(systemSymbol: .docZipper) + Text(viewModel.fileName) + } + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + .background(.gray.opacity(0.5)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .padding() + } + + private var failedCard: some View { + Text(viewModel.errorMessage) + .frame(maxWidth: .infinity, alignment: .leading) + .multilineTextAlignment(.leading) + .padding() + .background(.red.opacity(0.5)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .padding() + } +} + +#Preview { + if #available(iOS 17.0, *) { + DownloadManagerView(viewModel: .init()) + } else { + Text("Hey there") + } +} diff --git a/Sources/App/WebView/DownloadManager/DownloadManagerViewModel.swift b/Sources/App/WebView/DownloadManager/DownloadManagerViewModel.swift new file mode 100644 index 000000000..fdb8a5f14 --- /dev/null +++ b/Sources/App/WebView/DownloadManager/DownloadManagerViewModel.swift @@ -0,0 +1,57 @@ +import Foundation +import Shared +import WebKit + +@MainActor +final class DownloadManagerViewModel: NSObject, ObservableObject { + @Published var fileName: String = "" + @Published var finished: Bool = false + @Published var failed: Bool = false + @Published var errorMessage: String = "" + @Published var lastURLCreated: URL? + + func deleteFile() { + if let url = lastURLCreated { + // Guarantee to delete file before leaving screen + do { + try FileManager.default.removeItem(at: url) + } catch { + Current.Log.error("Failed to remove file before leaving download manager at \(url), error: \(error)") + } + } + } +} + +extension DownloadManagerViewModel: WKDownloadDelegate { + func download( + _ download: WKDownload, + decideDestinationUsing response: URLResponse, + suggestedFilename: String + ) async -> URL? { + let urls = FileManager.default.urls(for: .cachesDirectory, in: .allDomainsMask) + let name = suggestedFilename.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? "Unknown" + fileName = name + if let url = URL(string: name, relativeTo: urls[0]) { + lastURLCreated = url + // Guarantee file does not exist, otherwise download will fail + do { + try FileManager.default.removeItem(at: url) + } catch { + Current.Log.error("Failed to remove file for download manager at \(url), error: \(error)") + } + + return url + } else { + return nil + } + } + + func downloadDidFinish(_ download: WKDownload) { + finished = true + } + + func download(_ download: WKDownload, didFailWithError error: any Error, resumeData: Data?) { + errorMessage = L10n.DownloadManager.Failed.title(error.localizedDescription) + failed = true + } +} diff --git a/Sources/App/WebView/WebViewController.swift b/Sources/App/WebView/WebViewController.swift index dc3d32a78..84d616af3 100644 --- a/Sources/App/WebView/WebViewController.swift +++ b/Sources/App/WebView/WebViewController.swift @@ -439,6 +439,16 @@ final class WebViewController: UIViewController, WKNavigationDelegate, WKUIDeleg updateWebViewSettings(reason: .load) } + func webView(_ webView: WKWebView, navigationAction: WKNavigationAction, didBecome download: WKDownload) { + if #available(iOS 17.0, *) { + let viewModel = DownloadManagerViewModel() + let downloadManager = DownloadManagerView(viewModel: viewModel) + let downloadController = UIHostingController(rootView: downloadManager) + presentOverlayController(controller: downloadController, animated: true) + download.delegate = viewModel + } + } + func webView( _ webView: WKWebView, decidePolicyFor navigationResponse: WKNavigationResponse, diff --git a/Sources/Shared/Resources/Swiftgen/Strings.swift b/Sources/Shared/Resources/Swiftgen/Strings.swift index 936328411..278700b18 100644 --- a/Sources/Shared/Resources/Swiftgen/Strings.swift +++ b/Sources/Shared/Resources/Swiftgen/Strings.swift @@ -744,6 +744,23 @@ public enum L10n { } } + public enum DownloadManager { + public enum Downloading { + /// Downloading + public static var title: String { return L10n.tr("Localizable", "download_manager.downloading.title") } + } + public enum Failed { + /// Failed to download file, error: %@ + public static func title(_ p1: Any) -> String { + return L10n.tr("Localizable", "download_manager.failed.title", String(describing: p1)) + } + } + public enum Finished { + /// Download finished + public static var title: String { return L10n.tr("Localizable", "download_manager.finished.title") } + } + } + public enum Extensions { public enum Map { public enum Location {