diff --git a/apple/Assets.xcassets/AppIcon.appiconset/AppIcon.png b/apple/Assets.xcassets/AppIcon.appiconset/AppIcon.png new file mode 100644 index 0000000..5339c61 Binary files /dev/null and b/apple/Assets.xcassets/AppIcon.appiconset/AppIcon.png differ diff --git a/apple/Assets.xcassets/AppIcon.appiconset/Contents.json b/apple/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..cefcc87 --- /dev/null +++ b/apple/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "AppIcon.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apple/Assets.xcassets/Contents.json b/apple/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/apple/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apple/Generated/ironstorage_apple.swift b/apple/Generated/ironstorage_apple.swift index 8cf91ce..668a1c5 100644 --- a/apple/Generated/ironstorage_apple.swift +++ b/apple/Generated/ironstorage_apple.swift @@ -507,6 +507,416 @@ fileprivate struct FfiConverterString: FfiConverter { writeBytes(&buf, value.utf8) } } + + +public struct MobilePage: Equatable, Hashable { + public var tab: MobileTab + public var title: String + public var systemImage: String + public var selectedSystemImage: String + public var state: MobileShellState + public var stateTitle: String + public var stateDetail: String + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(tab: MobileTab, title: String, systemImage: String, selectedSystemImage: String, state: MobileShellState, stateTitle: String, stateDetail: String) { + self.tab = tab + self.title = title + self.systemImage = systemImage + self.selectedSystemImage = selectedSystemImage + self.state = state + self.stateTitle = stateTitle + self.stateDetail = stateDetail + } + + + + +} + +#if compiler(>=6) +extension MobilePage: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeMobilePage: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobilePage { + return + try MobilePage( + tab: FfiConverterTypeMobileTab.read(from: &buf), + title: FfiConverterString.read(from: &buf), + systemImage: FfiConverterString.read(from: &buf), + selectedSystemImage: FfiConverterString.read(from: &buf), + state: FfiConverterTypeMobileShellState.read(from: &buf), + stateTitle: FfiConverterString.read(from: &buf), + stateDetail: FfiConverterString.read(from: &buf) + ) + } + + public static func write(_ value: MobilePage, into buf: inout [UInt8]) { + FfiConverterTypeMobileTab.write(value.tab, into: &buf) + FfiConverterString.write(value.title, into: &buf) + FfiConverterString.write(value.systemImage, into: &buf) + FfiConverterString.write(value.selectedSystemImage, into: &buf) + FfiConverterTypeMobileShellState.write(value.state, into: &buf) + FfiConverterString.write(value.stateTitle, into: &buf) + FfiConverterString.write(value.stateDetail, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobilePage_lift(_ buf: RustBuffer) throws -> MobilePage { + return try FfiConverterTypeMobilePage.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobilePage_lower(_ value: MobilePage) -> RustBuffer { + return FfiConverterTypeMobilePage.lower(value) +} + + +public struct MobileShell: Equatable, Hashable { + public var selectedTab: MobileTab + public var pages: [MobilePage] + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(selectedTab: MobileTab, pages: [MobilePage]) { + self.selectedTab = selectedTab + self.pages = pages + } + + + + +} + +#if compiler(>=6) +extension MobileShell: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeMobileShell: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobileShell { + return + try MobileShell( + selectedTab: FfiConverterTypeMobileTab.read(from: &buf), + pages: FfiConverterSequenceTypeMobilePage.read(from: &buf) + ) + } + + public static func write(_ value: MobileShell, into buf: inout [UInt8]) { + FfiConverterTypeMobileTab.write(value.selectedTab, into: &buf) + FfiConverterSequenceTypeMobilePage.write(value.pages, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobileShell_lift(_ buf: RustBuffer) throws -> MobileShell { + return try FfiConverterTypeMobileShell.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobileShell_lower(_ value: MobileShell) -> RustBuffer { + return FfiConverterTypeMobileShell.lower(value) +} + + +public +enum MobilePreferenceError: Swift.Error, Equatable, Hashable, Foundation.LocalizedError { + + + + case Configuration(message: String + ) + + + + + + + public var errorDescription: String? { + String(reflecting: self) + } + +} + +#if compiler(>=6) +extension MobilePreferenceError: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeMobilePreferenceError: FfiConverterRustBuffer { + typealias SwiftType = MobilePreferenceError + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobilePreferenceError { + let variant: Int32 = try readInt(&buf) + switch variant { + + + + + case 1: return .Configuration( + message: try FfiConverterString.read(from: &buf) + ) + + default: throw UniffiInternalError.unexpectedEnumCase + } + } + + public static func write(_ value: MobilePreferenceError, into buf: inout [UInt8]) { + switch value { + + + + + + case let .Configuration(message): + writeInt(&buf, Int32(1)) + FfiConverterString.write(message, into: &buf) + + } + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobilePreferenceError_lift(_ buf: RustBuffer) throws -> MobilePreferenceError { + return try FfiConverterTypeMobilePreferenceError.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobilePreferenceError_lower(_ value: MobilePreferenceError) -> RustBuffer { + return FfiConverterTypeMobilePreferenceError.lower(value) +} + + + +public enum MobileShellState: Equatable, Hashable { + + case loading + case empty + case ready + case locked + case error + + + + + +} + +#if compiler(>=6) +extension MobileShellState: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeMobileShellState: FfiConverterRustBuffer { + typealias SwiftType = MobileShellState + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobileShellState { + let variant: Int32 = try readInt(&buf) + switch variant { + + case 1: return .loading + + case 2: return .empty + + case 3: return .ready + + case 4: return .locked + + case 5: return .error + + default: throw UniffiInternalError.unexpectedEnumCase + } + } + + public static func write(_ value: MobileShellState, into buf: inout [UInt8]) { + switch value { + + + case .loading: + writeInt(&buf, Int32(1)) + + + case .empty: + writeInt(&buf, Int32(2)) + + + case .ready: + writeInt(&buf, Int32(3)) + + + case .locked: + writeInt(&buf, Int32(4)) + + + case .error: + writeInt(&buf, Int32(5)) + + } + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobileShellState_lift(_ buf: RustBuffer) throws -> MobileShellState { + return try FfiConverterTypeMobileShellState.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobileShellState_lower(_ value: MobileShellState) -> RustBuffer { + return FfiConverterTypeMobileShellState.lower(value) +} + + + + +public enum MobileTab: Equatable, Hashable { + + case home + case passwords + case totp + case preferences + + + + + +} + +#if compiler(>=6) +extension MobileTab: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeMobileTab: FfiConverterRustBuffer { + typealias SwiftType = MobileTab + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobileTab { + let variant: Int32 = try readInt(&buf) + switch variant { + + case 1: return .home + + case 2: return .passwords + + case 3: return .totp + + case 4: return .preferences + + default: throw UniffiInternalError.unexpectedEnumCase + } + } + + public static func write(_ value: MobileTab, into buf: inout [UInt8]) { + switch value { + + + case .home: + writeInt(&buf, Int32(1)) + + + case .passwords: + writeInt(&buf, Int32(2)) + + + case .totp: + writeInt(&buf, Int32(3)) + + + case .preferences: + writeInt(&buf, Int32(4)) + + } + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobileTab_lift(_ buf: RustBuffer) throws -> MobileTab { + return try FfiConverterTypeMobileTab.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobileTab_lower(_ value: MobileTab) -> RustBuffer { + return FfiConverterTypeMobileTab.lower(value) +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +fileprivate struct FfiConverterSequenceTypeMobilePage: FfiConverterRustBuffer { + typealias SwiftType = [MobilePage] + + public static func write(_ value: [MobilePage], into buf: inout [UInt8]) { + let len = Int32(value.count) + writeInt(&buf, len) + for item in value { + FfiConverterTypeMobilePage.write(item, into: &buf) + } + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> [MobilePage] { + let len: Int32 = try readInt(&buf) + var seq = [MobilePage]() + seq.reserveCapacity(Int(len)) + for _ in 0 ..< len { + seq.append(try FfiConverterTypeMobilePage.read(from: &buf)) + } + return seq + } +} +public func mobileShell() -> MobileShell { + return try! FfiConverterTypeMobileShell_lift(try! rustCall() { + uniffiCallStatus in + uniffi_ironstorage_apple_fn_func_mobile_shell(uniffiCallStatus + ) +}) +} +public func mobileShellFixture(state: MobileShellState) -> MobileShell { + return try! FfiConverterTypeMobileShell_lift(try! rustCall() { + uniffiCallStatus in + uniffi_ironstorage_apple_fn_func_mobile_shell_fixture( + FfiConverterTypeMobileShellState_lower(state),uniffiCallStatus + ) +}) +} public func productName() -> String { return try! FfiConverterString.lift(try! rustCall() { uniffiCallStatus in @@ -514,6 +924,13 @@ public func productName() -> String { ) }) } +public func setSelectedMobileTab(tab: MobileTab)throws {try rustCallWithError(FfiConverterTypeMobilePreferenceError_lift) { + uniffiCallStatus in + uniffi_ironstorage_apple_fn_func_set_selected_mobile_tab( + FfiConverterTypeMobileTab_lower(tab),uniffiCallStatus + ) +} +} private enum InitializationResult { case ok @@ -530,9 +947,18 @@ private let initializationResult: InitializationResult = { if bindings_contract_version != scaffolding_contract_version { return InitializationResult.contractVersionMismatch } + if (uniffi_ironstorage_apple_checksum_func_mobile_shell() != 42687) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_ironstorage_apple_checksum_func_mobile_shell_fixture() != 1649) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_ironstorage_apple_checksum_func_product_name() != 43533) { return InitializationResult.apiChecksumMismatch } + if (uniffi_ironstorage_apple_checksum_func_set_selected_mobile_tab() != 65280) { + return InitializationResult.apiChecksumMismatch + } return InitializationResult.ok }() @@ -550,4 +976,4 @@ public func uniffiEnsureIronstorageAppleInitialized() { } } -// swiftlint:enable all +// swiftlint:enable all \ No newline at end of file diff --git a/apple/Generated/ironstorage_appleFFI.h b/apple/Generated/ironstorage_appleFFI.h index 3850602..764485b 100644 --- a/apple/Generated/ironstorage_appleFFI.h +++ b/apple/Generated/ironstorage_appleFFI.h @@ -242,11 +242,27 @@ typedef struct UniffiForeignFutureResultVoid { typedef void (*UniffiForeignFutureCompleteVoid)(uint64_t, UniffiForeignFutureResultVoid ); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_FUNC_MOBILE_SHELL +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_FUNC_MOBILE_SHELL +RustBuffer uniffi_ironstorage_apple_fn_func_mobile_shell(RustCallStatus *_Nonnull out_status + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_FUNC_MOBILE_SHELL_FIXTURE +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_FUNC_MOBILE_SHELL_FIXTURE +RustBuffer uniffi_ironstorage_apple_fn_func_mobile_shell_fixture(RustBuffer state, RustCallStatus *_Nonnull out_status +); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_FUNC_PRODUCT_NAME #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_FUNC_PRODUCT_NAME RustBuffer uniffi_ironstorage_apple_fn_func_product_name(RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_FUNC_SET_SELECTED_MOBILE_TAB +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_FUNC_SET_SELECTED_MOBILE_TAB +void uniffi_ironstorage_apple_fn_func_set_selected_mobile_tab(RustBuffer tab, RustCallStatus *_Nonnull out_status ); #endif #ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_APPLE_RUSTBUFFER_ALLOC @@ -507,12 +523,30 @@ void ffi_ironstorage_apple_rust_future_free_void(uint64_t handle #ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_APPLE_RUST_FUTURE_COMPLETE_VOID #define UNIFFI_FFIDEF_FFI_IRONSTORAGE_APPLE_RUST_FUTURE_COMPLETE_VOID void ffi_ironstorage_apple_rust_future_complete_void(uint64_t handle, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_FUNC_MOBILE_SHELL +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_FUNC_MOBILE_SHELL +uint16_t uniffi_ironstorage_apple_checksum_func_mobile_shell(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_FUNC_MOBILE_SHELL_FIXTURE +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_FUNC_MOBILE_SHELL_FIXTURE +uint16_t uniffi_ironstorage_apple_checksum_func_mobile_shell_fixture(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_FUNC_PRODUCT_NAME #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_FUNC_PRODUCT_NAME uint16_t uniffi_ironstorage_apple_checksum_func_product_name(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_FUNC_SET_SELECTED_MOBILE_TAB +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_FUNC_SET_SELECTED_MOBILE_TAB +uint16_t uniffi_ironstorage_apple_checksum_func_set_selected_mobile_tab(void + ); #endif #ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_APPLE_UNIFFI_CONTRACT_VERSION diff --git a/apple/Sources/App/IronStorageApp.swift b/apple/Sources/App/IronStorageApp.swift index e8361bf..e95b511 100644 --- a/apple/Sources/App/IronStorageApp.swift +++ b/apple/Sources/App/IronStorageApp.swift @@ -1,20 +1,195 @@ -import SwiftUI +import UIKit @main -struct IronStorageApp: App { - var body: some Scene { - WindowGroup { - ContentView() +final class AppDelegate: UIResponder, UIApplicationDelegate { + var window: UIWindow? + private var context: AppContext? + + func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil + ) -> Bool { + let window = UIWindow(frame: UIScreen.main.bounds) + let context = AppContext() + self.context = context + window.rootViewController = context.makeRootController() + window.makeKeyAndVisible() + self.window = window + return true + } +} + +@MainActor +private final class AppContext: NSObject, UITabBarControllerDelegate { + private let tabs = UITabBarController() + private var navigationControllers: [UINavigationController] = [] + private var restoreTask: Task? + + func makeRootController() -> UIViewController { + let shell = mobileShellFixture(state: .loading) + navigationControllers = shell.pages.map { page in + let root = ShellViewController(page: page) + let navigation = UINavigationController(rootViewController: root) + navigation.navigationBar.prefersLargeTitles = true + navigation.tabBarItem = UITabBarItem( + title: page.title, + image: UIImage(systemName: page.systemImage), + selectedImage: UIImage(systemName: page.selectedSystemImage) + ) + return navigation + } + tabs.viewControllers = navigationControllers + tabs.delegate = self + restoreSelectedTab() + return tabs + } + + func tabBarController( + _ tabBarController: UITabBarController, + didSelect viewController: UIViewController + ) { + guard + let navigation = viewController as? UINavigationController, + let root = navigation.viewControllers.first as? ShellViewController + else { + return + } + do { + try setSelectedMobileTab(tab: root.shellTab) + } catch { + let alert = UIAlertController( + title: "Selection Was Not Saved", + message: error.localizedDescription, + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: "OK", style: .default)) + tabs.present(alert, animated: true) + } + } + + private func restoreSelectedTab() { + restoreTask?.cancel() + restoreTask = Task { [weak self] in + let shell = await Task.detached(priority: .userInitiated) { mobileShell() }.value + guard !Task.isCancelled, let self else { return } + if let index = shell.pages.firstIndex(where: { $0.tab == shell.selectedTab }) { + tabs.selectedIndex = index + } } } } -private struct ContentView: View { - var body: some View { - ContentUnavailableView( - "No Password Store", - systemImage: "lock.shield", - description: Text("Open or clone a store in \(productName()) to begin.") - ) +@MainActor +private final class ShellViewController: UITableViewController { + fileprivate let shellTab: MobileTab + private var page: MobilePage + private var loadTask: Task? + private var loadGeneration = 0 + + init(page: MobilePage) { + shellTab = page.tab + self.page = page + super.init(style: .insetGrouped) + title = page.title + navigationItem.largeTitleDisplayMode = .always + refreshControl = UIRefreshControl() + refreshControl?.addTarget(self, action: #selector(refreshRequested), for: .valueChanged) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) is not supported") + } + + deinit { + loadTask?.cancel() + } + + override func viewDidLoad() { + super.viewDidLoad() + apply(page) + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + reloadShell() + } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + loadTask?.cancel() + } + + override func numberOfSections(in tableView: UITableView) -> Int { + page.state == .ready ? 1 : 0 + } + + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + 1 + } + + override func tableView( + _ tableView: UITableView, + cellForRowAt indexPath: IndexPath + ) -> UITableViewCell { + let cell = UITableViewCell(style: .subtitle, reuseIdentifier: nil) + var content = cell.defaultContentConfiguration() + content.image = UIImage(systemName: page.systemImage) + content.text = page.stateTitle + content.secondaryText = page.stateDetail + content.secondaryTextProperties.numberOfLines = 0 + cell.contentConfiguration = content + cell.selectionStyle = .none + cell.accessibilityLabel = "\(page.stateTitle). \(page.stateDetail)" + return cell + } + + @objc private func refreshRequested() { + reloadShell() + } + + private func reloadShell() { + loadGeneration += 1 + let generation = loadGeneration + loadTask?.cancel() + loadTask = Task { [weak self] in + let shell = await Task.detached(priority: .userInitiated) { mobileShell() }.value + guard + !Task.isCancelled, + let self, + generation == loadGeneration, + let page = shell.pages.first(where: { $0.tab == self.shellTab }) + else { return } + apply(page) + } + } + + private func apply(_ page: MobilePage) { + self.page = page + title = page.title + refreshControl?.endRefreshing() + tableView.reloadData() + + guard page.state != .ready else { + contentUnavailableConfiguration = nil + return + } + var configuration = page.state == .loading + ? UIContentUnavailableConfiguration.loading() + : UIContentUnavailableConfiguration.empty() + configuration.text = page.stateTitle + configuration.secondaryText = page.stateDetail + configuration.image = UIImage(systemName: stateImage(page.state)) + contentUnavailableConfiguration = configuration + } + + private func stateImage(_ state: MobileShellState) -> String { + switch state { + case .loading: "hourglass" + case .empty: "lock.shield" + case .ready: page.systemImage + case .locked: "lock.fill" + case .error: "exclamationmark.triangle" + } } } diff --git a/apple/project.yml b/apple/project.yml index d2a3c65..ab7b6a1 100644 --- a/apple/project.yml +++ b/apple/project.yml @@ -26,13 +26,9 @@ targets: UISupportedInterfaceOrientations: - UIInterfaceOrientationPortrait sources: + - Assets.xcassets - Sources/App - Generated/ironstorage_apple.swift - dependencies: - - target: IronStorageAutoFill - embed: true - - target: IronStorageWatch - embed: true preBuildScripts: - name: Build Rust core basedOnDependencyAnalysis: false diff --git a/crates/apple/src/lib.rs b/crates/apple/src/lib.rs index 3e90ed0..422922d 100644 --- a/crates/apple/src/lib.rs +++ b/crates/apple/src/lib.rs @@ -3,17 +3,181 @@ //! Mechanical UniFFI exports for Apple presentation code. +use std::{error::Error, fmt}; + +use ironstorage::{ + config::ConfigError, + mobile::{self, MobileShellState as StorageShellState, MobileTab as StorageTab}, +}; + uniffi::setup_scaffolding!(); +#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] +pub enum MobileTab { + Home, + Passwords, + Totp, + Preferences, +} + +impl From for MobileTab { + fn from(tab: StorageTab) -> Self { + match tab { + StorageTab::Home => Self::Home, + StorageTab::Passwords => Self::Passwords, + StorageTab::Totp => Self::Totp, + StorageTab::Preferences => Self::Preferences, + } + } +} + +impl From for StorageTab { + fn from(tab: MobileTab) -> Self { + match tab { + MobileTab::Home => Self::Home, + MobileTab::Passwords => Self::Passwords, + MobileTab::Totp => Self::Totp, + MobileTab::Preferences => Self::Preferences, + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] +pub enum MobileShellState { + Loading, + Empty, + Ready, + Locked, + Error, +} + +impl From for MobileShellState { + fn from(state: StorageShellState) -> Self { + match state { + StorageShellState::Loading => Self::Loading, + StorageShellState::Empty => Self::Empty, + StorageShellState::Ready => Self::Ready, + StorageShellState::Locked => Self::Locked, + StorageShellState::Error => Self::Error, + } + } +} + +impl From for StorageShellState { + fn from(state: MobileShellState) -> Self { + match state { + MobileShellState::Loading => Self::Loading, + MobileShellState::Empty => Self::Empty, + MobileShellState::Ready => Self::Ready, + MobileShellState::Locked => Self::Locked, + MobileShellState::Error => Self::Error, + } + } +} + +#[derive(Clone, uniffi::Record)] +pub struct MobilePage { + pub tab: MobileTab, + pub title: String, + pub system_image: String, + pub selected_system_image: String, + pub state: MobileShellState, + pub state_title: String, + pub state_detail: String, +} + +#[derive(Clone, uniffi::Record)] +pub struct MobileShell { + pub selected_tab: MobileTab, + pub pages: Vec, +} + +impl From for MobileShell { + fn from(shell: mobile::MobileShell) -> Self { + Self { + selected_tab: shell.selected_tab().into(), + pages: shell + .pages() + .iter() + .map(|page| MobilePage { + tab: page.tab().into(), + title: page.title().to_owned(), + system_image: page.system_image().to_owned(), + selected_system_image: page.selected_system_image().to_owned(), + state: page.state().into(), + state_title: page.state_title().to_owned(), + state_detail: page.state_detail().to_owned(), + }) + .collect(), + } + } +} + +#[derive(Debug, uniffi::Error)] +pub enum MobilePreferenceError { + Configuration { message: String }, +} + +impl fmt::Display for MobilePreferenceError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Configuration { message } => formatter.write_str(message), + } + } +} + +impl Error for MobilePreferenceError {} + +impl From for MobilePreferenceError { + fn from(error: ConfigError) -> Self { + Self::Configuration { + message: error.to_string(), + } + } +} + #[uniffi::export] pub fn product_name() -> String { ironstorage::PRODUCT_NAME.to_owned() } +#[uniffi::export] +pub fn mobile_shell() -> MobileShell { + mobile::MobileShell::load().into() +} + +#[uniffi::export] +pub fn mobile_shell_fixture(state: MobileShellState) -> MobileShell { + mobile::MobileShell::fixture(state.into()).into() +} + +#[uniffi::export] +pub fn set_selected_mobile_tab(tab: MobileTab) -> Result<(), MobilePreferenceError> { + mobile::store_selected_tab(tab.into()).map_err(Into::into) +} + #[cfg(test)] mod tests { + use super::{MobileShellState, MobileTab}; + #[test] fn bridge_reads_product_name_from_storage_crate() { assert_eq!(super::product_name(), ironstorage::PRODUCT_NAME); } + + #[test] + fn bridge_exposes_every_view_ready_shell_fixture() { + for state in [ + MobileShellState::Loading, + MobileShellState::Empty, + MobileShellState::Ready, + MobileShellState::Locked, + MobileShellState::Error, + ] { + let shell = super::mobile_shell_fixture(state); + assert_eq!(shell.selected_tab, MobileTab::Home); + assert_eq!(shell.pages.len(), 4); + assert!(shell.pages.iter().all(|page| page.state == state)); + } + } } diff --git a/crates/storage/src/config.rs b/crates/storage/src/config.rs index 904518e..ef9b744 100644 --- a/crates/storage/src/config.rs +++ b/crates/storage/src/config.rs @@ -16,6 +16,7 @@ use serde::Deserialize; use url::Url; use crate::authentication::{AuthenticationTimeout, DEFAULT_AUTHENTICATION_TIMEOUT}; +use crate::mobile::MobileTab; use crate::presentation::{ClipboardTimeout, DEFAULT_CLIPBOARD_TIMEOUT}; const APPLICATION_DIRECTORY: &str = "ironstorage"; @@ -33,6 +34,7 @@ pub struct Config { editor: Option, clipboard_timeout: ClipboardTimeout, authentication_timeout: AuthenticationTimeout, + mobile_tab: MobileTab, git_remotes: Vec, } @@ -118,6 +120,10 @@ impl Config { self.authentication_timeout } + pub fn mobile_tab(&self) -> MobileTab { + self.mobile_tab + } + pub fn git_remotes(&self) -> &[GitRemote] { &self.git_remotes } @@ -136,6 +142,31 @@ impl Config { } } + pub fn update_mobile_tab(&self, tab: MobileTab) -> Result<(), ConfigError> { + let mut document = self.document.clone(); + let root = document + .as_table_mut() + .ok_or_else(|| ConfigError::Malformed { + path: self.source.clone(), + })?; + let ui = root + .entry("ui") + .or_insert_with(|| toml::Value::Table(toml::Table::new())) + .as_table_mut() + .ok_or(ConfigError::InvalidField { field: "ui" })?; + ui.insert( + "selected_mobile_tab".to_owned(), + toml::Value::String(tab.config_value().to_owned()), + ); + let raw = document + .clone() + .try_into::() + .map_err(|_| ConfigError::Malformed { + path: self.source.clone(), + })?; + validate_config(self.source.clone(), document, raw)?.persist() + } + /// Select a configured remote by name, or the configured default (first /// remote) when no name was requested. pub fn git_remote(&self, requested: Option<&str>) -> Option<&GitRemote> { @@ -605,6 +636,8 @@ struct RawConfig { #[serde(default)] security: RawSecurity, #[serde(default)] + ui: RawUi, + #[serde(default)] git: RawGit, } @@ -614,6 +647,12 @@ struct RawSecurity { inactivity_timeout_seconds: Option, } +#[derive(Default, Deserialize)] +#[serde(deny_unknown_fields)] +struct RawUi { + selected_mobile_tab: Option, +} + #[derive(Deserialize)] #[serde(untagged)] enum RawEditor { @@ -686,6 +725,13 @@ fn validate_config( .map_err(|_| ConfigError::InvalidField { field: "security.inactivity_timeout_seconds", })?; + let mobile_tab = raw + .ui + .selected_mobile_tab + .as_deref() + .map(MobileTab::from_config) + .transpose()? + .unwrap_or_default(); let git_remotes = validate_remotes(raw.git.remotes)?; Ok(Config { @@ -697,6 +743,7 @@ fn validate_config( editor, clipboard_timeout, authentication_timeout, + mobile_tab, git_remotes, }) } @@ -840,6 +887,7 @@ fn validate_known_fields(value: &toml::Value, source: &Path) -> Result<(), Confi "editor", "clipboard_timeout_seconds", "security", + "ui", "git", ], )?; @@ -849,6 +897,12 @@ fn validate_known_fields(value: &toml::Value, source: &Path) -> Result<(), Confi })?; validate_table(security, "security", &["inactivity_timeout_seconds"])?; } + if let Some(ui) = root.get("ui") { + let ui = ui.as_table().ok_or_else(|| ConfigError::Malformed { + path: source.to_owned(), + })?; + validate_table(ui, "ui", &["selected_mobile_tab"])?; + } let Some(git) = root.get("git") else { return Ok(()); }; diff --git a/crates/storage/src/lib.rs b/crates/storage/src/lib.rs index 2efab1b..dd6b601 100644 --- a/crates/storage/src/lib.rs +++ b/crates/storage/src/lib.rs @@ -14,6 +14,7 @@ pub mod document; pub mod generate; pub mod git; pub mod kdbx; +pub mod mobile; pub mod mutation; pub mod otp; pub mod presentation; diff --git a/crates/storage/src/mobile.rs b/crates/storage/src/mobile.rs new file mode 100644 index 0000000..d2e83a3 --- /dev/null +++ b/crates/storage/src/mobile.rs @@ -0,0 +1,279 @@ +//! View-ready state for native mobile presentation shells. + +use crate::{ + config::{Config, ConfigError}, + repository::{Repository, RepositoryError}, +}; + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum MobileTab { + #[default] + Home, + Passwords, + Totp, + Preferences, +} + +impl MobileTab { + pub const ALL: [Self; 4] = [Self::Home, Self::Passwords, Self::Totp, Self::Preferences]; + + pub const fn title(self) -> &'static str { + match self { + Self::Home => "Home", + Self::Passwords => "Passwords", + Self::Totp => "TOTP", + Self::Preferences => "Preferences", + } + } + + pub const fn system_image(self) -> &'static str { + match self { + Self::Home => "house", + Self::Passwords => "key", + Self::Totp => "timer", + Self::Preferences => "gearshape", + } + } + + pub const fn selected_system_image(self) -> &'static str { + match self { + Self::Home => "house.fill", + Self::Passwords => "key.fill", + Self::Totp => "timer", + Self::Preferences => "gearshape.fill", + } + } + + pub(crate) fn config_value(self) -> &'static str { + match self { + Self::Home => "home", + Self::Passwords => "passwords", + Self::Totp => "totp", + Self::Preferences => "preferences", + } + } + + pub(crate) fn from_config(value: &str) -> Result { + match value { + "home" => Ok(Self::Home), + "passwords" => Ok(Self::Passwords), + "totp" => Ok(Self::Totp), + "preferences" => Ok(Self::Preferences), + _ => Err(ConfigError::InvalidField { + field: "ui.selected_mobile_tab", + }), + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum MobileShellState { + Loading, + Empty, + Ready, + Locked, + Error, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MobilePage { + tab: MobileTab, + title: String, + system_image: String, + selected_system_image: String, + state: MobileShellState, + state_title: String, + state_detail: String, +} + +impl MobilePage { + pub fn tab(&self) -> MobileTab { + self.tab + } + + pub fn title(&self) -> &str { + &self.title + } + + pub fn system_image(&self) -> &str { + &self.system_image + } + + pub fn selected_system_image(&self) -> &str { + &self.selected_system_image + } + + pub fn state(&self) -> MobileShellState { + self.state + } + + pub fn state_title(&self) -> &str { + &self.state_title + } + + pub fn state_detail(&self) -> &str { + &self.state_detail + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MobileShell { + selected_tab: MobileTab, + pages: Vec, +} + +impl MobileShell { + pub fn load() -> Self { + match Config::load(None) { + Ok(config) => match Repository::open(config.vault()) { + Ok(_) => Self::new(config.mobile_tab(), MobileShellState::Ready, None), + Err(RepositoryError::Io { + source: std::io::ErrorKind::NotFound, + .. + }) => Self::new(config.mobile_tab(), MobileShellState::Empty, None), + Err(error) => Self::new( + config.mobile_tab(), + MobileShellState::Error, + Some(format!("Password store could not be opened: {error}")), + ), + }, + Err(ConfigError::NotFound { .. }) => { + Self::new(MobileTab::Home, MobileShellState::Empty, None) + } + Err(error) => Self::new( + MobileTab::Home, + MobileShellState::Error, + Some(format!("Configuration could not be loaded: {error}")), + ), + } + } + + pub fn fixture(state: MobileShellState) -> Self { + Self::new(MobileTab::Home, state, None) + } + + pub fn selected_tab(&self) -> MobileTab { + self.selected_tab + } + + pub fn pages(&self) -> &[MobilePage] { + &self.pages + } + + fn new(selected_tab: MobileTab, state: MobileShellState, error: Option) -> Self { + let pages = MobileTab::ALL + .into_iter() + .map(|tab| { + let (state_title, state_detail) = state_copy(tab, state, error.as_deref()); + MobilePage { + tab, + title: tab.title().to_owned(), + system_image: tab.system_image().to_owned(), + selected_system_image: tab.selected_system_image().to_owned(), + state, + state_title, + state_detail, + } + }) + .collect(); + Self { + selected_tab, + pages, + } + } +} + +pub fn store_selected_tab(tab: MobileTab) -> Result<(), ConfigError> { + store_selected_tab_from(Config::load(None), tab) +} + +fn store_selected_tab_from( + config: Result, + tab: MobileTab, +) -> Result<(), ConfigError> { + match config { + Ok(config) => config.update_mobile_tab(tab), + Err(ConfigError::NotFound { .. }) => Ok(()), + Err(error) => Err(error), + } +} + +fn state_copy(tab: MobileTab, state: MobileShellState, error: Option<&str>) -> (String, String) { + match state { + MobileShellState::Loading => ( + format!("Loading {}", tab.title()), + "Preparing the password-store presentation.".to_owned(), + ), + MobileShellState::Empty => ( + "No Password Store".to_owned(), + "Set up a local store to use IronStorage.".to_owned(), + ), + MobileShellState::Ready => ( + tab.title().to_owned(), + "The password store is ready.".to_owned(), + ), + MobileShellState::Locked => ( + "IronStorage is Locked".to_owned(), + "Authenticate to reveal protected content.".to_owned(), + ), + MobileShellState::Error => ( + "IronStorage Is Unavailable".to_owned(), + error + .unwrap_or("The password-store state could not be loaded.") + .to_owned(), + ), + } +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use crate::config::ConfigError; + + use super::{MobilePage, MobileShell, MobileShellState, MobileTab, store_selected_tab_from}; + + #[test] + fn every_fixture_is_view_ready_for_all_four_tabs() { + for state in [ + MobileShellState::Loading, + MobileShellState::Empty, + MobileShellState::Ready, + MobileShellState::Locked, + MobileShellState::Error, + ] { + let shell = MobileShell::fixture(state); + assert_eq!(shell.selected_tab(), MobileTab::Home); + assert_eq!(shell.pages().len(), 4); + assert_eq!( + shell + .pages() + .iter() + .map(MobilePage::tab) + .collect::>(), + MobileTab::ALL + ); + assert!(shell.pages().iter().all(|page| { + page.state() == state + && !page.title().is_empty() + && !page.system_image().is_empty() + && !page.selected_system_image().is_empty() + && !page.state_title().is_empty() + && !page.state_detail().is_empty() + })); + } + } + + #[test] + fn tab_selection_before_onboarding_is_a_supported_no_op() { + assert_eq!( + store_selected_tab_from( + Err(ConfigError::NotFound { + path: PathBuf::from("config.toml"), + }), + MobileTab::Preferences, + ), + Ok(()) + ); + } +} diff --git a/crates/storage/tests/config_contract.rs b/crates/storage/tests/config_contract.rs index e339107..a78f375 100644 --- a/crates/storage/tests/config_contract.rs +++ b/crates/storage/tests/config_contract.rs @@ -7,6 +7,7 @@ use ironstorage::{ authentication::{DEFAULT_AUTHENTICATION_TIMEOUT, MAX_AUTHENTICATION_TIMEOUT}, config::{ConfigError, ConfigLoader, EditorSource}, desktop::DesktopStorage, + mobile::MobileTab, }; use tempfile::TempDir; @@ -178,6 +179,37 @@ fn native_default_path_is_used_without_an_explicit_path() -> TestResult { Ok(()) } +#[test] +fn mobile_tab_defaults_and_persists_through_storage_configuration() -> TestResult { + let fixture = ConfigurationFixture::new()?; + fs::create_dir_all(fixture.temporary.path().join("cwd/vault"))?; + fixture.write_explicit(fixture.valid_contents())?; + let config = fixture.loader().load(Some(&fixture.explicit_path()))?; + assert_eq!(config.mobile_tab(), MobileTab::Home); + + config.update_mobile_tab(MobileTab::Totp)?; + let reloaded = fixture.loader().load(Some(&fixture.explicit_path()))?; + assert_eq!(reloaded.mobile_tab(), MobileTab::Totp); + assert!( + fs::read_to_string(fixture.explicit_path())?.contains("selected_mobile_tab = \"totp\"") + ); + + fixture.write_explicit(&format!( + "{}\n[ui]\nselected_mobile_tab = \"unknown\"\n", + fixture.valid_contents() + ))?; + assert_eq!( + fixture + .loader() + .load(Some(&fixture.explicit_path())) + .expect_err("unknown mobile tab"), + ConfigError::InvalidField { + field: "ui.selected_mobile_tab" + } + ); + Ok(()) +} + #[test] fn desktop_vault_switch_preserves_and_reloads_the_shared_configuration() -> TestResult { let fixture = ConfigurationFixture::new()?;