Implement iPhone password swipe actions

This commit is contained in:
2026-08-11 22:13:29 +02:00
parent a748744425
commit 6edcb5fc86
7 changed files with 1816 additions and 6 deletions

View File

@@ -2309,6 +2309,483 @@ private extension UInt64 {
}
}
@MainActor
private final class PasswordMutationCoordinator {
private weak var presenter: UIViewController?
private let authentication: MobileAuthentication?
private var operationTask: Task<Void, Never>?
private var working = false
private var contextualCompletion: ((Bool) -> Void)?
init(presenter: UIViewController, authentication: MobileAuthentication?) {
self.presenter = presenter
self.authentication = authentication
}
deinit {
operationTask?.cancel()
}
func accessoryView(for row: MobilePasswordRow) -> UIView {
let button = UIButton(type: .system)
button.frame = CGRect(x: 0, y: 0, width: 44, height: 44)
button.configuration = .plain()
button.configuration?.image = UIImage(systemName: "ellipsis.circle")
button.configuration?.buttonSize = .medium
button.menu = menu(for: row)
button.showsMenuAsPrimaryAction = true
button.accessibilityLabel = "Actions for \(row.title)"
return button
}
func accessibilityActions(for row: MobilePasswordRow) -> [UIAccessibilityCustomAction] {
[
accessibilityAction("Move", image: "folder", action: .move, row: row),
accessibilityAction("Copy", image: "doc.on.doc", action: .copy, row: row),
accessibilityAction("Delete", image: "trash", action: .delete, row: row),
]
}
func swipeConfiguration(for row: MobilePasswordRow) -> UISwipeActionsConfiguration {
let delete = UIContextualAction(style: .destructive, title: "Delete") {
[weak self] _, _, completion in
self?.begin(.delete, row: row, completion: completion)
}
delete.image = UIImage(systemName: "trash")
let copy = UIContextualAction(style: .normal, title: "Copy") {
[weak self] _, _, completion in
self?.begin(.copy, row: row, completion: completion)
}
copy.image = UIImage(systemName: "doc.on.doc")
copy.backgroundColor = .systemBlue
let move = UIContextualAction(style: .normal, title: "Move") {
[weak self] _, _, completion in
self?.begin(.move, row: row, completion: completion)
}
move.image = UIImage(systemName: "folder")
move.backgroundColor = .systemOrange
let configuration = UISwipeActionsConfiguration(actions: [delete, copy, move])
configuration.performsFirstActionWithFullSwipe = true
return configuration
}
private func menu(for row: MobilePasswordRow) -> UIMenu {
UIMenu(children: [
UIAction(title: "Move", image: UIImage(systemName: "folder")) { [weak self] _ in
self?.begin(.move, row: row)
},
UIAction(title: "Copy", image: UIImage(systemName: "doc.on.doc")) { [weak self] _ in
self?.begin(.copy, row: row)
},
UIAction(
title: "Delete",
image: UIImage(systemName: "trash"),
attributes: .destructive
) { [weak self] _ in
self?.begin(.delete, row: row)
},
])
}
private func accessibilityAction(
_ title: String,
image: String,
action: MobileMutationAction,
row: MobilePasswordRow
) -> UIAccessibilityCustomAction {
UIAccessibilityCustomAction(
name: title,
image: UIImage(systemName: image)
) { [weak self] _ in
self?.begin(action, row: row)
return true
}
}
private func begin(
_ action: MobileMutationAction,
row: MobilePasswordRow,
completion: ((Bool) -> Void)? = nil
) {
guard !working else {
completion?(false)
return
}
guard let authentication else {
completion?(false)
presenter?.presentAuthenticationFailure(.unavailable)
return
}
working = true
contextualCompletion = completion
operationTask = Task { [weak self] in
let result = await Task.detached(priority: .userInitiated) {
do {
return Result<MobileMutationPlan, AuthenticationFailure>.success(
try authentication.prepareEntryMutation(path: row.path, action: action)
)
} catch let error as MobileAuthenticationFfiError {
return .failure(AuthenticationFailure(error))
} catch {
return .failure(.unexpected)
}
}.value
guard !Task.isCancelled, let self else { return }
operationTask = nil
switch result {
case let .success(plan):
present(plan)
case let .failure(failure):
fail(failure)
}
}
}
private func present(_ plan: MobileMutationPlan) {
switch plan.action {
case .delete:
confirmDelete(plan)
case .move, .copy:
guard !plan.destinations.isEmpty else {
fail(AuthenticationFailure(
kind: .entry,
title: "No Destination Available",
detail: "Create another password folder before moving or copying this entry."
))
return
}
let destinations = PasswordDestinationViewController(
plan: plan,
selected: { [weak self] destination in
self?.presenter?.dismiss(animated: true) {
self?.destinationSelected(destination, plan: plan)
}
},
cancelled: { [weak self] in
self?.presenter?.dismiss(animated: true) { self?.finish(false) }
}
)
let navigation = UINavigationController(rootViewController: destinations)
navigation.modalPresentationStyle = .formSheet
presenter?.present(navigation, animated: true)
}
}
private func destinationSelected(
_ destination: MobileMutationDestination,
plan: MobileMutationPlan
) {
if destination.requiresOverwrite {
let verb = plan.action == .move ? "Move" : "Copy"
let alert = UIAlertController(
title: "Replace Existing Password?",
message: "\(destination.title) already contains “\(plan.sourceTitle)”. \(verb) and replace it?",
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) {
[weak self] _ in self?.finish(false)
})
alert.addAction(UIAlertAction(title: "Replace", style: .destructive) {
[weak self] _ in
self?.confirmEditorIfNeeded(plan, destination: destination.path, overwrite: true)
})
presenter?.present(alert, animated: true)
} else {
confirmEditorIfNeeded(plan, destination: destination.path, overwrite: false)
}
}
private func confirmDelete(_ plan: MobileMutationPlan) {
let suffix = plan.hasDirtyEditor
? " Unsaved changes in its open editor will also be discarded."
: " This cant be undone."
let alert = UIAlertController(
title: "Delete “\(plan.sourceTitle)”?",
message: "The encrypted password entry will be permanently deleted.\(suffix)",
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) {
[weak self] _ in self?.finish(false)
})
alert.addAction(UIAlertAction(title: "Delete", style: .destructive) {
[weak self] _ in
self?.perform(
plan,
destination: nil,
overwrite: false,
discardEditor: plan.hasOpenEditor,
mayAuthenticate: true
)
})
presenter?.present(alert, animated: true)
}
private func confirmEditorIfNeeded(
_ plan: MobileMutationPlan,
destination: String,
overwrite: Bool
) {
guard plan.hasOpenEditor else {
perform(
plan,
destination: destination,
overwrite: overwrite,
discardEditor: false,
mayAuthenticate: true
)
return
}
let verb = plan.action == .move ? "Move" : "Copy"
let alert = UIAlertController(
title: plan.hasDirtyEditor ? "Discard Changes and \(verb)?" : "Close Editor and \(verb)?",
message: plan.hasDirtyEditor
? "Unsaved changes in the open editor for “\(plan.sourceTitle)” will be discarded."
: "The open editor for “\(plan.sourceTitle)” must close before this action.",
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) {
[weak self] _ in self?.finish(false)
})
alert.addAction(UIAlertAction(
title: plan.hasDirtyEditor ? "Discard and \(verb)" : "Close and \(verb)",
style: plan.hasDirtyEditor ? .destructive : .default
) { [weak self] _ in
self?.perform(
plan,
destination: destination,
overwrite: overwrite,
discardEditor: true,
mayAuthenticate: true
)
})
presenter?.present(alert, animated: true)
}
private func perform(
_ plan: MobileMutationPlan,
destination: String?,
overwrite: Bool,
discardEditor: Bool,
mayAuthenticate: Bool
) {
guard let authentication else {
fail(.unavailable)
return
}
let request = MobileMutationRequest(
action: plan.action,
source: plan.source,
revision: plan.revision,
destination: destination,
confirmed: plan.action == .delete,
overwrite: overwrite,
discardEditor: discardEditor
)
operationTask = Task { [weak self] in
let result = await Task.detached(priority: .userInitiated) {
do {
return Result<MobileMutationOutcome, AuthenticationFailure>.success(
try authentication.performEntryMutation(request: request)
)
} catch let error as MobileAuthenticationFfiError {
return .failure(AuthenticationFailure(error))
} catch {
return .failure(.unexpected)
}
}.value
guard !Task.isCancelled, let self else { return }
operationTask = nil
switch result {
case let .success(outcome):
UINotificationFeedbackGenerator().notificationOccurred(.success)
UIAccessibility.post(notification: .announcement, argument: outcome.detail)
NotificationCenter.default.post(name: .ironStorageLocalStoreDidChange, object: nil)
finish(true)
case let .failure(failure)
where mayAuthenticate && failure.kind == .expired:
unlockAndRetry(
plan,
destination: destination,
overwrite: overwrite,
discardEditor: discardEditor,
passphrase: nil
)
case let .failure(failure):
fail(failure)
}
}
}
private func unlockAndRetry(
_ plan: MobileMutationPlan,
destination: String?,
overwrite: Bool,
discardEditor: Bool,
passphrase: String?
) {
guard let authentication else {
fail(.unavailable)
return
}
operationTask = Task { [weak self] in
let result = await Task.detached(priority: .userInitiated) {
do {
_ = try authentication.unlockEntry(path: plan.source, passphrase: passphrase)
return Result<Void, AuthenticationFailure>.success(())
} catch let error as MobileAuthenticationFfiError {
return .failure(AuthenticationFailure(error))
} catch {
return .failure(.unexpected)
}
}.value
guard !Task.isCancelled, let self else { return }
operationTask = nil
switch result {
case .success:
perform(
plan,
destination: destination,
overwrite: overwrite,
discardEditor: discardEditor,
mayAuthenticate: false
)
case let .failure(failure)
where passphrase == nil
&& (failure.kind == .passphraseRequired
|| failure.kind == .biometryUnavailable):
promptForPassphrase(
plan,
destination: destination,
overwrite: overwrite,
discardEditor: discardEditor,
message: failure.detail
)
case let .failure(failure):
fail(failure)
}
}
}
private func promptForPassphrase(
_ plan: MobileMutationPlan,
destination: String?,
overwrite: Bool,
discardEditor: Bool,
message: String
) {
let alert = UIAlertController(
title: "GPG Key Passphrase",
message: message,
preferredStyle: .alert
)
alert.addTextField { field in
field.isSecureTextEntry = true
field.textContentType = .password
field.placeholder = "Passphrase"
field.returnKeyType = .go
}
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) {
[weak self] _ in self?.finish(false)
})
alert.addAction(UIAlertAction(title: "Unlock", style: .default) {
[weak self, weak alert] _ in
guard let value = alert?.textFields?.first?.text, !value.isEmpty else {
self?.finish(false)
return
}
alert?.textFields?.first?.text = nil
self?.unlockAndRetry(
plan,
destination: destination,
overwrite: overwrite,
discardEditor: discardEditor,
passphrase: value
)
})
presenter?.present(alert, animated: true)
}
private func fail(_ failure: AuthenticationFailure) {
presenter?.presentAuthenticationFailure(failure)
finish(false)
}
private func finish(_ applied: Bool) {
contextualCompletion?(applied)
contextualCompletion = nil
working = false
}
}
@MainActor
private final class PasswordDestinationViewController: UITableViewController {
private let plan: MobileMutationPlan
private let selected: (MobileMutationDestination) -> Void
private let cancelled: () -> Void
init(
plan: MobileMutationPlan,
selected: @escaping (MobileMutationDestination) -> Void,
cancelled: @escaping () -> Void
) {
self.plan = plan
self.selected = selected
self.cancelled = cancelled
super.init(style: .insetGrouped)
title = plan.action == .move ? "Move to Folder" : "Copy to Folder"
navigationItem.largeTitleDisplayMode = .never
navigationItem.leftBarButtonItem = UIBarButtonItem(
systemItem: .cancel,
primaryAction: UIAction { [weak self] _ in self?.cancelled() }
)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) is not supported")
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
plan.destinations.count
}
override func tableView(
_ tableView: UITableView,
titleForHeaderInSection section: Int
) -> String? {
plan.sourceTitle
}
override func tableView(
_ tableView: UITableView,
cellForRowAt indexPath: IndexPath
) -> UITableViewCell {
let destination = plan.destinations[indexPath.row]
let cell = UITableViewCell(style: .subtitle, reuseIdentifier: nil)
var content = cell.defaultContentConfiguration()
content.image = UIImage(systemName: "folder")
content.text = destination.title
content.secondaryText = destination.requiresOverwrite
? "\(destination.detail) · Replaces existing password"
: destination.detail
content.secondaryTextProperties.numberOfLines = 2
cell.contentConfiguration = content
cell.accessoryType = .disclosureIndicator
cell.accessibilityHint = destination.requiresOverwrite
? "Requires confirmation before replacing the existing password."
: "Selects this destination folder."
return cell
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
selected(plan.destinations[indexPath.row])
}
}
@MainActor
private final class PasswordSearchViewController: UITableViewController, MobileTabRoot,
UISearchResultsUpdating
@@ -2321,6 +2798,10 @@ private final class PasswordSearchViewController: UITableViewController, MobileT
private var searchTask: Task<Void, Never>?
private var shellTask: Task<Void, Never>?
private var generation = 0
private lazy var mutationCoordinator = PasswordMutationCoordinator(
presenter: self,
authentication: authentication
)
init(shellPage: MobilePage, authentication: MobileAuthentication?) {
self.shellPage = shellPage
@@ -2395,12 +2876,22 @@ private final class PasswordSearchViewController: UITableViewController, MobileT
content.textProperties.numberOfLines = 2
content.secondaryTextProperties.numberOfLines = 2
cell.contentConfiguration = content
cell.accessoryType = .disclosureIndicator
cell.accessoryView = mutationCoordinator.accessoryView(for: row)
cell.accessibilityCustomActions = mutationCoordinator.accessibilityActions(for: row)
cell.accessibilityLabel = "\(row.title), in \(row.detail)"
cell.accessibilityHint = "Opens the locked password viewer."
cell.accessibilityHint = "Opens the locked password viewer. More actions follow."
return cell
}
override func tableView(
_ tableView: UITableView,
trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath
) -> UISwipeActionsConfiguration? {
guard let rows = searchPage?.rows, rows.indices.contains(indexPath.row) else { return nil }
let row = rows[indexPath.row]
return mutationCoordinator.swipeConfiguration(for: row)
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
guard let row = searchPage?.rows[indexPath.row] else { return }
@@ -2534,6 +3025,10 @@ private final class PasswordDirectoryViewController: UITableViewController, Mobi
private var loadTask: Task<Void, Never>?
private var createTask: Task<Void, Never>?
private var loadGeneration = 0
private lazy var mutationCoordinator = PasswordMutationCoordinator(
presenter: self,
authentication: authentication
)
init(
shellPage: MobilePage,
@@ -2626,14 +3121,29 @@ private final class PasswordDirectoryViewController: UITableViewController, Mobi
content.textProperties.numberOfLines = 2
content.secondaryTextProperties.numberOfLines = 1
cell.contentConfiguration = content
cell.accessoryType = .disclosureIndicator
if row.kind == .entry {
cell.accessoryView = mutationCoordinator.accessoryView(for: row)
cell.accessibilityCustomActions = mutationCoordinator.accessibilityActions(for: row)
} else {
cell.accessoryType = .disclosureIndicator
}
cell.accessibilityLabel = "\(row.title), \(row.detail)"
cell.accessibilityHint = row.kind == .directory
? "Opens this password folder."
: "Opens the locked password viewer."
: "Opens the locked password viewer. More actions follow."
return cell
}
override func tableView(
_ tableView: UITableView,
trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath
) -> UISwipeActionsConfiguration? {
guard let rows = directoryPage?.rows, rows.indices.contains(indexPath.row) else { return nil }
let row = rows[indexPath.row]
guard row.kind == .entry else { return nil }
return mutationCoordinator.swipeConfiguration(for: row)
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
guard let row = directoryPage?.rows[indexPath.row] else { return }
let controller: UIViewController = switch row.kind {
@@ -4125,7 +4635,7 @@ private struct AuthenticationFailure: Error, Sendable {
detail: "Finish password-store setup before unlocking entries."
)
private init(kind: MobileAuthenticationErrorKind, title: String, detail: String) {
fileprivate init(kind: MobileAuthenticationErrorKind, title: String, detail: String) {
self.kind = kind
self.title = title
self.detail = detail