Build native iPhone application shell

This commit is contained in:
2026-08-11 15:12:26 +02:00
parent 64c90aa1d2
commit db898152bf
12 changed files with 1199 additions and 18 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

View File

@@ -0,0 +1,14 @@
{
"images" : [
{
"filename" : "AppIcon.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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<Void, Never>?
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<Void, Never>?
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"
}
}
}

View File

@@ -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