diff --git a/.github/workflows/adhoc.yml b/.github/workflows/adhoc.yml index 5fededcc1a..ded6413c6c 100644 --- a/.github/workflows/adhoc.yml +++ b/.github/workflows/adhoc.yml @@ -21,7 +21,7 @@ on: jobs: make-adhoc: - runs-on: macos-14-xlarge + runs-on: macos-15 name: Make ad-hoc build steps: diff --git a/DuckDuckGo.xcodeproj/xcshareddata/xcschemes/DuckDuckGo.xcscheme b/DuckDuckGo.xcodeproj/xcshareddata/xcschemes/DuckDuckGo.xcscheme index 9a439f9d03..7658de6ab4 100644 --- a/DuckDuckGo.xcodeproj/xcshareddata/xcschemes/DuckDuckGo.xcscheme +++ b/DuckDuckGo.xcodeproj/xcshareddata/xcschemes/DuckDuckGo.xcscheme @@ -110,6 +110,16 @@ ReferencedContainer = "container:DuckDuckGo.xcodeproj"> + + + + WKNavigationActionPolicy { if let url = navigationAction.request.url { - if url == chatModel.aiChatURL || navigationAction.targetFrame?.isMainFrame == false { + if url.isDuckAIURL || navigationAction.targetFrame?.isMainFrame == false { return .allow } else { delegate?.aiChatWebViewController(self, didRequestToLoad: url) diff --git a/LocalPackages/AIChat/Sources/AIChat/Public API/AIChatViewController.swift b/LocalPackages/AIChat/Sources/AIChat/Public API/AIChatViewController.swift index 2d4bddede5..9a55405b25 100644 --- a/LocalPackages/AIChat/Sources/AIChat/Public API/AIChatViewController.swift +++ b/LocalPackages/AIChat/Sources/AIChat/Public API/AIChatViewController.swift @@ -102,6 +102,17 @@ extension AIChatViewController { } } +// MARK: - Public functions +extension AIChatViewController { + public func loadQuery(_ query: URLQueryItem) { + // Ensure the webViewController is added before loading the query + if webViewController == nil { + addWebViewController() + } + webViewController?.loadQuery(query) + } +} + // MARK: - Views Setup extension AIChatViewController { diff --git a/LocalPackages/AIChat/Sources/AIChat/URL+Extension.swift b/LocalPackages/AIChat/Sources/AIChat/URL+Extension.swift new file mode 100644 index 0000000000..b09269d7bb --- /dev/null +++ b/LocalPackages/AIChat/Sources/AIChat/URL+Extension.swift @@ -0,0 +1,54 @@ +// +// URL+Extension.swift +// DuckDuckGo +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +extension URL { + enum Constants { + static let duckDuckGoHost = "duckduckgo.com" + static let chatQueryName = "ia" + static let chatQueryValue = "chat" + } + + func addingOrReplacingQueryItem(_ queryItem: URLQueryItem) -> URL { + guard var urlComponents = URLComponents(url: self, resolvingAgainstBaseURL: false) else { + return self + } + + var queryItems = urlComponents.queryItems ?? [] + queryItems.removeAll { $0.name == queryItem.name } + queryItems.append(queryItem) + + urlComponents.queryItems = queryItems + return urlComponents.url ?? self + } + + var isDuckAIURL: Bool { + guard let host = self.host, host == Constants.duckDuckGoHost else { + return false + } + + guard let urlComponents = URLComponents(url: self, resolvingAgainstBaseURL: false), + let queryItems = urlComponents.queryItems else { + return false + } + + return queryItems.contains { $0.name == Constants.chatQueryName && $0.value == Constants.chatQueryValue } + } +} diff --git a/LocalPackages/AIChat/Tests/URLExtensionTests.swift b/LocalPackages/AIChat/Tests/URLExtensionTests.swift new file mode 100644 index 0000000000..caa5b50236 --- /dev/null +++ b/LocalPackages/AIChat/Tests/URLExtensionTests.swift @@ -0,0 +1,149 @@ +// +// URLExtensionTests.swift +// DuckDuckGo +// +// Copyright © 2022 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest +@testable import AIChat + +final class URLExtensionTests: XCTestCase { + private enum TestURLs { + static let exampleDomain = "https://example.com" + static let duckDuckGoDomain = "https://duckduckgo.com" + + static let example = "\(exampleDomain)" + static let exampleWithKeyOldValue = "\(exampleDomain)?key=oldValue" + static let exampleWithExistingQuery = "\(exampleDomain)?existingKey=existingValue" + static let exampleWithMultipleQueryItems = "\(exampleDomain)?key1=value1&key2=value2" + static let duckDuckGoChat = "\(duckDuckGoDomain)/?ia=chat" + static let duckDuckGoWithMissingQuery = "\(duckDuckGoDomain)/" + static let duckDuckGoDifferentQuery = "\(duckDuckGoDomain)/?ia=search" + static let duckDuckGoAdditionalQueryItems = "\(duckDuckGoDomain)/?ia=chat&other=param" + } + + func testAddingQueryItemToEmptyURL() { + let url = URL(string: TestURLs.example)! + let queryItem = URLQueryItem(name: "key", value: "value") + let result = url.addingOrReplacingQueryItem(queryItem) + + XCTAssertEqual(result.scheme, "https") + XCTAssertEqual(result.host, "example.com") + XCTAssertEqual(result.queryItemsDictionary, ["key": "value"]) + } + + func testReplacingExistingQueryItem() { + let url = URL(string: TestURLs.exampleWithKeyOldValue)! + let queryItem = URLQueryItem(name: "key", value: "newValue") + let result = url.addingOrReplacingQueryItem(queryItem) + + XCTAssertEqual(result.scheme, "https") + XCTAssertEqual(result.host, "example.com") + XCTAssertEqual(result.queryItemsDictionary, ["key": "newValue"]) + } + + func testAddingQueryItemToExistingQuery() { + let url = URL(string: TestURLs.exampleWithExistingQuery)! + let queryItem = URLQueryItem(name: "newKey", value: "newValue") + let result = url.addingOrReplacingQueryItem(queryItem) + + XCTAssertEqual(result.scheme, "https") + XCTAssertEqual(result.host, "example.com") + XCTAssertEqual(result.queryItemsDictionary, ["existingKey": "existingValue", "newKey": "newValue"]) + } + + func testReplacingOneOfMultipleQueryItems() { + let url = URL(string: TestURLs.exampleWithMultipleQueryItems)! + let queryItem = URLQueryItem(name: "key1", value: "newValue1") + let result = url.addingOrReplacingQueryItem(queryItem) + + XCTAssertEqual(result.scheme, "https") + XCTAssertEqual(result.host, "example.com") + XCTAssertEqual(result.queryItemsDictionary, ["key1": "newValue1", "key2": "value2"]) + } + + func testAddingQueryItemWithNilValue() { + let url = URL(string: TestURLs.example)! + let queryItem = URLQueryItem(name: "key", value: nil) + let result = url.addingOrReplacingQueryItem(queryItem) + + XCTAssertEqual(result.scheme, "https") + XCTAssertEqual(result.host, "example.com") + XCTAssertEqual(result.queryItemsDictionary, ["key": ""]) + } + + func testReplacingQueryItemWithNilValue() { + let url = URL(string: "\(TestURLs.example)?key=value")! + let queryItem = URLQueryItem(name: "key", value: nil) + let result = url.addingOrReplacingQueryItem(queryItem) + + XCTAssertEqual(result.scheme, "https") + XCTAssertEqual(result.host, "example.com") + XCTAssertEqual(result.queryItemsDictionary, ["key": ""]) + } + + func testIsDuckAIURLWithValidURL() { + if let url = URL(string: TestURLs.duckDuckGoChat) { + XCTAssertTrue(url.isDuckAIURL, "The URL should be identified as a DuckDuckGo AI URL.") + } else { + XCTFail("Failed to create URL from string.") + } + } + + func testIsDuckAIURLWithInvalidDomain() { + if let url = URL(string: TestURLs.exampleWithExistingQuery) { + XCTAssertFalse(url.isDuckAIURL, "The URL should not be identified as a DuckDuckGo AI URL due to the domain.") + } else { + XCTFail("Failed to create URL from string.") + } + } + + func testIsDuckAIURLWithMissingQueryItem() { + if let url = URL(string: TestURLs.duckDuckGoWithMissingQuery) { + XCTAssertFalse(url.isDuckAIURL, "The URL should not be identified as a DuckDuckGo AI URL due to missing query item.") + } else { + XCTFail("Failed to create URL from string.") + } + } + + func testIsDuckAIURLWithDifferentQueryItem() { + if let url = URL(string: TestURLs.duckDuckGoDifferentQuery) { + XCTAssertFalse(url.isDuckAIURL, "The URL should not be identified as a DuckDuckGo AI URL due to different query item value.") + } else { + XCTFail("Failed to create URL from string.") + } + } + + func testIsDuckAIURLWithAdditionalQueryItems() { + if let url = URL(string: TestURLs.duckDuckGoAdditionalQueryItems) { + XCTAssertTrue(url.isDuckAIURL, "The URL should be identified as a DuckDuckGo AI URL even with additional query items.") + } else { + XCTFail("Failed to create URL from string.") + } + } +} + +extension URL { + var queryItemsDictionary: [String: String] { + var dict = [String: String]() + if let queryItems = URLComponents(url: self, resolvingAgainstBaseURL: false)?.queryItems { + for item in queryItems { + dict[item.name] = item.value ?? "" + } + } + return dict + } +}