Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 15 additions & 20 deletions EuriaCore/UniversalLinkHandler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 NavigationDestination: 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) -> NavigationDestination? {
if let widgetLink = tryToHandleWidgetLink(url) {
return widgetLink
}
Expand All @@ -52,33 +47,33 @@
return nil
}

private func tryToHandleWidgetLink(_ url: URL) -> IdentifiableURL? {
private func tryToHandleWidgetLink(_ url: URL) -> NavigationDestination? {
switch url {
case DeeplinkConstants.newChatURL:
return IdentifiableURL(string: "https://\(ApiEnvironment.current.euriaHost)/")
return NavigationDestination(string: "/")
case DeeplinkConstants.ephemeralURL:
return IdentifiableURL(string: "https://\(ApiEnvironment.current.euriaHost)/?ephemeral=true")
return NavigationDestination(string: NavigationConstants.ephemeralRoute)
case DeeplinkConstants.speechURL:
return IdentifiableURL(string: "https://\(ApiEnvironment.current.euriaHost)/?speech=true")
return NavigationDestination(string: NavigationConstants.speechRoute)
default:
return nil
}
}

private func tryToHandleEuriaUniversalLink(_ url: URL) -> IdentifiableURL? {
private func tryToHandleEuriaUniversalLink(_ url: URL) -> NavigationDestination? {
guard url.host() == ApiEnvironment.current.euriaHost else { return nil }

Check warning on line 64 in EuriaCore/UniversalLinkHandler.swift

View workflow job for this annotation

GitHub Actions / Build and Test project

reference to static property 'current' is not concurrency-safe because it involves shared mutable state; this is an error in the Swift 6 language mode
return IdentifiableURL(url: url)
return NavigationDestination(string: url.path())
}

private func tryToHandleKSuiteUniversalLink(_ url: URL) -> IdentifiableURL? {
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 IdentifiableURL(string: "https://\(ApiEnvironment.current.euriaHost)\(remainingPath)")
return NavigationDestination(string: remainingPath)
} else {
let remainingPath = urlPath.replacingOccurrences(of: "/euria", with: "")
return IdentifiableURL(string: "https://\(ApiEnvironment.current.euriaHost)\(remainingPath)")
return NavigationDestination(string: remainingPath)
}
}
}
5 changes: 5 additions & 0 deletions EuriaCore/Utils/Constants.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
2 changes: 1 addition & 1 deletion EuriaCoreUI/Shared State/UniversalLinksState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import Foundation

@MainActor
public class UniversalLinksState: ObservableObject {
@Published public var linkedWebView: IdentifiableURL?
@Published public var linkedWebView: NavigationDestination?

public init() {}
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ extension EuriaWebViewDelegate: WKScriptMessageHandler {
case logout
case unauthenticated
case keepDeviceAwake
case ready
}

func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
Expand All @@ -42,6 +43,9 @@ extension EuriaWebViewDelegate: WKScriptMessageHandler {
case .keepDeviceAwake:
guard let shouldKeepDeviceAwake = message.body as? Bool else { return }
keepDeviceAwake(shouldKeepDeviceAwake)
case .ready:
isReadyToReceiveEvents = true
navigateIfPossible()
}
}

Expand Down
31 changes: 30 additions & 1 deletion EuriaFeatures/MainView/Delegate/EuriaWebViewDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import UIKit
import WebKit

@MainActor
class EuriaWebViewDelegate: NSObject, ObservableObject {
final class EuriaWebViewDelegate: NSObject, WebViewCoordinator, ObservableObject {
@Published var isLoaded = false

@Published var isPresentingDocument: URL?
Expand All @@ -38,6 +38,11 @@ class EuriaWebViewDelegate: NSObject, ObservableObject {

var downloads = [WKDownload: URL]()

var isReadyToReceiveEvents = false
private var pendingDestination: String?

weak var webView: WKWebView?

enum Cookie: String {
case userToken = "USER-TOKEN"
case userLanguage = "USER-LANGUAGE"
Expand Down Expand Up @@ -125,4 +130,28 @@ class EuriaWebViewDelegate: NSObject, ObservableObject {
Logger.general.error("Error while cleaning temporary folder: \(error)")
}
}

func enqueueNavigation(destination: String) {
pendingDestination = destination
navigateIfPossible()
}

func navigateIfPossible() {
guard isReadyToReceiveEvents, let destination = pendingDestination else {
return
}

pendingDestination = nil

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))
}
}
}
23 changes: 23 additions & 0 deletions EuriaFeatures/MainView/JSBridge.swift
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.
*/

enum JSBridge {
static func goTo(_ destination: String) -> String {
return "goTo(\"\(destination)\")"
}
}
16 changes: 4 additions & 12 deletions EuriaFeatures/MainView/MainView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@

@StateObject private var webViewDelegate: EuriaWebViewDelegate

@State private var navigationDestination: NavigationDestination?
@State private var isShowingWebView = true
@State private var isShowingErrorAlert = false

Expand All @@ -54,7 +53,7 @@
public init(session: any UserSessionable) {
self.session = session
_webViewDelegate = StateObject(wrappedValue: EuriaWebViewDelegate(
host: ApiEnvironment.current.euriaHost,

Check warning on line 56 in EuriaFeatures/MainView/MainView.swift

View workflow job for this annotation

GitHub Actions / Build and Test project

reference to static property 'current' is not concurrency-safe because it involves shared mutable state; this is an error in the Swift 6 language mode
session: session
))
}
Expand All @@ -64,7 +63,6 @@
if isShowingWebView {
WebView(
url: URL(string: "https://\(ApiEnvironment.current.euriaHost)/")!,
navigationDestination: navigationDestination,
webConfiguration: webViewDelegate.webConfiguration,
webViewCoordinator: webViewDelegate
)
Expand Down Expand Up @@ -97,16 +95,10 @@
guard !webViewDelegate.isLoaded else { return }
isShowingWebView = isConnected
}
.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)
}
.onChange(of: universalLinksState.linkedWebView) { navigationDestination in
guard let navigationDestination else { return }

webViewDelegate.enqueueNavigation(destination: navigationDestination.string)
universalLinksState.linkedWebView = nil
}
.sceneLifecycle(willEnterForeground: willEnterForeground, didEnterBackground: didEnterBackground)
Expand Down
27 changes: 7 additions & 20 deletions EuriaFeatures/MainView/WebView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,9 @@ 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
}
@MainActor
public protocol WebViewCoordinator: AnyObject {
var webView: WKWebView? { get set }
}

final class EuriaWebView: WKWebView {
Expand All @@ -37,20 +32,17 @@ final class EuriaWebView: WKWebView {
}
}

struct WebView<WebViewCoordinator>: UIViewRepresentable {
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
}
Expand All @@ -59,20 +51,15 @@ struct WebView<WebViewCoordinator>: UIViewRepresentable {
let webView = EuriaWebView(frame: .zero, configuration: webConfiguration)
setupWebView(webView, coordinator: webViewCoordinator)

webViewCoordinator?.webView = 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)
Expand Down
Loading