From 7e6a5f5b7b5ab531241e1d08e8a6f5387633dca9 Mon Sep 17 00:00:00 2001 From: Baptiste Griva Date: Wed, 19 Nov 2025 08:09:05 +0100 Subject: [PATCH 1/9] refactor: Navigate with js calls --- EuriaCore/UniversalLinkHandler.swift | 10 +++---- ...bViewDelegate+WKScriptMessageHandler.swift | 4 +++ .../Delegate/EuriaWebViewDelegate.swift | 30 +++++++++++++++++-- EuriaFeatures/MainView/MainView.swift | 10 +------ EuriaFeatures/MainView/WebView.swift | 26 ++++------------ 5 files changed, 43 insertions(+), 37 deletions(-) diff --git a/EuriaCore/UniversalLinkHandler.swift b/EuriaCore/UniversalLinkHandler.swift index 9b1e625..6e0f1ca 100644 --- a/EuriaCore/UniversalLinkHandler.swift +++ b/EuriaCore/UniversalLinkHandler.swift @@ -55,11 +55,11 @@ public struct UniversalLinkHandler: Sendable { private func tryToHandleWidgetLink(_ url: URL) -> IdentifiableURL? { switch url { case DeeplinkConstants.newChatURL: - return IdentifiableURL(string: "https://\(ApiEnvironment.current.euriaHost)/") + return IdentifiableURL(string: "/") case DeeplinkConstants.ephemeralURL: - return IdentifiableURL(string: "https://\(ApiEnvironment.current.euriaHost)/?ephemeral=true") + return IdentifiableURL(string: "/?ephemeral=true") case DeeplinkConstants.speechURL: - return IdentifiableURL(string: "https://\(ApiEnvironment.current.euriaHost)/?speech=true") + return IdentifiableURL(string: "/?speech=true") default: return nil } @@ -75,10 +75,10 @@ public struct UniversalLinkHandler: Sendable { if urlPath.starts(with: "/all"), let range = urlPath.range(of: "euria") { let remainingPath = String(urlPath[range.upperBound...]) - return IdentifiableURL(string: "https://\(ApiEnvironment.current.euriaHost)\(remainingPath)") + return IdentifiableURL(string: remainingPath) } else { let remainingPath = urlPath.replacingOccurrences(of: "/euria", with: "") - return IdentifiableURL(string: "https://\(ApiEnvironment.current.euriaHost)\(remainingPath)") + return IdentifiableURL(string: remainingPath) } } } diff --git a/EuriaFeatures/MainView/Delegate/EuriaWebViewDelegate+WKScriptMessageHandler.swift b/EuriaFeatures/MainView/Delegate/EuriaWebViewDelegate+WKScriptMessageHandler.swift index b673046..a611645 100644 --- a/EuriaFeatures/MainView/Delegate/EuriaWebViewDelegate+WKScriptMessageHandler.swift +++ b/EuriaFeatures/MainView/Delegate/EuriaWebViewDelegate+WKScriptMessageHandler.swift @@ -29,6 +29,7 @@ extension EuriaWebViewDelegate: WKScriptMessageHandler { case logout case unauthenticated case keepDeviceAwake + case ready } func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { @@ -42,6 +43,9 @@ extension EuriaWebViewDelegate: WKScriptMessageHandler { case .keepDeviceAwake: guard let shouldKeepDeviceAwake = message.body as? Bool else { return } keepDeviceAwake(shouldKeepDeviceAwake) + case .ready: + isWebViewReady = true + drainIfPossible() } } diff --git a/EuriaFeatures/MainView/Delegate/EuriaWebViewDelegate.swift b/EuriaFeatures/MainView/Delegate/EuriaWebViewDelegate.swift index 9ab2685..6ab9f57 100644 --- a/EuriaFeatures/MainView/Delegate/EuriaWebViewDelegate.swift +++ b/EuriaFeatures/MainView/Delegate/EuriaWebViewDelegate.swift @@ -37,6 +37,9 @@ class EuriaWebViewDelegate: NSObject, ObservableObject { let webConfiguration: WKWebViewConfiguration var downloads = [WKDownload: URL]() + var isWebViewReady = false + weak var weakWebView: WKWebView? + private var pendingURLs: [URL] = [] enum Cookie: String { case userToken = "USER-TOKEN" @@ -49,9 +52,9 @@ class EuriaWebViewDelegate: NSObject, ObservableObject { var errorDescription: String? { switch self { - case .urlGenerationFailed(let error): + case let .urlGenerationFailed(error): return error.localizedDescription - case .downloadFailed(let error): + case let .downloadFailed(error): return error.localizedDescription } } @@ -125,4 +128,27 @@ class EuriaWebViewDelegate: NSObject, ObservableObject { Logger.general.error("Error while cleaning temporary folder: \(error)") } } + + func enqueueNavigation(url: URL) { + pendingURLs.append(url) + drainIfPossible() + } + + func drainIfPossible() { + guard let webView = weakWebView, isWebViewReady else { + return + } + + while !pendingURLs.isEmpty { + let nextURL = pendingURLs.removeFirst() + + let script = "goTo(\"\(nextURL)\")" + + webView.evaluateJavaScript(script) { _, error in + if let error { + Logger.general.error("JS goTo failed: \(error.localizedDescription)") + } + } + } + } } diff --git a/EuriaFeatures/MainView/MainView.swift b/EuriaFeatures/MainView/MainView.swift index f1b869e..b62bf1e 100644 --- a/EuriaFeatures/MainView/MainView.swift +++ b/EuriaFeatures/MainView/MainView.swift @@ -35,7 +35,6 @@ public struct MainView: View { @StateObject private var webViewDelegate: EuriaWebViewDelegate - @State private var navigationDestination: NavigationDestination? @State private var isShowingWebView = true @State private var isShowingErrorAlert = false @@ -64,7 +63,6 @@ public struct MainView: View { if isShowingWebView { WebView( url: URL(string: "https://\(ApiEnvironment.current.euriaHost)/")!, - navigationDestination: navigationDestination, webConfiguration: webViewDelegate.webConfiguration, webViewCoordinator: webViewDelegate ) @@ -100,13 +98,7 @@ public struct MainView: View { .onChange(of: universalLinksState.linkedWebView) { identifiableURL in guard let identifiableURL else { return } - Task { - // Sometimes, when navigating from a universal link, Euria can’t access the local storage right away, - // which causes the user to be logged out. - // To avoid this, we wait a few milliseconds before updating the state, giving Euria time to access it. - try? await Task.sleep(for: .milliseconds(400)) - navigationDestination = NavigationDestination(url: identifiableURL.url) - } + webViewDelegate.enqueueNavigation(url: identifiableURL.url) universalLinksState.linkedWebView = nil } .sceneLifecycle(willEnterForeground: willEnterForeground, didEnterBackground: didEnterBackground) diff --git a/EuriaFeatures/MainView/WebView.swift b/EuriaFeatures/MainView/WebView.swift index de17887..ae23d6f 100644 --- a/EuriaFeatures/MainView/WebView.swift +++ b/EuriaFeatures/MainView/WebView.swift @@ -21,16 +21,6 @@ import SwiftUI import UIKit import WebKit -struct NavigationDestination: Identifiable { - let id: UUID - let url: URL - - init(id: UUID = UUID(), url: URL) { - self.id = id - self.url = url - } -} - final class EuriaWebView: WKWebView { override var inputAccessoryView: UIView? { return nil @@ -39,18 +29,15 @@ final class EuriaWebView: WKWebView { struct WebView: UIViewRepresentable { let url: URL - let navigationDestination: NavigationDestination? let webConfiguration: WKWebViewConfiguration var webViewCoordinator: WebViewCoordinator? init( url: URL, - navigationDestination: NavigationDestination? = nil, webConfiguration: WKWebViewConfiguration = WKWebViewConfiguration(), webViewCoordinator: WebViewCoordinator? ) { self.url = url - self.navigationDestination = navigationDestination self.webConfiguration = webConfiguration self.webViewCoordinator = webViewCoordinator } @@ -59,20 +46,17 @@ struct WebView: UIViewRepresentable { let webView = EuriaWebView(frame: .zero, configuration: webConfiguration) setupWebView(webView, coordinator: webViewCoordinator) + if let euriaWebViewDelegate = webViewCoordinator as? EuriaWebViewDelegate { + euriaWebViewDelegate.weakWebView = webView + } + let request = URLRequest(url: url) webView.load(request) return webView } - func updateUIView(_ webView: WKWebView, context: Context) { - guard let navigationDestination else { - return - } - - let request = URLRequest(url: navigationDestination.url) - webView.load(request) - } + func updateUIView(_ webView: WKWebView, context: Context) {} private func setupWebView(_ webView: WKWebView, coordinator webViewCoordinator: WebViewCoordinator?) { setupDelegates(webView, coordinator: webViewCoordinator) From 01c67bcb18e706e32d02a01dcd46201c7749ff1e Mon Sep 17 00:00:00 2001 From: Baptiste Griva Date: Wed, 19 Nov 2025 08:59:56 +0100 Subject: [PATCH 2/9] refactor: Remove IdentifiableURL --- EuriaCore/UniversalLinkHandler.swift | 35 ++++++++----------- .../Shared State/UniversalLinksState.swift | 2 +- .../Delegate/EuriaWebViewDelegate.swift | 12 +++---- EuriaFeatures/MainView/MainView.swift | 6 ++-- 4 files changed, 25 insertions(+), 30 deletions(-) diff --git a/EuriaCore/UniversalLinkHandler.swift b/EuriaCore/UniversalLinkHandler.swift index 6e0f1ca..68c1d19 100644 --- a/EuriaCore/UniversalLinkHandler.swift +++ b/EuriaCore/UniversalLinkHandler.swift @@ -19,24 +19,19 @@ import Foundation import InfomaniakCore -public struct IdentifiableURL: Identifiable, Equatable { - public var id: String { return url.absoluteString } - public let url: URL +public struct IdentifiableDestination: Identifiable, Equatable { + public var id: String { return string } + public let string: String - init(url: URL) { - self.url = url - } - - init?(string: String) { - guard let url = URL(string: string) else { return nil } - self.init(url: url) + init(string: String) { + self.string = string } } public struct UniversalLinkHandler: Sendable { public init() {} - public func handlePossibleUniversalLink(_ url: URL) -> IdentifiableURL? { + public func handlePossibleUniversalLink(_ url: URL) -> IdentifiableDestination? { if let widgetLink = tryToHandleWidgetLink(url) { return widgetLink } @@ -52,33 +47,33 @@ public struct UniversalLinkHandler: Sendable { return nil } - private func tryToHandleWidgetLink(_ url: URL) -> IdentifiableURL? { + private func tryToHandleWidgetLink(_ url: URL) -> IdentifiableDestination? { switch url { case DeeplinkConstants.newChatURL: - return IdentifiableURL(string: "/") + return IdentifiableDestination(string: "/") case DeeplinkConstants.ephemeralURL: - return IdentifiableURL(string: "/?ephemeral=true") + return IdentifiableDestination(string: "/?ephemeral=true") case DeeplinkConstants.speechURL: - return IdentifiableURL(string: "/?speech=true") + return IdentifiableDestination(string: "/?speech=true") default: return nil } } - private func tryToHandleEuriaUniversalLink(_ url: URL) -> IdentifiableURL? { + private func tryToHandleEuriaUniversalLink(_ url: URL) -> IdentifiableDestination? { guard url.host() == ApiEnvironment.current.euriaHost else { return nil } - return IdentifiableURL(url: url) + return IdentifiableDestination(string: url.path()) } - private func tryToHandleKSuiteUniversalLink(_ url: URL) -> IdentifiableURL? { + private func tryToHandleKSuiteUniversalLink(_ url: URL) -> IdentifiableDestination? { let urlPath = url.path() if urlPath.starts(with: "/all"), let range = urlPath.range(of: "euria") { let remainingPath = String(urlPath[range.upperBound...]) - return IdentifiableURL(string: remainingPath) + return IdentifiableDestination(string: remainingPath) } else { let remainingPath = urlPath.replacingOccurrences(of: "/euria", with: "") - return IdentifiableURL(string: remainingPath) + return IdentifiableDestination(string: remainingPath) } } } diff --git a/EuriaCoreUI/Shared State/UniversalLinksState.swift b/EuriaCoreUI/Shared State/UniversalLinksState.swift index daa2b23..f9c5ba5 100644 --- a/EuriaCoreUI/Shared State/UniversalLinksState.swift +++ b/EuriaCoreUI/Shared State/UniversalLinksState.swift @@ -21,7 +21,7 @@ import Foundation @MainActor public class UniversalLinksState: ObservableObject { - @Published public var linkedWebView: IdentifiableURL? + @Published public var linkedWebView: IdentifiableDestination? public init() {} } diff --git a/EuriaFeatures/MainView/Delegate/EuriaWebViewDelegate.swift b/EuriaFeatures/MainView/Delegate/EuriaWebViewDelegate.swift index 6ab9f57..7395967 100644 --- a/EuriaFeatures/MainView/Delegate/EuriaWebViewDelegate.swift +++ b/EuriaFeatures/MainView/Delegate/EuriaWebViewDelegate.swift @@ -39,7 +39,7 @@ class EuriaWebViewDelegate: NSObject, ObservableObject { var downloads = [WKDownload: URL]() var isWebViewReady = false weak var weakWebView: WKWebView? - private var pendingURLs: [URL] = [] + private var pendingDestinations: [String] = [] enum Cookie: String { case userToken = "USER-TOKEN" @@ -129,8 +129,8 @@ class EuriaWebViewDelegate: NSObject, ObservableObject { } } - func enqueueNavigation(url: URL) { - pendingURLs.append(url) + func enqueueNavigation(destination: String) { + pendingDestinations.append(destination) drainIfPossible() } @@ -139,10 +139,10 @@ class EuriaWebViewDelegate: NSObject, ObservableObject { return } - while !pendingURLs.isEmpty { - let nextURL = pendingURLs.removeFirst() + while !pendingDestinations.isEmpty { + let nextDestination = pendingDestinations.removeFirst() - let script = "goTo(\"\(nextURL)\")" + let script = "goTo(\"\(nextDestination)\")" webView.evaluateJavaScript(script) { _, error in if let error { diff --git a/EuriaFeatures/MainView/MainView.swift b/EuriaFeatures/MainView/MainView.swift index b62bf1e..478fb86 100644 --- a/EuriaFeatures/MainView/MainView.swift +++ b/EuriaFeatures/MainView/MainView.swift @@ -95,10 +95,10 @@ public struct MainView: View { guard !webViewDelegate.isLoaded else { return } isShowingWebView = isConnected } - .onChange(of: universalLinksState.linkedWebView) { identifiableURL in - guard let identifiableURL else { return } + .onChange(of: universalLinksState.linkedWebView) { IdentifiableDestination in + guard let IdentifiableDestination else { return } - webViewDelegate.enqueueNavigation(url: identifiableURL.url) + webViewDelegate.enqueueNavigation(destination: IdentifiableDestination.string) universalLinksState.linkedWebView = nil } .sceneLifecycle(willEnterForeground: willEnterForeground, didEnterBackground: didEnterBackground) From 4b472926eb28f74643e7d10ccfdce665e7ad32a4 Mon Sep 17 00:00:00 2001 From: Baptiste Griva Date: Wed, 19 Nov 2025 09:59:15 +0100 Subject: [PATCH 3/9] chore: Use the bridge with an async call --- ...bViewDelegate+WKScriptMessageHandler.swift | 2 +- .../Delegate/EuriaWebViewDelegate.swift | 14 ++++------- EuriaFeatures/MainView/JSBridge.swift | 23 +++++++++++++++++++ 3 files changed, 29 insertions(+), 10 deletions(-) create mode 100644 EuriaFeatures/MainView/JSBridge.swift diff --git a/EuriaFeatures/MainView/Delegate/EuriaWebViewDelegate+WKScriptMessageHandler.swift b/EuriaFeatures/MainView/Delegate/EuriaWebViewDelegate+WKScriptMessageHandler.swift index a611645..cf01992 100644 --- a/EuriaFeatures/MainView/Delegate/EuriaWebViewDelegate+WKScriptMessageHandler.swift +++ b/EuriaFeatures/MainView/Delegate/EuriaWebViewDelegate+WKScriptMessageHandler.swift @@ -44,7 +44,7 @@ extension EuriaWebViewDelegate: WKScriptMessageHandler { guard let shouldKeepDeviceAwake = message.body as? Bool else { return } keepDeviceAwake(shouldKeepDeviceAwake) case .ready: - isWebViewReady = true + isReadyToReceiveEvents = true drainIfPossible() } } diff --git a/EuriaFeatures/MainView/Delegate/EuriaWebViewDelegate.swift b/EuriaFeatures/MainView/Delegate/EuriaWebViewDelegate.swift index 7395967..38a0be7 100644 --- a/EuriaFeatures/MainView/Delegate/EuriaWebViewDelegate.swift +++ b/EuriaFeatures/MainView/Delegate/EuriaWebViewDelegate.swift @@ -27,7 +27,7 @@ import UIKit import WebKit @MainActor -class EuriaWebViewDelegate: NSObject, ObservableObject { +final class EuriaWebViewDelegate: NSObject, ObservableObject { @Published var isLoaded = false @Published var isPresentingDocument: URL? @@ -37,7 +37,7 @@ class EuriaWebViewDelegate: NSObject, ObservableObject { let webConfiguration: WKWebViewConfiguration var downloads = [WKDownload: URL]() - var isWebViewReady = false + var isReadyToReceiveEvents = false weak var weakWebView: WKWebView? private var pendingDestinations: [String] = [] @@ -135,19 +135,15 @@ class EuriaWebViewDelegate: NSObject, ObservableObject { } func drainIfPossible() { - guard let webView = weakWebView, isWebViewReady else { + guard let webView = weakWebView, isReadyToReceiveEvents else { return } while !pendingDestinations.isEmpty { let nextDestination = pendingDestinations.removeFirst() - let script = "goTo(\"\(nextDestination)\")" - - webView.evaluateJavaScript(script) { _, error in - if let error { - Logger.general.error("JS goTo failed: \(error.localizedDescription)") - } + Task { + webView.callAsyncJavaScript(JSBridge().goTo(nextDestination), in: nil, in: .page) } } } diff --git a/EuriaFeatures/MainView/JSBridge.swift b/EuriaFeatures/MainView/JSBridge.swift new file mode 100644 index 0000000..ffa2972 --- /dev/null +++ b/EuriaFeatures/MainView/JSBridge.swift @@ -0,0 +1,23 @@ +/* + Infomaniak Euria - iOS App + Copyright (C) 2025 Infomaniak Network SA + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + */ + +struct JSBridge { + func goTo(_ destination: String) -> String { + return "goTo(\"\(destination)\")" + } +} From c5154be81b9f3f5c21c39038373f3f0fdc20651f Mon Sep 17 00:00:00 2001 From: Baptiste Griva Date: Wed, 19 Nov 2025 10:01:31 +0100 Subject: [PATCH 4/9] chore: Rename string --- EuriaFeatures/MainView/MainView.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/EuriaFeatures/MainView/MainView.swift b/EuriaFeatures/MainView/MainView.swift index 478fb86..0c270b3 100644 --- a/EuriaFeatures/MainView/MainView.swift +++ b/EuriaFeatures/MainView/MainView.swift @@ -95,10 +95,10 @@ public struct MainView: View { guard !webViewDelegate.isLoaded else { return } isShowingWebView = isConnected } - .onChange(of: universalLinksState.linkedWebView) { IdentifiableDestination in - guard let IdentifiableDestination else { return } + .onChange(of: universalLinksState.linkedWebView) { identifiableDestination in + guard let identifiableDestination else { return } - webViewDelegate.enqueueNavigation(destination: IdentifiableDestination.string) + webViewDelegate.enqueueNavigation(destination: identifiableDestination.string) universalLinksState.linkedWebView = nil } .sceneLifecycle(willEnterForeground: willEnterForeground, didEnterBackground: didEnterBackground) From 209f002606fc380658b6d1595395a8bf9f7d7f9d Mon Sep 17 00:00:00 2001 From: Baptiste Griva Date: Wed, 19 Nov 2025 10:04:46 +0100 Subject: [PATCH 5/9] chore: SwiftFormat --- EuriaFeatures/MainView/Delegate/EuriaWebViewDelegate.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/EuriaFeatures/MainView/Delegate/EuriaWebViewDelegate.swift b/EuriaFeatures/MainView/Delegate/EuriaWebViewDelegate.swift index 38a0be7..bd18661 100644 --- a/EuriaFeatures/MainView/Delegate/EuriaWebViewDelegate.swift +++ b/EuriaFeatures/MainView/Delegate/EuriaWebViewDelegate.swift @@ -52,9 +52,9 @@ final class EuriaWebViewDelegate: NSObject, ObservableObject { var errorDescription: String? { switch self { - case let .urlGenerationFailed(error): + case .urlGenerationFailed(let error): return error.localizedDescription - case let .downloadFailed(error): + case .downloadFailed(let error): return error.localizedDescription } } From 5e49c0a7948e51aa02b5ecbee020faaea9c9c7d3 Mon Sep 17 00:00:00 2001 From: Baptiste Griva Date: Wed, 19 Nov 2025 10:07:46 +0100 Subject: [PATCH 6/9] chore: Var declaration --- EuriaFeatures/MainView/Delegate/EuriaWebViewDelegate.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/EuriaFeatures/MainView/Delegate/EuriaWebViewDelegate.swift b/EuriaFeatures/MainView/Delegate/EuriaWebViewDelegate.swift index bd18661..198c446 100644 --- a/EuriaFeatures/MainView/Delegate/EuriaWebViewDelegate.swift +++ b/EuriaFeatures/MainView/Delegate/EuriaWebViewDelegate.swift @@ -39,7 +39,7 @@ final class EuriaWebViewDelegate: NSObject, ObservableObject { var downloads = [WKDownload: URL]() var isReadyToReceiveEvents = false weak var weakWebView: WKWebView? - private var pendingDestinations: [String] = [] + private var pendingDestinations = [String]() enum Cookie: String { case userToken = "USER-TOKEN" From d9ed21cf5debfaa9f7a66a62b7f7ffd331212138 Mon Sep 17 00:00:00 2001 From: Baptiste Griva Date: Tue, 25 Nov 2025 10:03:26 +0100 Subject: [PATCH 7/9] fix: Add delay for widget deeplinks --- EuriaCore/UniversalLinkHandler.swift | 22 +++++++++---------- .../Shared State/UniversalLinksState.swift | 2 +- .../Delegate/EuriaWebViewDelegate.swift | 3 +++ EuriaFeatures/MainView/MainView.swift | 6 ++--- 4 files changed, 18 insertions(+), 15 deletions(-) diff --git a/EuriaCore/UniversalLinkHandler.swift b/EuriaCore/UniversalLinkHandler.swift index 68c1d19..0769405 100644 --- a/EuriaCore/UniversalLinkHandler.swift +++ b/EuriaCore/UniversalLinkHandler.swift @@ -19,7 +19,7 @@ import Foundation import InfomaniakCore -public struct IdentifiableDestination: Identifiable, Equatable { +public struct NavigationDestination: Identifiable, Equatable { public var id: String { return string } public let string: String @@ -31,7 +31,7 @@ public struct IdentifiableDestination: Identifiable, Equatable { public struct UniversalLinkHandler: Sendable { public init() {} - public func handlePossibleUniversalLink(_ url: URL) -> IdentifiableDestination? { + public func handlePossibleUniversalLink(_ url: URL) -> NavigationDestination? { if let widgetLink = tryToHandleWidgetLink(url) { return widgetLink } @@ -47,33 +47,33 @@ public struct UniversalLinkHandler: Sendable { return nil } - private func tryToHandleWidgetLink(_ url: URL) -> IdentifiableDestination? { + private func tryToHandleWidgetLink(_ url: URL) -> NavigationDestination? { switch url { case DeeplinkConstants.newChatURL: - return IdentifiableDestination(string: "/") + return NavigationDestination(string: "/") case DeeplinkConstants.ephemeralURL: - return IdentifiableDestination(string: "/?ephemeral=true") + return NavigationDestination(string: "/?ephemeral=true") case DeeplinkConstants.speechURL: - return IdentifiableDestination(string: "/?speech=true") + return NavigationDestination(string: "/?speech=true") default: return nil } } - private func tryToHandleEuriaUniversalLink(_ url: URL) -> IdentifiableDestination? { + private func tryToHandleEuriaUniversalLink(_ url: URL) -> NavigationDestination? { guard url.host() == ApiEnvironment.current.euriaHost else { return nil } - return IdentifiableDestination(string: url.path()) + return NavigationDestination(string: url.path()) } - private func tryToHandleKSuiteUniversalLink(_ url: URL) -> IdentifiableDestination? { + private func tryToHandleKSuiteUniversalLink(_ url: URL) -> NavigationDestination? { let urlPath = url.path() if urlPath.starts(with: "/all"), let range = urlPath.range(of: "euria") { let remainingPath = String(urlPath[range.upperBound...]) - return IdentifiableDestination(string: remainingPath) + return NavigationDestination(string: remainingPath) } else { let remainingPath = urlPath.replacingOccurrences(of: "/euria", with: "") - return IdentifiableDestination(string: remainingPath) + return NavigationDestination(string: remainingPath) } } } diff --git a/EuriaCoreUI/Shared State/UniversalLinksState.swift b/EuriaCoreUI/Shared State/UniversalLinksState.swift index f9c5ba5..4c838fd 100644 --- a/EuriaCoreUI/Shared State/UniversalLinksState.swift +++ b/EuriaCoreUI/Shared State/UniversalLinksState.swift @@ -21,7 +21,7 @@ import Foundation @MainActor public class UniversalLinksState: ObservableObject { - @Published public var linkedWebView: IdentifiableDestination? + @Published public var linkedWebView: NavigationDestination? public init() {} } diff --git a/EuriaFeatures/MainView/Delegate/EuriaWebViewDelegate.swift b/EuriaFeatures/MainView/Delegate/EuriaWebViewDelegate.swift index 198c446..1c1da17 100644 --- a/EuriaFeatures/MainView/Delegate/EuriaWebViewDelegate.swift +++ b/EuriaFeatures/MainView/Delegate/EuriaWebViewDelegate.swift @@ -143,6 +143,9 @@ final class EuriaWebViewDelegate: NSObject, ObservableObject { let nextDestination = pendingDestinations.removeFirst() Task { + if nextDestination.hasPrefix("/?") { + try? await Task.sleep(for: .milliseconds(400)) + } webView.callAsyncJavaScript(JSBridge().goTo(nextDestination), in: nil, in: .page) } } diff --git a/EuriaFeatures/MainView/MainView.swift b/EuriaFeatures/MainView/MainView.swift index 0c270b3..8142044 100644 --- a/EuriaFeatures/MainView/MainView.swift +++ b/EuriaFeatures/MainView/MainView.swift @@ -95,10 +95,10 @@ public struct MainView: View { guard !webViewDelegate.isLoaded else { return } isShowingWebView = isConnected } - .onChange(of: universalLinksState.linkedWebView) { identifiableDestination in - guard let identifiableDestination else { return } + .onChange(of: universalLinksState.linkedWebView) { navigationDestination in + guard let navigationDestination else { return } - webViewDelegate.enqueueNavigation(destination: identifiableDestination.string) + webViewDelegate.enqueueNavigation(destination: navigationDestination.string) universalLinksState.linkedWebView = nil } .sceneLifecycle(willEnterForeground: willEnterForeground, didEnterBackground: didEnterBackground) From 06e99d200fcc0ec27d6ecf3b63d71b61335327bf Mon Sep 17 00:00:00 2001 From: Baptiste Griva Date: Tue, 25 Nov 2025 13:21:05 +0100 Subject: [PATCH 8/9] chore: Explicitly define the routes to be delayed --- EuriaCore/UniversalLinkHandler.swift | 4 ++-- EuriaCore/Utils/Constants.swift | 5 +++++ EuriaFeatures/MainView/Delegate/EuriaWebViewDelegate.swift | 3 ++- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/EuriaCore/UniversalLinkHandler.swift b/EuriaCore/UniversalLinkHandler.swift index 0769405..2728b59 100644 --- a/EuriaCore/UniversalLinkHandler.swift +++ b/EuriaCore/UniversalLinkHandler.swift @@ -52,9 +52,9 @@ public struct UniversalLinkHandler: Sendable { case DeeplinkConstants.newChatURL: return NavigationDestination(string: "/") case DeeplinkConstants.ephemeralURL: - return NavigationDestination(string: "/?ephemeral=true") + return NavigationDestination(string: NavigationConstants.ephemeralRoute) case DeeplinkConstants.speechURL: - return NavigationDestination(string: "/?speech=true") + return NavigationDestination(string: NavigationConstants.speechRoute) default: return nil } diff --git a/EuriaCore/Utils/Constants.swift b/EuriaCore/Utils/Constants.swift index 4ada621..0fea243 100644 --- a/EuriaCore/Utils/Constants.swift +++ b/EuriaCore/Utils/Constants.swift @@ -34,3 +34,8 @@ public enum DeeplinkConstants { public static let ephemeralURL = URL(string: "euria://widget-ephemeral")! public static let speechURL = URL(string: "euria://widget-speech")! } + +public enum NavigationConstants { + public static let ephemeralRoute = "/?ephemeral=true" + public static let speechRoute = "/?speech=true" +} diff --git a/EuriaFeatures/MainView/Delegate/EuriaWebViewDelegate.swift b/EuriaFeatures/MainView/Delegate/EuriaWebViewDelegate.swift index 1c1da17..8e87e59 100644 --- a/EuriaFeatures/MainView/Delegate/EuriaWebViewDelegate.swift +++ b/EuriaFeatures/MainView/Delegate/EuriaWebViewDelegate.swift @@ -143,7 +143,8 @@ final class EuriaWebViewDelegate: NSObject, ObservableObject { let nextDestination = pendingDestinations.removeFirst() Task { - if nextDestination.hasPrefix("/?") { + if nextDestination.hasPrefix(NavigationConstants.ephemeralRoute) || + nextDestination.hasPrefix(NavigationConstants.speechRoute) { try? await Task.sleep(for: .milliseconds(400)) } webView.callAsyncJavaScript(JSBridge().goTo(nextDestination), in: nil, in: .page) From 04d60e34c5a7ec1d7b0890c313b16b083fc89464 Mon Sep 17 00:00:00 2001 From: Valentin Perignon Date: Tue, 25 Nov 2025 16:49:00 +0100 Subject: [PATCH 9/9] chore: Clean code --- ...bViewDelegate+WKScriptMessageHandler.swift | 2 +- .../Delegate/EuriaWebViewDelegate.swift | 33 ++++++++++--------- EuriaFeatures/MainView/JSBridge.swift | 4 +-- EuriaFeatures/MainView/WebView.swift | 11 ++++--- 4 files changed, 28 insertions(+), 22 deletions(-) diff --git a/EuriaFeatures/MainView/Delegate/EuriaWebViewDelegate+WKScriptMessageHandler.swift b/EuriaFeatures/MainView/Delegate/EuriaWebViewDelegate+WKScriptMessageHandler.swift index cf01992..db17ac2 100644 --- a/EuriaFeatures/MainView/Delegate/EuriaWebViewDelegate+WKScriptMessageHandler.swift +++ b/EuriaFeatures/MainView/Delegate/EuriaWebViewDelegate+WKScriptMessageHandler.swift @@ -45,7 +45,7 @@ extension EuriaWebViewDelegate: WKScriptMessageHandler { keepDeviceAwake(shouldKeepDeviceAwake) case .ready: isReadyToReceiveEvents = true - drainIfPossible() + navigateIfPossible() } } diff --git a/EuriaFeatures/MainView/Delegate/EuriaWebViewDelegate.swift b/EuriaFeatures/MainView/Delegate/EuriaWebViewDelegate.swift index 8e87e59..b810dfc 100644 --- a/EuriaFeatures/MainView/Delegate/EuriaWebViewDelegate.swift +++ b/EuriaFeatures/MainView/Delegate/EuriaWebViewDelegate.swift @@ -27,7 +27,7 @@ import UIKit import WebKit @MainActor -final class EuriaWebViewDelegate: NSObject, ObservableObject { +final class EuriaWebViewDelegate: NSObject, WebViewCoordinator, ObservableObject { @Published var isLoaded = false @Published var isPresentingDocument: URL? @@ -37,9 +37,11 @@ final class EuriaWebViewDelegate: NSObject, ObservableObject { let webConfiguration: WKWebViewConfiguration var downloads = [WKDownload: URL]() + var isReadyToReceiveEvents = false - weak var weakWebView: WKWebView? - private var pendingDestinations = [String]() + private var pendingDestination: String? + + weak var webView: WKWebView? enum Cookie: String { case userToken = "USER-TOKEN" @@ -130,25 +132,26 @@ final class EuriaWebViewDelegate: NSObject, ObservableObject { } func enqueueNavigation(destination: String) { - pendingDestinations.append(destination) - drainIfPossible() + pendingDestination = destination + navigateIfPossible() } - func drainIfPossible() { - guard let webView = weakWebView, isReadyToReceiveEvents else { + func navigateIfPossible() { + guard isReadyToReceiveEvents, let destination = pendingDestination else { return } - while !pendingDestinations.isEmpty { - let nextDestination = pendingDestinations.removeFirst() + pendingDestination = nil - Task { - if nextDestination.hasPrefix(NavigationConstants.ephemeralRoute) || - nextDestination.hasPrefix(NavigationConstants.speechRoute) { - try? await Task.sleep(for: .milliseconds(400)) - } - webView.callAsyncJavaScript(JSBridge().goTo(nextDestination), in: nil, in: .page) + Task { + // Sometimes, when navigating from a universal link, Euria can’t access the local storage right away, + // which causes the user to be logged out. + // To avoid this situation, we wait a few milliseconds. + if destination == NavigationConstants.ephemeralRoute || destination == NavigationConstants.speechRoute { + try? await Task.sleep(for: .milliseconds(400)) } + + try await webView?.evaluateJavaScript(JSBridge.goTo(destination)) } } } diff --git a/EuriaFeatures/MainView/JSBridge.swift b/EuriaFeatures/MainView/JSBridge.swift index ffa2972..c8e1ec4 100644 --- a/EuriaFeatures/MainView/JSBridge.swift +++ b/EuriaFeatures/MainView/JSBridge.swift @@ -16,8 +16,8 @@ along with this program. If not, see . */ -struct JSBridge { - func goTo(_ destination: String) -> String { +enum JSBridge { + static func goTo(_ destination: String) -> String { return "goTo(\"\(destination)\")" } } diff --git a/EuriaFeatures/MainView/WebView.swift b/EuriaFeatures/MainView/WebView.swift index ae23d6f..774be8f 100644 --- a/EuriaFeatures/MainView/WebView.swift +++ b/EuriaFeatures/MainView/WebView.swift @@ -21,13 +21,18 @@ import SwiftUI import UIKit import WebKit +@MainActor +public protocol WebViewCoordinator: AnyObject { + var webView: WKWebView? { get set } +} + final class EuriaWebView: WKWebView { override var inputAccessoryView: UIView? { return nil } } -struct WebView: UIViewRepresentable { +struct WebView: UIViewRepresentable { let url: URL let webConfiguration: WKWebViewConfiguration var webViewCoordinator: WebViewCoordinator? @@ -46,9 +51,7 @@ struct WebView: UIViewRepresentable { let webView = EuriaWebView(frame: .zero, configuration: webConfiguration) setupWebView(webView, coordinator: webViewCoordinator) - if let euriaWebViewDelegate = webViewCoordinator as? EuriaWebViewDelegate { - euriaWebViewDelegate.weakWebView = webView - } + webViewCoordinator?.webView = webView let request = URLRequest(url: url) webView.load(request)