From 37b320590a921b0a315d52fe912bb8c1d34528a9 Mon Sep 17 00:00:00 2001 From: Boriss Melikjan Date: Mon, 30 Mar 2026 15:27:13 +0300 Subject: [PATCH 1/2] NFC-141 Add Associated Domains entitlement and Universal Links hanlding. --- RIADigiDoc.xcodeproj/project.pbxproj | 1 + RIADigiDoc/RIADigiDoc.entitlements | 4 + RIADigiDoc/UI/Component/HomeView.swift | 2 +- .../UI/Component/WebEid/WebEidView.swift | 101 +++++++------- RIADigiDoc/Util/WebEid/WebEidUriUtil.swift | 64 +++++++++ .../Util/WebEid/WebEidUriUtilTests.swift | 129 ++++++++++++++++++ 6 files changed, 253 insertions(+), 48 deletions(-) create mode 100644 RIADigiDoc/Util/WebEid/WebEidUriUtil.swift create mode 100644 RIADigiDocTests/Util/WebEid/WebEidUriUtilTests.swift diff --git a/RIADigiDoc.xcodeproj/project.pbxproj b/RIADigiDoc.xcodeproj/project.pbxproj index d4a91dd2..4f31250d 100644 --- a/RIADigiDoc.xcodeproj/project.pbxproj +++ b/RIADigiDoc.xcodeproj/project.pbxproj @@ -263,6 +263,7 @@ Util/Signature/SignatureUtilProtocol.swift, Util/Theme/ThemeSettings.swift, Util/Theme/ThemeSettingsProtocol.swift, + Util/WebEid/WebEidUriUtil.swift, ViewModel/AdvancedSettingsViewModel.swift, ViewModel/CertificateDetailViewModel.swift, ViewModel/Crypto/DecryptRootViewModel.swift, diff --git a/RIADigiDoc/RIADigiDoc.entitlements b/RIADigiDoc/RIADigiDoc.entitlements index cc9ae069..0ddb2583 100644 --- a/RIADigiDoc/RIADigiDoc.entitlements +++ b/RIADigiDoc/RIADigiDoc.entitlements @@ -2,6 +2,10 @@ + com.apple.developer.associated-domains + + applinks:riadigidoc.ee + com.apple.developer.nfc.readersession.formats TAG diff --git a/RIADigiDoc/UI/Component/HomeView.swift b/RIADigiDoc/UI/Component/HomeView.swift index b586261a..9f2bec87 100644 --- a/RIADigiDoc/UI/Component/HomeView.swift +++ b/RIADigiDoc/UI/Component/HomeView.swift @@ -308,7 +308,7 @@ struct HomeView: View { } private func handleIncoming(url: URL) { - let webEidURL = (url.scheme == "web-eid-mobile") ? url : nil + let webEidURL = (WebEidUriUtil.isWebEidUri(url)) ? url : nil let externalFileURLs: [URL] = (webEidURL != nil) ? [] : getExternalFileURLs(from: url) handleFiles(externalFileURLs) diff --git a/RIADigiDoc/UI/Component/WebEid/WebEidView.swift b/RIADigiDoc/UI/Component/WebEid/WebEidView.swift index 807f6284..a4f7ecc1 100644 --- a/RIADigiDoc/UI/Component/WebEid/WebEidView.swift +++ b/RIADigiDoc/UI/Component/WebEid/WebEidView.swift @@ -50,6 +50,53 @@ struct WebEidView: View { ) } + private var alertTitle: String { + languageSettings.localized( + viewModel.alertMessageKey ?? "", + viewModel.alertMessageExtraArguments + ) + } + + private var alertInfoURL: URL? { + guard + let messageUrl = viewModel.alertMessageUrl, + !messageUrl.isEmpty + else { + return nil + } + + let localizedUrl = languageSettings.localized(messageUrl) + guard let url = URL(string: localizedUrl), UIApplication.shared.canOpenURL(url) else { + return nil + } + + return url + } + + private func handleWebEidOperation(for url: URL) { + if let operation = WebEidUriUtil.getOperation(from: url) { + switch operation { + case .auth: + viewModel.handleAuth(url: url) + case .cert: + viewModel.handleCertificate(url: url) + case .sign: + viewModel.handleSign(url: url) + } + } else { + viewModel.handleUnknown(url: url) + } + } + + private func activateWebEidSession() { + Task { + if await viewModel.isWebEidSessionActive() { + await nfcViewModel.clearTempCAN() + } + await viewModel.setWebEidSessionActive(true) + } + } + init( webEidUrl: URL, ) { @@ -119,40 +166,22 @@ struct WebEidView: View { } } .alert( - languageSettings.localized( - viewModel.alertMessageKey ?? "", - viewModel.alertMessageExtraArguments - ), + alertTitle, isPresented: $viewModel.showAlertMessage ) { Button(languageSettings.localized("OK")) { viewModel.resetErrors() } - if let messageUrl = viewModel.alertMessageUrl, !messageUrl.isEmpty { + if let alertInfoURL { Button(languageSettings.localized("Additional information")) { - if let url = URL(string: languageSettings.localized(messageUrl)), - UIApplication.shared.canOpenURL(url) { - openURL(url) - } + openURL(alertInfoURL) viewModel.resetErrors() } } } .onAppear { - if let host = webEidUrl.host { - - switch host { - case "auth": - viewModel.handleAuth(url: webEidUrl) - case "cert": - viewModel.handleCertificate(url: webEidUrl) - case "sign": - viewModel.handleSign(url: webEidUrl) - default: - viewModel.handleUnknown(url: webEidUrl) - } - } + handleWebEidOperation(for: webEidUrl) } .onChange(of: viewModel.relyingPartyResponseEvents) { _, responseURL in guard let responseURL else { return } @@ -166,35 +195,13 @@ struct WebEidView: View { Toast.show(errorMessage) } .onChange(of: webEidUrl) {_, url in - if let host = url.host { - - switch host { - case "auth": - viewModel.handleAuth(url: url) - case "cert": - viewModel.handleCertificate(url: url) - case "sign": - viewModel.handleSign(url: url) - default: - viewModel.handleUnknown(url: url) - } - } + handleWebEidOperation(for: url) } .onChange(of: viewModel.authRequest) {_, _ in - Task { - if await viewModel.isWebEidSessionActive() { - await nfcViewModel.clearTempCAN() - } - await viewModel.setWebEidSessionActive(true) - } + activateWebEidSession() } .onChange(of: viewModel.certRequest) {_, _ in - Task { - if await viewModel.isWebEidSessionActive() { - await nfcViewModel.clearTempCAN() - } - await viewModel.setWebEidSessionActive(true) - } + activateWebEidSession() } } } diff --git a/RIADigiDoc/Util/WebEid/WebEidUriUtil.swift b/RIADigiDoc/Util/WebEid/WebEidUriUtil.swift new file mode 100644 index 00000000..0ba0ff3d --- /dev/null +++ b/RIADigiDoc/Util/WebEid/WebEidUriUtil.swift @@ -0,0 +1,64 @@ +/* + * Copyright 2017 - 2026 Riigi Infosüsteemi Amet + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library 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 + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * + */ + +import Foundation + +public enum WebEidOperation: String, CaseIterable, Sendable { + case auth + case cert + case sign + + public static func fromOperation(_ operation: String) -> WebEidOperation? { + Self.allCases.first { $0.rawValue == operation } + } +} + +public enum WebEidUriUtil { + private static let customScheme = "web-eid-mobile" + private static let appLinksHost = "riadigidoc.ee" + + public static func isWebEidUri(_ url: URL) -> Bool { + getOperation(from: url) != nil + } + + public static func getOperation(from url: URL) -> WebEidOperation? { + let operation: String? + + #if DEBUG + if url.scheme == customScheme { + operation = url.host + } else if url.scheme == "https", url.host == appLinksHost { + let pathComponents = url.pathComponents.filter { $0 != "/" } + operation = pathComponents.first + } else { + operation = nil + } + #else + if url.scheme == "https", url.host == appLinksHost { + let pathComponents = url.pathComponents.filter { $0 != "/" } + operation = pathComponents.first + } else { + operation = nil + } + #endif + + guard let operation else { return nil } + return WebEidOperation.fromOperation(operation) + } +} diff --git a/RIADigiDocTests/Util/WebEid/WebEidUriUtilTests.swift b/RIADigiDocTests/Util/WebEid/WebEidUriUtilTests.swift new file mode 100644 index 00000000..a9dbd402 --- /dev/null +++ b/RIADigiDocTests/Util/WebEid/WebEidUriUtilTests.swift @@ -0,0 +1,129 @@ +/* + * Copyright 2017 - 2026 Riigi Infosüsteemi Amet + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library 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 + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * + */ + + +import Foundation +import Testing + +struct WebEidUriUtilTests { + + private func makeURL(_ string: String) -> URL { + URL(string: string)! + } + + @Test + func isWebEidUri_appLinks_auth() { + #expect(WebEidUriUtil.isWebEidUri(makeURL("https://riadigidoc.ee/auth"))) + } + + @Test + func isWebEidUri_appLinks_cert() { + #expect(WebEidUriUtil.isWebEidUri(makeURL("https://riadigidoc.ee/cert"))) + } + + @Test + func isWebEidUri_appLinks_sign() { + #expect(WebEidUriUtil.isWebEidUri(makeURL("https://riadigidoc.ee/sign"))) + } + + @Test + func isWebEidUri_appLinks_unknownOperation() { + #expect(!WebEidUriUtil.isWebEidUri(makeURL("https://riadigidoc.ee/unknown"))) + } + + @Test + func isWebEidUri_wrongHost() { + #expect(!WebEidUriUtil.isWebEidUri(makeURL("https://evil.com/auth"))) + } + + @Test + func isWebEidUri_contentScheme() { + #expect(!WebEidUriUtil.isWebEidUri(makeURL("content://some/path"))) + } + + @Test + func isWebEidUri_fileScheme() { + #expect(!WebEidUriUtil.isWebEidUri(makeURL("file:///some/path"))) + } + + @Test + func getOperation_appLinks_auth() { + #expect(WebEidUriUtil.getOperation(from: makeURL("https://riadigidoc.ee/auth#dGVzdA")) == .auth) + } + + @Test + func getOperation_appLinks_cert() { + #expect(WebEidUriUtil.getOperation(from: makeURL("https://riadigidoc.ee/cert#dGVzdA")) == .cert) + } + + @Test + func getOperation_appLinks_sign() { + #expect(WebEidUriUtil.getOperation(from: makeURL("https://riadigidoc.ee/sign#dGVzdA")) == .sign) + } + + @Test + func getOperation_appLinks_unknownOperation_returnsNil() { + #expect(WebEidUriUtil.getOperation(from: makeURL("https://riadigidoc.ee/unknown")) == nil) + } + + @Test + func getOperation_unrelatedUri_returnsNil() { + #expect(WebEidUriUtil.getOperation(from: makeURL("https://example.com/auth")) == nil) + } + + @Test + func isWebEidUri_customScheme_auth() { + #expect(WebEidUriUtil.isWebEidUri(makeURL("web-eid-mobile://auth"))) + } + + @Test + func isWebEidUri_customScheme_cert() { + #expect(WebEidUriUtil.isWebEidUri(makeURL("web-eid-mobile://cert"))) + } + + @Test + func isWebEidUri_customScheme_sign() { + #expect(WebEidUriUtil.isWebEidUri(makeURL("web-eid-mobile://sign"))) + } + + @Test + func isWebEidUri_customScheme_unknownOperation() { + #expect(!WebEidUriUtil.isWebEidUri(makeURL("web-eid-mobile://unknown"))) + } + + @Test + func getOperation_customScheme_auth() { + #expect(WebEidUriUtil.getOperation(from: makeURL("web-eid-mobile://auth#dGVzdA")) == .auth) + } + + @Test + func getOperation_customScheme_cert() { + #expect(WebEidUriUtil.getOperation(from: makeURL("web-eid-mobile://cert#dGVzdA")) == .cert) + } + + @Test + func getOperation_customScheme_sign() { + #expect(WebEidUriUtil.getOperation(from: makeURL("web-eid-mobile://sign#dGVzdA")) == .sign) + } + + @Test + func getOperation_unknownOperation_returnsNil() { + #expect(WebEidUriUtil.getOperation(from: makeURL("web-eid-mobile://unknown")) == nil) + } +} From aacf475e408ee895d0e66f836e7caa36b9c0ca31 Mon Sep 17 00:00:00 2001 From: Boriss Melikjan Date: Mon, 30 Mar 2026 16:47:44 +0300 Subject: [PATCH 2/2] NFC-141 Refactor WebEidUriUtil. --- .../UI/Component/WebEid/WebEidView.swift | 19 +++++----- RIADigiDoc/Util/WebEid/WebEidUriUtil.swift | 38 +++++++++---------- .../Util/WebEid/WebEidUriUtilTests.swift | 6 +-- 3 files changed, 30 insertions(+), 33 deletions(-) diff --git a/RIADigiDoc/UI/Component/WebEid/WebEidView.swift b/RIADigiDoc/UI/Component/WebEid/WebEidView.swift index a4f7ecc1..0d488917 100644 --- a/RIADigiDoc/UI/Component/WebEid/WebEidView.swift +++ b/RIADigiDoc/UI/Component/WebEid/WebEidView.swift @@ -74,16 +74,15 @@ struct WebEidView: View { } private func handleWebEidOperation(for url: URL) { - if let operation = WebEidUriUtil.getOperation(from: url) { - switch operation { - case .auth: - viewModel.handleAuth(url: url) - case .cert: - viewModel.handleCertificate(url: url) - case .sign: - viewModel.handleSign(url: url) - } - } else { + let operation = WebEidUriUtil.getOperation(from: url) + switch operation { + case .auth: + viewModel.handleAuth(url: url) + case .cert: + viewModel.handleCertificate(url: url) + case .sign: + viewModel.handleSign(url: url) + case .unknown: viewModel.handleUnknown(url: url) } } diff --git a/RIADigiDoc/Util/WebEid/WebEidUriUtil.swift b/RIADigiDoc/Util/WebEid/WebEidUriUtil.swift index 0ba0ff3d..d38bc1bf 100644 --- a/RIADigiDoc/Util/WebEid/WebEidUriUtil.swift +++ b/RIADigiDoc/Util/WebEid/WebEidUriUtil.swift @@ -23,9 +23,10 @@ public enum WebEidOperation: String, CaseIterable, Sendable { case auth case cert case sign + case unknown - public static func fromOperation(_ operation: String) -> WebEidOperation? { - Self.allCases.first { $0.rawValue == operation } + public static func fromOperation(_ operation: String) -> WebEidOperation { + Self.allCases.first { $0.rawValue == operation } ?? WebEidOperation.unknown } } @@ -34,31 +35,28 @@ public enum WebEidUriUtil { private static let appLinksHost = "riadigidoc.ee" public static func isWebEidUri(_ url: URL) -> Bool { - getOperation(from: url) != nil + getOperation(from: url) != WebEidOperation.unknown } - public static func getOperation(from url: URL) -> WebEidOperation? { - let operation: String? + public static func getOperation(from url: URL) -> WebEidOperation { + var operation: String? #if DEBUG - if url.scheme == customScheme { - operation = url.host - } else if url.scheme == "https", url.host == appLinksHost { - let pathComponents = url.pathComponents.filter { $0 != "/" } - operation = pathComponents.first - } else { - operation = nil - } + let isCustomSchemeMatch = url.scheme == customScheme #else - if url.scheme == "https", url.host == appLinksHost { - let pathComponents = url.pathComponents.filter { $0 != "/" } - operation = pathComponents.first - } else { - operation = nil - } + let isCustomSchemeMatch = false #endif - guard let operation else { return nil } + if isCustomSchemeMatch { + operation = url.host + } else if url.scheme == "https", url.host == appLinksHost { + operation = url.pathComponents.dropFirst().first + } else { + operation = WebEidOperation.unknown.rawValue + } + + + guard let operation else { return WebEidOperation.unknown } return WebEidOperation.fromOperation(operation) } } diff --git a/RIADigiDocTests/Util/WebEid/WebEidUriUtilTests.swift b/RIADigiDocTests/Util/WebEid/WebEidUriUtilTests.swift index a9dbd402..d8b6e229 100644 --- a/RIADigiDocTests/Util/WebEid/WebEidUriUtilTests.swift +++ b/RIADigiDocTests/Util/WebEid/WebEidUriUtilTests.swift @@ -79,12 +79,12 @@ struct WebEidUriUtilTests { @Test func getOperation_appLinks_unknownOperation_returnsNil() { - #expect(WebEidUriUtil.getOperation(from: makeURL("https://riadigidoc.ee/unknown")) == nil) + #expect(WebEidUriUtil.getOperation(from: makeURL("https://riadigidoc.ee/unknown")) == .unknown) } @Test func getOperation_unrelatedUri_returnsNil() { - #expect(WebEidUriUtil.getOperation(from: makeURL("https://example.com/auth")) == nil) + #expect(WebEidUriUtil.getOperation(from: makeURL("https://example.com/auth")) == .unknown) } @Test @@ -124,6 +124,6 @@ struct WebEidUriUtilTests { @Test func getOperation_unknownOperation_returnsNil() { - #expect(WebEidUriUtil.getOperation(from: makeURL("web-eid-mobile://unknown")) == nil) + #expect(WebEidUriUtil.getOperation(from: makeURL("web-eid-mobile://unknown")) == .unknown) } }