Skip to content
This repository was archived by the owner on Feb 24, 2025. It is now read-only.

Commit 871d33b

Browse files
authored
CSV Login Import UI (#162)
* Work on CSV import. * Begin using the vault to import items. * Continue fleshing out import. * Add basic CSV parsing tests. * Test some of the CSV importing functionality. * More UI work for the CSV import feature. * Add a login summary object. * Fix an issue with merge conflict resolution. * Show a summary view when completing the import. * Import an array of summary objects. * Show a count of the import summary stats. * Use an enum to report the result of data import. * Rework the file store to support full URLs. * Update the login exporter to use FileStore. * Add mocks for testing the export feature. * Update BrowserServicesKit. * Fix some issues with CSV parsing. * Move strings to a localized text file. * Fix another localized string. * Parse an optional title when importing. * Add the title to the summary if it's present. * Export the title as the first column in each row. * Remove BrowserImportViewController, that's for the next PR. * Ignore header rows if they're found. * Remove a local BSK reference. * Move the Import/Export options to the File menu. * Tweak import UI. * Copy Brindy's approach for the @testable issue. * Add pixels for import/export. * Present an alert if password export fails. * Infer the position of the columns. * Normalize the header values when inferring positions. * Allow exporting credentials with blank fields. * Hide the cancel button in the success state. * Fix an incorrect bool value. * Update the CSV header text. * Add titles to the exported CSV.
1 parent b7e2391 commit 871d33b

35 files changed

+2008
-61
lines changed

DuckDuckGo.xcodeproj/project.pbxproj

+156
Large diffs are not rendered by default.

DuckDuckGo/AppDelegate/AppDelegate.swift

+2-2
Original file line numberDiff line numberDiff line change
@@ -52,10 +52,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
5252

5353
do {
5454
let encryptionKey = Self.isRunningTests ? nil : try keyStore.readKey()
55-
fileStore = FileStore(encryptionKey: encryptionKey)
55+
fileStore = EncryptedFileStore(encryptionKey: encryptionKey)
5656
} catch {
5757
os_log("App Encryption Key could not be read: %s", "\(error)")
58-
fileStore = FileStore()
58+
fileStore = EncryptedFileStore()
5959
}
6060
stateRestorationManager = AppStateRestorationManager(fileStore: fileStore)
6161

DuckDuckGo/Common/Extensions/NSOpenPanelExtensions.swift

+12
Original file line numberDiff line numberDiff line change
@@ -31,4 +31,16 @@ extension NSOpenPanel {
3131

3232
return panel
3333
}
34+
35+
static func filePanel(allowedExtension: String) -> NSOpenPanel {
36+
let panel = NSOpenPanel()
37+
38+
panel.directoryURL = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Desktop")
39+
panel.canChooseFiles = true
40+
panel.allowedFileTypes = [allowedExtension]
41+
panel.canChooseDirectories = false
42+
43+
return panel
44+
}
45+
3446
}

DuckDuckGo/Common/Extensions/URLExtension.swift

+5
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,11 @@ extension URL {
2929
return libraryURL.appendingPathComponent(sandboxPathComponent)
3030
}
3131

32+
static func persistenceLocation(for fileName: String) -> URL {
33+
let applicationSupportPath = URL.sandboxApplicationSupportURL
34+
return applicationSupportPath.appendingPathComponent(fileName)
35+
}
36+
3237
// MARK: - Factory
3338

3439
static func makeSearchUrl(from searchQuery: String) -> URL? {

DuckDuckGo/Common/FileSystem/FileStore.swift

+37-19
Original file line numberDiff line numberDiff line change
@@ -19,22 +19,22 @@
1919
import Foundation
2020
import CryptoKit
2121

22-
protocol FileStoring {
23-
func persist(_ data: Data, fileName: String) -> Bool
24-
func loadData(named fileName: String) -> Data?
25-
func hasData(for fileName: String) -> Bool
26-
func remove(_ fileName: String)
22+
protocol FileStore {
23+
func persist(_ data: Data, url: URL) -> Bool
24+
func loadData(at url: URL) -> Data?
25+
func hasData(at url: URL) -> Bool
26+
func remove(fileAtURL url: URL)
2727
}
2828

29-
final class FileStore: FileStoring {
29+
final class EncryptedFileStore: FileStore {
3030

3131
private let encryptionKey: SymmetricKey?
3232

3333
init(encryptionKey: SymmetricKey? = nil) {
3434
self.encryptionKey = encryptionKey
3535
}
3636

37-
func persist(_ data: Data, fileName: String) -> Bool {
37+
func persist(_ data: Data, url: URL) -> Bool {
3838
do {
3939
let dataToWrite: Data
4040

@@ -44,18 +44,18 @@ final class FileStore: FileStoring {
4444
dataToWrite = data
4545
}
4646

47-
try dataToWrite.write(to: persistenceLocation(for: fileName))
47+
try dataToWrite.write(to: url)
4848

4949
return true
5050
} catch {
5151
Pixel.fire(.debug(event: .fileStoreWriteFailed, error: error),
52-
withAdditionalParameters: ["config": fileName])
52+
withAdditionalParameters: ["config": url.lastPathComponent])
5353
return false
5454
}
5555
}
5656

57-
func loadData(named fileName: String) -> Data? {
58-
guard let data = try? Data(contentsOf: persistenceLocation(for: fileName)) else {
57+
func loadData(at url: URL) -> Data? {
58+
guard let data = try? Data(contentsOf: url) else {
5959
return nil
6060
}
6161

@@ -66,22 +66,40 @@ final class FileStore: FileStoring {
6666
}
6767
}
6868

69-
func hasData(for fileName: String) -> Bool {
70-
let path = persistenceLocation(for: fileName).path
71-
return FileManager.default.fileExists(atPath: path)
69+
func hasData(at url: URL) -> Bool {
70+
return FileManager.default.fileExists(atPath: url.path)
7271
}
7372

74-
func remove(_ fileName: String) {
75-
let url = persistenceLocation(for: fileName)
73+
func remove(fileAtURL url: URL) {
7674
var isDir: ObjCBool = false
7775
let fileManager = FileManager()
7876
guard fileManager.fileExists(atPath: url.path, isDirectory: &isDir) && !isDir.boolValue else { return }
7977
try? fileManager.removeItem(at: url)
8078
}
8179

82-
func persistenceLocation(for fileName: String) -> URL {
83-
let applicationSupportPath = URL.sandboxApplicationSupportURL
84-
return applicationSupportPath.appendingPathComponent(fileName)
80+
}
81+
82+
extension FileManager: FileStore {
83+
84+
func persist(_ data: Data, url: URL) -> Bool {
85+
do {
86+
try data.write(to: url)
87+
return true
88+
} catch {
89+
return false
90+
}
91+
}
92+
93+
func loadData(at url: URL) -> Data? {
94+
try? Data(contentsOf: url)
95+
}
96+
97+
func hasData(at url: URL) -> Bool {
98+
return fileExists(atPath: url.path)
99+
}
100+
101+
func remove(fileAtURL url: URL) {
102+
try? removeItem(at: url)
85103
}
86104

87105
}

DuckDuckGo/Common/Localizables/UserText.swift

+19
Original file line numberDiff line numberDiff line change
@@ -147,4 +147,23 @@ struct UserText {
147147
return String(format: localized, version, build)
148148
}
149149

150+
// MARK: - Login Import & Export
151+
152+
static let importLoginsCSV = NSLocalizedString("import.logins.csv.title", value: "CSV Logins File", comment: "Title text for the CSV importer")
153+
154+
static let csvImportDescription = NSLocalizedString("import.logins.csv.description", value: "The CSV importer will try to match column headers to their position.\nIf there is no header, it supports two formats:\n\n1. URL, Username, Password\n2. Title, URL, Username, Password", comment: "Description text for the CSV importer")
155+
static let importLoginsSelectCSVFile = NSLocalizedString("import.logins.select-csv-file", value: "Select CSV File", comment: "Button text for selecting a CSV file")
156+
static let importLoginsSelectAnotherFile = NSLocalizedString("import.logins.select-another-file", value: "Select Another File", comment: "Button text for selecting another file")
157+
static let importLoginsFailedToReadCSVFile = NSLocalizedString("import.logins.failed-to-read-file", value: "Failed to get CSV file URL", comment: "Error text when importing a CSV file")
158+
159+
static func importingFile(at filePath: String, validLogins: Int) -> String {
160+
let localized = NSLocalizedString("import.logins.csv.file-description",
161+
value: "Importing File: %@ (%@ valid logins)",
162+
comment: "Displays the path of the file being imported")
163+
return String(format: localized, filePath, String(validLogins))
164+
}
165+
166+
static let initiateImport = NSLocalizedString("import.logins.initiate", value: "Import", comment: "Button text for importing data")
167+
static let doneImporting = NSLocalizedString("import.logins.done", value: "Done", comment: "Button text for finishing the data import")
168+
150169
}
+33
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
//
2+
// DataExport.swift
3+
//
4+
// Copyright © 2021 DuckDuckGo. All rights reserved.
5+
//
6+
// Licensed under the Apache License, Version 2.0 (the "License");
7+
// you may not use this file except in compliance with the License.
8+
// You may obtain a copy of the License at
9+
//
10+
// http://www.apache.org/licenses/LICENSE-2.0
11+
//
12+
// Unless required by applicable law or agreed to in writing, software
13+
// distributed under the License is distributed on an "AS IS" BASIS,
14+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
// See the License for the specific language governing permissions and
16+
// limitations under the License.
17+
//
18+
19+
import Foundation
20+
21+
enum DataExport {
22+
23+
enum DataType {
24+
case logins
25+
}
26+
27+
}
28+
29+
enum DataExportError: Error {
30+
31+
case fileAlreadyExists
32+
33+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
//
2+
// CSVLoginExporter.swift
3+
//
4+
// Copyright © 2021 DuckDuckGo. All rights reserved.
5+
//
6+
// Licensed under the Apache License, Version 2.0 (the "License");
7+
// you may not use this file except in compliance with the License.
8+
// You may obtain a copy of the License at
9+
//
10+
// http://www.apache.org/licenses/LICENSE-2.0
11+
//
12+
// Unless required by applicable law or agreed to in writing, software
13+
// distributed under the License is distributed on an "AS IS" BASIS,
14+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
// See the License for the specific language governing permissions and
16+
// limitations under the License.
17+
//
18+
19+
import Foundation
20+
import BrowserServicesKit
21+
22+
final class CSVLoginExporter: LoginExporter {
23+
24+
enum CSVLoginExportError: Error {
25+
case failedToEncodeLogins
26+
}
27+
28+
private let secureVault: SecureVault
29+
private let fileStore: FileStore
30+
31+
init(secureVault: SecureVault, fileStore: FileStore = FileManager.default) {
32+
self.secureVault = secureVault
33+
self.fileStore = fileStore
34+
}
35+
36+
func exportVaultLogins(to url: URL) throws {
37+
guard let accounts = try? secureVault.accounts() else {
38+
return
39+
}
40+
41+
var credentialsToExport: [SecureVaultModels.WebsiteCredentials] = []
42+
43+
for account in accounts {
44+
guard let accountID = account.id else {
45+
continue
46+
}
47+
48+
if let credentials = try? secureVault.websiteCredentialsFor(accountId: accountID) {
49+
credentialsToExport.append(credentials)
50+
}
51+
}
52+
53+
try save(credentials: credentialsToExport, to: url)
54+
}
55+
56+
private func save(credentials: [SecureVaultModels.WebsiteCredentials], to url: URL) throws {
57+
let credentialsAsCSVRows: [String] = credentials.compactMap { credential in
58+
let title = credential.account.title ?? ""
59+
let domain = credential.account.domain
60+
let username = credential.account.username
61+
let password = credential.password.utf8String() ?? ""
62+
63+
// Ensure that exported passwords escape any quotes they contain
64+
let escapedPassword = password.replacingOccurrences(of: "\"", with: "\\\"")
65+
66+
return "\"\(title)\",\"\(domain)\",\"\(username)\",\"\(escapedPassword)\""
67+
}
68+
69+
let headerRow = ["\"title\",\"url\",\"username\",\"password\""]
70+
let csvString = (headerRow + credentialsAsCSVRows).joined(separator: "\n")
71+
72+
if let stringData = csvString.data(using: .utf8) {
73+
_ = fileStore.persist(stringData, url: url)
74+
} else {
75+
throw CSVLoginExportError.failedToEncodeLogins
76+
}
77+
}
78+
79+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
//
2+
// LoginExport.swift
3+
//
4+
// Copyright © 2021 DuckDuckGo. All rights reserved.
5+
//
6+
// Licensed under the Apache License, Version 2.0 (the "License");
7+
// you may not use this file except in compliance with the License.
8+
// You may obtain a copy of the License at
9+
//
10+
// http://www.apache.org/licenses/LICENSE-2.0
11+
//
12+
// Unless required by applicable law or agreed to in writing, software
13+
// distributed under the License is distributed on an "AS IS" BASIS,
14+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
// See the License for the specific language governing permissions and
16+
// limitations under the License.
17+
//
18+
19+
import Foundation
20+
21+
protocol LoginExporter {
22+
23+
func exportVaultLogins(to: URL) throws
24+
25+
}
+63
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
//
2+
// DataImport.swift
3+
//
4+
// Copyright © 2021 DuckDuckGo. All rights reserved.
5+
//
6+
// Licensed under the Apache License, Version 2.0 (the "License");
7+
// you may not use this file except in compliance with the License.
8+
// You may obtain a copy of the License at
9+
//
10+
// http://www.apache.org/licenses/LICENSE-2.0
11+
//
12+
// Unless required by applicable law or agreed to in writing, software
13+
// distributed under the License is distributed on an "AS IS" BASIS,
14+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
// See the License for the specific language governing permissions and
16+
// limitations under the License.
17+
//
18+
19+
import Foundation
20+
21+
enum DataImport {
22+
23+
// Third-party browser support will be added later.
24+
enum Source: CaseIterable {
25+
case csv
26+
27+
var importSourceName: String {
28+
switch self {
29+
case .csv:
30+
return UserText.importLoginsCSV
31+
}
32+
}
33+
}
34+
35+
// Different data types (e.g. bookmarks) will be added later.
36+
enum DataType {
37+
case logins
38+
}
39+
40+
enum Summary: Equatable {
41+
case logins(successfulImports: [String], duplicateImports: [String], failedImports: [String])
42+
}
43+
44+
}
45+
46+
enum DataImportError: Error {
47+
48+
case cannotReadFile
49+
case cannotAccessSecureVault
50+
51+
}
52+
53+
/// Represents an object able to import data from an outside source. The outside source may be capable of importing multiple types of data.
54+
/// For instance, a browser data importer may be able to import logins and bookmarks.
55+
protocol DataImporter {
56+
57+
/// Performs a quick check to determine if the data is able to be imported. It does not guarantee that the import will succeed.
58+
/// For example, a CSV importer will return true if the URL it has been created with is a CSV file, but does not check whether the CSV data matches the expected format.
59+
func importableTypes() -> [DataImport.DataType]
60+
61+
func importData(types: [DataImport.DataType], completion: @escaping (Result<[DataImport.Summary], DataImportError>) -> Void)
62+
63+
}

0 commit comments

Comments
 (0)