From c35f49ebadb1386740ff527bcf07d1c03d85deb7 Mon Sep 17 00:00:00 2001 From: 2001y Date: Wed, 22 Apr 2026 19:11:50 +0900 Subject: [PATCH 001/104] feat: add safari extension settings scaffold --- .../SafariExtensionAction.swift | 7 +++ .../SafariExtensionProcessor.swift | 40 +++++++++++++ .../SafariExtensionProcessorTests.swift | 58 +++++++++++++++++++ .../SafariExtensionState.swift | 10 ++++ .../SafariExtension/SafariExtensionView.swift | 47 +++++++++++++++ .../Settings/SettingsCoordinator.swift | 32 ++++++++++ .../Settings/SettingsCoordinatorTests.swift | 20 +++++++ .../UI/Platform/Settings/SettingsRoute.swift | 6 ++ 8 files changed, 220 insertions(+) create mode 100644 BitwardenShared/UI/Platform/Settings/Settings/AutoFill/SafariExtension/SafariExtensionAction.swift create mode 100644 BitwardenShared/UI/Platform/Settings/Settings/AutoFill/SafariExtension/SafariExtensionProcessor.swift create mode 100644 BitwardenShared/UI/Platform/Settings/Settings/AutoFill/SafariExtension/SafariExtensionProcessorTests.swift create mode 100644 BitwardenShared/UI/Platform/Settings/Settings/AutoFill/SafariExtension/SafariExtensionState.swift create mode 100644 BitwardenShared/UI/Platform/Settings/Settings/AutoFill/SafariExtension/SafariExtensionView.swift diff --git a/BitwardenShared/UI/Platform/Settings/Settings/AutoFill/SafariExtension/SafariExtensionAction.swift b/BitwardenShared/UI/Platform/Settings/Settings/AutoFill/SafariExtension/SafariExtensionAction.swift new file mode 100644 index 0000000000..32ddce09da --- /dev/null +++ b/BitwardenShared/UI/Platform/Settings/Settings/AutoFill/SafariExtension/SafariExtensionAction.swift @@ -0,0 +1,7 @@ +// MARK: - SafariExtensionAction + +/// Actions that can be processed by a `SafariExtensionProcessor`. +enum SafariExtensionAction: Equatable { + /// The activate button was tapped. + case activateButtonTapped +} diff --git a/BitwardenShared/UI/Platform/Settings/Settings/AutoFill/SafariExtension/SafariExtensionProcessor.swift b/BitwardenShared/UI/Platform/Settings/Settings/AutoFill/SafariExtension/SafariExtensionProcessor.swift new file mode 100644 index 0000000000..0d7295969d --- /dev/null +++ b/BitwardenShared/UI/Platform/Settings/Settings/AutoFill/SafariExtension/SafariExtensionProcessor.swift @@ -0,0 +1,40 @@ +import BitwardenKit + +// MARK: - SafariExtensionSetupDelegate + +/// A delegate of the Safari extension setup flow that is notified when the user enables the extension. +@MainActor +protocol SafariExtensionSetupDelegate: AnyObject { + /// Called when the user dismisses the Safari extension setup process. + func didDismissSafariExtensionSetup(enabled: Bool) +} + +// MARK: - SafariExtensionProcessor + +/// The processor used to manage state and handle actions for the `SafariExtensionView`. +final class SafariExtensionProcessor: StateProcessor { + private let coordinator: AnyCoordinator + + init( + coordinator: AnyCoordinator, + state: SafariExtensionState, + ) { + self.coordinator = coordinator + super.init(state: state) + } + + override func receive(_ action: SafariExtensionAction) { + switch action { + case .activateButtonTapped: + coordinator.navigate(to: .safariExtensionSetup, context: self) + } + } +} + +extension SafariExtensionProcessor: SafariExtensionSetupDelegate { + func didDismissSafariExtensionSetup(enabled: Bool) { + guard !state.extensionEnabled else { return } + state.extensionActivated = true + state.extensionEnabled = enabled + } +} diff --git a/BitwardenShared/UI/Platform/Settings/Settings/AutoFill/SafariExtension/SafariExtensionProcessorTests.swift b/BitwardenShared/UI/Platform/Settings/Settings/AutoFill/SafariExtension/SafariExtensionProcessorTests.swift new file mode 100644 index 0000000000..e026aa2164 --- /dev/null +++ b/BitwardenShared/UI/Platform/Settings/Settings/AutoFill/SafariExtension/SafariExtensionProcessorTests.swift @@ -0,0 +1,58 @@ +import BitwardenKitMocks +import BitwardenSdk +import XCTest + +@testable import BitwardenShared + +class SafariExtensionProcessorTests: BitwardenTestCase { + // MARK: Properties + + var coordinator: MockCoordinator! + var subject: SafariExtensionProcessor! + + // MARK: Setup & Teardown + + override func setUp() { + super.setUp() + + coordinator = MockCoordinator() + + subject = SafariExtensionProcessor( + coordinator: coordinator.asAnyCoordinator(), + state: SafariExtensionState(), + ) + } + + override func tearDown() { + super.tearDown() + + coordinator = nil + subject = nil + } + + // MARK: Tests + + @MainActor + func test_didDismissSafariExtensionSetup_enabled() { + subject.didDismissSafariExtensionSetup(enabled: true) + + XCTAssertTrue(subject.state.extensionActivated) + XCTAssertTrue(subject.state.extensionEnabled) + } + + @MainActor + func test_didDismissSafariExtensionSetup_notEnabled() { + subject.didDismissSafariExtensionSetup(enabled: false) + + XCTAssertTrue(subject.state.extensionActivated) + XCTAssertFalse(subject.state.extensionEnabled) + } + + @MainActor + func test_receive_activateButtonTapped() { + subject.receive(.activateButtonTapped) + + XCTAssertEqual(coordinator.routes.last, .safariExtensionSetup) + XCTAssertIdentical(coordinator.contexts.last as? SafariExtensionProcessor, subject) + } +} diff --git a/BitwardenShared/UI/Platform/Settings/Settings/AutoFill/SafariExtension/SafariExtensionState.swift b/BitwardenShared/UI/Platform/Settings/Settings/AutoFill/SafariExtension/SafariExtensionState.swift new file mode 100644 index 0000000000..a9b159c497 --- /dev/null +++ b/BitwardenShared/UI/Platform/Settings/Settings/AutoFill/SafariExtension/SafariExtensionState.swift @@ -0,0 +1,10 @@ +// MARK: - SafariExtensionState + +/// The state used to present the `SafariExtensionView`. +struct SafariExtensionState: Equatable { + /// Whether the extension was activated. + var extensionActivated = false + + /// Whether the extension is enabled. + var extensionEnabled = false +} diff --git a/BitwardenShared/UI/Platform/Settings/Settings/AutoFill/SafariExtension/SafariExtensionView.swift b/BitwardenShared/UI/Platform/Settings/Settings/AutoFill/SafariExtension/SafariExtensionView.swift new file mode 100644 index 0000000000..b9ca2d2b84 --- /dev/null +++ b/BitwardenShared/UI/Platform/Settings/Settings/AutoFill/SafariExtension/SafariExtensionView.swift @@ -0,0 +1,47 @@ +import BitwardenKit +import BitwardenResources +import SwiftUI + +// MARK: - SafariExtensionView + +/// A temporary setup view for the Safari extension flow. +struct SafariExtensionView: View { + @ObservedObject var store: Store + + var body: some View { + GeometryReader { geometry in + VStack(spacing: 20) { + Spacer() + + Text("Safari Extension") + .styleGuide(.title) + .multilineTextAlignment(.center) + + Text("Prepare Bitwarden Safari integration for save/update, generator, and page-aware fill.") + .styleGuide(.body) + .foregroundStyle(SharedAsset.Colors.textPrimary.swiftUIColor) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + + Button("Activate Safari Extension") { + store.send(.activateButtonTapped) + } + .buttonStyle(.secondary()) + + Spacer() + } + .padding(.vertical, 16) + .frame(minHeight: geometry.size.height) + .scrollView(addVerticalPadding: false) + } + .navigationBar(title: "Safari Extension", titleDisplayMode: .inline) + } +} + +#Preview { + SafariExtensionView( + store: Store( + processor: StateProcessor(state: SafariExtensionState()), + ), + ) +} diff --git a/BitwardenShared/UI/Platform/Settings/SettingsCoordinator.swift b/BitwardenShared/UI/Platform/Settings/SettingsCoordinator.swift index fcb7d5d350..ba1b543094 100644 --- a/BitwardenShared/UI/Platform/Settings/SettingsCoordinator.swift +++ b/BitwardenShared/UI/Platform/Settings/SettingsCoordinator.swift @@ -166,6 +166,10 @@ final class SettingsCoordinator: Coordinator, HasStackNavigator { // swiftlint:d showAppExtensionSetup(delegate: context as? AppExtensionSetupDelegate) case .autoFill: showAutoFill() + case .safariExtension: + showSafariExtension() + case .safariExtensionSetup: + showSafariExtensionSetup(delegate: context as? SafariExtensionSetupDelegate) case .deleteAccount: showDeleteAccount() case .dismiss: @@ -334,6 +338,34 @@ final class SettingsCoordinator: Coordinator, HasStackNavigator { // swiftlint:d stackNavigator?.push(viewController, navigationTitle: Localizations.autofill) } + /// Shows the Safari extension screen. + private func showSafariExtension() { + let processor = SafariExtensionProcessor( + coordinator: asAnyCoordinator(), + state: SafariExtensionState(), + ) + let view = SafariExtensionView(store: Store(processor: processor)) + let viewController = UIHostingController(rootView: view) + viewController.navigationItem.largeTitleDisplayMode = .never + stackNavigator?.push(viewController, navigationTitle: "Safari Extension") + } + + /// Shows the Safari extension setup screen. + private func showSafariExtensionSetup(delegate: SafariExtensionSetupDelegate?) { + let extensionItem = NSExtensionItem() + extensionItem.attachments = [ + NSItemProvider( + item: "" as NSString, + typeIdentifier: Constants.UTType.appExtensionSetup, + ), + ] + let viewController = UIActivityViewController(activityItems: [extensionItem], applicationActivities: nil) + viewController.completionWithItemsHandler = { _, completed, _, _ in + delegate?.didDismissSafariExtensionSetup(enabled: completed) + } + stackNavigator?.present(viewController) + } + /// Shows the delete account screen. /// private func showDeleteAccount() { diff --git a/BitwardenShared/UI/Platform/Settings/SettingsCoordinatorTests.swift b/BitwardenShared/UI/Platform/Settings/SettingsCoordinatorTests.swift index 117a669923..1f7a2ceb0a 100644 --- a/BitwardenShared/UI/Platform/Settings/SettingsCoordinatorTests.swift +++ b/BitwardenShared/UI/Platform/Settings/SettingsCoordinatorTests.swift @@ -130,6 +130,26 @@ class SettingsCoordinatorTests: BitwardenTestCase { // swiftlint:disable:this ty XCTAssertTrue(action.view is UIActivityViewController) } + /// `navigate(to:)` with `.safariExtension` pushes the safari extension view onto the stack navigator. + @MainActor + func test_navigateTo_safariExtension() throws { + subject.navigate(to: .safariExtension) + + let action = try XCTUnwrap(stackNavigator.actions.last) + XCTAssertEqual(action.type, .pushed) + XCTAssertTrue(action.view is UIHostingController) + } + + /// `navigate(to:)` with `.safariExtensionSetup` presents the safari extension setup flow. + @MainActor + func test_navigateTo_safariExtensionSetup() throws { + subject.navigate(to: .safariExtensionSetup) + + let action = try XCTUnwrap(stackNavigator.actions.last) + XCTAssertEqual(action.type, .presented) + XCTAssertTrue(action.view is UIActivityViewController) + } + /// `navigate(to:)` with `.autoFill` pushes the auto-fill view onto the stack navigator. @MainActor func test_navigateTo_autoFill() throws { diff --git a/BitwardenShared/UI/Platform/Settings/SettingsRoute.swift b/BitwardenShared/UI/Platform/Settings/SettingsRoute.swift index 1e4e11b900..d55801fb03 100644 --- a/BitwardenShared/UI/Platform/Settings/SettingsRoute.swift +++ b/BitwardenShared/UI/Platform/Settings/SettingsRoute.swift @@ -80,6 +80,12 @@ public enum SettingsRoute: Equatable, Hashable { /// A route to the settings screen. case settings(SettingsPresentationMode) + /// A route to the Safari extension screen. + case safariExtension + + /// A route to the Safari extension setup sheet. + case safariExtensionSetup + /// A route to the share sheet to share a URL. case shareURL(URL) From 8f31a3ef38d27955e05c39fe5a3fd23614d90213 Mon Sep 17 00:00:00 2001 From: 2001y Date: Wed, 22 Apr 2026 21:19:47 +0900 Subject: [PATCH 002/104] feat: add safari extension shared request model --- .../Models/SafariExtensionRequest.swift | 86 ++++++++++++++++ .../Models/SafariExtensionRequestTests.swift | 98 +++++++++++++++++++ 2 files changed, 184 insertions(+) create mode 100644 BitwardenShared/Core/SafariExtension/Models/SafariExtensionRequest.swift create mode 100644 BitwardenShared/Core/SafariExtension/Models/SafariExtensionRequestTests.swift diff --git a/BitwardenShared/Core/SafariExtension/Models/SafariExtensionRequest.swift b/BitwardenShared/Core/SafariExtension/Models/SafariExtensionRequest.swift new file mode 100644 index 0000000000..93cd1cd196 --- /dev/null +++ b/BitwardenShared/Core/SafariExtension/Models/SafariExtensionRequest.swift @@ -0,0 +1,86 @@ +import Foundation + +// MARK: - SafariExtensionRequestKind + +/// The high-level action requested by the Safari extension host/content bridge. +enum SafariExtensionRequestKind: String, Codable, Equatable { + case setup + case fill + case saveLogin + case changePassword + case generatePassword +} + +// MARK: - SafariExtensionRequest + +/// A shared Codable payload for Safari extension requests flowing between web/native layers. +struct SafariExtensionRequest: Codable, Equatable { + /// The requested action type. + var kind: SafariExtensionRequestKind + + /// The login title extracted from the page, if any. + var loginTitle: String? + + /// Notes extracted from the page or flow, if any. + var notes: String? + + /// The previous password for change-password flows. + var oldPassword: String? + + /// Parsed page details for page-aware fill/save/update flows. + var pageDetails: PageDetails? + + /// The current or generated password value. + var password: String? + + /// Password generation options associated with the page/action. + var passwordOptions: PasswordGenerationOptions? + + /// The page URL used for matching and save/update suggestions. + var urlString: String? + + /// The username extracted from the page, if any. + var username: String? + + /// Whether this request can drive page-aware autofill. + var canAutofill: Bool { + kind == .fill && pageDetails?.hasPasswordField == true + } + + /// Whether this request contains enough information to save a login. + var canSaveLogin: Bool { + kind == .saveLogin && !(username?.isEmpty ?? true) && !(password?.isEmpty ?? true) + } + + /// Whether this request contains enough information to update a password. + var canChangePassword: Bool { + kind == .changePassword && !(oldPassword?.isEmpty ?? true) && !(password?.isEmpty ?? true) + } + + /// Whether this request can drive password generation UI. + var canGeneratePassword: Bool { + kind == .generatePassword + } + + init( + kind: SafariExtensionRequestKind, + loginTitle: String? = nil, + notes: String? = nil, + oldPassword: String? = nil, + pageDetails: PageDetails? = nil, + password: String? = nil, + passwordOptions: PasswordGenerationOptions? = nil, + urlString: String? = nil, + username: String? = nil, + ) { + self.kind = kind + self.loginTitle = loginTitle + self.notes = notes + self.oldPassword = oldPassword + self.pageDetails = pageDetails + self.password = password + self.passwordOptions = passwordOptions + self.urlString = urlString + self.username = username + } +} diff --git a/BitwardenShared/Core/SafariExtension/Models/SafariExtensionRequestTests.swift b/BitwardenShared/Core/SafariExtension/Models/SafariExtensionRequestTests.swift new file mode 100644 index 0000000000..d08fa06d6d --- /dev/null +++ b/BitwardenShared/Core/SafariExtension/Models/SafariExtensionRequestTests.swift @@ -0,0 +1,98 @@ +import XCTest + +@testable import BitwardenShared + +class SafariExtensionRequestTests: BitwardenTestCase { + func test_roundTripDecodeEncode_fillRequest() throws { + let subject = SafariExtensionRequest( + kind: .fill, + loginTitle: "Bitwarden", + notes: "example", + oldPassword: nil, + pageDetails: PageDetails( + collectedTimestamp: Date(timeIntervalSince1970: 1_715_000_000), + documentUUID: "doc-1", + documentUrl: "https://example.com/login", + fields: [ + PageDetails.Field( + disabled: false, + elementNumber: 1, + form: "login-form", + htmlClass: nil, + htmlId: "password", + htmlName: "password", + labelLeft: "Password", + labelRight: nil, + labelTag: "Password", + onepasswordFieldType: nil, + opId: "password-field", + placeholder: "Password", + readOnly: false, + type: "password", + value: nil, + viewable: true, + visible: true, + ), + ], + forms: [ + "login-form": PageDetails.Form( + htmlAction: "/login", + htmlId: "login-form", + htmlMethod: "post", + htmlName: "login", + opId: "login-form", + ), + ], + tabUrl: "https://example.com/login", + title: "Login", + url: "https://example.com/login", + ), + password: "secret", + passwordOptions: PasswordGenerationOptions(length: 20, type: .password), + urlString: "https://example.com/login", + username: "user@example.com", + ) + + let data = try JSONEncoder().encode(subject) + let decoded = try JSONDecoder().decode(SafariExtensionRequest.self, from: data) + + XCTAssertEqual(decoded, subject) + XCTAssertTrue(decoded.canAutofill) + XCTAssertFalse(decoded.canSaveLogin) + XCTAssertFalse(decoded.canChangePassword) + XCTAssertFalse(decoded.canGeneratePassword) + } + + func test_canSaveLogin_requiresUsernameAndPassword() { + let subject = SafariExtensionRequest( + kind: .saveLogin, + password: "secret", + urlString: "https://example.com/login", + username: "user@example.com", + ) + + XCTAssertTrue(subject.canSaveLogin) + XCTAssertFalse(subject.canAutofill) + } + + func test_canChangePassword_requiresOldAndNewPassword() { + let subject = SafariExtensionRequest( + kind: .changePassword, + oldPassword: "old-secret", + password: "new-secret", + urlString: "https://example.com/change-password", + ) + + XCTAssertTrue(subject.canChangePassword) + XCTAssertFalse(subject.canSaveLogin) + } + + func test_canGeneratePassword_trueForGenerateRequest() { + let subject = SafariExtensionRequest(kind: .generatePassword) + + XCTAssertTrue(subject.canGeneratePassword) + XCTAssertFalse(subject.canAutofill) + XCTAssertFalse(subject.canSaveLogin) + XCTAssertFalse(subject.canChangePassword) + } +} From 738c5bf736bda0299919bfe83674a5562f7885b9 Mon Sep 17 00:00:00 2001 From: 2001y Date: Wed, 22 Apr 2026 21:46:14 +0900 Subject: [PATCH 003/104] feat: add safari extension suggestion action model --- .../SafariExtensionSuggestionAction.swift | 26 +++++++ ...SafariExtensionSuggestionActionTests.swift | 77 +++++++++++++++++++ 2 files changed, 103 insertions(+) create mode 100644 BitwardenShared/Core/SafariExtension/Models/SafariExtensionSuggestionAction.swift create mode 100644 BitwardenShared/Core/SafariExtension/Models/SafariExtensionSuggestionActionTests.swift diff --git a/BitwardenShared/Core/SafariExtension/Models/SafariExtensionSuggestionAction.swift b/BitwardenShared/Core/SafariExtension/Models/SafariExtensionSuggestionAction.swift new file mode 100644 index 0000000000..b5586e43c0 --- /dev/null +++ b/BitwardenShared/Core/SafariExtension/Models/SafariExtensionSuggestionAction.swift @@ -0,0 +1,26 @@ +// MARK: - SafariExtensionSuggestionAction + +/// The primary action the Safari extension UI should suggest for a given request. +enum SafariExtensionSuggestionAction: String, Codable, Equatable { + case none + case fill + case saveLogin + case updatePassword + case generatePassword + + static func from(_ request: SafariExtensionRequest) -> Self { + if request.canAutofill { + return .fill + } + if request.canSaveLogin { + return .saveLogin + } + if request.canChangePassword { + return .updatePassword + } + if request.canGeneratePassword { + return .generatePassword + } + return .none + } +} diff --git a/BitwardenShared/Core/SafariExtension/Models/SafariExtensionSuggestionActionTests.swift b/BitwardenShared/Core/SafariExtension/Models/SafariExtensionSuggestionActionTests.swift new file mode 100644 index 0000000000..b3c8b89695 --- /dev/null +++ b/BitwardenShared/Core/SafariExtension/Models/SafariExtensionSuggestionActionTests.swift @@ -0,0 +1,77 @@ +import XCTest + +@testable import BitwardenShared + +class SafariExtensionSuggestionActionTests: BitwardenTestCase { + func test_from_fillRequest_returnsFill() { + let request = SafariExtensionRequest( + kind: .fill, + pageDetails: PageDetails( + collectedTimestamp: Date(timeIntervalSince1970: 1_715_000_000), + documentUUID: "doc-1", + documentUrl: "https://example.com/login", + fields: [ + PageDetails.Field( + disabled: false, + elementNumber: 1, + form: nil, + htmlClass: nil, + htmlId: "password", + htmlName: "password", + labelLeft: nil, + labelRight: nil, + labelTag: nil, + onepasswordFieldType: nil, + opId: "password-field", + placeholder: nil, + readOnly: false, + type: "password", + value: nil, + viewable: true, + visible: true, + ), + ], + forms: [:], + tabUrl: "https://example.com/login", + title: "Login", + url: "https://example.com/login", + ), + ) + + XCTAssertEqual(SafariExtensionSuggestionAction.from(request), .fill) + } + + func test_from_saveLoginRequest_returnsSaveLogin() { + let request = SafariExtensionRequest( + kind: .saveLogin, + password: "secret", + urlString: "https://example.com/login", + username: "user@example.com", + ) + + XCTAssertEqual(SafariExtensionSuggestionAction.from(request), .saveLogin) + } + + func test_from_changePasswordRequest_returnsUpdatePassword() { + let request = SafariExtensionRequest( + kind: .changePassword, + oldPassword: "old-secret", + password: "new-secret", + urlString: "https://example.com/change-password", + ) + + XCTAssertEqual(SafariExtensionSuggestionAction.from(request), .updatePassword) + } + + func test_from_generatePasswordRequest_returnsGeneratePassword() { + let request = SafariExtensionRequest(kind: .generatePassword) + + XCTAssertEqual(SafariExtensionSuggestionAction.from(request), .generatePassword) + } + + func test_from_incompleteRequest_returnsNone() { + let request = SafariExtensionRequest(kind: .saveLogin) + + XCTAssertEqual(SafariExtensionSuggestionAction.from(request), .none) + } +} From ba31bab31d3fc671ca7568d9fa895d953913ac0f Mon Sep 17 00:00:00 2001 From: 2001y Date: Wed, 22 Apr 2026 21:54:06 +0900 Subject: [PATCH 004/104] feat: add safari extension submission classifier --- .../SafariExtensionSubmissionAction.swift | 57 +++++++++++ ...SafariExtensionSubmissionActionTests.swift | 98 +++++++++++++++++++ 2 files changed, 155 insertions(+) create mode 100644 BitwardenShared/Core/SafariExtension/Models/SafariExtensionSubmissionAction.swift create mode 100644 BitwardenShared/Core/SafariExtension/Models/SafariExtensionSubmissionActionTests.swift diff --git a/BitwardenShared/Core/SafariExtension/Models/SafariExtensionSubmissionAction.swift b/BitwardenShared/Core/SafariExtension/Models/SafariExtensionSubmissionAction.swift new file mode 100644 index 0000000000..ac8dedffdf --- /dev/null +++ b/BitwardenShared/Core/SafariExtension/Models/SafariExtensionSubmissionAction.swift @@ -0,0 +1,57 @@ +// MARK: - SafariExtensionMatchedLogin + +/// A lightweight snapshot of an existing matching login item. +struct SafariExtensionMatchedLogin: Codable, Equatable { + var id: String + var username: String? + var password: String? + var urlString: String? +} + +// MARK: - SafariExtensionSubmissionAction + +/// The action the native layer should take when deciding between save/update flows. +enum SafariExtensionSubmissionAction: String, Codable, Equatable { + case none + case fill + case saveNewLogin + case updateExistingLogin + case updatePassword + case generatePassword + + static func classify( + _ request: SafariExtensionRequest, + matchedLogin: SafariExtensionMatchedLogin?, + ) -> Self { + if request.canAutofill { + return .fill + } + + if request.canGeneratePassword { + return .generatePassword + } + + if request.canChangePassword { + return matchedLogin == nil ? .none : .updatePassword + } + + if request.canSaveLogin { + guard let matchedLogin else { + return .saveNewLogin + } + + let normalizedRequestUsername = request.username?.trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedMatchedUsername = matchedLogin.username?.trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedRequestPassword = request.password?.trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedMatchedPassword = matchedLogin.password?.trimmingCharacters(in: .whitespacesAndNewlines) + + if normalizedRequestUsername != normalizedMatchedUsername || normalizedRequestPassword != normalizedMatchedPassword { + return .updateExistingLogin + } + + return .none + } + + return .none + } +} diff --git a/BitwardenShared/Core/SafariExtension/Models/SafariExtensionSubmissionActionTests.swift b/BitwardenShared/Core/SafariExtension/Models/SafariExtensionSubmissionActionTests.swift new file mode 100644 index 0000000000..0077af9127 --- /dev/null +++ b/BitwardenShared/Core/SafariExtension/Models/SafariExtensionSubmissionActionTests.swift @@ -0,0 +1,98 @@ +import XCTest + +@testable import BitwardenShared + +class SafariExtensionSubmissionActionTests: BitwardenTestCase { + func test_classify_fill_withoutMatchedLogin_returnsFill() { + let request = SafariExtensionRequest( + kind: .fill, + pageDetails: PageDetails( + collectedTimestamp: Date(timeIntervalSince1970: 1_715_000_000), + documentUUID: "doc-1", + documentUrl: "https://example.com/login", + fields: [ + PageDetails.Field( + disabled: false, + elementNumber: 1, + form: nil, + htmlClass: nil, + htmlId: "password", + htmlName: "password", + labelLeft: nil, + labelRight: nil, + labelTag: nil, + onepasswordFieldType: nil, + opId: "password-field", + placeholder: nil, + readOnly: false, + type: "password", + value: nil, + viewable: true, + visible: true, + ), + ], + forms: [:], + tabUrl: "https://example.com/login", + title: "Login", + url: "https://example.com/login", + ) + ) + + XCTAssertEqual(SafariExtensionSubmissionAction.classify(request, matchedLogin: nil), .fill) + } + + func test_classify_saveLogin_withoutMatchedLogin_returnsSaveNewLogin() { + let request = SafariExtensionRequest( + kind: .saveLogin, + password: "secret", + urlString: "https://example.com/login", + username: "new-user@example.com", + ) + + XCTAssertEqual(SafariExtensionSubmissionAction.classify(request, matchedLogin: nil), .saveNewLogin) + } + + func test_classify_saveLogin_withMatchedLoginAndDifferentPassword_returnsUpdateExistingLogin() { + let request = SafariExtensionRequest( + kind: .saveLogin, + password: "new-secret", + urlString: "https://example.com/login", + username: "user@example.com", + ) + let matchedLogin = SafariExtensionMatchedLogin( + id: "cipher-1", + username: "user@example.com", + password: "old-secret", + urlString: "https://example.com/login", + ) + + XCTAssertEqual(SafariExtensionSubmissionAction.classify(request, matchedLogin: matchedLogin), .updateExistingLogin) + } + + func test_classify_changePassword_withMatchedLogin_returnsUpdatePassword() { + let request = SafariExtensionRequest( + kind: .changePassword, + oldPassword: "old-secret", + password: "new-secret", + urlString: "https://example.com/change-password", + ) + let matchedLogin = SafariExtensionMatchedLogin( + id: "cipher-1", + username: "user@example.com", + password: "old-secret", + urlString: "https://example.com/login", + ) + + XCTAssertEqual(SafariExtensionSubmissionAction.classify(request, matchedLogin: matchedLogin), .updatePassword) + } + + func test_classify_incompleteRequest_returnsNone() { + XCTAssertEqual( + SafariExtensionSubmissionAction.classify( + SafariExtensionRequest(kind: .saveLogin), + matchedLogin: nil, + ), + .none, + ) + } +} From 92eaea3a8cb801a1bc827466875bd6362d09196e Mon Sep 17 00:00:00 2001 From: 2001y Date: Wed, 22 Apr 2026 22:39:06 +0900 Subject: [PATCH 005/104] feat: add safari extension response models --- ...riExtensionMatchedLoginResolverTests.swift | 52 ++++++ ...SafariExtensionMatchedLoginResolving.swift | 32 ++++ .../Models/SafariExtensionResponse.swift | 87 ++++++++++ .../Models/SafariExtensionResponseTests.swift | 152 ++++++++++++++++++ 4 files changed, 323 insertions(+) create mode 100644 BitwardenShared/Core/SafariExtension/Models/SafariExtensionMatchedLoginResolverTests.swift create mode 100644 BitwardenShared/Core/SafariExtension/Models/SafariExtensionMatchedLoginResolving.swift create mode 100644 BitwardenShared/Core/SafariExtension/Models/SafariExtensionResponse.swift create mode 100644 BitwardenShared/Core/SafariExtension/Models/SafariExtensionResponseTests.swift diff --git a/BitwardenShared/Core/SafariExtension/Models/SafariExtensionMatchedLoginResolverTests.swift b/BitwardenShared/Core/SafariExtension/Models/SafariExtensionMatchedLoginResolverTests.swift new file mode 100644 index 0000000000..2914b4e146 --- /dev/null +++ b/BitwardenShared/Core/SafariExtension/Models/SafariExtensionMatchedLoginResolverTests.swift @@ -0,0 +1,52 @@ +import XCTest + +@testable import BitwardenShared + +class SafariExtensionMatchedLoginResolverTests: BitwardenTestCase { + func test_resolveContext_withoutMatchedLogin_classifiesSaveNewLogin() async throws { + let request = SafariExtensionRequest( + kind: .saveLogin, + password: "secret", + urlString: "https://example.com/login", + username: "user@example.com", + ) + let subject = MockSafariExtensionMatchedLoginResolver(matchedLogin: nil) + + let resolved = try await subject.resolveContext(for: request) + + XCTAssertNil(resolved.matchedLogin) + XCTAssertEqual(resolved.suggestionAction, .saveLogin) + XCTAssertEqual(resolved.submissionAction, .saveNewLogin) + } + + func test_resolveContext_withMatchedLogin_classifiesUpdatePassword() async throws { + let request = SafariExtensionRequest( + kind: .changePassword, + oldPassword: "old-secret", + password: "new-secret", + urlString: "https://example.com/change-password", + ) + let subject = MockSafariExtensionMatchedLoginResolver( + matchedLogin: SafariExtensionMatchedLogin( + id: "cipher-1", + username: "user@example.com", + password: "old-secret", + urlString: "https://example.com/login", + ), + ) + + let resolved = try await subject.resolveContext(for: request) + + XCTAssertEqual(resolved.matchedLogin?.id, "cipher-1") + XCTAssertEqual(resolved.suggestionAction, .updatePassword) + XCTAssertEqual(resolved.submissionAction, .updatePassword) + } +} + +private struct MockSafariExtensionMatchedLoginResolver: SafariExtensionMatchedLoginResolving { + var matchedLogin: SafariExtensionMatchedLogin? + + func resolveMatchedLogin(for request: SafariExtensionRequest) async throws -> SafariExtensionMatchedLogin? { + matchedLogin + } +} diff --git a/BitwardenShared/Core/SafariExtension/Models/SafariExtensionMatchedLoginResolving.swift b/BitwardenShared/Core/SafariExtension/Models/SafariExtensionMatchedLoginResolving.swift new file mode 100644 index 0000000000..b7d1f1312a --- /dev/null +++ b/BitwardenShared/Core/SafariExtension/Models/SafariExtensionMatchedLoginResolving.swift @@ -0,0 +1,32 @@ +// MARK: - SafariExtensionResolvedContext + +/// The request plus any matched login, after vault resolution has run. +struct SafariExtensionResolvedContext: Equatable { + var request: SafariExtensionRequest + var matchedLogin: SafariExtensionMatchedLogin? + + var suggestionAction: SafariExtensionSuggestionAction { + SafariExtensionSuggestionAction.from(request) + } + + var submissionAction: SafariExtensionSubmissionAction { + SafariExtensionSubmissionAction.classify(request, matchedLogin: matchedLogin) + } +} + +// MARK: - SafariExtensionMatchedLoginResolving + +/// Resolves an existing login match for Safari save/update/change-password flows. +protocol SafariExtensionMatchedLoginResolving { + func resolveMatchedLogin(for request: SafariExtensionRequest) async throws -> SafariExtensionMatchedLogin? +} + +extension SafariExtensionMatchedLoginResolving { + func resolveContext(for request: SafariExtensionRequest) async throws -> SafariExtensionResolvedContext { + let matchedLogin = try await resolveMatchedLogin(for: request) + return SafariExtensionResolvedContext( + request: request, + matchedLogin: matchedLogin, + ) + } +} diff --git a/BitwardenShared/Core/SafariExtension/Models/SafariExtensionResponse.swift b/BitwardenShared/Core/SafariExtension/Models/SafariExtensionResponse.swift new file mode 100644 index 0000000000..cbd6460da0 --- /dev/null +++ b/BitwardenShared/Core/SafariExtension/Models/SafariExtensionResponse.swift @@ -0,0 +1,87 @@ +import Foundation + +// MARK: - SafariExtensionResponse + +/// A shared Codable payload returned from the native Safari extension layer back to the web bridge/UI. +struct SafariExtensionResponse: Codable, Equatable { + /// The originating request. + var request: SafariExtensionRequest + + /// The high-level action the UI should present. + var suggestionAction: SafariExtensionSuggestionAction + + /// The concrete native submission action selected for the request. + var submissionAction: SafariExtensionSubmissionAction + + /// A matching login, when one was found in the vault. + var matchedLogin: SafariExtensionMatchedLogin? + + /// The encoded fill script JSON to send back into the page, when available. + var fillScriptJSON: String? + + /// A generated password to offer back to the page, when available. + var generatedPassword: String? + + /// Optional user-facing copy for setup/save/update flows. + var userMessage: String? + + /// Whether the response includes a fill script that can be finalized into the page. + var canFinalizeWithScript: Bool { + submissionAction == .fill && !(fillScriptJSON?.isEmpty ?? true) + } + + /// Whether the response includes a generated password payload. + var hasGeneratedPassword: Bool { + !(generatedPassword?.isEmpty ?? true) + } + + /// Build a response for page fill flows. + static func fill( + request: SafariExtensionRequest, + username: String, + password: String, + fields: [(String, String)], + matchedLogin: SafariExtensionMatchedLogin?, + ) throws -> Self { + guard request.canAutofill else { + throw CocoaError(.coderInvalidValue) + } + + let fillScript = FillScript( + pageDetails: request.pageDetails, + fillUsername: username, + fillPassword: password, + fillFields: fields, + ) + let fillScriptData = try JSONEncoder().encode(fillScript) + guard let fillScriptJSON = String(data: fillScriptData, encoding: .utf8) else { + throw CocoaError(.coderInvalidValue) + } + return Self( + request: request, + suggestionAction: SafariExtensionSuggestionAction.from(request), + submissionAction: SafariExtensionSubmissionAction.classify(request, matchedLogin: matchedLogin), + matchedLogin: matchedLogin, + fillScriptJSON: fillScriptJSON, + generatedPassword: nil, + userMessage: nil, + ) + } + + /// Build a response for password generation flows. + static func generatedPassword(_ generatedPassword: String, for request: SafariExtensionRequest) throws -> Self { + guard request.canGeneratePassword else { + throw CocoaError(.coderInvalidValue) + } + + return Self( + request: request, + suggestionAction: SafariExtensionSuggestionAction.from(request), + submissionAction: SafariExtensionSubmissionAction.classify(request, matchedLogin: nil), + matchedLogin: nil, + fillScriptJSON: nil, + generatedPassword: generatedPassword, + userMessage: nil, + ) + } +} diff --git a/BitwardenShared/Core/SafariExtension/Models/SafariExtensionResponseTests.swift b/BitwardenShared/Core/SafariExtension/Models/SafariExtensionResponseTests.swift new file mode 100644 index 0000000000..8231f82f38 --- /dev/null +++ b/BitwardenShared/Core/SafariExtension/Models/SafariExtensionResponseTests.swift @@ -0,0 +1,152 @@ +import XCTest + +@testable import BitwardenShared + +class SafariExtensionResponseTests: BitwardenTestCase { + func test_fill_buildsEncodedFillScriptResponse() throws { + let subject = try SafariExtensionResponse.fill( + request: makeFillRequest(), + username: "user@example.com", + password: "secret", + fields: [("otp", "123456")], + matchedLogin: nil, + ) + + XCTAssertEqual(subject.suggestionAction, .fill) + XCTAssertEqual(subject.submissionAction, .fill) + XCTAssertTrue(subject.canFinalizeWithScript) + XCTAssertNil(subject.generatedPassword) + + let fillScriptJSON = try XCTUnwrap(subject.fillScriptJSON) + let scriptData = try XCTUnwrap(fillScriptJSON.data(using: .utf8)) + let fillScript = try JSONDecoder().decode(FillScript.self, from: scriptData) + XCTAssertEqual(fillScript.documentUUID, "doc-1") + XCTAssertFalse(fillScript.script.isEmpty) + } + + func test_generatedPassword_buildsGeneratePasswordResponse() throws { + let request = SafariExtensionRequest(kind: .generatePassword) + + let subject = try SafariExtensionResponse.generatedPassword("generated-secret", for: request) + + XCTAssertEqual(subject.suggestionAction, .generatePassword) + XCTAssertEqual(subject.submissionAction, .generatePassword) + XCTAssertEqual(subject.generatedPassword, "generated-secret") + XCTAssertTrue(subject.hasGeneratedPassword) + XCTAssertFalse(subject.canFinalizeWithScript) + } + + func test_fill_withoutAutofillableRequest_throws() { + let request = SafariExtensionRequest(kind: .fill) + + XCTAssertThrowsError( + try SafariExtensionResponse.fill( + request: request, + username: "user@example.com", + password: "secret", + fields: [], + matchedLogin: nil, + ) + ) + } + + func test_generatedPassword_withNonGenerateRequest_throws() { + let request = SafariExtensionRequest( + kind: .saveLogin, + password: "secret", + urlString: "https://example.com/login", + username: "user@example.com", + ) + + XCTAssertThrowsError(try SafariExtensionResponse.generatedPassword("generated-secret", for: request)) + } + + func test_roundTripEncodeDecode_saveNewLoginResponse() throws { + let request = SafariExtensionRequest( + kind: .saveLogin, + password: "secret", + urlString: "https://example.com/login", + username: "user@example.com", + ) + let subject = SafariExtensionResponse( + request: request, + suggestionAction: .saveLogin, + submissionAction: .saveNewLogin, + matchedLogin: nil, + fillScriptJSON: nil, + generatedPassword: nil, + userMessage: "Save login", + ) + + let data = try JSONEncoder().encode(subject) + let decoded = try JSONDecoder().decode(SafariExtensionResponse.self, from: data) + + XCTAssertEqual(decoded, subject) + XCTAssertFalse(decoded.canFinalizeWithScript) + XCTAssertFalse(decoded.hasGeneratedPassword) + } + + private func makeFillRequest() -> SafariExtensionRequest { + SafariExtensionRequest( + kind: .fill, + pageDetails: PageDetails( + collectedTimestamp: Date(timeIntervalSince1970: 1_715_000_000), + documentUUID: "doc-1", + documentUrl: "https://example.com/login", + fields: [ + PageDetails.Field( + disabled: false, + elementNumber: 1, + form: "login-form", + htmlClass: nil, + htmlId: "email", + htmlName: "email", + labelLeft: nil, + labelRight: nil, + labelTag: "Email", + onepasswordFieldType: nil, + opId: "username-field", + placeholder: "Email", + readOnly: false, + type: "email", + value: nil, + viewable: true, + visible: true, + ), + PageDetails.Field( + disabled: false, + elementNumber: 2, + form: "login-form", + htmlClass: nil, + htmlId: "password", + htmlName: "password", + labelLeft: nil, + labelRight: nil, + labelTag: "Password", + onepasswordFieldType: nil, + opId: "password-field", + placeholder: "Password", + readOnly: false, + type: "password", + value: nil, + viewable: true, + visible: true, + ), + ], + forms: [ + "login-form": PageDetails.Form( + htmlAction: "/login", + htmlId: "login-form", + htmlMethod: "post", + htmlName: "login", + opId: "login-form", + ), + ], + tabUrl: "https://example.com/login", + title: "Login", + url: "https://example.com/login", + ), + urlString: "https://example.com/login", + ) + } +} From 3886cc5297e3a873f2fe0c4ca6fb4e544fee4803 Mon Sep 17 00:00:00 2001 From: 2001y Date: Wed, 22 Apr 2026 23:31:23 +0900 Subject: [PATCH 006/104] feat: add safari web extension target scaffold --- .../BitwardenSafariWebExtension.entitlements | 14 +++++++ .../Application/Support/Info.plist | 39 ++++++++++++++++++ .../Application/Support/background.js | 11 +++++ .../Application/Support/content.js | 1 + .../Application/Support/icon-48.png | Bin 0 -> 68 bytes .../Application/Support/icon-96.png | Bin 0 -> 68 bytes .../Application/Support/manifest.json | 17 ++++++++ .../SafariWebExtensionHandler.swift | 7 ++++ Configs/BitwardenSafariWebExtension.xcconfig | 4 ++ project-pm.yml | 23 +++++++++++ 10 files changed, 116 insertions(+) create mode 100644 BitwardenSafariWebExtension/Application/Support/BitwardenSafariWebExtension.entitlements create mode 100644 BitwardenSafariWebExtension/Application/Support/Info.plist create mode 100644 BitwardenSafariWebExtension/Application/Support/background.js create mode 100644 BitwardenSafariWebExtension/Application/Support/content.js create mode 100644 BitwardenSafariWebExtension/Application/Support/icon-48.png create mode 100644 BitwardenSafariWebExtension/Application/Support/icon-96.png create mode 100644 BitwardenSafariWebExtension/Application/Support/manifest.json create mode 100644 BitwardenSafariWebExtension/SafariWebExtensionHandler.swift create mode 100644 Configs/BitwardenSafariWebExtension.xcconfig diff --git a/BitwardenSafariWebExtension/Application/Support/BitwardenSafariWebExtension.entitlements b/BitwardenSafariWebExtension/Application/Support/BitwardenSafariWebExtension.entitlements new file mode 100644 index 0000000000..c74bd27f7c --- /dev/null +++ b/BitwardenSafariWebExtension/Application/Support/BitwardenSafariWebExtension.entitlements @@ -0,0 +1,14 @@ + + + + + com.apple.security.application-groups + + group.$(BASE_BUNDLE_ID) + + keychain-access-groups + + $(AppIdentifierPrefix)$(BASE_BUNDLE_ID) + + + diff --git a/BitwardenSafariWebExtension/Application/Support/Info.plist b/BitwardenSafariWebExtension/Application/Support/Info.plist new file mode 100644 index 0000000000..5835734fdb --- /dev/null +++ b/BitwardenSafariWebExtension/Application/Support/Info.plist @@ -0,0 +1,39 @@ + + + + + BitwardenAppIdentifier + $(BASE_BUNDLE_ID) + BitwardenKeychainAccessGroup + $(AppIdentifierPrefix)$(BASE_BUNDLE_ID) + CFBundleDevelopmentRegion + en + CFBundleDisplayName + Bitwarden Safari + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + Bitwarden Safari Web Extension + CFBundlePackageType + XPC! + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + ITSAppUsesNonExemptEncryption + + ITSEncryptionExportComplianceCode + ecf076d3-4824-4d7b-b716-2a9a47d7d296 + NSExtension + + NSExtensionPointIdentifier + com.apple.Safari.web-extension + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).SafariWebExtensionHandler + + + diff --git a/BitwardenSafariWebExtension/Application/Support/background.js b/BitwardenSafariWebExtension/Application/Support/background.js new file mode 100644 index 0000000000..243384404c --- /dev/null +++ b/BitwardenSafariWebExtension/Application/Support/background.js @@ -0,0 +1,11 @@ +browser.runtime.onInstalled.addListener(() => { + console.log("Bitwarden Safari Web Extension scaffold installed."); +}); + +browser.runtime.onMessage.addListener((message) => { + if (message?.type === "bitwarden:ping") { + return Promise.resolve({ type: "bitwarden:pong" }); + } + + return false; +}); diff --git a/BitwardenSafariWebExtension/Application/Support/content.js b/BitwardenSafariWebExtension/Application/Support/content.js new file mode 100644 index 0000000000..1f17024219 --- /dev/null +++ b/BitwardenSafariWebExtension/Application/Support/content.js @@ -0,0 +1 @@ +// Reserved for future page classification and fill/save/update bridge logic. diff --git a/BitwardenSafariWebExtension/Application/Support/icon-48.png b/BitwardenSafariWebExtension/Application/Support/icon-48.png new file mode 100644 index 0000000000000000000000000000000000000000..1d92c364e3743107a744ffd1bb997795a497a851 GIT binary patch literal 68 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx0wlM}@Gt=>Zci7-kcwN$fBwreFf%hTyr1>* Q3s9KB)78&qol`;+0Fo#VCjbBd literal 0 HcmV?d00001 diff --git a/BitwardenSafariWebExtension/Application/Support/icon-96.png b/BitwardenSafariWebExtension/Application/Support/icon-96.png new file mode 100644 index 0000000000000000000000000000000000000000..1d92c364e3743107a744ffd1bb997795a497a851 GIT binary patch literal 68 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx0wlM}@Gt=>Zci7-kcwN$fBwreFf%hTyr1>* Q3s9KB)78&qol`;+0Fo#VCjbBd literal 0 HcmV?d00001 diff --git a/BitwardenSafariWebExtension/Application/Support/manifest.json b/BitwardenSafariWebExtension/Application/Support/manifest.json new file mode 100644 index 0000000000..ff9a163a07 --- /dev/null +++ b/BitwardenSafariWebExtension/Application/Support/manifest.json @@ -0,0 +1,17 @@ +{ + "manifest_version": 3, + "name": "Bitwarden Safari", + "description": "Bitwarden Safari Web Extension scaffold for iOS and iPadOS.", + "version": "0.0.1", + "icons": { + "48": "icon-48.png", + "96": "icon-96.png" + }, + "background": { + "service_worker": "background.js" + }, + "permissions": [ + "activeTab", + "nativeMessaging" + ] +} diff --git a/BitwardenSafariWebExtension/SafariWebExtensionHandler.swift b/BitwardenSafariWebExtension/SafariWebExtensionHandler.swift new file mode 100644 index 0000000000..ce7d620872 --- /dev/null +++ b/BitwardenSafariWebExtension/SafariWebExtensionHandler.swift @@ -0,0 +1,7 @@ +import Foundation + +final class SafariWebExtensionHandler: NSObject, NSExtensionRequestHandling { + func beginRequest(with context: NSExtensionContext) { + context.completeRequest(returningItems: nil, completionHandler: nil) + } +} diff --git a/Configs/BitwardenSafariWebExtension.xcconfig b/Configs/BitwardenSafariWebExtension.xcconfig new file mode 100644 index 0000000000..15f1302262 --- /dev/null +++ b/Configs/BitwardenSafariWebExtension.xcconfig @@ -0,0 +1,4 @@ +#include "./Common-bwpm.xcconfig" +#include? "./Local-bwpm.xcconfig" + +PRODUCT_BUNDLE_IDENTIFIER = $(BASE_BUNDLE_ID).safari-web-extension diff --git a/project-pm.yml b/project-pm.yml index e825e131c7..860543ee9d 100644 --- a/project-pm.yml +++ b/project-pm.yml @@ -38,6 +38,7 @@ schemes: - Bitwarden - BitwardenActionExtension - BitwardenAutoFillExtension + - BitwardenSafariWebExtension - BitwardenShareExtension - BitwardenShared - BitwardenKit/AuthenticatorBridgeKit @@ -111,6 +112,10 @@ schemes: - path: TestPlans/Bitwarden-Default.xctestplan defaultPlan: true - path: TestPlans/Bitwarden-Unit.xctestplan + BitwardenSafariWebExtension: + build: + targets: + BitwardenSafariWebExtension: all BitwardenShared: build: targets: @@ -170,6 +175,7 @@ targets: - target: BitwardenShared - target: BitwardenActionExtension - target: BitwardenAutoFillExtension + - target: BitwardenSafariWebExtension - target: BitwardenShareExtension - target: BitwardenWatchApp - target: BitwardenKit/AuthenticatorBridgeKit @@ -329,6 +335,23 @@ targets: - target: BitwardenKit/TestHelpers randomExecutionOrder: true + BitwardenSafariWebExtension: + type: app-extension + platform: iOS + configFiles: + Debug: Configs/BitwardenSafariWebExtension.xcconfig + Release: Configs/BitwardenSafariWebExtension.xcconfig + settings: + base: + INFOPLIST_FILE: BitwardenSafariWebExtension/Application/Support/Info.plist + CODE_SIGN_ENTITLEMENTS: BitwardenSafariWebExtension/Application/Support/BitwardenSafariWebExtension.entitlements + templates: + - CommonTarget + templateAttributes: + sourcesPath: BitwardenSafariWebExtension + dependencies: + - target: BitwardenShared + BitwardenShared: type: framework platform: iOS From 2c55177bb67c540f252a506ec8a16ef57eda8106 Mon Sep 17 00:00:00 2001 From: 2001y Date: Thu, 23 Apr 2026 00:39:35 +0900 Subject: [PATCH 007/104] feat: add safari web extension native bridge --- .../Application/Support/background.js | 33 ++++++++++ .../Application/Support/content.js | 18 +++++- .../Application/Support/icon-48.png | Bin 68 -> 125 bytes .../Application/Support/icon-96.png | Bin 68 -> 216 bytes .../TestHelpers/Support/Info.plist | 22 +++++++ .../SafariWebExtensionBridge.swift | 61 ++++++++++++++++++ .../SafariWebExtensionBridgeTests.swift | 53 +++++++++++++++ .../SafariWebExtensionHandler.swift | 48 +++++++++++++- .../SafariWebExtensionHandlerTests.swift | 41 ++++++++++++ .../Models/SafariExtensionRequest.swift | 28 ++++++-- .../Models/SafariExtensionResponse.swift | 29 +++++++-- .../SafariExtensionSubmissionAction.swift | 6 +- .../SafariExtensionSuggestionAction.swift | 4 +- project-pm.yml | 31 +++++++++ 14 files changed, 355 insertions(+), 19 deletions(-) create mode 100644 BitwardenSafariWebExtension/Application/TestHelpers/Support/Info.plist create mode 100644 BitwardenSafariWebExtension/SafariWebExtensionBridge.swift create mode 100644 BitwardenSafariWebExtension/SafariWebExtensionBridgeTests.swift create mode 100644 BitwardenSafariWebExtension/SafariWebExtensionHandlerTests.swift diff --git a/BitwardenSafariWebExtension/Application/Support/background.js b/BitwardenSafariWebExtension/Application/Support/background.js index 243384404c..31d507e95b 100644 --- a/BitwardenSafariWebExtension/Application/Support/background.js +++ b/BitwardenSafariWebExtension/Application/Support/background.js @@ -2,7 +2,40 @@ browser.runtime.onInstalled.addListener(() => { console.log("Bitwarden Safari Web Extension scaffold installed."); }); +function bitwardenParseNativeResponse(nativeResponse) { + const message = nativeResponse?.message; + if (typeof message !== "string") { + return nativeResponse; + } + + try { + return JSON.parse(message); + } catch { + return { + errorMessage: "Invalid native response payload", + id: null, + response: null, + }; + } +} + +async function bitwardenSendNativeRequest(request) { + const bridgeRequest = { + id: crypto.randomUUID(), + request, + }; + const nativeResponse = await browser.runtime.sendNativeMessage("bitwarden", { + message: JSON.stringify(bridgeRequest), + }); + + return bitwardenParseNativeResponse(nativeResponse); +} + browser.runtime.onMessage.addListener((message) => { + if (message?.type === "bitwarden:generate-password") { + return bitwardenSendNativeRequest({ kind: "generatePassword" }); + } + if (message?.type === "bitwarden:ping") { return Promise.resolve({ type: "bitwarden:pong" }); } diff --git a/BitwardenSafariWebExtension/Application/Support/content.js b/BitwardenSafariWebExtension/Application/Support/content.js index 1f17024219..5dfa0a9700 100644 --- a/BitwardenSafariWebExtension/Application/Support/content.js +++ b/BitwardenSafariWebExtension/Application/Support/content.js @@ -1 +1,17 @@ -// Reserved for future page classification and fill/save/update bridge logic. +(() => { + function bitwardenBuildRequest(kind) { + return { + id: crypto.randomUUID(), + request: { kind }, + }; + } + + async function bitwardenGeneratePassword() { + return browser.runtime.sendMessage({ type: "bitwarden:generate-password" }); + } + + window.bitwardenSafariWebExtension = { + buildRequest: bitwardenBuildRequest, + generatePassword: bitwardenGeneratePassword, + }; +})(); diff --git a/BitwardenSafariWebExtension/Application/Support/icon-48.png b/BitwardenSafariWebExtension/Application/Support/icon-48.png index 1d92c364e3743107a744ffd1bb997795a497a851..24462564d4bb6c9127e6a5cee0f6445a1dacce29 100644 GIT binary patch literal 125 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA1|-9oezpTC7f%<*kcwMx&pQe-Ft9Kf=xz}T r_SO2a>vl4uSfuUz)&_#$z*QzkyJ-F+KgG0w<}rA>`njxgN@xNAgH|aF literal 68 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx0wlM}@Gt=>Zci7-kcwN$fBwreFf%hTyr1>* Q3s9KB)78&qol`;+0Fo#VCjbBd diff --git a/BitwardenSafariWebExtension/Application/Support/icon-96.png b/BitwardenSafariWebExtension/Application/Support/icon-96.png index 1d92c364e3743107a744ffd1bb997795a497a851..07f030adafee33d573cf8840e2d18624e54ecf1f 100644 GIT binary patch literal 216 zcmeAS@N?(olHy`uVBq!ia0vp^2_VeD1|%QND7OGo^F3W0Ln>~)y=chGz`$|9Am#zr z%o{9{Doc*@teJRq@$N$`3=Av`4Gatd3=B*R4nQ>w42%p4lnF8%AkNeS?P4{(IUZZT StSbdNmBG{1&t;ucLK6UL03+4_ literal 68 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx0wlM}@Gt=>Zci7-kcwN$fBwreFf%hTyr1>* Q3s9KB)78&qol`;+0Fo#VCjbBd diff --git a/BitwardenSafariWebExtension/Application/TestHelpers/Support/Info.plist b/BitwardenSafariWebExtension/Application/TestHelpers/Support/Info.plist new file mode 100644 index 0000000000..6c6c23c43a --- /dev/null +++ b/BitwardenSafariWebExtension/Application/TestHelpers/Support/Info.plist @@ -0,0 +1,22 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + BNDL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + + diff --git a/BitwardenSafariWebExtension/SafariWebExtensionBridge.swift b/BitwardenSafariWebExtension/SafariWebExtensionBridge.swift new file mode 100644 index 0000000000..33a362c86b --- /dev/null +++ b/BitwardenSafariWebExtension/SafariWebExtensionBridge.swift @@ -0,0 +1,61 @@ +import BitwardenShared +import Foundation +import SafariServices + +struct SafariWebExtensionBridgeRequest: Codable, Equatable { + var id: String + var request: SafariExtensionRequest +} + +struct SafariWebExtensionBridgeResponse: Codable, Equatable { + var id: String + var response: SafariExtensionResponse + var errorMessage: String? +} + +enum SafariWebExtensionBridge { + static let legacyMessageUserInfoKey = "message" + + static var messageUserInfoKey: String { + if #available(iOS 15.0, macOS 11.0, *) { + return SFExtensionMessageKey + } + return legacyMessageUserInfoKey + } + + static func decodeRequest(from userInfo: [String: Any]) -> SafariWebExtensionBridgeRequest? { + let rawMessage = userInfo[messageUserInfoKey] ?? userInfo[legacyMessageUserInfoKey] + + if let message = rawMessage as? String, + let data = message.data(using: .utf8) { + return try? JSONDecoder().decode(SafariWebExtensionBridgeRequest.self, from: data) + } + + if let message = rawMessage as? [String: Any], + let data = try? JSONSerialization.data(withJSONObject: message) { + return try? JSONDecoder().decode(SafariWebExtensionBridgeRequest.self, from: data) + } + + return nil + } + + static func makeResponseItem( + for request: SafariWebExtensionBridgeRequest, + response: SafariExtensionResponse, + errorMessage: String? = nil, + ) throws -> NSExtensionItem { + let bridgeResponse = SafariWebExtensionBridgeResponse( + id: request.id, + response: response, + errorMessage: errorMessage, + ) + let data = try JSONEncoder().encode(bridgeResponse) + guard let message = String(data: data, encoding: .utf8) else { + throw CocoaError(.coderInvalidValue) + } + + let item = NSExtensionItem() + item.userInfo = [messageUserInfoKey: message] + return item + } +} diff --git a/BitwardenSafariWebExtension/SafariWebExtensionBridgeTests.swift b/BitwardenSafariWebExtension/SafariWebExtensionBridgeTests.swift new file mode 100644 index 0000000000..3701853190 --- /dev/null +++ b/BitwardenSafariWebExtension/SafariWebExtensionBridgeTests.swift @@ -0,0 +1,53 @@ +import XCTest + +@testable import BitwardenSafariWebExtension +@testable import BitwardenShared + +class SafariWebExtensionBridgeTests: BitwardenTestCase { + func test_decodeRequest_parsesBridgeEnvelope() throws { + let bridgeJSON = """ + { + "id": "req-1", + "request": { + "kind": "generatePassword" + } + } + """ + let userInfo: [String: Any] = [ + SafariWebExtensionBridge.legacyMessageUserInfoKey: bridgeJSON, + ] + + let subject = try XCTUnwrap(SafariWebExtensionBridge.decodeRequest(from: userInfo)) + + XCTAssertEqual(subject.id, "req-1") + XCTAssertEqual(subject.request, SafariExtensionRequest(kind: .generatePassword)) + } + + func test_makeResponseItem_wrapsBridgeResponseInExtensionItem() throws { + let response = try SafariExtensionResponse.generatedPassword( + "generated-secret", + for: SafariExtensionRequest(kind: .generatePassword), + ) + + let item = try SafariWebExtensionBridge.makeResponseItem( + for: SafariWebExtensionBridgeRequest( + id: "req-1", + request: SafariExtensionRequest(kind: .generatePassword), + ), + response: response, + ) + + let userInfo = try XCTUnwrap(item.userInfo as? [String: Any]) + let message = try XCTUnwrap( + (userInfo[SafariWebExtensionBridge.messageUserInfoKey] ?? userInfo[SafariWebExtensionBridge.legacyMessageUserInfoKey]) as? String, + ) + let bridgeResponse = try JSONDecoder().decode( + SafariWebExtensionBridgeResponse.self, + from: XCTUnwrap(message.data(using: .utf8)), + ) + + XCTAssertEqual(bridgeResponse.id, "req-1") + XCTAssertEqual(bridgeResponse.response.generatedPassword, "generated-secret") + XCTAssertNil(bridgeResponse.errorMessage) + } +} diff --git a/BitwardenSafariWebExtension/SafariWebExtensionHandler.swift b/BitwardenSafariWebExtension/SafariWebExtensionHandler.swift index ce7d620872..25b12c1daa 100644 --- a/BitwardenSafariWebExtension/SafariWebExtensionHandler.swift +++ b/BitwardenSafariWebExtension/SafariWebExtensionHandler.swift @@ -1,7 +1,53 @@ +import BitwardenShared import Foundation final class SafariWebExtensionHandler: NSObject, NSExtensionRequestHandling { func beginRequest(with context: NSExtensionContext) { - context.completeRequest(returningItems: nil, completionHandler: nil) + let inputItems = context.inputItems as? [NSExtensionItem] ?? [] + let responseItems = inputItems.compactMap { item in + makeResponseItem(from: item.userInfo as? [String: Any] ?? [:]) + } + context.completeRequest(returningItems: responseItems, completionHandler: nil) + } + + func makeResponseItem(from userInfo: [String: Any]) -> NSExtensionItem? { + guard let bridgeRequest = SafariWebExtensionBridge.decodeRequest(from: userInfo), + let response = makeResponse(for: bridgeRequest.request) else { + return nil + } + + return try? SafariWebExtensionBridge.makeResponseItem( + for: bridgeRequest, + response: response, + ) + } + + private func makeResponse(for request: SafariExtensionRequest) -> SafariExtensionResponse? { + switch request.kind { + case .generatePassword: + return try? SafariExtensionResponse.generatedPassword("generated-password", for: request) + case .setup: + return SafariExtensionResponse( + request: request, + suggestionAction: .none, + submissionAction: .none, + matchedLogin: nil, + fillScriptJSON: nil, + generatedPassword: nil, + userMessage: "Safari Web Extension setup", + ) + case .fill, .saveLogin, .changePassword: + let suggestionAction = SafariExtensionSuggestionAction.from(request) + let submissionAction = SafariExtensionSubmissionAction.classify(request, matchedLogin: nil) + return SafariExtensionResponse( + request: request, + suggestionAction: suggestionAction, + submissionAction: submissionAction, + matchedLogin: nil, + fillScriptJSON: nil, + generatedPassword: nil, + userMessage: submissionAction == .none ? nil : suggestionAction.rawValue, + ) + } } } diff --git a/BitwardenSafariWebExtension/SafariWebExtensionHandlerTests.swift b/BitwardenSafariWebExtension/SafariWebExtensionHandlerTests.swift new file mode 100644 index 0000000000..1d97a7a2b5 --- /dev/null +++ b/BitwardenSafariWebExtension/SafariWebExtensionHandlerTests.swift @@ -0,0 +1,41 @@ +import XCTest + +@testable import BitwardenSafariWebExtension +@testable import BitwardenShared + +class SafariWebExtensionHandlerTests: BitwardenTestCase { + func test_handleUserInfo_generatePassword_returnsBridgeResponse() throws { + let bridgeJSON = """ + { + "id": "req-1", + "request": { + "kind": "generatePassword" + } + } + """ + let subject = SafariWebExtensionHandler() + + let item = try XCTUnwrap(subject.makeResponseItem(from: [ + SafariWebExtensionBridge.legacyMessageUserInfoKey: bridgeJSON, + ])) + + let userInfo = try XCTUnwrap(item.userInfo as? [String: Any]) + let message = try XCTUnwrap( + (userInfo[SafariWebExtensionBridge.messageUserInfoKey] ?? userInfo[SafariWebExtensionBridge.legacyMessageUserInfoKey]) as? String, + ) + let bridgeResponse = try JSONDecoder().decode( + SafariWebExtensionBridgeResponse.self, + from: XCTUnwrap(message.data(using: .utf8)), + ) + + XCTAssertEqual(bridgeResponse.id, "req-1") + XCTAssertEqual(bridgeResponse.response.generatedPassword, "generated-password") + XCTAssertEqual(bridgeResponse.response.submissionAction, .generatePassword) + } + + func test_handleUserInfo_invalidPayload_returnsNil() { + let subject = SafariWebExtensionHandler() + + XCTAssertNil(subject.makeResponseItem(from: [:])) + } +} diff --git a/BitwardenShared/Core/SafariExtension/Models/SafariExtensionRequest.swift b/BitwardenShared/Core/SafariExtension/Models/SafariExtensionRequest.swift index 93cd1cd196..9ab9a71b21 100644 --- a/BitwardenShared/Core/SafariExtension/Models/SafariExtensionRequest.swift +++ b/BitwardenShared/Core/SafariExtension/Models/SafariExtensionRequest.swift @@ -3,7 +3,7 @@ import Foundation // MARK: - SafariExtensionRequestKind /// The high-level action requested by the Safari extension host/content bridge. -enum SafariExtensionRequestKind: String, Codable, Equatable { +public enum SafariExtensionRequestKind: String, Codable, Equatable { case setup case fill case saveLogin @@ -14,9 +14,9 @@ enum SafariExtensionRequestKind: String, Codable, Equatable { // MARK: - SafariExtensionRequest /// A shared Codable payload for Safari extension requests flowing between web/native layers. -struct SafariExtensionRequest: Codable, Equatable { +public struct SafariExtensionRequest: Codable, Equatable { /// The requested action type. - var kind: SafariExtensionRequestKind + public var kind: SafariExtensionRequestKind /// The login title extracted from the page, if any. var loginTitle: String? @@ -43,25 +43,39 @@ struct SafariExtensionRequest: Codable, Equatable { var username: String? /// Whether this request can drive page-aware autofill. - var canAutofill: Bool { + public var canAutofill: Bool { kind == .fill && pageDetails?.hasPasswordField == true } /// Whether this request contains enough information to save a login. - var canSaveLogin: Bool { + public var canSaveLogin: Bool { kind == .saveLogin && !(username?.isEmpty ?? true) && !(password?.isEmpty ?? true) } /// Whether this request contains enough information to update a password. - var canChangePassword: Bool { + public var canChangePassword: Bool { kind == .changePassword && !(oldPassword?.isEmpty ?? true) && !(password?.isEmpty ?? true) } /// Whether this request can drive password generation UI. - var canGeneratePassword: Bool { + public var canGeneratePassword: Bool { kind == .generatePassword } + public init(kind: SafariExtensionRequestKind) { + self.init( + kind: kind, + loginTitle: nil, + notes: nil, + oldPassword: nil, + pageDetails: nil, + password: nil, + passwordOptions: nil, + urlString: nil, + username: nil, + ) + } + init( kind: SafariExtensionRequestKind, loginTitle: String? = nil, diff --git a/BitwardenShared/Core/SafariExtension/Models/SafariExtensionResponse.swift b/BitwardenShared/Core/SafariExtension/Models/SafariExtensionResponse.swift index cbd6460da0..626b9e3171 100644 --- a/BitwardenShared/Core/SafariExtension/Models/SafariExtensionResponse.swift +++ b/BitwardenShared/Core/SafariExtension/Models/SafariExtensionResponse.swift @@ -3,7 +3,7 @@ import Foundation // MARK: - SafariExtensionResponse /// A shared Codable payload returned from the native Safari extension layer back to the web bridge/UI. -struct SafariExtensionResponse: Codable, Equatable { +public struct SafariExtensionResponse: Codable, Equatable { /// The originating request. var request: SafariExtensionRequest @@ -26,17 +26,36 @@ struct SafariExtensionResponse: Codable, Equatable { var userMessage: String? /// Whether the response includes a fill script that can be finalized into the page. - var canFinalizeWithScript: Bool { + public var canFinalizeWithScript: Bool { submissionAction == .fill && !(fillScriptJSON?.isEmpty ?? true) } /// Whether the response includes a generated password payload. - var hasGeneratedPassword: Bool { + public var hasGeneratedPassword: Bool { !(generatedPassword?.isEmpty ?? true) } /// Build a response for page fill flows. - static func fill( + public init( + request: SafariExtensionRequest, + suggestionAction: SafariExtensionSuggestionAction, + submissionAction: SafariExtensionSubmissionAction, + matchedLogin: SafariExtensionMatchedLogin?, + fillScriptJSON: String?, + generatedPassword: String?, + userMessage: String?, + ) { + self.request = request + self.suggestionAction = suggestionAction + self.submissionAction = submissionAction + self.matchedLogin = matchedLogin + self.fillScriptJSON = fillScriptJSON + self.generatedPassword = generatedPassword + self.userMessage = userMessage + } + + /// Build a response for page fill flows. + public static func fill( request: SafariExtensionRequest, username: String, password: String, @@ -69,7 +88,7 @@ struct SafariExtensionResponse: Codable, Equatable { } /// Build a response for password generation flows. - static func generatedPassword(_ generatedPassword: String, for request: SafariExtensionRequest) throws -> Self { + public static func generatedPassword(_ generatedPassword: String, for request: SafariExtensionRequest) throws -> Self { guard request.canGeneratePassword else { throw CocoaError(.coderInvalidValue) } diff --git a/BitwardenShared/Core/SafariExtension/Models/SafariExtensionSubmissionAction.swift b/BitwardenShared/Core/SafariExtension/Models/SafariExtensionSubmissionAction.swift index ac8dedffdf..09d4fdc81d 100644 --- a/BitwardenShared/Core/SafariExtension/Models/SafariExtensionSubmissionAction.swift +++ b/BitwardenShared/Core/SafariExtension/Models/SafariExtensionSubmissionAction.swift @@ -1,7 +1,7 @@ // MARK: - SafariExtensionMatchedLogin /// A lightweight snapshot of an existing matching login item. -struct SafariExtensionMatchedLogin: Codable, Equatable { +public struct SafariExtensionMatchedLogin: Codable, Equatable { var id: String var username: String? var password: String? @@ -11,7 +11,7 @@ struct SafariExtensionMatchedLogin: Codable, Equatable { // MARK: - SafariExtensionSubmissionAction /// The action the native layer should take when deciding between save/update flows. -enum SafariExtensionSubmissionAction: String, Codable, Equatable { +public enum SafariExtensionSubmissionAction: String, Codable, Equatable { case none case fill case saveNewLogin @@ -19,7 +19,7 @@ enum SafariExtensionSubmissionAction: String, Codable, Equatable { case updatePassword case generatePassword - static func classify( + public static func classify( _ request: SafariExtensionRequest, matchedLogin: SafariExtensionMatchedLogin?, ) -> Self { diff --git a/BitwardenShared/Core/SafariExtension/Models/SafariExtensionSuggestionAction.swift b/BitwardenShared/Core/SafariExtension/Models/SafariExtensionSuggestionAction.swift index b5586e43c0..967099381c 100644 --- a/BitwardenShared/Core/SafariExtension/Models/SafariExtensionSuggestionAction.swift +++ b/BitwardenShared/Core/SafariExtension/Models/SafariExtensionSuggestionAction.swift @@ -1,14 +1,14 @@ // MARK: - SafariExtensionSuggestionAction /// The primary action the Safari extension UI should suggest for a given request. -enum SafariExtensionSuggestionAction: String, Codable, Equatable { +public enum SafariExtensionSuggestionAction: String, Codable, Equatable { case none case fill case saveLogin case updatePassword case generatePassword - static func from(_ request: SafariExtensionRequest) -> Self { + public static func from(_ request: SafariExtensionRequest) -> Self { if request.canAutofill { return .fill } diff --git a/project-pm.yml b/project-pm.yml index 860543ee9d..fe58133395 100644 --- a/project-pm.yml +++ b/project-pm.yml @@ -48,6 +48,7 @@ schemes: - BitwardenTests - BitwardenActionExtensionTests - BitwardenAutoFillExtensionTests + - BitwardenSafariWebExtensionTests - BitwardenShareExtensionTests - BitwardenSharedTests - BitwardenSharedSnapshotTests @@ -116,6 +117,19 @@ schemes: build: targets: BitwardenSafariWebExtension: all + BitwardenSafariWebExtensionTests: [test] + test: + environmentVariables: + TZ: UTC + gatherCoverageData: true + language: en + region: US + targets: + - BitwardenSafariWebExtensionTests + testPlans: + - path: TestPlans/Bitwarden-Default.xctestplan + defaultPlan: true + - path: TestPlans/Bitwarden-Unit.xctestplan BitwardenShared: build: targets: @@ -351,6 +365,23 @@ targets: sourcesPath: BitwardenSafariWebExtension dependencies: - target: BitwardenShared + BitwardenSafariWebExtensionTests: + type: bundle.unit-test + platform: iOS + settings: + base: + INFOPLIST_FILE: BitwardenSafariWebExtension/Application/TestHelpers/Support/Info.plist + templates: + - TestTarget + templateAttributes: + sourcesPath: BitwardenSafariWebExtension + sources: + - path: GlobalTestHelpers + dependencies: + - target: BitwardenSafariWebExtension + - target: BitwardenShared + - target: BitwardenKit/TestHelpers + randomExecutionOrder: true BitwardenShared: type: framework From 7269d51fb84fc9d6160ed8d7ee746bb304ef044c Mon Sep 17 00:00:00 2001 From: 2001y Date: Thu, 23 Apr 2026 01:07:17 +0900 Subject: [PATCH 008/104] refactor: move safari bridge tests into shared models --- .../SafariWebExtensionBridgeTests.swift | 53 ---------------- .../SafariWebExtensionHandler.swift | 50 ++++++--------- .../SafariWebExtensionHandlerTests.swift | 41 ------------ .../Models/SafariExtensionBridgeCodec.swift | 63 +++++++++++++++++++ .../SafariExtensionBridgeCodecTests.swift | 52 +++++++++++++++ .../SafariExtensionRequestProcessor.swift | 34 ++++++++++ ...SafariExtensionRequestProcessorTests.swift | 39 ++++++++++++ project-pm.yml | 31 --------- 8 files changed, 207 insertions(+), 156 deletions(-) delete mode 100644 BitwardenSafariWebExtension/SafariWebExtensionBridgeTests.swift delete mode 100644 BitwardenSafariWebExtension/SafariWebExtensionHandlerTests.swift create mode 100644 BitwardenShared/Core/SafariExtension/Models/SafariExtensionBridgeCodec.swift create mode 100644 BitwardenShared/Core/SafariExtension/Models/SafariExtensionBridgeCodecTests.swift create mode 100644 BitwardenShared/Core/SafariExtension/Models/SafariExtensionRequestProcessor.swift create mode 100644 BitwardenShared/Core/SafariExtension/Models/SafariExtensionRequestProcessorTests.swift diff --git a/BitwardenSafariWebExtension/SafariWebExtensionBridgeTests.swift b/BitwardenSafariWebExtension/SafariWebExtensionBridgeTests.swift deleted file mode 100644 index 3701853190..0000000000 --- a/BitwardenSafariWebExtension/SafariWebExtensionBridgeTests.swift +++ /dev/null @@ -1,53 +0,0 @@ -import XCTest - -@testable import BitwardenSafariWebExtension -@testable import BitwardenShared - -class SafariWebExtensionBridgeTests: BitwardenTestCase { - func test_decodeRequest_parsesBridgeEnvelope() throws { - let bridgeJSON = """ - { - "id": "req-1", - "request": { - "kind": "generatePassword" - } - } - """ - let userInfo: [String: Any] = [ - SafariWebExtensionBridge.legacyMessageUserInfoKey: bridgeJSON, - ] - - let subject = try XCTUnwrap(SafariWebExtensionBridge.decodeRequest(from: userInfo)) - - XCTAssertEqual(subject.id, "req-1") - XCTAssertEqual(subject.request, SafariExtensionRequest(kind: .generatePassword)) - } - - func test_makeResponseItem_wrapsBridgeResponseInExtensionItem() throws { - let response = try SafariExtensionResponse.generatedPassword( - "generated-secret", - for: SafariExtensionRequest(kind: .generatePassword), - ) - - let item = try SafariWebExtensionBridge.makeResponseItem( - for: SafariWebExtensionBridgeRequest( - id: "req-1", - request: SafariExtensionRequest(kind: .generatePassword), - ), - response: response, - ) - - let userInfo = try XCTUnwrap(item.userInfo as? [String: Any]) - let message = try XCTUnwrap( - (userInfo[SafariWebExtensionBridge.messageUserInfoKey] ?? userInfo[SafariWebExtensionBridge.legacyMessageUserInfoKey]) as? String, - ) - let bridgeResponse = try JSONDecoder().decode( - SafariWebExtensionBridgeResponse.self, - from: XCTUnwrap(message.data(using: .utf8)), - ) - - XCTAssertEqual(bridgeResponse.id, "req-1") - XCTAssertEqual(bridgeResponse.response.generatedPassword, "generated-secret") - XCTAssertNil(bridgeResponse.errorMessage) - } -} diff --git a/BitwardenSafariWebExtension/SafariWebExtensionHandler.swift b/BitwardenSafariWebExtension/SafariWebExtensionHandler.swift index 25b12c1daa..142cc02ea0 100644 --- a/BitwardenSafariWebExtension/SafariWebExtensionHandler.swift +++ b/BitwardenSafariWebExtension/SafariWebExtensionHandler.swift @@ -1,7 +1,10 @@ import BitwardenShared import Foundation +import SafariServices final class SafariWebExtensionHandler: NSObject, NSExtensionRequestHandling { + private let requestProcessor = SafariExtensionRequestProcessor() + func beginRequest(with context: NSExtensionContext) { let inputItems = context.inputItems as? [NSExtensionItem] ?? [] let responseItems = inputItems.compactMap { item in @@ -11,43 +14,28 @@ final class SafariWebExtensionHandler: NSObject, NSExtensionRequestHandling { } func makeResponseItem(from userInfo: [String: Any]) -> NSExtensionItem? { - guard let bridgeRequest = SafariWebExtensionBridge.decodeRequest(from: userInfo), - let response = makeResponse(for: bridgeRequest.request) else { + let rawMessage = userInfo[bridgeMessageUserInfoKey] ?? userInfo[SafariWebExtensionBridge.legacyMessageUserInfoKey] + guard let bridgeRequest = SafariExtensionBridgeCodec.decodeRequest(from: rawMessage), + let response = requestProcessor.makeResponse(for: bridgeRequest.request) else { return nil } - return try? SafariWebExtensionBridge.makeResponseItem( - for: bridgeRequest, + guard let message = try? SafariExtensionBridgeCodec.encodeResponse( + requestID: bridgeRequest.id, response: response, - ) + ) else { + return nil + } + + let item = NSExtensionItem() + item.userInfo = [bridgeMessageUserInfoKey: message] + return item } - private func makeResponse(for request: SafariExtensionRequest) -> SafariExtensionResponse? { - switch request.kind { - case .generatePassword: - return try? SafariExtensionResponse.generatedPassword("generated-password", for: request) - case .setup: - return SafariExtensionResponse( - request: request, - suggestionAction: .none, - submissionAction: .none, - matchedLogin: nil, - fillScriptJSON: nil, - generatedPassword: nil, - userMessage: "Safari Web Extension setup", - ) - case .fill, .saveLogin, .changePassword: - let suggestionAction = SafariExtensionSuggestionAction.from(request) - let submissionAction = SafariExtensionSubmissionAction.classify(request, matchedLogin: nil) - return SafariExtensionResponse( - request: request, - suggestionAction: suggestionAction, - submissionAction: submissionAction, - matchedLogin: nil, - fillScriptJSON: nil, - generatedPassword: nil, - userMessage: submissionAction == .none ? nil : suggestionAction.rawValue, - ) + private var bridgeMessageUserInfoKey: String { + if #available(iOS 15.0, macOS 11.0, *) { + return SFExtensionMessageKey } + return SafariWebExtensionBridge.legacyMessageUserInfoKey } } diff --git a/BitwardenSafariWebExtension/SafariWebExtensionHandlerTests.swift b/BitwardenSafariWebExtension/SafariWebExtensionHandlerTests.swift deleted file mode 100644 index 1d97a7a2b5..0000000000 --- a/BitwardenSafariWebExtension/SafariWebExtensionHandlerTests.swift +++ /dev/null @@ -1,41 +0,0 @@ -import XCTest - -@testable import BitwardenSafariWebExtension -@testable import BitwardenShared - -class SafariWebExtensionHandlerTests: BitwardenTestCase { - func test_handleUserInfo_generatePassword_returnsBridgeResponse() throws { - let bridgeJSON = """ - { - "id": "req-1", - "request": { - "kind": "generatePassword" - } - } - """ - let subject = SafariWebExtensionHandler() - - let item = try XCTUnwrap(subject.makeResponseItem(from: [ - SafariWebExtensionBridge.legacyMessageUserInfoKey: bridgeJSON, - ])) - - let userInfo = try XCTUnwrap(item.userInfo as? [String: Any]) - let message = try XCTUnwrap( - (userInfo[SafariWebExtensionBridge.messageUserInfoKey] ?? userInfo[SafariWebExtensionBridge.legacyMessageUserInfoKey]) as? String, - ) - let bridgeResponse = try JSONDecoder().decode( - SafariWebExtensionBridgeResponse.self, - from: XCTUnwrap(message.data(using: .utf8)), - ) - - XCTAssertEqual(bridgeResponse.id, "req-1") - XCTAssertEqual(bridgeResponse.response.generatedPassword, "generated-password") - XCTAssertEqual(bridgeResponse.response.submissionAction, .generatePassword) - } - - func test_handleUserInfo_invalidPayload_returnsNil() { - let subject = SafariWebExtensionHandler() - - XCTAssertNil(subject.makeResponseItem(from: [:])) - } -} diff --git a/BitwardenShared/Core/SafariExtension/Models/SafariExtensionBridgeCodec.swift b/BitwardenShared/Core/SafariExtension/Models/SafariExtensionBridgeCodec.swift new file mode 100644 index 0000000000..cbaf2c554c --- /dev/null +++ b/BitwardenShared/Core/SafariExtension/Models/SafariExtensionBridgeCodec.swift @@ -0,0 +1,63 @@ +import Foundation + +// MARK: - SafariExtensionBridgeRequest + +public struct SafariExtensionBridgeRequest: Codable, Equatable { + public var id: String + public var request: SafariExtensionRequest + + public init(id: String, request: SafariExtensionRequest) { + self.id = id + self.request = request + } +} + +// MARK: - SafariExtensionBridgeResponse + +public struct SafariExtensionBridgeResponse: Codable, Equatable { + public var id: String + public var response: SafariExtensionResponse + public var errorMessage: String? + + public init(id: String, response: SafariExtensionResponse, errorMessage: String?) { + self.id = id + self.response = response + self.errorMessage = errorMessage + } +} + +// MARK: - SafariExtensionBridgeCodec + +public enum SafariExtensionBridgeCodec { + public static func decodeRequest(from message: Any?) -> SafariExtensionBridgeRequest? { + if let message, + JSONSerialization.isValidJSONObject(message), + let data = try? JSONSerialization.data(withJSONObject: message) { + return try? JSONDecoder().decode(SafariExtensionBridgeRequest.self, from: data) + } + + if let message = message as? String, + let data = message.data(using: .utf8) { + return try? JSONDecoder().decode(SafariExtensionBridgeRequest.self, from: data) + } + + return nil + } + + public static func encodeResponse( + requestID: String, + response: SafariExtensionResponse, + errorMessage: String? = nil, + ) throws -> String { + let bridgeResponse = SafariExtensionBridgeResponse( + id: requestID, + response: response, + errorMessage: errorMessage, + ) + let data = try JSONEncoder().encode(bridgeResponse) + guard let message = String(data: data, encoding: .utf8) else { + throw CocoaError(.coderInvalidValue) + } + return message + } +} diff --git a/BitwardenShared/Core/SafariExtension/Models/SafariExtensionBridgeCodecTests.swift b/BitwardenShared/Core/SafariExtension/Models/SafariExtensionBridgeCodecTests.swift new file mode 100644 index 0000000000..a75344c732 --- /dev/null +++ b/BitwardenShared/Core/SafariExtension/Models/SafariExtensionBridgeCodecTests.swift @@ -0,0 +1,52 @@ +import XCTest + +@testable import BitwardenShared + +class SafariExtensionBridgeCodecTests: BitwardenTestCase { + func test_decodeRequestFromJSONString_parsesBridgeEnvelope() throws { + let message = """ + { + "id": "req-1", + "request": { + "kind": "generatePassword" + } + } + """ + + let subject = try XCTUnwrap(SafariExtensionBridgeCodec.decodeRequest(from: message)) + + XCTAssertEqual(subject.id, "req-1") + XCTAssertEqual(subject.request, SafariExtensionRequest(kind: .generatePassword)) + } + + func test_decodeRequestFromDictionary_parsesBridgeEnvelope() throws { + let message: [String: Any] = [ + "id": "req-2", + "request": [ + "kind": "setup", + ], + ] + + let subject = try XCTUnwrap(SafariExtensionBridgeCodec.decodeRequest(from: message)) + + XCTAssertEqual(subject.id, "req-2") + XCTAssertEqual(subject.request, SafariExtensionRequest(kind: .setup)) + } + + func test_encodeResponse_returnsJSONStringEnvelope() throws { + let response = try SafariExtensionResponse.generatedPassword( + "generated-secret", + for: SafariExtensionRequest(kind: .generatePassword), + ) + + let encoded = try SafariExtensionBridgeCodec.encodeResponse( + requestID: "req-1", + response: response, + ) + let decoded = try JSONDecoder().decode(SafariExtensionBridgeResponse.self, from: XCTUnwrap(encoded.data(using: .utf8))) + + XCTAssertEqual(decoded.id, "req-1") + XCTAssertEqual(decoded.response.generatedPassword, "generated-secret") + XCTAssertNil(decoded.errorMessage) + } +} diff --git a/BitwardenShared/Core/SafariExtension/Models/SafariExtensionRequestProcessor.swift b/BitwardenShared/Core/SafariExtension/Models/SafariExtensionRequestProcessor.swift new file mode 100644 index 0000000000..d68462f2e1 --- /dev/null +++ b/BitwardenShared/Core/SafariExtension/Models/SafariExtensionRequestProcessor.swift @@ -0,0 +1,34 @@ +// MARK: - SafariExtensionRequestProcessor + +public struct SafariExtensionRequestProcessor { + public init() {} + + public func makeResponse(for request: SafariExtensionRequest) -> SafariExtensionResponse? { + switch request.kind { + case .generatePassword: + return try? SafariExtensionResponse.generatedPassword("generated-password", for: request) + case .setup: + return SafariExtensionResponse( + request: request, + suggestionAction: .none, + submissionAction: .none, + matchedLogin: nil, + fillScriptJSON: nil, + generatedPassword: nil, + userMessage: "Safari Web Extension setup", + ) + case .fill, .saveLogin, .changePassword: + let suggestionAction = SafariExtensionSuggestionAction.from(request) + let submissionAction = SafariExtensionSubmissionAction.classify(request, matchedLogin: nil) + return SafariExtensionResponse( + request: request, + suggestionAction: suggestionAction, + submissionAction: submissionAction, + matchedLogin: nil, + fillScriptJSON: nil, + generatedPassword: nil, + userMessage: submissionAction == .none ? nil : suggestionAction.rawValue, + ) + } + } +} diff --git a/BitwardenShared/Core/SafariExtension/Models/SafariExtensionRequestProcessorTests.swift b/BitwardenShared/Core/SafariExtension/Models/SafariExtensionRequestProcessorTests.swift new file mode 100644 index 0000000000..a53c628d03 --- /dev/null +++ b/BitwardenShared/Core/SafariExtension/Models/SafariExtensionRequestProcessorTests.swift @@ -0,0 +1,39 @@ +import XCTest + +@testable import BitwardenShared + +class SafariExtensionRequestProcessorTests: BitwardenTestCase { + func test_makeResponse_generatePassword_returnsGeneratedPasswordResponse() throws { + let subject = SafariExtensionRequestProcessor() + + let response = try XCTUnwrap(subject.makeResponse(for: SafariExtensionRequest(kind: .generatePassword))) + + XCTAssertEqual(response.generatedPassword, "generated-password") + XCTAssertEqual(response.submissionAction, .generatePassword) + } + + func test_makeResponse_setup_returnsSetupMessage() throws { + let subject = SafariExtensionRequestProcessor() + + let response = try XCTUnwrap(subject.makeResponse(for: SafariExtensionRequest(kind: .setup))) + + XCTAssertEqual(response.userMessage, "Safari Web Extension setup") + XCTAssertEqual(response.submissionAction, .none) + } + + func test_makeResponse_saveLogin_returnsSuggestedAction() throws { + let subject = SafariExtensionRequestProcessor() + let request = SafariExtensionRequest( + kind: .saveLogin, + password: "secret", + urlString: "https://example.com/login", + username: "user@example.com" + ) + + let response = try XCTUnwrap(subject.makeResponse(for: request)) + + XCTAssertEqual(response.suggestionAction, .saveLogin) + XCTAssertEqual(response.submissionAction, .saveNewLogin) + XCTAssertEqual(response.userMessage, "saveLogin") + } +} diff --git a/project-pm.yml b/project-pm.yml index fe58133395..860543ee9d 100644 --- a/project-pm.yml +++ b/project-pm.yml @@ -48,7 +48,6 @@ schemes: - BitwardenTests - BitwardenActionExtensionTests - BitwardenAutoFillExtensionTests - - BitwardenSafariWebExtensionTests - BitwardenShareExtensionTests - BitwardenSharedTests - BitwardenSharedSnapshotTests @@ -117,19 +116,6 @@ schemes: build: targets: BitwardenSafariWebExtension: all - BitwardenSafariWebExtensionTests: [test] - test: - environmentVariables: - TZ: UTC - gatherCoverageData: true - language: en - region: US - targets: - - BitwardenSafariWebExtensionTests - testPlans: - - path: TestPlans/Bitwarden-Default.xctestplan - defaultPlan: true - - path: TestPlans/Bitwarden-Unit.xctestplan BitwardenShared: build: targets: @@ -365,23 +351,6 @@ targets: sourcesPath: BitwardenSafariWebExtension dependencies: - target: BitwardenShared - BitwardenSafariWebExtensionTests: - type: bundle.unit-test - platform: iOS - settings: - base: - INFOPLIST_FILE: BitwardenSafariWebExtension/Application/TestHelpers/Support/Info.plist - templates: - - TestTarget - templateAttributes: - sourcesPath: BitwardenSafariWebExtension - sources: - - path: GlobalTestHelpers - dependencies: - - target: BitwardenSafariWebExtension - - target: BitwardenShared - - target: BitwardenKit/TestHelpers - randomExecutionOrder: true BitwardenShared: type: framework From c60a4c390ccbe2488d063e62d290670461438761 Mon Sep 17 00:00:00 2001 From: 2001y Date: Thu, 23 Apr 2026 01:07:48 +0900 Subject: [PATCH 009/104] chore: remove safari web extension test helper plist --- .../TestHelpers/Support/Info.plist | 22 ------------------- 1 file changed, 22 deletions(-) delete mode 100644 BitwardenSafariWebExtension/Application/TestHelpers/Support/Info.plist diff --git a/BitwardenSafariWebExtension/Application/TestHelpers/Support/Info.plist b/BitwardenSafariWebExtension/Application/TestHelpers/Support/Info.plist deleted file mode 100644 index 6c6c23c43a..0000000000 --- a/BitwardenSafariWebExtension/Application/TestHelpers/Support/Info.plist +++ /dev/null @@ -1,22 +0,0 @@ - - - - - CFBundleDevelopmentRegion - en - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - $(PRODUCT_NAME) - CFBundlePackageType - BNDL - CFBundleShortVersionString - 1.0 - CFBundleVersion - 1 - - From e905aab06ebc3b0d8863187356959f2e6b0a2dfc Mon Sep 17 00:00:00 2001 From: 2001y Date: Thu, 23 Apr 2026 01:19:35 +0900 Subject: [PATCH 010/104] feat: add safari page request builders --- .../Application/Support/background.js | 22 +- .../Application/Support/content.js | 197 +++++++++++++++++- 2 files changed, 211 insertions(+), 8 deletions(-) diff --git a/BitwardenSafariWebExtension/Application/Support/background.js b/BitwardenSafariWebExtension/Application/Support/background.js index 31d507e95b..ad762e0e85 100644 --- a/BitwardenSafariWebExtension/Application/Support/background.js +++ b/BitwardenSafariWebExtension/Application/Support/background.js @@ -31,9 +31,27 @@ async function bitwardenSendNativeRequest(request) { return bitwardenParseNativeResponse(nativeResponse); } +function bitwardenMessageToRequest(message) { + switch (message?.type) { + case "bitwarden:change-password": + return { kind: "changePassword" }; + case "bitwarden:fill": + return { kind: "fill" }; + case "bitwarden:generate-password": + return { kind: "generatePassword" }; + case "bitwarden:save-login": + return { kind: "saveLogin" }; + case "bitwarden:setup": + return { kind: "setup" }; + default: + return null; + } +} + browser.runtime.onMessage.addListener((message) => { - if (message?.type === "bitwarden:generate-password") { - return bitwardenSendNativeRequest({ kind: "generatePassword" }); + const request = bitwardenMessageToRequest(message); + if (request) { + return bitwardenSendNativeRequest(request); } if (message?.type === "bitwarden:ping") { diff --git a/BitwardenSafariWebExtension/Application/Support/content.js b/BitwardenSafariWebExtension/Application/Support/content.js index 5dfa0a9700..f3705ad15a 100644 --- a/BitwardenSafariWebExtension/Application/Support/content.js +++ b/BitwardenSafariWebExtension/Application/Support/content.js @@ -1,17 +1,202 @@ (() => { - function bitwardenBuildRequest(kind) { + function bitwardenUUID() { + return crypto.randomUUID(); + } + + function bitwardenCurrentURL() { + return window.location.href; + } + + function bitwardenTrimmedValue(value) { + if (typeof value !== "string") { + return null; + } + + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; + } + + function bitwardenFieldType(element) { + const type = (element.getAttribute("type") || element.type || "text").toLowerCase(); + return type.length > 0 ? type : "text"; + } + + function bitwardenIsVisible(element) { + const style = window.getComputedStyle(element); + if (style.display === "none" || style.visibility === "hidden") { + return false; + } + + const rect = element.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + } + + function bitwardenLabelText(element) { + if (element.labels && element.labels.length > 0) { + return bitwardenTrimmedValue( + Array.from(element.labels) + .map((label) => label.textContent || "") + .join(" "), + ); + } + + const ariaLabel = bitwardenTrimmedValue(element.getAttribute("aria-label")); + if (ariaLabel) { + return ariaLabel; + } + + const placeholder = bitwardenTrimmedValue(element.getAttribute("placeholder")); + if (placeholder) { + return placeholder; + } + + return null; + } + + function bitwardenCollectForms(document) { + return Array.from(document.querySelectorAll("form")).reduce((forms, form, index) => { + const opid = form.dataset.bitwardenOpid || `form__${index}`; + form.dataset.bitwardenOpid = opid; + forms[opid] = { + htmlAction: form.action || bitwardenCurrentURL(), + htmlID: form.id || opid, + htmlMethod: (form.method || "get").toLowerCase(), + htmlName: form.name || form.id || opid, + opid, + }; + return forms; + }, {}); + } + + function bitwardenCollectFields(document) { + const selector = "input, select, textarea, button"; + return Array.from(document.querySelectorAll(selector)).map((element, index) => { + const opid = element.dataset.bitwardenOpid || `field__${index}`; + element.dataset.bitwardenOpid = opid; + const label = bitwardenLabelText(element); + return { + disabled: element.disabled || false, + elementNumber: index, + form: element.form?.dataset.bitwardenOpid || null, + htmlClass: bitwardenTrimmedValue(element.className), + htmlID: bitwardenTrimmedValue(element.id), + htmlName: bitwardenTrimmedValue(element.name), + "label-left": label, + "label-right": null, + "label-tag": label, + onepasswordFieldType: bitwardenFieldType(element), + opid, + placeholder: bitwardenTrimmedValue(element.getAttribute("placeholder")), + readOnly: element.readOnly || false, + type: bitwardenFieldType(element), + value: bitwardenTrimmedValue(element.value), + viewable: bitwardenIsVisible(element), + visible: bitwardenIsVisible(element), + }; + }); + } + + function bitwardenCollectPageDetails(document = window.document) { + const forms = bitwardenCollectForms(document); + const fields = bitwardenCollectFields(document); + return { + collectedTimestamp: new Date().toISOString(), + documentUUID: document.documentElement.dataset.bitwardenDocumentUUID || bitwardenUUID(), + documentUrl: document.location.href, + fields, + forms, + tabUrl: window.location.href, + title: document.title || "", + url: document.location.href, + }; + } + + function bitwardenFirstFieldValue(pageDetails, predicate) { + const field = pageDetails.fields.find(predicate); + return field?.value || null; + } + + function bitwardenBuildRequest(kind, overrides = {}) { return { - id: crypto.randomUUID(), - request: { kind }, + id: bitwardenUUID(), + request: { + kind, + ...overrides, + }, }; } - async function bitwardenGeneratePassword() { - return browser.runtime.sendMessage({ type: "bitwarden:generate-password" }); + function bitwardenBuildFillRequest() { + return bitwardenBuildRequest("fill", { + pageDetails: bitwardenCollectPageDetails(), + urlString: bitwardenCurrentURL(), + }); + } + + function bitwardenBuildSaveLoginRequest() { + const pageDetails = bitwardenCollectPageDetails(); + const username = bitwardenFirstFieldValue( + pageDetails, + (field) => ["email", "text", "tel"].includes(field.type) && field.viewable, + ); + const password = bitwardenFirstFieldValue( + pageDetails, + (field) => field.type === "password" && field.viewable, + ); + + return bitwardenBuildRequest("saveLogin", { + loginTitle: document.title || null, + pageDetails, + password, + urlString: bitwardenCurrentURL(), + username, + }); + } + + function bitwardenBuildChangePasswordRequest() { + const pageDetails = bitwardenCollectPageDetails(); + const passwordFields = pageDetails.fields.filter((field) => field.type === "password"); + const oldPassword = passwordFields.at(0)?.value || null; + const password = passwordFields.at(-1)?.value || null; + + return bitwardenBuildRequest("changePassword", { + loginTitle: document.title || null, + oldPassword, + pageDetails, + password, + urlString: bitwardenCurrentURL(), + }); + } + + function bitwardenBuildGeneratePasswordRequest() { + return bitwardenBuildRequest("generatePassword", { + pageDetails: bitwardenCollectPageDetails(), + urlString: bitwardenCurrentURL(), + }); + } + + function bitwardenBuildSetupRequest() { + return bitwardenBuildRequest("setup", { + urlString: bitwardenCurrentURL(), + }); + } + + async function bitwardenSendMessage(type) { + return browser.runtime.sendMessage({ type }); } window.bitwardenSafariWebExtension = { buildRequest: bitwardenBuildRequest, - generatePassword: bitwardenGeneratePassword, + buildChangePasswordRequest: bitwardenBuildChangePasswordRequest, + buildFillRequest: bitwardenBuildFillRequest, + buildGeneratePasswordRequest: bitwardenBuildGeneratePasswordRequest, + buildSaveLoginRequest: bitwardenBuildSaveLoginRequest, + buildSetupRequest: bitwardenBuildSetupRequest, + collectPageDetails: bitwardenCollectPageDetails, + generatePassword: () => bitwardenSendMessage("bitwarden:generate-password"), + requestFill: () => bitwardenSendMessage("bitwarden:fill"), + requestSaveLogin: () => bitwardenSendMessage("bitwarden:save-login"), + requestChangePassword: () => bitwardenSendMessage("bitwarden:change-password"), + requestSetup: () => bitwardenSendMessage("bitwarden:setup"), }; })(); From 2e7ef1e78ad9c15cd2576e3dca0f83a2db7cbac9 Mon Sep 17 00:00:00 2001 From: 2001y Date: Thu, 23 Apr 2026 10:04:16 +0900 Subject: [PATCH 011/104] feat: add safari extension entry to autofill settings --- .../Settings/Settings/AutoFill/AutoFillAction.swift | 3 +++ .../Settings/Settings/AutoFill/AutoFillProcessor.swift | 2 ++ .../Settings/AutoFill/AutoFillProcessorTests.swift | 7 +++++++ .../AutoFill/AutoFillView+ViewInspectorTests.swift | 8 ++++++++ .../Settings/Settings/AutoFill/AutoFillView.swift | 4 ++++ 5 files changed, 24 insertions(+) diff --git a/BitwardenShared/UI/Platform/Settings/Settings/AutoFill/AutoFillAction.swift b/BitwardenShared/UI/Platform/Settings/Settings/AutoFill/AutoFillAction.swift index 591a5e4da1..4499929650 100644 --- a/BitwardenShared/UI/Platform/Settings/Settings/AutoFill/AutoFillAction.swift +++ b/BitwardenShared/UI/Platform/Settings/Settings/AutoFill/AutoFillAction.swift @@ -20,6 +20,9 @@ enum AutoFillAction: Equatable { /// The password auto-fill button was tapped. case passwordAutoFillTapped + /// The Safari extension button was tapped. + case safariExtensionTapped + /// The copy TOTP automatically toggle value changed. case toggleCopyTOTPToggle(Bool) diff --git a/BitwardenShared/UI/Platform/Settings/Settings/AutoFill/AutoFillProcessor.swift b/BitwardenShared/UI/Platform/Settings/Settings/AutoFill/AutoFillProcessor.swift index f8ceca95df..dfa4b9c834 100644 --- a/BitwardenShared/UI/Platform/Settings/Settings/AutoFill/AutoFillProcessor.swift +++ b/BitwardenShared/UI/Platform/Settings/Settings/AutoFill/AutoFillProcessor.swift @@ -72,6 +72,8 @@ final class AutoFillProcessor: StateProcessor