This repository was archived by the owner on Feb 24, 2025. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 16
/
Copy pathDataImportViewModel.swift
771 lines (652 loc) · 31.1 KB
/
DataImportViewModel.swift
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
//
// DataImportViewModel.swift
//
// Copyright © 2023 DuckDuckGo. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import AppKit
import Common
import UniformTypeIdentifiers
import PixelKit
import os.log
import BrowserServicesKit
struct DataImportViewModel {
typealias Source = DataImport.Source
typealias BrowserProfileList = DataImport.BrowserProfileList
typealias BrowserProfile = DataImport.BrowserProfile
typealias DataType = DataImport.DataType
typealias DataTypeSummary = DataImport.DataTypeSummary
@UserDefaultsWrapper(key: .homePageContinueSetUpImport, defaultValue: nil)
var successfulImportHappened: Bool?
let availableImportSources: [DataImport.Source]
/// Browser to import data from
let importSource: Source
/// BrowserProfileList loader (factory method) - used
private let loadProfiles: (ThirdPartyBrowser) -> BrowserProfileList
/// Loaded BrowserProfileList
let browserProfiles: BrowserProfileList?
typealias DataImporterFactory = @MainActor (Source, DataType?, URL, /* primaryPassword: */ String?) -> DataImporter
/// Factory for a DataImporter for importSource
private let dataImporterFactory: DataImporterFactory
/// Show a main password input dialog callback
private let requestPrimaryPasswordCallback: @MainActor (Source) -> String?
/// Show Open Panel to choose CSV/HTML file
private let openPanelCallback: @MainActor (DataType) -> URL?
typealias ReportSenderFactory = () -> (DataImportReportModel) -> Void
/// Factory for a DataImporter for importSource
private let reportSenderFactory: ReportSenderFactory
private let onFinished: () -> Void
private let onCancelled: () -> Void
enum Screen: Hashable {
case profileAndDataTypesPicker
case moreInfo
case getReadPermission(URL)
case fileImport(dataType: DataType, summary: Set<DataType> = [])
case summary(Set<DataType>, isFileImport: Bool = false)
case feedback
case shortcuts(Set<DataType>)
var isFileImport: Bool {
if case .fileImport = self { true } else { false }
}
var isGetReadPermission: Bool {
if case .getReadPermission = self { true } else { false }
}
var fileImportDataType: DataType? {
switch self {
case .fileImport(dataType: let dataType, summary: _):
return dataType
default:
return nil
}
}
}
/// Currently displayed screen
private(set) var screen: Screen
/// selected Browser Profile (if any)
var selectedProfile: BrowserProfile?
/// selected Data Types to import (bookmarks/passwords)
var selectedDataTypes: Set<DataType> = []
/// data import concurrency Task launched in `initiateImport`
/// used to cancel import and in `importProgress` to trace import progress and import completion
private var importTask: DataImportTask?
struct DataTypeImportResult: Equatable {
let dataType: DataImport.DataType
let result: DataImportResult<DataTypeSummary>
init(_ dataType: DataImport.DataType, _ result: DataImportResult<DataTypeSummary>) {
self.dataType = dataType
self.result = result
}
static func == (lhs: DataTypeImportResult, rhs: DataTypeImportResult) -> Bool {
lhs.dataType == rhs.dataType &&
lhs.result.description == rhs.result.description
}
}
/// collected import summary for current import operation per selected import source
private(set) var summary: [DataTypeImportResult]
private var userReportText: String = ""
#if DEBUG || REVIEW
// simulated test import failure
struct TestImportError: DataImportError {
enum OperationType: Int {
case imp
}
var type: OperationType { .imp }
var action: DataImportAction
var underlyingError: Error? { CocoaError(.fileReadUnknown) }
var errorType: DataImport.ErrorType
}
var testImportResults = [DataType: DataImportResult<DataTypeSummary>]()
#endif
let isPasswordManagerAutolockEnabled: Bool
init(importSource: Source? = nil,
screen: Screen? = nil,
availableImportSources: [DataImport.Source] = DataImport.Source.allCases.filter { $0.canImportData },
preferredImportSources: [Source] = [.chrome, .firefox, .safari],
summary: [DataTypeImportResult] = [],
isPasswordManagerAutolockEnabled: Bool = AutofillPreferences().isAutoLockEnabled,
loadProfiles: @escaping (ThirdPartyBrowser) -> BrowserProfileList = { $0.browserProfiles() },
dataImporterFactory: @escaping DataImporterFactory = dataImporter,
requestPrimaryPasswordCallback: @escaping @MainActor (Source) -> String? = Self.requestPrimaryPasswordCallback,
openPanelCallback: @escaping @MainActor (DataType) -> URL? = Self.openPanelCallback,
reportSenderFactory: @escaping ReportSenderFactory = { FeedbackSender().sendDataImportReport },
onFinished: @escaping () -> Void = {},
onCancelled: @escaping () -> Void = {}) {
self.availableImportSources = availableImportSources
let importSource = importSource ?? preferredImportSources.first(where: { availableImportSources.contains($0) }) ?? .csv
self.importSource = importSource
self.loadProfiles = loadProfiles
self.dataImporterFactory = dataImporterFactory
self.screen = screen ?? importSource.initialScreen
self.browserProfiles = ThirdPartyBrowser.browser(for: importSource).map(loadProfiles)
self.selectedProfile = browserProfiles?.defaultProfile
self.selectedDataTypes = importSource.supportedDataTypes
self.summary = summary
self.isPasswordManagerAutolockEnabled = isPasswordManagerAutolockEnabled
self.requestPrimaryPasswordCallback = requestPrimaryPasswordCallback
self.openPanelCallback = openPanelCallback
self.reportSenderFactory = reportSenderFactory
self.onFinished = onFinished
self.onCancelled = onCancelled
PixelExperiment.fireOnboardingImportRequestedPixel()
}
/// Import button press (starts browser data import)
@MainActor
mutating func initiateImport(primaryPassword: String? = nil, fileURL: URL? = nil) {
guard let url = fileURL ?? selectedProfile?.profileURL else {
assertionFailure("URL not provided")
return
}
assert(actionButton == .initiateImport(disabled: false) || screen.fileImportDataType != nil || screen.isGetReadPermission)
// are we handling file import or browser selected data types import?
let dataType: DataType? = self.screen.fileImportDataType
// either import only data type for file import
let dataTypes = dataType.map { [$0] }
// or all the selected data types subtracting the ones that are already imported
?? selectedDataTypes.subtracting(self.summary.filter { $0.result.isSuccess }.map(\.dataType))
let importer = dataImporterFactory(importSource, dataType, url, primaryPassword)
Logger.dataImportExport.debug("import \(dataTypes) at \"\(url.path)\" using \(type(of: importer))")
// validate file access/encryption password requirement before starting import
if let errors = importer.validateAccess(for: dataTypes),
handleErrors(errors) == true {
return
}
#if DEBUG || REVIEW
// simulated test import failures
guard dataTypes.compactMap({ testImportResults[$0] }).isEmpty else {
importTask = .detachedWithProgress { [testImportResults] _ in
var result = DataImportSummary()
let selectedDataTypesWithoutFailureReasons = dataTypes.intersection(importer.importableTypes).subtracting(testImportResults.keys)
var realSummary = DataImportSummary()
if !selectedDataTypesWithoutFailureReasons.isEmpty {
realSummary = await importer.importData(types: selectedDataTypesWithoutFailureReasons).task.value
}
for dataType in dataTypes {
if let importResult = testImportResults[dataType] {
result[dataType] = importResult
} else {
result[dataType] = realSummary[dataType]
}
}
return result
}
return
}
#endif
importTask = importer.importData(types: dataTypes)
}
/// Called with data import task result to update the state by merging the summary with an existing summary
@MainActor
private mutating func mergeImportSummary(with summary: DataImportSummary) {
self.importTask = nil
Logger.dataImportExport.debug("merging summary \(summary)")
// append successful import results first keeping the original DataType sorting order
self.summary.append(contentsOf: DataType.allCases.compactMap { dataType in
(try? summary[dataType]?.get()).map {
.init(dataType, .success($0))
}
})
// if there‘s read permission/primary password requested - request it and reinitiate import
if handleErrors(summary.compactMapValues { $0.error }) { return }
var nextScreen: Screen?
// merge new import results into the model import summary keeping the original DataType sorting order
for (dataType, result) in DataType.allCases.compactMap({ dataType in summary[dataType].map { (dataType, $0) } }) {
let sourceVersion = importSource.installedAppsMajorVersionDescription(selectedProfile: selectedProfile)
switch result {
case .success(let dataTypeSummary):
// if a data type can‘t be imported (Yandex/Passwords) - switch to its file import displaying successful import results
if dataTypeSummary.isEmpty, !(screen.isFileImport && screen.fileImportDataType == dataType), nextScreen == nil {
nextScreen = .fileImport(dataType: dataType, summary: Set(summary.filter({ $0.value.isSuccess }).keys))
}
PixelKit.fire(GeneralPixel.dataImportSucceeded(action: .init(dataType), source: importSource, sourceVersion: sourceVersion))
case .failure(let error):
// successful imports are appended above
self.summary.append( .init(dataType, result) )
// show file import screen when import fails or no bookmarks|passwords found
if !(screen.isFileImport && screen.fileImportDataType == dataType), nextScreen == nil {
// switch to file import of the failed data type displaying successful import results
nextScreen = .fileImport(dataType: dataType, summary: Set(summary.filter({ $0.value.isSuccess }).keys))
}
PixelKit.fire(GeneralPixel.dataImportFailed(source: importSource, sourceVersion: sourceVersion, error: error))
}
}
if let nextScreen {
Logger.dataImportExport.debug("mergeImportSummary: next screen: \(String(describing: nextScreen))")
self.screen = nextScreen
} else if screenForNextDataTypeRemainingToImport(after: DataType.allCases.last(where: summary.keys.contains)) == nil, // no next data type manual import screen
// and there should be failed data types (and non-recovered)
selectedDataTypes.contains(where: { dataType in self.summary.last(where: { $0.dataType == dataType })?.result.error != nil }) {
Logger.dataImportExport.debug("mergeImportSummary: feedback")
// after last failed datatype show feedback
self.screen = .feedback
} else if self.screen.isFileImport, let dataType = self.screen.fileImportDataType {
Logger.dataImportExport.debug("mergeImportSummary: file import summary(\(dataType))")
self.screen = .summary([dataType], isFileImport: true)
} else if screenForNextDataTypeRemainingToImport(after: DataType.allCases.last(where: summary.keys.contains)) == nil { // no next data type manual import screen
let allKeys = self.summary.reduce(into: Set()) { $0.insert($1.dataType) }
Logger.dataImportExport.debug("mergeImportSummary: final summary(\(Set(allKeys)))")
self.screen = .summary(allKeys)
} else {
Logger.dataImportExport.debug("mergeImportSummary: intermediary summary(\(Set(summary.keys)))")
self.screen = .summary(Set(summary.keys))
}
if self.areAllSelectedDataTypesSuccessfullyImported {
successfulImportHappened = true
NotificationCenter.default.post(name: .dataImportComplete, object: nil)
}
}
/// handle recoverable errors (request primary password or file permission)
@MainActor
private mutating func handleErrors(_ summary: [DataType: any DataImportError]) -> Bool {
for error in summary.values {
switch error {
// chromium user denied keychain prompt error
case let error as ChromiumLoginReader.ImportError where error.type == .userDeniedKeychainPrompt:
PixelKit.fire(GeneralPixel.passwordImportKeychainPromptDenied)
// stay on the same screen
return true
// firefox passwords db is main-password protected: request password
case let error as FirefoxLoginReader.ImportError where error.type == .requiresPrimaryPassword:
Logger.dataImportExport.debug("primary password required")
// stay on the same screen but request password synchronously
if let password = self.requestPrimaryPasswordCallback(importSource) {
self.initiateImport(primaryPassword: password)
}
return true
// no file read permission error: user must grant permission
case let importError where (importError.underlyingError as? CocoaError)?.code == .fileReadNoPermission:
guard let error = importError.underlyingError as? CocoaError,
let url = error.filePath.map(URL.init(fileURLWithPath:)) ?? error.url else {
assertionFailure("No url")
break
}
Logger.dataImportExport.debug("file read no permission for \(url.path)")
if url != selectedProfile?.profileURL.appendingPathComponent(SafariDataImporter.bookmarksFileName) {
PixelKit.fire(GeneralPixel.dataImportFailed(source: importSource, sourceVersion: importSource.installedAppsMajorVersionDescription(selectedProfile: selectedProfile), error: importError))
}
screen = .getReadPermission(url)
return true
default: continue
}
}
return false
}
/// Skip button press
@MainActor mutating func skipImportOrDismiss(using dismiss: @escaping () -> Void) {
if let screen = screenForNextDataTypeRemainingToImport(after: screen.fileImportDataType) {
// skip to next non-imported data type
self.screen = screen
} else if selectedDataTypes.first(where: {
let error = error(for: $0)
return error != nil && error?.errorType != .noData
}) != nil {
// errors occurred during import: show feedback screen
self.screen = .feedback
} else {
// When we skip a manual import, and there are no next non-imported data types,
// if some data was successfully imported we present the shortcuts screen, otherwise we dismiss
var dataTypes: Set<DataType> = []
// Filter out only the successful results with a positive count of successful summaries
for dataTypeImportResult in summary {
guard case .success(let summary) = dataTypeImportResult.result, summary.successful > 0 else {
continue
}
dataTypes.insert(dataTypeImportResult.dataType)
}
if !dataTypes.isEmpty {
self.screen = .shortcuts(dataTypes)
} else {
self.dismiss(using: dismiss)
}
}
}
/// Select CSV/HTML file for import button press
@MainActor mutating func selectFile() {
guard let dataType = screen.fileImportDataType else {
assertionFailure("Expected File Import")
return
}
guard let url = openPanelCallback(dataType) else { return }
self.initiateImport(fileURL: url)
}
mutating func goBack() {
// reset to initial screen
screen = importSource.initialScreen
summary.removeAll()
}
func submitReport() {
let sendReport = reportSenderFactory()
sendReport(reportModel)
}
}
@MainActor
private func dataImporter(for source: DataImport.Source, fileDataType: DataImport.DataType?, url: URL, primaryPassword: String?) -> DataImporter {
var profile: DataImport.BrowserProfile {
let browser = ThirdPartyBrowser.browser(for: source) ?? {
assertionFailure("Trying to get browser name for file import source \(source)")
return .chrome
}()
return DataImport.BrowserProfile(browser: browser, profileURL: url)
}
return switch source {
case .bookmarksHTML,
/* any */_ where fileDataType == .bookmarks:
BookmarkHTMLImporter(fileURL: url, bookmarkImporter: CoreDataBookmarkImporter(bookmarkManager: LocalBookmarkManager.shared))
case .onePassword8, .onePassword7, .bitwarden, .lastPass, .csv,
/* any */_ where fileDataType == .passwords:
CSVImporter(fileURL: url, loginImporter: SecureVaultLoginImporter(loginImportState: AutofillLoginImportState()), defaultColumnPositions: .init(source: source), reporter: SecureVaultReporter.shared)
case .brave, .chrome, .chromium, .coccoc, .edge, .opera, .operaGX, .vivaldi:
ChromiumDataImporter(profile: profile,
loginImporter: SecureVaultLoginImporter(loginImportState: AutofillLoginImportState()),
bookmarkImporter: CoreDataBookmarkImporter(bookmarkManager: LocalBookmarkManager.shared))
case .yandex:
YandexDataImporter(profile: profile,
bookmarkImporter: CoreDataBookmarkImporter(bookmarkManager: LocalBookmarkManager.shared))
case .firefox, .tor:
FirefoxDataImporter(profile: profile,
primaryPassword: primaryPassword,
loginImporter: SecureVaultLoginImporter(loginImportState: AutofillLoginImportState()),
bookmarkImporter: CoreDataBookmarkImporter(bookmarkManager: LocalBookmarkManager.shared),
faviconManager: FaviconManager.shared)
case .safari, .safariTechnologyPreview:
SafariDataImporter(profile: profile,
bookmarkImporter: CoreDataBookmarkImporter(bookmarkManager: LocalBookmarkManager.shared))
}
}
private var isOpenPanelShownFirstTime = true
private var openPanelDirectoryURL: URL? {
// only show Desktop once per launch, then open the last user-selected dir
if isOpenPanelShownFirstTime {
isOpenPanelShownFirstTime = false
return FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Desktop")
} else {
return nil
}
}
extension DataImport.Source {
var initialScreen: DataImportViewModel.Screen {
switch self {
case .brave, .chrome, .chromium, .coccoc, .edge, .firefox, .opera,
.operaGX, .safari, .safariTechnologyPreview, .tor, .vivaldi, .yandex:
return .profileAndDataTypesPicker
case .onePassword8, .onePassword7, .bitwarden, .lastPass, .csv:
return .fileImport(dataType: .passwords)
case .bookmarksHTML:
return .fileImport(dataType: .bookmarks)
}
}
}
extension DataImport.DataType {
static func dataTypes(before dataType: DataImport.DataType, inclusive: Bool) -> [Self].SubSequence {
let index = Self.allCases.firstIndex(of: dataType)!
if inclusive {
return Self.allCases[...index]
} else {
return Self.allCases[..<index]
}
}
static func dataTypes(after dataType: DataImport.DataType) -> [Self].SubSequence {
let nextIndex = Self.allCases.firstIndex(of: dataType)! + 1
return Self.allCases[nextIndex...]
}
var allowedFileTypes: [UTType] {
switch self {
case .bookmarks: [.html]
case .passwords: [.commaSeparatedText]
}
}
}
extension DataImportViewModel {
private var areAllSelectedDataTypesSuccessfullyImported: Bool {
selectedDataTypes.allSatisfy(isDataTypeSuccessfullyImported)
}
func summary(for dataType: DataType) -> DataTypeSummary? {
if case .success(let summary) = self.summary.last(where: { $0.dataType == dataType })?.result {
return summary
}
return nil
}
func isDataTypeSuccessfullyImported(_ dataType: DataType) -> Bool {
summary(for: dataType) != nil
}
private func screenForNextDataTypeRemainingToImport(after currentDataType: DataType? = nil) -> Screen? {
// keep the original sort order among all data types or only after current data type
for dataType in (currentDataType.map { DataType.dataTypes(after: $0) } ?? DataType.allCases[0...]) where selectedDataTypes.contains(dataType) {
// if some of selected data types failed to import or not imported yet
switch summary.last(where: { $0.dataType == dataType })?.result {
case .success(let summary) where summary.isEmpty:
return .fileImport(dataType: dataType)
case .failure(let error) where error.errorType == .noData:
return .fileImport(dataType: dataType)
case .failure, .none:
return .fileImport(dataType: dataType)
case .success:
continue
}
}
return nil
}
func error(for dataType: DataType) -> (any DataImportError)? {
if case .failure(let error) = summary.last(where: { $0.dataType == dataType })?.result {
return error
}
return nil
}
private struct DataImportViewSummarizedError: LocalizedError {
let errors: [any DataImportError]
var errorDescription: String? {
errors.enumerated().map {
"\($0.offset + 1): \($0.element.localizedDescription)"
}.joined(separator: "\n")
}
}
var summarizedError: LocalizedError {
let errors = summary.compactMap { $0.result.error }
if errors.count == 1 {
return errors[0]
}
return DataImportViewSummarizedError(errors: errors)
}
var hasAnySummaryError: Bool {
!summary.allSatisfy { $0.result.isSuccess }
}
private static func requestPrimaryPasswordCallback(_ source: DataImport.Source) -> String? {
let alert = NSAlert.passwordRequiredAlert(source: source)
let response = alert.runModal()
guard case .alertFirstButtonReturn = response,
let password = (alert.accessoryView as? NSSecureTextField)?.stringValue else { return nil }
return password
}
private static func openPanelCallback(for dataType: DataImport.DataType) -> URL? {
let panel = NSOpenPanel(allowedFileTypes: dataType.allowedFileTypes,
directoryURL: openPanelDirectoryURL)
guard case .OK = panel.runModal(),
let url = panel.url else { return nil }
return url
}
var isImportSourcePickerDisabled: Bool {
importSource.initialScreen != screen || importTask != nil
}
// AsyncStream of Data Import task progress events
var importProgress: TaskProgress<Self, Never, DataImportProgressEvent>? {
guard let importTask else { return nil }
return AsyncStream {
for await event in importTask.progress {
switch event {
case .progress(let update):
Logger.dataImportExport.debug("progress: \(String(describing: update))")
return .progress(update)
// on completion returns new DataImportViewModel with merged import summary
case .completed(.success(let summary)):
onFinished()
return await .completed(.success(self.mergingImportSummary(summary)))
}
}
return nil
}
}
enum ButtonType: Hashable {
case next(Screen)
case initiateImport(disabled: Bool)
case skip
case cancel
case back
case done
case submit
var isDisabled: Bool {
switch self {
case .initiateImport(disabled: let disabled):
return disabled
case .next, .skip, .done, .cancel, .back, .submit:
return false
}
}
}
@MainActor var actionButton: ButtonType? {
func initiateImport() -> ButtonType {
.initiateImport(disabled: selectedDataTypes.isEmpty || importTask != nil)
}
switch screen {
case .profileAndDataTypesPicker:
guard let importer = selectedProfile.map({
dataImporterFactory(/* importSource: */ importSource,
/* dataType: */ nil,
/* profileURL: */ $0.profileURL,
/* primaryPassword: */ nil)
}),
selectedDataTypes.intersects(importer.importableTypes) else {
// no profiles found
// or selected data type not supported by selected browser data importer
guard let type = DataType.allCases.filter(selectedDataTypes.contains).first else {
// disabled Import button
return initiateImport()
}
// use CSV/HTML file import
return .next(.fileImport(dataType: type))
}
if importer.requiresKeychainPassword(for: selectedDataTypes) {
return .next(.moreInfo)
}
return initiateImport()
case .moreInfo:
return initiateImport()
case .getReadPermission:
return .initiateImport(disabled: true)
case .fileImport where screen == importSource.initialScreen:
// no default action for File Import sources
return nil
case .fileImport(dataType: let dataType, summary: _)
// exlude all skipped datatypes that are ordered before
where selectedDataTypes.subtracting(DataType.dataTypes(before: dataType, inclusive: true)).isEmpty
// and no failures recorded - otherwise will skip to Feedback
&& !summary.contains(where: { !$0.result.isSuccess }):
// no other data types to skip:
return .cancel
case .fileImport:
return .skip
case .summary(let dataTypes, isFileImport: _):
if let screen = screenForNextDataTypeRemainingToImport(after: DataType.allCases.last(where: dataTypes.contains)) {
return .next(screen)
} else {
return .next(.shortcuts(dataTypes))
}
case .feedback:
return .submit
case .shortcuts:
return .done
}
}
var secondaryButton: ButtonType? {
if importTask == nil {
switch screen {
case importSource.initialScreen, .feedback:
return .cancel
case .moreInfo, .getReadPermission:
return .back
default:
return nil
}
} else {
return .cancel
}
}
var isSelectFileButtonDisabled: Bool {
importTask != nil
}
@MainActor var buttons: [ButtonType] {
[secondaryButton, actionButton].compactMap { $0 }
}
mutating func update(with importSource: Source) {
self = .init(importSource: importSource, isPasswordManagerAutolockEnabled: isPasswordManagerAutolockEnabled, loadProfiles: loadProfiles, dataImporterFactory: dataImporterFactory, requestPrimaryPasswordCallback: requestPrimaryPasswordCallback, reportSenderFactory: reportSenderFactory, onFinished: onFinished, onCancelled: onCancelled)
}
@MainActor
mutating func performAction(for buttonType: ButtonType, dismiss: @escaping () -> Void) {
assert(buttons.contains(buttonType))
switch buttonType {
case .next(let screen):
self.screen = screen
case .back:
goBack()
case .initiateImport:
initiateImport()
case .skip:
skipImportOrDismiss(using: dismiss)
case .cancel:
importTask?.cancel()
onCancelled()
self.dismiss(using: dismiss)
case .submit:
submitReport()
self.dismiss(using: dismiss)
case .done:
self.dismiss(using: dismiss)
}
}
private mutating func dismiss(using dismiss: @escaping () -> Void) {
// send `bookmarkPromptShouldShow` notification after dismiss if at least one bookmark was imported
if summary.reduce(into: 0, { $0 += $1.dataType == .bookmarks ? (try? $1.result.get().successful) ?? 0 : 0 }) > 0 {
DispatchQueue.main.async {
NotificationCenter.default.post(name: .bookmarkPromptShouldShow, object: nil)
}
}
Logger.dataImportExport.debug("dismiss")
dismiss()
if case .xcPreviews = NSApp.runType {
self.update(with: importSource) // reset
}
}
@MainActor
private func mergingImportSummary(_ summary: DataImportSummary) -> Self {
var newState = self
newState.mergeImportSummary(with: summary)
return newState
}
private var retryNumber: Int {
summary.reduce(into: [:]) {
// get maximum number of failures per data type
$0[$1.dataType, default: 0] += $1.result.isSuccess ? 0 : 1
}.values.max() ?? 0
}
var reportModel: DataImportReportModel {
get {
DataImportReportModel(importSource: importSource,
importSourceVersion: importSource.installedAppsMajorVersionDescription(selectedProfile: selectedProfile),
error: summarizedError,
text: userReportText,
retryNumber: retryNumber)
}
set {
userReportText = newValue.text
}
}
}