Prepare Apple App Store distribution (#59)
This commit is contained in:
@@ -20,6 +20,9 @@ extension Notification.Name {
|
||||
static let ironStorageKeyMaterialDidChange = Notification.Name(
|
||||
"de.rfc1437.ironstorage.key-material-did-change"
|
||||
)
|
||||
static let ironStorageConfigurationDidChange = Notification.Name(
|
||||
"de.rfc1437.ironstorage.configuration-did-change"
|
||||
)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@@ -182,6 +185,12 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
name: .ironStorageKeyMaterialDidChange,
|
||||
object: nil
|
||||
)
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(keyMaterialDidChange),
|
||||
name: .ironStorageConfigurationDidChange,
|
||||
object: nil
|
||||
)
|
||||
}
|
||||
|
||||
deinit {
|
||||
@@ -636,7 +645,7 @@ private final class ShellViewController: UITableViewController, MobileTabRoot {
|
||||
}
|
||||
|
||||
@objc private func setupRequested() {
|
||||
navigationController?.pushViewController(OnboardingViewController(), animated: true)
|
||||
navigationController?.pushViewController(SetupChoiceViewController(), animated: true)
|
||||
}
|
||||
|
||||
@objc private func tokenUpdateRequested() {
|
||||
@@ -703,8 +712,10 @@ private final class ShellViewController: UITableViewController, MobileTabRoot {
|
||||
)
|
||||
case .empty:
|
||||
content.image = UIImage(systemName: "checkmark.circle")
|
||||
content.text = "No Remote Activity"
|
||||
content.secondaryText = "There are no commits to pull or push."
|
||||
content.text = homePage.remoteConfigured ? "No Remote Activity" : "Local Store Ready"
|
||||
content.secondaryText = homePage.remoteConfigured
|
||||
? "There are no commits to pull or push."
|
||||
: "Changes are committed to the local Git repository."
|
||||
cell.selectionStyle = .none
|
||||
case .notice:
|
||||
if let notice = homePage.notice {
|
||||
@@ -734,6 +745,11 @@ private final class ShellViewController: UITableViewController, MobileTabRoot {
|
||||
}
|
||||
|
||||
private func freshnessDescription(_ page: MobileHomePage) -> String {
|
||||
if !page.remoteConfigured {
|
||||
return page.freshness == .current
|
||||
? "Current local Git status."
|
||||
: "Local Git status from this device."
|
||||
}
|
||||
let refreshed = page.refreshedAt.map(formattedDate)
|
||||
switch page.freshness {
|
||||
case .neverRefreshed:
|
||||
@@ -867,7 +883,7 @@ private final class ShellViewController: UITableViewController, MobileTabRoot {
|
||||
private func showHomeLoading() {
|
||||
var configuration = UIContentUnavailableConfiguration.loading()
|
||||
configuration.text = "Loading Activity"
|
||||
configuration.secondaryText = "Reading cached remote-branch state from storage."
|
||||
configuration.secondaryText = "Reading Git history and password-store status."
|
||||
contentUnavailableConfiguration = configuration
|
||||
}
|
||||
|
||||
@@ -1078,7 +1094,8 @@ private final class PreferencesViewController: UITableViewController, MobileTabR
|
||||
) -> Int {
|
||||
switch section {
|
||||
case 0: 2
|
||||
case 1, 2, 3: 3
|
||||
case 1: preferences?.syncConfigured == false ? 1 : 3
|
||||
case 2, 3: 3
|
||||
default: 1
|
||||
}
|
||||
}
|
||||
@@ -1089,7 +1106,7 @@ private final class PreferencesViewController: UITableViewController, MobileTabR
|
||||
) -> String? {
|
||||
switch section {
|
||||
case 0: "Password Store"
|
||||
case 1: "Application Token"
|
||||
case 1: preferences?.syncConfigured == false ? "Git Sync" : "Application Token"
|
||||
case 2: "GPG Key & Recovery"
|
||||
case 3: "Authentication"
|
||||
case 4: "Appearance"
|
||||
@@ -1106,7 +1123,9 @@ private final class PreferencesViewController: UITableViewController, MobileTabR
|
||||
case 0:
|
||||
"Non-secret repository and server identities from the shared configuration."
|
||||
case 1:
|
||||
"The application token is stored only in protected system storage and is never displayed."
|
||||
preferences?.syncConfigured == false
|
||||
? "The store has local Git history. An HTTPS remote can be configured later for synchronization."
|
||||
: "The application token is stored only in protected system storage and is never displayed."
|
||||
case 2:
|
||||
"Import provides initial or recovery key setup. Private-key transfers require explicit confirmation and passphrase validation."
|
||||
case 3:
|
||||
@@ -1138,11 +1157,18 @@ private final class PreferencesViewController: UITableViewController, MobileTabR
|
||||
content.secondaryText = preferences?.serverIdentity ?? "Server identity unavailable"
|
||||
cell.selectionStyle = .none
|
||||
case (1, 0):
|
||||
content.image = UIImage(systemName: "key.fill")
|
||||
content.text = preferences?.applicationAccount ?? "No Application Token"
|
||||
content.secondaryText = preferences?.applicationAccount == nil
|
||||
? "Git network operations require a replacement token"
|
||||
: "Token stored in protected system storage"
|
||||
if preferences?.syncConfigured == false {
|
||||
content.image = UIImage(systemName: "network.badge.shield.half.filled")
|
||||
content.text = "Add HTTPS Remote"
|
||||
content.secondaryText = "Keep local history and enable optional synchronization"
|
||||
cell.accessoryType = .disclosureIndicator
|
||||
} else {
|
||||
content.image = UIImage(systemName: "key.fill")
|
||||
content.text = preferences?.applicationAccount ?? "No Application Token"
|
||||
content.secondaryText = preferences?.applicationAccount == nil
|
||||
? "Git network operations require a replacement token"
|
||||
: "Token stored in protected system storage"
|
||||
}
|
||||
cell.selectionStyle = .none
|
||||
case (1, 1):
|
||||
content.image = UIImage(systemName: "arrow.triangle.2.circlepath")
|
||||
@@ -1228,6 +1254,11 @@ private final class PreferencesViewController: UITableViewController, MobileTabR
|
||||
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
switch (indexPath.section, indexPath.row) {
|
||||
case (1, 0) where preferences?.syncConfigured == false:
|
||||
navigationController?.pushViewController(
|
||||
OnboardingViewController(attachLocalStore: true),
|
||||
animated: true
|
||||
)
|
||||
case (1, 1):
|
||||
tokenUpdateRequested()
|
||||
case (1, 2):
|
||||
@@ -1461,8 +1492,9 @@ private final class PreferencesViewController: UITableViewController, MobileTabR
|
||||
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
|
||||
alert.addAction(UIAlertAction(title: "Remove Token", style: .destructive) {
|
||||
[weak self] _ in
|
||||
guard let authentication = self?.authentication else { return }
|
||||
self?.runPreferenceUpdate(announcement: "Application token removed") {
|
||||
try self?.authentication?.removeApplicationToken()
|
||||
try authentication.removeApplicationToken()
|
||||
}
|
||||
})
|
||||
present(alert, animated: true)
|
||||
@@ -1483,8 +1515,9 @@ private final class PreferencesViewController: UITableViewController, MobileTabR
|
||||
alert.addAction(UIAlertAction(title: "Save", style: .default) {
|
||||
[weak self, weak alert] _ in
|
||||
let seconds = UInt64(alert?.textFields?.first?.text ?? "") ?? 0
|
||||
guard let authentication = self?.authentication else { return }
|
||||
self?.runPreferenceUpdate(announcement: "Inactivity timeout updated") {
|
||||
try self?.authentication?.setAuthenticationTimeout(seconds: seconds)
|
||||
try authentication.setAuthenticationTimeout(seconds: seconds)
|
||||
}
|
||||
})
|
||||
present(alert, animated: true)
|
||||
@@ -1498,8 +1531,9 @@ private final class PreferencesViewController: UITableViewController, MobileTabR
|
||||
title: "\(selected ? "✓ " : "")\(appearanceTitle(appearance))",
|
||||
style: .default
|
||||
) { [weak self] _ in
|
||||
guard let authentication = self?.authentication else { return }
|
||||
self?.runPreferenceUpdate(announcement: "Appearance updated") {
|
||||
try self?.authentication?.setMobileAppearance(appearance: appearance)
|
||||
try authentication.setMobileAppearance(appearance: appearance)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1656,6 +1690,14 @@ private final class PreferencesViewController: UITableViewController, MobileTabR
|
||||
watchPaired: watchSnapshot.isPaired,
|
||||
watchAppInstalled: watchSnapshot.isWatchAppInstalled
|
||||
)
|
||||
navigationItem.rightBarButtonItem = preferences?.syncConfigured == true
|
||||
? UIBarButtonItem(
|
||||
title: "Update Token",
|
||||
style: .plain,
|
||||
target: self,
|
||||
action: #selector(tokenUpdateRequested)
|
||||
)
|
||||
: nil
|
||||
if let appearance = preferences?.appearance {
|
||||
tabBarController?.overrideUserInterfaceStyle = switch appearance {
|
||||
case .system: .unspecified
|
||||
@@ -1673,6 +1715,7 @@ private final class KeyImportScannerViewController: UIViewController,
|
||||
DataScannerViewControllerDelegate
|
||||
{
|
||||
private let importer: MobileKeyTransferImport
|
||||
private let localSetup: Bool
|
||||
private let scanner = DataScannerViewController(
|
||||
recognizedDataTypes: [.barcode(symbologies: [.qr])],
|
||||
qualityLevel: .balanced,
|
||||
@@ -1688,11 +1731,20 @@ private final class KeyImportScannerViewController: UIViewController,
|
||||
|
||||
init(transfer: MobileKeyTransfer) {
|
||||
importer = transfer.importer()
|
||||
localSetup = false
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
title = "Import GPG Key"
|
||||
navigationItem.largeTitleDisplayMode = .never
|
||||
}
|
||||
|
||||
init(localImporter: MobileKeyTransferImport) {
|
||||
importer = localImporter
|
||||
localSetup = true
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
title = "Import Local GPG Key"
|
||||
navigationItem.largeTitleDisplayMode = .never
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) is not supported")
|
||||
@@ -1855,11 +1907,20 @@ private final class KeyImportScannerViewController: UIViewController,
|
||||
|
||||
private func confirmImport(_ key: MobileKeyTransferKey) {
|
||||
let privateKey = key.kind == .private
|
||||
if localSetup, !privateKey {
|
||||
completing = false
|
||||
presentFailure("A local store requires a private GPG key with an encryption subkey.") {
|
||||
[weak self] in try? self?.scanner.startScanning()
|
||||
}
|
||||
return
|
||||
}
|
||||
let warning = privateKey
|
||||
? "Import this private key only if you trust the device that displayed it."
|
||||
: "Import this public key?"
|
||||
let alert = UIAlertController(
|
||||
title: privateKey ? "Import Private GPG Key?" : "Import Public GPG Key?",
|
||||
title: localSetup
|
||||
? "Create Local Store with This Key?"
|
||||
: (privateKey ? "Import Private GPG Key?" : "Import Public GPG Key?"),
|
||||
message: "\(warning)\n\n\(key.title)\n\(key.detail)",
|
||||
preferredStyle: .alert
|
||||
)
|
||||
@@ -1874,21 +1935,37 @@ private final class KeyImportScannerViewController: UIViewController,
|
||||
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) { [weak self] _ in
|
||||
self?.navigationController?.popViewController(animated: true)
|
||||
})
|
||||
alert.addAction(UIAlertAction(title: "Import", style: .default) { [weak self, weak alert] _ in
|
||||
self?.finishImport(passphrase: alert?.textFields?.first?.text, makeDefault: false)
|
||||
})
|
||||
if privateKey {
|
||||
alert.addAction(UIAlertAction(title: "Import as Default", style: .default) {
|
||||
if !localSetup {
|
||||
alert.addAction(UIAlertAction(title: "Import", style: .default) {
|
||||
[weak self, weak alert] _ in
|
||||
self?.finishImport(passphrase: alert?.textFields?.first?.text, makeDefault: true)
|
||||
self?.finishImport(
|
||||
passphrase: alert?.textFields?.first?.text,
|
||||
makeDefault: false
|
||||
)
|
||||
})
|
||||
}
|
||||
if privateKey {
|
||||
alert.addAction(UIAlertAction(
|
||||
title: localSetup ? "Create Local Store" : "Import as Default",
|
||||
style: .default
|
||||
) {
|
||||
[weak self, weak alert] _ in
|
||||
self?.finishImport(
|
||||
passphrase: alert?.textFields?.first?.text,
|
||||
makeDefault: true
|
||||
)
|
||||
})
|
||||
}
|
||||
present(alert, animated: true)
|
||||
}
|
||||
|
||||
private func finishImport(passphrase: String?, makeDefault: Bool) {
|
||||
private func finishImport(passphrase: String?, makeDefault: Bool, overwrite: Bool = false) {
|
||||
do {
|
||||
let outcome = try importer.import(passphrase: passphrase, makeDefault: makeDefault)
|
||||
let outcome = try importer.import(
|
||||
passphrase: passphrase,
|
||||
makeDefault: makeDefault,
|
||||
overwrite: overwrite
|
||||
)
|
||||
let alert = UIAlertController(
|
||||
title: outcome.title,
|
||||
message: outcome.detail,
|
||||
@@ -1899,6 +1976,16 @@ private final class KeyImportScannerViewController: UIViewController,
|
||||
NotificationCenter.default.post(name: .ironStorageKeyMaterialDidChange, object: nil)
|
||||
})
|
||||
present(alert, animated: true)
|
||||
} catch let error as MobileKeyTransferFfiError {
|
||||
if case let .Failed(kind, _) = error, kind == .existingKey, !overwrite {
|
||||
confirmOverwrite(passphrase: passphrase, makeDefault: makeDefault)
|
||||
return
|
||||
}
|
||||
completing = false
|
||||
presentFailure(error.localizedDescription) { [weak self] in
|
||||
guard let self else { return }
|
||||
try? scanner.startScanning()
|
||||
}
|
||||
} catch {
|
||||
completing = false
|
||||
presentFailure(error.localizedDescription) { [weak self] in
|
||||
@@ -1908,6 +1995,27 @@ private final class KeyImportScannerViewController: UIViewController,
|
||||
}
|
||||
}
|
||||
|
||||
private func confirmOverwrite(passphrase: String?, makeDefault: Bool) {
|
||||
let alert = UIAlertController(
|
||||
title: "Replace Existing GPG Key?",
|
||||
message: "A key with this fingerprint already exists. Replacing key material can change which private key is used to unlock entries.",
|
||||
preferredStyle: .alert
|
||||
)
|
||||
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) { [weak self] _ in
|
||||
self?.completing = false
|
||||
try? self?.scanner.startScanning()
|
||||
})
|
||||
alert.addAction(UIAlertAction(title: "Replace Key", style: .destructive) {
|
||||
[weak self] _ in
|
||||
self?.finishImport(
|
||||
passphrase: passphrase,
|
||||
makeDefault: makeDefault,
|
||||
overwrite: true
|
||||
)
|
||||
})
|
||||
present(alert, animated: true)
|
||||
}
|
||||
|
||||
private func presentFailure(_ detail: String, completion: (() -> Void)? = nil) {
|
||||
guard presentedViewController == nil else { return }
|
||||
let alert = UIAlertController(
|
||||
@@ -4202,6 +4310,55 @@ private final class PasswordDirectoryViewController: UITableViewController, Mobi
|
||||
present(alert, animated: true)
|
||||
}
|
||||
|
||||
private func createFolderRequested() {
|
||||
let alert = UIAlertController(
|
||||
title: "New Folder",
|
||||
message: "Enter a name for the password folder in this folder.",
|
||||
preferredStyle: .alert
|
||||
)
|
||||
alert.addTextField { field in
|
||||
field.placeholder = "Folder Name"
|
||||
field.autocapitalizationType = .words
|
||||
field.clearButtonMode = .whileEditing
|
||||
field.returnKeyType = .go
|
||||
}
|
||||
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
|
||||
alert.addAction(UIAlertAction(title: "Create", style: .default) { [weak self, weak alert] _ in
|
||||
guard let name = alert?.textFields?.first?.text, !name.isEmpty else { return }
|
||||
self?.beginCreateFolder(name: name)
|
||||
})
|
||||
present(alert, animated: true)
|
||||
}
|
||||
|
||||
private func beginCreateFolder(name: String) {
|
||||
guard let authentication else {
|
||||
presentAuthenticationFailure(.unavailable)
|
||||
return
|
||||
}
|
||||
createTask?.cancel()
|
||||
let parent = path ?? ""
|
||||
createTask = Task { [weak self] in
|
||||
let result = await Task.detached(priority: .userInitiated) {
|
||||
do {
|
||||
try authentication.createDirectory(parent: parent, name: name)
|
||||
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 }
|
||||
switch result {
|
||||
case .success:
|
||||
NotificationCenter.default.post(name: .ironStorageLocalStoreDidChange, object: nil)
|
||||
reload()
|
||||
case let .failure(failure):
|
||||
presentAuthenticationFailure(failure)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func beginCreate(name: String, passphrase: String?) {
|
||||
guard let authentication else {
|
||||
presentAuthenticationFailure(.unavailable)
|
||||
@@ -4345,12 +4502,21 @@ private final class PasswordDirectoryViewController: UITableViewController, Mobi
|
||||
case let .success(page):
|
||||
directoryPage = page
|
||||
title = page.title
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||
let add = UIBarButtonItem(
|
||||
barButtonSystemItem: .add,
|
||||
target: self,
|
||||
action: #selector(createRequested)
|
||||
target: nil,
|
||||
action: nil
|
||||
)
|
||||
navigationItem.rightBarButtonItem?.accessibilityLabel = "Create password entry"
|
||||
add.menu = UIMenu(children: [
|
||||
UIAction(title: "New Password", image: UIImage(systemName: "key")) {
|
||||
[weak self] _ in self?.createRequested()
|
||||
},
|
||||
UIAction(title: "New Folder", image: UIImage(systemName: "folder.badge.plus")) {
|
||||
[weak self] _ in self?.createFolderRequested()
|
||||
},
|
||||
])
|
||||
add.accessibilityLabel = "Create password or folder"
|
||||
navigationItem.rightBarButtonItem = add
|
||||
contentUnavailableConfiguration = nil
|
||||
tableView.reloadData()
|
||||
if page.rows.isEmpty {
|
||||
@@ -6058,8 +6224,315 @@ private final class TokenUpdateViewController: UITableViewController {
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class SetupChoiceViewController: UITableViewController {
|
||||
init() {
|
||||
super.init(style: .insetGrouped)
|
||||
title = "Set Up IronStorage"
|
||||
navigationItem.largeTitleDisplayMode = .never
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) is not supported")
|
||||
}
|
||||
|
||||
override func numberOfSections(in tableView: UITableView) -> Int { 2 }
|
||||
|
||||
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
section == 0 ? 2 : 1
|
||||
}
|
||||
|
||||
override func tableView(
|
||||
_ tableView: UITableView,
|
||||
titleForHeaderInSection section: Int
|
||||
) -> String? {
|
||||
section == 0 ? "Local Store" : "Existing Git Store"
|
||||
}
|
||||
|
||||
override func tableView(
|
||||
_ tableView: UITableView,
|
||||
titleForFooterInSection section: Int
|
||||
) -> String? {
|
||||
section == 0
|
||||
? "Creates a local Git repository. Add an HTTPS remote later when you want to sync."
|
||||
: "Clone an existing password-store repository over HTTPS."
|
||||
}
|
||||
|
||||
override func tableView(
|
||||
_ tableView: UITableView,
|
||||
cellForRowAt indexPath: IndexPath
|
||||
) -> UITableViewCell {
|
||||
let cell = UITableViewCell(style: .subtitle, reuseIdentifier: nil)
|
||||
var content = cell.defaultContentConfiguration()
|
||||
switch (indexPath.section, indexPath.row) {
|
||||
case (0, 0):
|
||||
content.image = UIImage(systemName: "key.badge.plus")
|
||||
content.text = "Generate Local GPG Key"
|
||||
content.secondaryText = "Create a protected key and local Git history"
|
||||
case (0, 1):
|
||||
content.image = UIImage(systemName: "qrcode.viewfinder")
|
||||
content.text = "Import Local GPG Key"
|
||||
content.secondaryText = "Scan a private IronStorage key transfer"
|
||||
default:
|
||||
content.image = UIImage(systemName: "network")
|
||||
content.text = "Connect Existing Store"
|
||||
content.secondaryText = "Clone an HTTPS Git repository"
|
||||
}
|
||||
content.secondaryTextProperties.numberOfLines = 0
|
||||
cell.contentConfiguration = content
|
||||
cell.accessoryType = .disclosureIndicator
|
||||
return cell
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
switch (indexPath.section, indexPath.row) {
|
||||
case (0, 0):
|
||||
navigationController?.pushViewController(LocalSetupViewController(), animated: true)
|
||||
case (0, 1):
|
||||
requestLocalKeyScanner()
|
||||
default:
|
||||
navigationController?.pushViewController(OnboardingViewController(), animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
private func requestLocalKeyScanner() {
|
||||
guard DataScannerViewController.isSupported else {
|
||||
presentMessage("This device does not support live QR scanning.")
|
||||
return
|
||||
}
|
||||
switch AVCaptureDevice.authorizationStatus(for: .video) {
|
||||
case .authorized:
|
||||
showLocalKeyScanner()
|
||||
case .notDetermined:
|
||||
AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in
|
||||
Task { @MainActor in
|
||||
if granted {
|
||||
self?.showLocalKeyScanner()
|
||||
} else {
|
||||
self?.presentMessage("Allow camera access in Settings to import a GPG key.")
|
||||
}
|
||||
}
|
||||
}
|
||||
default:
|
||||
presentMessage("Allow camera access in Settings to import a GPG key.")
|
||||
}
|
||||
}
|
||||
|
||||
private func showLocalKeyScanner() {
|
||||
guard DataScannerViewController.isAvailable else {
|
||||
presentMessage("Close other camera apps and try again.")
|
||||
return
|
||||
}
|
||||
navigationController?.pushViewController(
|
||||
KeyImportScannerViewController(localImporter: mobileLocalKeyTransferImporter()),
|
||||
animated: true
|
||||
)
|
||||
}
|
||||
|
||||
private func presentMessage(_ detail: String) {
|
||||
let alert = UIAlertController(
|
||||
title: "GPG Key Import Is Unavailable",
|
||||
message: detail,
|
||||
preferredStyle: .alert
|
||||
)
|
||||
alert.addAction(UIAlertAction(title: "OK", style: .default))
|
||||
present(alert, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class LocalSetupViewController: UITableViewController {
|
||||
private let keyNameField = UITextField()
|
||||
private let passphraseField = UITextField()
|
||||
private let confirmationField = UITextField()
|
||||
private var task: Task<Void, Never>?
|
||||
private var operation: MobileLocalOnboardingOperation?
|
||||
private var isWorking = false
|
||||
|
||||
init() {
|
||||
super.init(style: .insetGrouped)
|
||||
title = "Create Local Store"
|
||||
navigationItem.largeTitleDisplayMode = .never
|
||||
configureFields()
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) is not supported")
|
||||
}
|
||||
|
||||
deinit {
|
||||
operation?.cancel()
|
||||
task?.cancel()
|
||||
}
|
||||
|
||||
override func numberOfSections(in tableView: UITableView) -> Int { 2 }
|
||||
|
||||
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
section == 0 ? 3 : 1
|
||||
}
|
||||
|
||||
override func tableView(
|
||||
_ tableView: UITableView,
|
||||
titleForHeaderInSection section: Int
|
||||
) -> String? {
|
||||
section == 0 ? "Protected GPG Key" : nil
|
||||
}
|
||||
|
||||
override func tableView(
|
||||
_ tableView: UITableView,
|
||||
titleForFooterInSection section: Int
|
||||
) -> String? {
|
||||
section == 0
|
||||
? "The passphrase protects the generated private key. Keep it somewhere safe; IronStorage cannot recover it."
|
||||
: "Creates a pass-compatible password store with local Git versioning and no network connection."
|
||||
}
|
||||
|
||||
override func tableView(
|
||||
_ tableView: UITableView,
|
||||
cellForRowAt indexPath: IndexPath
|
||||
) -> UITableViewCell {
|
||||
let cell = UITableViewCell(style: .default, reuseIdentifier: nil)
|
||||
if indexPath.section == 1 {
|
||||
cell.textLabel?.text = isWorking ? "Creating…" : "Create Local Store"
|
||||
cell.textLabel?.textAlignment = .center
|
||||
cell.textLabel?.textColor = isWorking ? .secondaryLabel : view.tintColor
|
||||
cell.isUserInteractionEnabled = !isWorking
|
||||
return cell
|
||||
}
|
||||
let field = [keyNameField, passphraseField, confirmationField][indexPath.row]
|
||||
field.translatesAutoresizingMaskIntoConstraints = false
|
||||
field.isEnabled = !isWorking
|
||||
cell.contentView.addSubview(field)
|
||||
NSLayoutConstraint.activate([
|
||||
field.leadingAnchor.constraint(equalTo: cell.contentView.layoutMarginsGuide.leadingAnchor),
|
||||
field.trailingAnchor.constraint(equalTo: cell.contentView.layoutMarginsGuide.trailingAnchor),
|
||||
field.topAnchor.constraint(equalTo: cell.contentView.topAnchor, constant: 10),
|
||||
field.bottomAnchor.constraint(equalTo: cell.contentView.bottomAnchor, constant: -10),
|
||||
])
|
||||
return cell
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
guard indexPath.section == 1, !isWorking else { return }
|
||||
create(replaceExistingKey: false)
|
||||
}
|
||||
|
||||
private func configureFields() {
|
||||
for field in [keyNameField, passphraseField, confirmationField] {
|
||||
field.borderStyle = .none
|
||||
field.clearButtonMode = .whileEditing
|
||||
field.autocorrectionType = .no
|
||||
field.adjustsFontForContentSizeCategory = true
|
||||
field.font = .preferredFont(forTextStyle: .body)
|
||||
}
|
||||
keyNameField.placeholder = "GPG key name"
|
||||
keyNameField.text = "IronStorage Local Key"
|
||||
keyNameField.textContentType = .name
|
||||
keyNameField.autocapitalizationType = .words
|
||||
passphraseField.placeholder = "GPG key passphrase"
|
||||
confirmationField.placeholder = "Confirm passphrase"
|
||||
for field in [passphraseField, confirmationField] {
|
||||
field.isSecureTextEntry = true
|
||||
field.textContentType = .newPassword
|
||||
field.autocapitalizationType = .none
|
||||
}
|
||||
}
|
||||
|
||||
private func create(replaceExistingKey: Bool) {
|
||||
let keyName = keyNameField.text ?? ""
|
||||
let passphrase = passphraseField.text ?? ""
|
||||
guard !keyName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
|
||||
!passphrase.isEmpty,
|
||||
passphrase == confirmationField.text
|
||||
else {
|
||||
presentFailure(
|
||||
title: "Check GPG Key Details",
|
||||
detail: "Enter a key name and matching non-empty passphrases."
|
||||
)
|
||||
return
|
||||
}
|
||||
let operation = mobileLocalOnboardingOperation()
|
||||
self.operation = operation
|
||||
isWorking = true
|
||||
navigationItem.prompt = "Generating a protected GPG key and local Git repository."
|
||||
tableView.reloadData()
|
||||
task = Task { [weak self] in
|
||||
let result = await Task.detached(priority: .userInitiated) {
|
||||
do {
|
||||
return Result<MobileOnboardingOutcome, OnboardingFailure>.success(
|
||||
try operation.generate(
|
||||
userId: keyName,
|
||||
passphrase: passphrase,
|
||||
replaceExistingKey: replaceExistingKey
|
||||
)
|
||||
)
|
||||
} catch let error as MobileOnboardingFfiError {
|
||||
return .failure(OnboardingFailure(error))
|
||||
} catch {
|
||||
return .failure(.unexpected)
|
||||
}
|
||||
}.value
|
||||
self?.finish(result, keyName: keyName, passphrase: passphrase)
|
||||
}
|
||||
}
|
||||
|
||||
private func finish(
|
||||
_ result: Result<MobileOnboardingOutcome, OnboardingFailure>,
|
||||
keyName: String,
|
||||
passphrase: String
|
||||
) {
|
||||
operation = nil
|
||||
task = nil
|
||||
isWorking = false
|
||||
navigationItem.prompt = nil
|
||||
tableView.reloadData()
|
||||
switch result {
|
||||
case let .success(outcome):
|
||||
passphraseField.text = nil
|
||||
confirmationField.text = nil
|
||||
let alert = UIAlertController(
|
||||
title: outcome.title,
|
||||
message: outcome.detail,
|
||||
preferredStyle: .alert
|
||||
)
|
||||
alert.addAction(UIAlertAction(title: "Done", style: .default) { _ in
|
||||
NotificationCenter.default.post(name: .ironStorageKeyMaterialDidChange, object: nil)
|
||||
})
|
||||
present(alert, animated: true)
|
||||
case let .failure(failure) where failure.kind == .existingKey:
|
||||
let alert = UIAlertController(
|
||||
title: failure.title,
|
||||
message: failure.detail,
|
||||
preferredStyle: .alert
|
||||
)
|
||||
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
|
||||
alert.addAction(UIAlertAction(title: "Replace Key", style: .destructive) {
|
||||
[weak self] _ in
|
||||
self?.keyNameField.text = keyName
|
||||
self?.passphraseField.text = passphrase
|
||||
self?.confirmationField.text = passphrase
|
||||
self?.create(replaceExistingKey: true)
|
||||
})
|
||||
present(alert, animated: true)
|
||||
case let .failure(failure):
|
||||
presentFailure(title: failure.title, detail: failure.detail)
|
||||
}
|
||||
}
|
||||
|
||||
private func presentFailure(title: String, detail: String) {
|
||||
let alert = UIAlertController(title: title, message: detail, preferredStyle: .alert)
|
||||
alert.addAction(UIAlertAction(title: "OK", style: .default))
|
||||
present(alert, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class OnboardingViewController: UITableViewController {
|
||||
private let attachLocalStore: Bool
|
||||
private let serverField = UITextField()
|
||||
private let accountField = UITextField()
|
||||
private let repositoryField = UITextField()
|
||||
@@ -6072,9 +6545,10 @@ private final class OnboardingViewController: UITableViewController {
|
||||
private var generation = 0
|
||||
private var isWorking = false
|
||||
|
||||
init() {
|
||||
init(attachLocalStore: Bool = false) {
|
||||
self.attachLocalStore = attachLocalStore
|
||||
super.init(style: .insetGrouped)
|
||||
title = "Connect Store"
|
||||
title = attachLocalStore ? "Add Git Remote" : "Connect Store"
|
||||
navigationItem.largeTitleDisplayMode = .never
|
||||
configureFields()
|
||||
}
|
||||
@@ -6096,7 +6570,7 @@ private final class OnboardingViewController: UITableViewController {
|
||||
}
|
||||
|
||||
override func numberOfSections(in tableView: UITableView) -> Int {
|
||||
branches.isEmpty ? 2 : 3
|
||||
attachLocalStore ? 2 : (branches.isEmpty ? 2 : 3)
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
@@ -6113,7 +6587,7 @@ private final class OnboardingViewController: UITableViewController {
|
||||
) -> String? {
|
||||
switch section {
|
||||
case 0: "HTTPS Repository"
|
||||
case 1: branches.isEmpty ? nil : "Remote"
|
||||
case 1: attachLocalStore || branches.isEmpty ? nil : "Remote"
|
||||
default: "Local Clone"
|
||||
}
|
||||
}
|
||||
@@ -6133,7 +6607,10 @@ private final class OnboardingViewController: UITableViewController {
|
||||
return fieldCell(indexPath.row)
|
||||
}
|
||||
let cell = UITableViewCell(style: .value1, reuseIdentifier: nil)
|
||||
if branches.isEmpty {
|
||||
if attachLocalStore {
|
||||
cell.textLabel?.text = "Connect Local Store"
|
||||
cell.textLabel?.textColor = view.tintColor
|
||||
} else if branches.isEmpty {
|
||||
cell.textLabel?.text = "Discover Branches"
|
||||
cell.textLabel?.textColor = view.tintColor
|
||||
cell.accessoryType = .disclosureIndicator
|
||||
@@ -6155,7 +6632,9 @@ private final class OnboardingViewController: UITableViewController {
|
||||
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
guard !isWorking else { return }
|
||||
if branches.isEmpty, indexPath.section == 1 {
|
||||
if attachLocalStore, indexPath.section == 1 {
|
||||
connectLocalStore()
|
||||
} else if branches.isEmpty, indexPath.section == 1 {
|
||||
discoverBranches()
|
||||
} else if indexPath.section == 1 {
|
||||
chooseBranch(from: tableView.cellForRow(at: indexPath))
|
||||
@@ -6221,6 +6700,17 @@ private final class OnboardingViewController: UITableViewController {
|
||||
}
|
||||
}
|
||||
|
||||
private func connectLocalStore() {
|
||||
do {
|
||||
let operation = try makeOperation()
|
||||
begin(operation, title: "Connecting Git Remote") { operation in
|
||||
.completed(try operation.connectLocalStore())
|
||||
}
|
||||
} catch {
|
||||
present(error)
|
||||
}
|
||||
}
|
||||
|
||||
private func runSetup(useExisting: Bool) {
|
||||
guard branches.indices.contains(selectedBranch) else { return }
|
||||
do {
|
||||
@@ -6290,7 +6780,14 @@ private final class OnboardingViewController: UITableViewController {
|
||||
tokenField.text = nil
|
||||
let alert = UIAlertController(title: outcome.title, message: outcome.detail, preferredStyle: .alert)
|
||||
alert.addAction(UIAlertAction(title: "Done", style: .default) { [weak self] _ in
|
||||
self?.navigationController?.popViewController(animated: true)
|
||||
if self?.attachLocalStore == true {
|
||||
NotificationCenter.default.post(
|
||||
name: .ironStorageConfigurationDidChange,
|
||||
object: nil
|
||||
)
|
||||
} else {
|
||||
self?.navigationController?.popViewController(animated: true)
|
||||
}
|
||||
})
|
||||
present(alert, animated: true)
|
||||
case let .failure(failure):
|
||||
|
||||
Reference in New Issue
Block a user