Show remote activity on iPhone Home
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -242,6 +242,46 @@ typedef struct UniffiForeignFutureResultVoid {
|
|||||||
typedef void (*UniffiForeignFutureCompleteVoid)(uint64_t, UniffiForeignFutureResultVoid
|
typedef void (*UniffiForeignFutureCompleteVoid)(uint64_t, UniffiForeignFutureResultVoid
|
||||||
);
|
);
|
||||||
|
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_CLONE_MOBILEHOMEOPERATION
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_CLONE_MOBILEHOMEOPERATION
|
||||||
|
uint64_t uniffi_ironstorage_apple_fn_clone_mobilehomeoperation(uint64_t handle, RustCallStatus *_Nonnull out_status
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_FREE_MOBILEHOMEOPERATION
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_FREE_MOBILEHOMEOPERATION
|
||||||
|
void uniffi_ironstorage_apple_fn_free_mobilehomeoperation(uint64_t handle, RustCallStatus *_Nonnull out_status
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEHOMEOPERATION_CACHED
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEHOMEOPERATION_CACHED
|
||||||
|
RustBuffer uniffi_ironstorage_apple_fn_method_mobilehomeoperation_cached(uint64_t ptr, RustCallStatus *_Nonnull out_status
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEHOMEOPERATION_CANCEL
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEHOMEOPERATION_CANCEL
|
||||||
|
void uniffi_ironstorage_apple_fn_method_mobilehomeoperation_cancel(uint64_t ptr, RustCallStatus *_Nonnull out_status
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEHOMEOPERATION_PROGRESS
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEHOMEOPERATION_PROGRESS
|
||||||
|
RustBuffer uniffi_ironstorage_apple_fn_method_mobilehomeoperation_progress(uint64_t ptr, RustCallStatus *_Nonnull out_status
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEHOMEOPERATION_PULL
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEHOMEOPERATION_PULL
|
||||||
|
RustBuffer uniffi_ironstorage_apple_fn_method_mobilehomeoperation_pull(uint64_t ptr, RustCallStatus *_Nonnull out_status
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEHOMEOPERATION_REFRESH
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEHOMEOPERATION_REFRESH
|
||||||
|
RustBuffer uniffi_ironstorage_apple_fn_method_mobilehomeoperation_refresh(uint64_t ptr, RustCallStatus *_Nonnull out_status
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEHOMEOPERATION_REFRESH_IF_STALE
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEHOMEOPERATION_REFRESH_IF_STALE
|
||||||
|
RustBuffer uniffi_ironstorage_apple_fn_method_mobilehomeoperation_refresh_if_stale(uint64_t ptr, RustCallStatus *_Nonnull out_status
|
||||||
|
);
|
||||||
#endif
|
#endif
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_CLONE_MOBILEONBOARDINGOPERATION
|
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_CLONE_MOBILEONBOARDINGOPERATION
|
||||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_CLONE_MOBILEONBOARDINGOPERATION
|
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_CLONE_MOBILEONBOARDINGOPERATION
|
||||||
@@ -271,6 +311,12 @@ RustBuffer uniffi_ironstorage_apple_fn_method_mobileonboardingoperation_progress
|
|||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEONBOARDINGOPERATION_SETUP
|
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEONBOARDINGOPERATION_SETUP
|
||||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEONBOARDINGOPERATION_SETUP
|
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEONBOARDINGOPERATION_SETUP
|
||||||
RustBuffer uniffi_ironstorage_apple_fn_method_mobileonboardingoperation_setup(uint64_t ptr, RustBuffer branch, int8_t use_existing, RustCallStatus *_Nonnull out_status
|
RustBuffer uniffi_ironstorage_apple_fn_method_mobileonboardingoperation_setup(uint64_t ptr, RustBuffer branch, int8_t use_existing, RustCallStatus *_Nonnull out_status
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_FUNC_MOBILE_HOME_OPERATION
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_FUNC_MOBILE_HOME_OPERATION
|
||||||
|
uint64_t uniffi_ironstorage_apple_fn_func_mobile_home_operation(RustCallStatus *_Nonnull out_status
|
||||||
|
|
||||||
);
|
);
|
||||||
#endif
|
#endif
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_FUNC_MOBILE_ONBOARDING_OPERATION
|
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_FUNC_MOBILE_ONBOARDING_OPERATION
|
||||||
@@ -563,6 +609,12 @@ void ffi_ironstorage_apple_rust_future_free_void(uint64_t handle
|
|||||||
#ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_APPLE_RUST_FUTURE_COMPLETE_VOID
|
#ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_APPLE_RUST_FUTURE_COMPLETE_VOID
|
||||||
#define 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
|
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_HOME_OPERATION
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_FUNC_MOBILE_HOME_OPERATION
|
||||||
|
uint16_t uniffi_ironstorage_apple_checksum_func_mobile_home_operation(void
|
||||||
|
|
||||||
);
|
);
|
||||||
#endif
|
#endif
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_FUNC_MOBILE_ONBOARDING_OPERATION
|
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_FUNC_MOBILE_ONBOARDING_OPERATION
|
||||||
@@ -599,6 +651,42 @@ uint16_t uniffi_ironstorage_apple_checksum_func_replace_configured_mobile_applic
|
|||||||
#define 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
|
uint16_t uniffi_ironstorage_apple_checksum_func_set_selected_mobile_tab(void
|
||||||
|
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEHOMEOPERATION_CACHED
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEHOMEOPERATION_CACHED
|
||||||
|
uint16_t uniffi_ironstorage_apple_checksum_method_mobilehomeoperation_cached(void
|
||||||
|
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEHOMEOPERATION_CANCEL
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEHOMEOPERATION_CANCEL
|
||||||
|
uint16_t uniffi_ironstorage_apple_checksum_method_mobilehomeoperation_cancel(void
|
||||||
|
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEHOMEOPERATION_PROGRESS
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEHOMEOPERATION_PROGRESS
|
||||||
|
uint16_t uniffi_ironstorage_apple_checksum_method_mobilehomeoperation_progress(void
|
||||||
|
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEHOMEOPERATION_PULL
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEHOMEOPERATION_PULL
|
||||||
|
uint16_t uniffi_ironstorage_apple_checksum_method_mobilehomeoperation_pull(void
|
||||||
|
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEHOMEOPERATION_REFRESH
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEHOMEOPERATION_REFRESH
|
||||||
|
uint16_t uniffi_ironstorage_apple_checksum_method_mobilehomeoperation_refresh(void
|
||||||
|
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEHOMEOPERATION_REFRESH_IF_STALE
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEHOMEOPERATION_REFRESH_IF_STALE
|
||||||
|
uint16_t uniffi_ironstorage_apple_checksum_method_mobilehomeoperation_refresh_if_stale(void
|
||||||
|
|
||||||
);
|
);
|
||||||
#endif
|
#endif
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEONBOARDINGOPERATION_CANCEL
|
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEONBOARDINGOPERATION_CANCEL
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
|
extension Notification.Name {
|
||||||
|
static let ironStorageLocalStoreDidChange = Notification.Name(
|
||||||
|
"de.rfc1437.ironstorage.local-store-did-change"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@main
|
@main
|
||||||
final class AppDelegate: UIResponder, UIApplicationDelegate {
|
final class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||||
var window: UIWindow?
|
var window: UIWindow?
|
||||||
@@ -85,6 +91,26 @@ private final class ShellViewController: UITableViewController {
|
|||||||
private var page: MobilePage
|
private var page: MobilePage
|
||||||
private var loadTask: Task<Void, Never>?
|
private var loadTask: Task<Void, Never>?
|
||||||
private var loadGeneration = 0
|
private var loadGeneration = 0
|
||||||
|
private var homePage: MobileHomePage?
|
||||||
|
private var homeTask: Task<Void, Never>?
|
||||||
|
private var homeProgressTask: Task<Void, Never>?
|
||||||
|
private var homeOperation: MobileHomeOperation?
|
||||||
|
private var homeGeneration = 0
|
||||||
|
private var isHomeWorking = false
|
||||||
|
|
||||||
|
private enum HomeSection {
|
||||||
|
case summary
|
||||||
|
case incoming
|
||||||
|
case outgoing
|
||||||
|
case empty
|
||||||
|
case notice
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum HomeRequest: Equatable {
|
||||||
|
case refreshIfStale
|
||||||
|
case refresh
|
||||||
|
case pull
|
||||||
|
}
|
||||||
|
|
||||||
init(page: MobilePage) {
|
init(page: MobilePage) {
|
||||||
shellTab = page.tab
|
shellTab = page.tab
|
||||||
@@ -94,6 +120,12 @@ private final class ShellViewController: UITableViewController {
|
|||||||
navigationItem.largeTitleDisplayMode = .always
|
navigationItem.largeTitleDisplayMode = .always
|
||||||
refreshControl = UIRefreshControl()
|
refreshControl = UIRefreshControl()
|
||||||
refreshControl?.addTarget(self, action: #selector(refreshRequested), for: .valueChanged)
|
refreshControl?.addTarget(self, action: #selector(refreshRequested), for: .valueChanged)
|
||||||
|
NotificationCenter.default.addObserver(
|
||||||
|
self,
|
||||||
|
selector: #selector(localStoreDidChange),
|
||||||
|
name: .ironStorageLocalStoreDidChange,
|
||||||
|
object: nil
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@available(*, unavailable)
|
@available(*, unavailable)
|
||||||
@@ -103,6 +135,10 @@ private final class ShellViewController: UITableViewController {
|
|||||||
|
|
||||||
deinit {
|
deinit {
|
||||||
loadTask?.cancel()
|
loadTask?.cancel()
|
||||||
|
homeOperation?.cancel()
|
||||||
|
homeTask?.cancel()
|
||||||
|
homeProgressTask?.cancel()
|
||||||
|
NotificationCenter.default.removeObserver(self)
|
||||||
}
|
}
|
||||||
|
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
@@ -113,25 +149,73 @@ private final class ShellViewController: UITableViewController {
|
|||||||
override func viewWillAppear(_ animated: Bool) {
|
override func viewWillAppear(_ animated: Bool) {
|
||||||
super.viewWillAppear(animated)
|
super.viewWillAppear(animated)
|
||||||
reloadShell()
|
reloadShell()
|
||||||
|
if shellTab == .home, page.state == .ready, homePage != nil {
|
||||||
|
runHome(.refreshIfStale)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override func viewDidDisappear(_ animated: Bool) {
|
override func viewDidDisappear(_ animated: Bool) {
|
||||||
super.viewDidDisappear(animated)
|
super.viewDidDisappear(animated)
|
||||||
loadTask?.cancel()
|
loadTask?.cancel()
|
||||||
|
cancelHomeWork()
|
||||||
}
|
}
|
||||||
|
|
||||||
override func numberOfSections(in tableView: UITableView) -> Int {
|
override func numberOfSections(in tableView: UITableView) -> Int {
|
||||||
page.state == .ready ? 1 : 0
|
guard page.state == .ready else { return 0 }
|
||||||
|
if shellTab == .home {
|
||||||
|
return homeSections.count
|
||||||
|
}
|
||||||
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||||
1
|
guard shellTab == .home else { return 1 }
|
||||||
|
switch homeSections[section] {
|
||||||
|
case .summary: return homePage?.summaries.count ?? 0
|
||||||
|
case .incoming: return homePage?.incoming.count ?? 0
|
||||||
|
case .outgoing: return homePage?.outgoing.count ?? 0
|
||||||
|
case .empty, .notice: return 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(
|
||||||
|
_ tableView: UITableView,
|
||||||
|
titleForHeaderInSection section: Int
|
||||||
|
) -> String? {
|
||||||
|
guard shellTab == .home else { return nil }
|
||||||
|
switch homeSections[section] {
|
||||||
|
case .summary: return "Status"
|
||||||
|
case .incoming: return "Incoming Activity"
|
||||||
|
case .outgoing: return "Outgoing Activity"
|
||||||
|
case .empty: return "Remote Activity"
|
||||||
|
case .notice: return "Result"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(
|
||||||
|
_ tableView: UITableView,
|
||||||
|
titleForFooterInSection section: Int
|
||||||
|
) -> String? {
|
||||||
|
guard shellTab == .home, let homePage else { return nil }
|
||||||
|
switch homeSections[section] {
|
||||||
|
case .summary:
|
||||||
|
return freshnessDescription(homePage)
|
||||||
|
case .incoming where homePage.incomingTotal > UInt32(homePage.incoming.count):
|
||||||
|
return "Showing \(homePage.incoming.count) of \(homePage.incomingTotal) incoming commits."
|
||||||
|
case .outgoing where homePage.outgoingTotal > UInt32(homePage.outgoing.count):
|
||||||
|
return "Showing \(homePage.outgoing.count) of \(homePage.outgoingTotal) outgoing commits."
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override func tableView(
|
override func tableView(
|
||||||
_ tableView: UITableView,
|
_ tableView: UITableView,
|
||||||
cellForRowAt indexPath: IndexPath
|
cellForRowAt indexPath: IndexPath
|
||||||
) -> UITableViewCell {
|
) -> UITableViewCell {
|
||||||
|
if shellTab == .home {
|
||||||
|
return homeCell(at: indexPath)
|
||||||
|
}
|
||||||
let cell = UITableViewCell(style: .subtitle, reuseIdentifier: nil)
|
let cell = UITableViewCell(style: .subtitle, reuseIdentifier: nil)
|
||||||
var content = cell.defaultContentConfiguration()
|
var content = cell.defaultContentConfiguration()
|
||||||
content.image = UIImage(systemName: page.systemImage)
|
content.image = UIImage(systemName: page.systemImage)
|
||||||
@@ -144,7 +228,42 @@ private final class ShellViewController: UITableViewController {
|
|||||||
return cell
|
return cell
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||||
|
tableView.deselectRow(at: indexPath, animated: true)
|
||||||
|
guard shellTab == .home, let homePage else { return }
|
||||||
|
let section = homeSections[indexPath.section]
|
||||||
|
let commit: MobileHomeCommit
|
||||||
|
let incoming: Bool
|
||||||
|
switch section {
|
||||||
|
case .incoming:
|
||||||
|
commit = homePage.incoming[indexPath.row]
|
||||||
|
incoming = true
|
||||||
|
case .outgoing:
|
||||||
|
commit = homePage.outgoing[indexPath.row]
|
||||||
|
incoming = false
|
||||||
|
default:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
navigationController?.pushViewController(
|
||||||
|
MobileCommitActivityViewController(commit: commit, incoming: incoming),
|
||||||
|
animated: true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@objc private func refreshRequested() {
|
@objc private func refreshRequested() {
|
||||||
|
if shellTab == .home, page.state == .ready {
|
||||||
|
guard !isHomeWorking else {
|
||||||
|
refreshControl?.endRefreshing()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
runHome(.pull)
|
||||||
|
} else {
|
||||||
|
reloadShell()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func localStoreDidChange() {
|
||||||
|
guard shellTab != .home else { return }
|
||||||
reloadShell()
|
reloadShell()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,6 +293,15 @@ private final class ShellViewController: UITableViewController {
|
|||||||
target: self,
|
target: self,
|
||||||
action: #selector(setupRequested)
|
action: #selector(setupRequested)
|
||||||
)
|
)
|
||||||
|
} else if page.state == .ready, shellTab == .home {
|
||||||
|
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||||
|
image: UIImage(systemName: "arrow.clockwise"),
|
||||||
|
style: .plain,
|
||||||
|
target: self,
|
||||||
|
action: #selector(statusRefreshRequested)
|
||||||
|
)
|
||||||
|
navigationItem.rightBarButtonItem?.accessibilityLabel = "Refresh remote status"
|
||||||
|
navigationItem.rightBarButtonItem?.isEnabled = !isHomeWorking
|
||||||
} else if page.state == .ready, shellTab == .preferences {
|
} else if page.state == .ready, shellTab == .preferences {
|
||||||
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||||
title: "Update Token",
|
title: "Update Token",
|
||||||
@@ -189,6 +317,9 @@ private final class ShellViewController: UITableViewController {
|
|||||||
|
|
||||||
guard page.state != .ready else {
|
guard page.state != .ready else {
|
||||||
contentUnavailableConfiguration = nil
|
contentUnavailableConfiguration = nil
|
||||||
|
if shellTab == .home {
|
||||||
|
loadHomeIfNeeded()
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var configuration = page.state == .loading
|
var configuration = page.state == .loading
|
||||||
@@ -208,6 +339,263 @@ private final class ShellViewController: UITableViewController {
|
|||||||
navigationController?.pushViewController(TokenUpdateViewController(), animated: true)
|
navigationController?.pushViewController(TokenUpdateViewController(), animated: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@objc private func statusRefreshRequested() {
|
||||||
|
runHome(.refresh)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var homeSections: [HomeSection] {
|
||||||
|
guard let homePage else { return [] }
|
||||||
|
var sections: [HomeSection] = [.summary]
|
||||||
|
if !homePage.incoming.isEmpty {
|
||||||
|
sections.append(.incoming)
|
||||||
|
}
|
||||||
|
if !homePage.outgoing.isEmpty {
|
||||||
|
sections.append(.outgoing)
|
||||||
|
}
|
||||||
|
if homePage.incomingTotal == 0, homePage.outgoingTotal == 0 {
|
||||||
|
sections.append(.empty)
|
||||||
|
}
|
||||||
|
if homePage.notice != nil {
|
||||||
|
sections.append(.notice)
|
||||||
|
}
|
||||||
|
return sections
|
||||||
|
}
|
||||||
|
|
||||||
|
private func homeCell(at indexPath: IndexPath) -> UITableViewCell {
|
||||||
|
let cell = UITableViewCell(style: .subtitle, reuseIdentifier: nil)
|
||||||
|
guard let homePage else { return cell }
|
||||||
|
var content = cell.defaultContentConfiguration()
|
||||||
|
switch homeSections[indexPath.section] {
|
||||||
|
case .summary:
|
||||||
|
let row = homePage.summaries[indexPath.row]
|
||||||
|
content.image = UIImage(systemName: row.systemImage)
|
||||||
|
content.text = row.title
|
||||||
|
content.secondaryText = row.detail
|
||||||
|
cell.selectionStyle = .none
|
||||||
|
case .incoming:
|
||||||
|
configureCommit(
|
||||||
|
homePage.incoming[indexPath.row],
|
||||||
|
content: &content,
|
||||||
|
cell: cell
|
||||||
|
)
|
||||||
|
case .outgoing:
|
||||||
|
configureCommit(
|
||||||
|
homePage.outgoing[indexPath.row],
|
||||||
|
content: &content,
|
||||||
|
cell: cell
|
||||||
|
)
|
||||||
|
case .empty:
|
||||||
|
content.image = UIImage(systemName: "checkmark.circle")
|
||||||
|
content.text = "No Remote Activity"
|
||||||
|
content.secondaryText = "There are no commits to pull or push."
|
||||||
|
cell.selectionStyle = .none
|
||||||
|
case .notice:
|
||||||
|
if let notice = homePage.notice {
|
||||||
|
content.image = UIImage(systemName: notice.systemImage)
|
||||||
|
content.text = notice.title
|
||||||
|
content.secondaryText = notice.detail
|
||||||
|
}
|
||||||
|
content.secondaryTextProperties.numberOfLines = 0
|
||||||
|
cell.selectionStyle = .none
|
||||||
|
}
|
||||||
|
content.secondaryTextProperties.numberOfLines = 0
|
||||||
|
cell.contentConfiguration = content
|
||||||
|
return cell
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configureCommit(
|
||||||
|
_ commit: MobileHomeCommit,
|
||||||
|
content: inout UIListContentConfiguration,
|
||||||
|
cell: UITableViewCell
|
||||||
|
) {
|
||||||
|
content.image = UIImage(systemName: commit.systemImage)
|
||||||
|
content.text = commit.title
|
||||||
|
content.secondaryText = "\(commit.detail)\n\(formattedDate(commit.timestamp))"
|
||||||
|
content.secondaryTextProperties.numberOfLines = 0
|
||||||
|
cell.accessoryType = .disclosureIndicator
|
||||||
|
cell.accessibilityHint = "Shows password-store changes in this commit."
|
||||||
|
}
|
||||||
|
|
||||||
|
private func freshnessDescription(_ page: MobileHomePage) -> String {
|
||||||
|
let refreshed = page.refreshedAt.map(formattedDate)
|
||||||
|
switch page.freshness {
|
||||||
|
case .neverRefreshed:
|
||||||
|
return "Not refreshed. Pull down to update the local clone or tap Refresh Status."
|
||||||
|
case .cached:
|
||||||
|
return "Cached · Last refreshed \(refreshed ?? "at an unknown time"). This is not live status."
|
||||||
|
case .current:
|
||||||
|
return "Current · Refreshed \(refreshed ?? "just now")."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func formattedDate(_ timestamp: Int64) -> String {
|
||||||
|
Date(timeIntervalSince1970: TimeInterval(timestamp)).formatted(
|
||||||
|
date: .abbreviated,
|
||||||
|
time: .shortened
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadHomeIfNeeded() {
|
||||||
|
guard homePage == nil, homeTask == nil, !isHomeWorking else { return }
|
||||||
|
homeGeneration += 1
|
||||||
|
let current = homeGeneration
|
||||||
|
let operation = mobileHomeOperation()
|
||||||
|
homeOperation = operation
|
||||||
|
isHomeWorking = true
|
||||||
|
navigationItem.rightBarButtonItem?.isEnabled = false
|
||||||
|
showHomeLoading()
|
||||||
|
homeTask = Task { [weak self] in
|
||||||
|
let result = await Task.detached(priority: .userInitiated) {
|
||||||
|
do {
|
||||||
|
return Result<MobileHomePage, HomeFailure>.success(try operation.cached())
|
||||||
|
} catch let error as MobileHomeFfiError {
|
||||||
|
return .failure(HomeFailure(error))
|
||||||
|
} catch {
|
||||||
|
return .failure(.unexpected)
|
||||||
|
}
|
||||||
|
}.value
|
||||||
|
guard let self, current == homeGeneration else { return }
|
||||||
|
finishHome(result, request: nil)
|
||||||
|
if case .success = result {
|
||||||
|
runHome(.refreshIfStale)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func runHome(_ request: HomeRequest) {
|
||||||
|
guard page.state == .ready, shellTab == .home, !isHomeWorking else { return }
|
||||||
|
cancelHomeWork()
|
||||||
|
homeGeneration += 1
|
||||||
|
let current = homeGeneration
|
||||||
|
let operation = mobileHomeOperation()
|
||||||
|
homeOperation = operation
|
||||||
|
isHomeWorking = true
|
||||||
|
navigationItem.rightBarButtonItem?.isEnabled = false
|
||||||
|
showHomeProgress(operation.progress())
|
||||||
|
homeProgressTask = Task { [weak self] in
|
||||||
|
while !Task.isCancelled {
|
||||||
|
do {
|
||||||
|
try await Task.sleep(for: .milliseconds(150))
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard !Task.isCancelled, let self, current == homeGeneration else { return }
|
||||||
|
showHomeProgress(operation.progress())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
homeTask = Task { [weak self] in
|
||||||
|
let result = await Task.detached(priority: .userInitiated) {
|
||||||
|
do {
|
||||||
|
let page: MobileHomePage = switch request {
|
||||||
|
case .refreshIfStale: try operation.refreshIfStale()
|
||||||
|
case .refresh: try operation.refresh()
|
||||||
|
case .pull: try operation.pull()
|
||||||
|
}
|
||||||
|
return Result<MobileHomePage, HomeFailure>.success(page)
|
||||||
|
} catch let error as MobileHomeFfiError {
|
||||||
|
return .failure(HomeFailure(error))
|
||||||
|
} catch {
|
||||||
|
return .failure(.unexpected)
|
||||||
|
}
|
||||||
|
}.value
|
||||||
|
guard let self, current == homeGeneration else { return }
|
||||||
|
finishHome(result, request: request)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func finishHome(
|
||||||
|
_ result: Result<MobileHomePage, HomeFailure>,
|
||||||
|
request: HomeRequest?
|
||||||
|
) {
|
||||||
|
homeProgressTask?.cancel()
|
||||||
|
homeProgressTask = nil
|
||||||
|
homeTask = nil
|
||||||
|
homeOperation = nil
|
||||||
|
isHomeWorking = false
|
||||||
|
navigationItem.titleView = nil
|
||||||
|
navigationItem.prompt = nil
|
||||||
|
navigationItem.rightBarButtonItem?.isEnabled = true
|
||||||
|
refreshControl?.endRefreshing()
|
||||||
|
switch result {
|
||||||
|
case let .success(homePage):
|
||||||
|
self.homePage = homePage
|
||||||
|
contentUnavailableConfiguration = nil
|
||||||
|
tableView.reloadData()
|
||||||
|
if request == .pull {
|
||||||
|
NotificationCenter.default.post(name: .ironStorageLocalStoreDidChange, object: nil)
|
||||||
|
if let notice = homePage.notice {
|
||||||
|
UIAccessibility.post(notification: .announcement, argument: notice.title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case let .failure(failure):
|
||||||
|
tableView.reloadData()
|
||||||
|
if homePage == nil {
|
||||||
|
showHomeFailure(failure)
|
||||||
|
} else if failure.kind != .interrupted {
|
||||||
|
presentHomeFailure(failure)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func showHomeLoading() {
|
||||||
|
var configuration = UIContentUnavailableConfiguration.loading()
|
||||||
|
configuration.text = "Loading Activity"
|
||||||
|
configuration.secondaryText = "Reading cached remote-branch state from storage."
|
||||||
|
contentUnavailableConfiguration = configuration
|
||||||
|
}
|
||||||
|
|
||||||
|
private func showHomeFailure(_ failure: HomeFailure) {
|
||||||
|
var configuration = UIContentUnavailableConfiguration.empty()
|
||||||
|
configuration.image = UIImage(systemName: "exclamationmark.triangle")
|
||||||
|
configuration.text = failure.title
|
||||||
|
configuration.secondaryText = failure.detail
|
||||||
|
contentUnavailableConfiguration = configuration
|
||||||
|
}
|
||||||
|
|
||||||
|
private func presentHomeFailure(_ failure: HomeFailure) {
|
||||||
|
let alert = UIAlertController(
|
||||||
|
title: failure.title,
|
||||||
|
message: failure.detail,
|
||||||
|
preferredStyle: .alert
|
||||||
|
)
|
||||||
|
if failure.kind == .authentication || failure.kind == .secureStorage {
|
||||||
|
alert.addAction(UIAlertAction(title: "Preferences", style: .default) { [weak self] _ in
|
||||||
|
self?.tabBarController?.selectedIndex = 3
|
||||||
|
})
|
||||||
|
}
|
||||||
|
alert.addAction(UIAlertAction(title: "OK", style: .cancel))
|
||||||
|
present(alert, animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func showHomeProgress(_ progress: MobileHomeProgress) {
|
||||||
|
let spinner = UIActivityIndicatorView(style: .medium)
|
||||||
|
spinner.startAnimating()
|
||||||
|
spinner.accessibilityLabel = "In progress"
|
||||||
|
let label = UILabel()
|
||||||
|
label.text = progress.title
|
||||||
|
label.font = .preferredFont(forTextStyle: .headline)
|
||||||
|
label.adjustsFontForContentSizeCategory = true
|
||||||
|
let stack = UIStackView(arrangedSubviews: [spinner, label])
|
||||||
|
stack.spacing = 8
|
||||||
|
navigationItem.titleView = stack
|
||||||
|
navigationItem.prompt = progress.detail
|
||||||
|
}
|
||||||
|
|
||||||
|
private func cancelHomeWork() {
|
||||||
|
homeGeneration += 1
|
||||||
|
homeOperation?.cancel()
|
||||||
|
homeOperation = nil
|
||||||
|
homeTask?.cancel()
|
||||||
|
homeTask = nil
|
||||||
|
homeProgressTask?.cancel()
|
||||||
|
homeProgressTask = nil
|
||||||
|
isHomeWorking = false
|
||||||
|
navigationItem.titleView = nil
|
||||||
|
navigationItem.prompt = nil
|
||||||
|
refreshControl?.endRefreshing()
|
||||||
|
navigationItem.rightBarButtonItem?.isEnabled = true
|
||||||
|
}
|
||||||
|
|
||||||
private func stateImage(_ state: MobileShellState) -> String {
|
private func stateImage(_ state: MobileShellState) -> String {
|
||||||
switch state {
|
switch state {
|
||||||
case .loading: "hourglass"
|
case .loading: "hourglass"
|
||||||
@@ -219,6 +607,96 @@ private final class ShellViewController: UITableViewController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private struct HomeFailure: Error, Sendable {
|
||||||
|
let kind: MobileHomeErrorKind
|
||||||
|
let title: String
|
||||||
|
let detail: String
|
||||||
|
|
||||||
|
init(_ error: MobileHomeFfiError) {
|
||||||
|
switch error {
|
||||||
|
case let .Failed(kind, title, detail):
|
||||||
|
self.kind = kind
|
||||||
|
self.title = title
|
||||||
|
self.detail = detail
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static let unexpected = HomeFailure(
|
||||||
|
kind: .repository,
|
||||||
|
title: "Remote Activity Failed",
|
||||||
|
detail: "IronStorage could not load the storage-provided Home page."
|
||||||
|
)
|
||||||
|
|
||||||
|
private init(kind: MobileHomeErrorKind, title: String, detail: String) {
|
||||||
|
self.kind = kind
|
||||||
|
self.title = title
|
||||||
|
self.detail = detail
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private final class MobileCommitActivityViewController: UITableViewController {
|
||||||
|
private let commit: MobileHomeCommit
|
||||||
|
|
||||||
|
init(commit: MobileHomeCommit, incoming: Bool) {
|
||||||
|
self.commit = commit
|
||||||
|
super.init(style: .insetGrouped)
|
||||||
|
title = incoming ? "Incoming Commit" : "Outgoing Commit"
|
||||||
|
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 ? 1 : max(commit.changes.count, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(
|
||||||
|
_ tableView: UITableView,
|
||||||
|
titleForHeaderInSection section: Int
|
||||||
|
) -> String? {
|
||||||
|
section == 0 ? "Commit" : "Password Store Changes"
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(
|
||||||
|
_ tableView: UITableView,
|
||||||
|
cellForRowAt indexPath: IndexPath
|
||||||
|
) -> UITableViewCell {
|
||||||
|
let cell = UITableViewCell(style: .subtitle, reuseIdentifier: nil)
|
||||||
|
var content = cell.defaultContentConfiguration()
|
||||||
|
if indexPath.section == 0 {
|
||||||
|
content.image = UIImage(systemName: commit.systemImage)
|
||||||
|
content.text = commit.title
|
||||||
|
content.secondaryText = "\(commit.detail)\n\(formattedDate)"
|
||||||
|
} else if commit.changes.isEmpty {
|
||||||
|
content.image = UIImage(systemName: "minus.circle")
|
||||||
|
content.text = "No File Changes"
|
||||||
|
content.secondaryText = "This commit does not change password-store files."
|
||||||
|
} else {
|
||||||
|
let change = commit.changes[indexPath.row]
|
||||||
|
content.image = UIImage(systemName: change.systemImage)
|
||||||
|
content.text = change.title
|
||||||
|
content.secondaryText = change.detail
|
||||||
|
}
|
||||||
|
content.secondaryTextProperties.numberOfLines = 0
|
||||||
|
cell.contentConfiguration = content
|
||||||
|
cell.selectionStyle = .none
|
||||||
|
return cell
|
||||||
|
}
|
||||||
|
|
||||||
|
private var formattedDate: String {
|
||||||
|
Date(timeIntervalSince1970: TimeInterval(commit.timestamp)).formatted(
|
||||||
|
date: .abbreviated,
|
||||||
|
time: .shortened
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private final class TokenUpdateViewController: UITableViewController {
|
private final class TokenUpdateViewController: UITableViewController {
|
||||||
private let accountField = UITextField()
|
private let accountField = UITextField()
|
||||||
|
|||||||
@@ -8,6 +8,12 @@ use std::{error::Error, fmt, sync::Arc};
|
|||||||
use ironstorage::{
|
use ironstorage::{
|
||||||
config::ConfigError,
|
config::ConfigError,
|
||||||
mobile::{self, MobileShellState as StorageShellState, MobileTab as StorageTab},
|
mobile::{self, MobileShellState as StorageShellState, MobileTab as StorageTab},
|
||||||
|
mobile_home::{
|
||||||
|
self, MobileHomeChangeKind as StorageHomeChangeKind,
|
||||||
|
MobileHomeChangeStatus as StorageHomeChangeStatus, MobileHomeError as StorageHomeError,
|
||||||
|
MobileHomeErrorKind as StorageHomeErrorKind, MobileHomeFreshness as StorageHomeFreshness,
|
||||||
|
MobileHomePhase as StorageHomePhase,
|
||||||
|
},
|
||||||
mobile_onboarding::{
|
mobile_onboarding::{
|
||||||
self, MobileOnboardingError as StorageOnboardingError,
|
self, MobileOnboardingError as StorageOnboardingError,
|
||||||
MobileOnboardingErrorKind as StorageOnboardingErrorKind,
|
MobileOnboardingErrorKind as StorageOnboardingErrorKind,
|
||||||
@@ -97,6 +103,284 @@ pub struct MobileShell {
|
|||||||
pub pages: Vec<MobilePage>,
|
pub pages: Vec<MobilePage>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
|
||||||
|
pub enum MobileHomeFreshness {
|
||||||
|
NeverRefreshed,
|
||||||
|
Cached,
|
||||||
|
Current,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<StorageHomeFreshness> for MobileHomeFreshness {
|
||||||
|
fn from(freshness: StorageHomeFreshness) -> Self {
|
||||||
|
match freshness {
|
||||||
|
StorageHomeFreshness::NeverRefreshed => Self::NeverRefreshed,
|
||||||
|
StorageHomeFreshness::Cached => Self::Cached,
|
||||||
|
StorageHomeFreshness::Current => Self::Current,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
|
||||||
|
pub enum MobileHomeChangeKind {
|
||||||
|
PasswordEntry,
|
||||||
|
RecipientPolicy,
|
||||||
|
RecipientSignature,
|
||||||
|
RepositoryFile,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<StorageHomeChangeKind> for MobileHomeChangeKind {
|
||||||
|
fn from(kind: StorageHomeChangeKind) -> Self {
|
||||||
|
match kind {
|
||||||
|
StorageHomeChangeKind::PasswordEntry => Self::PasswordEntry,
|
||||||
|
StorageHomeChangeKind::RecipientPolicy => Self::RecipientPolicy,
|
||||||
|
StorageHomeChangeKind::RecipientSignature => Self::RecipientSignature,
|
||||||
|
StorageHomeChangeKind::RepositoryFile => Self::RepositoryFile,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
|
||||||
|
pub enum MobileHomeChangeStatus {
|
||||||
|
Added,
|
||||||
|
Modified,
|
||||||
|
Deleted,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<StorageHomeChangeStatus> for MobileHomeChangeStatus {
|
||||||
|
fn from(status: StorageHomeChangeStatus) -> Self {
|
||||||
|
match status {
|
||||||
|
StorageHomeChangeStatus::Added => Self::Added,
|
||||||
|
StorageHomeChangeStatus::Modified => Self::Modified,
|
||||||
|
StorageHomeChangeStatus::Deleted => Self::Deleted,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, uniffi::Record)]
|
||||||
|
pub struct MobileHomeSummaryRow {
|
||||||
|
pub id: String,
|
||||||
|
pub title: String,
|
||||||
|
pub detail: String,
|
||||||
|
pub system_image: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, uniffi::Record)]
|
||||||
|
pub struct MobileHomeChange {
|
||||||
|
pub id: String,
|
||||||
|
pub title: String,
|
||||||
|
pub detail: String,
|
||||||
|
pub system_image: String,
|
||||||
|
pub kind: MobileHomeChangeKind,
|
||||||
|
pub status: MobileHomeChangeStatus,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, uniffi::Record)]
|
||||||
|
pub struct MobileHomeCommit {
|
||||||
|
pub id: String,
|
||||||
|
pub title: String,
|
||||||
|
pub detail: String,
|
||||||
|
pub system_image: String,
|
||||||
|
pub timestamp: i64,
|
||||||
|
pub changes: Vec<MobileHomeChange>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, uniffi::Record)]
|
||||||
|
pub struct MobileHomeNotice {
|
||||||
|
pub title: String,
|
||||||
|
pub detail: String,
|
||||||
|
pub system_image: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, uniffi::Record)]
|
||||||
|
pub struct MobileHomePage {
|
||||||
|
pub freshness: MobileHomeFreshness,
|
||||||
|
pub refreshed_at: Option<i64>,
|
||||||
|
pub summaries: Vec<MobileHomeSummaryRow>,
|
||||||
|
pub incoming: Vec<MobileHomeCommit>,
|
||||||
|
pub outgoing: Vec<MobileHomeCommit>,
|
||||||
|
pub incoming_total: u32,
|
||||||
|
pub outgoing_total: u32,
|
||||||
|
pub notice: Option<MobileHomeNotice>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<mobile_home::MobileHomePage> for MobileHomePage {
|
||||||
|
fn from(page: mobile_home::MobileHomePage) -> Self {
|
||||||
|
Self {
|
||||||
|
freshness: page.freshness().into(),
|
||||||
|
refreshed_at: page.refreshed_at(),
|
||||||
|
summaries: page
|
||||||
|
.summaries()
|
||||||
|
.iter()
|
||||||
|
.map(|row| MobileHomeSummaryRow {
|
||||||
|
id: row.id().to_owned(),
|
||||||
|
title: row.title().to_owned(),
|
||||||
|
detail: row.detail().to_owned(),
|
||||||
|
system_image: row.system_image().to_owned(),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
incoming: page.incoming().iter().map(mobile_home_commit).collect(),
|
||||||
|
outgoing: page.outgoing().iter().map(mobile_home_commit).collect(),
|
||||||
|
incoming_total: u32::try_from(page.incoming_total()).unwrap_or(u32::MAX),
|
||||||
|
outgoing_total: u32::try_from(page.outgoing_total()).unwrap_or(u32::MAX),
|
||||||
|
notice: page.notice().map(|notice| MobileHomeNotice {
|
||||||
|
title: notice.title().to_owned(),
|
||||||
|
detail: notice.detail().to_owned(),
|
||||||
|
system_image: notice.system_image().to_owned(),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mobile_home_commit(commit: &mobile_home::MobileHomeCommit) -> MobileHomeCommit {
|
||||||
|
MobileHomeCommit {
|
||||||
|
id: commit.id().to_owned(),
|
||||||
|
title: commit.title().to_owned(),
|
||||||
|
detail: commit.detail().to_owned(),
|
||||||
|
system_image: commit.system_image().to_owned(),
|
||||||
|
timestamp: commit.timestamp(),
|
||||||
|
changes: commit
|
||||||
|
.changes()
|
||||||
|
.iter()
|
||||||
|
.map(|change| MobileHomeChange {
|
||||||
|
id: change.id().to_owned(),
|
||||||
|
title: change.title().to_owned(),
|
||||||
|
detail: change.detail().to_owned(),
|
||||||
|
system_image: change.system_image().to_owned(),
|
||||||
|
kind: change.kind().into(),
|
||||||
|
status: change.status().into(),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
|
||||||
|
pub enum MobileHomePhase {
|
||||||
|
Validating,
|
||||||
|
Authenticating,
|
||||||
|
Receiving,
|
||||||
|
Integrating,
|
||||||
|
Finishing,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<StorageHomePhase> for MobileHomePhase {
|
||||||
|
fn from(phase: StorageHomePhase) -> Self {
|
||||||
|
match phase {
|
||||||
|
StorageHomePhase::Validating => Self::Validating,
|
||||||
|
StorageHomePhase::Authenticating => Self::Authenticating,
|
||||||
|
StorageHomePhase::Receiving => Self::Receiving,
|
||||||
|
StorageHomePhase::Integrating => Self::Integrating,
|
||||||
|
StorageHomePhase::Finishing => Self::Finishing,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, uniffi::Record)]
|
||||||
|
pub struct MobileHomeProgress {
|
||||||
|
pub phase: MobileHomePhase,
|
||||||
|
pub title: String,
|
||||||
|
pub detail: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
|
||||||
|
pub enum MobileHomeErrorKind {
|
||||||
|
MissingConfiguration,
|
||||||
|
Configuration,
|
||||||
|
Authentication,
|
||||||
|
Conflict,
|
||||||
|
DirtyLocalChanges,
|
||||||
|
Offline,
|
||||||
|
Interrupted,
|
||||||
|
SecureStorage,
|
||||||
|
Repository,
|
||||||
|
PartialProgress,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<StorageHomeErrorKind> for MobileHomeErrorKind {
|
||||||
|
fn from(kind: StorageHomeErrorKind) -> Self {
|
||||||
|
match kind {
|
||||||
|
StorageHomeErrorKind::MissingConfiguration => Self::MissingConfiguration,
|
||||||
|
StorageHomeErrorKind::Configuration => Self::Configuration,
|
||||||
|
StorageHomeErrorKind::Authentication => Self::Authentication,
|
||||||
|
StorageHomeErrorKind::Conflict => Self::Conflict,
|
||||||
|
StorageHomeErrorKind::DirtyLocalChanges => Self::DirtyLocalChanges,
|
||||||
|
StorageHomeErrorKind::Offline => Self::Offline,
|
||||||
|
StorageHomeErrorKind::Interrupted => Self::Interrupted,
|
||||||
|
StorageHomeErrorKind::SecureStorage => Self::SecureStorage,
|
||||||
|
StorageHomeErrorKind::Repository => Self::Repository,
|
||||||
|
StorageHomeErrorKind::PartialProgress => Self::PartialProgress,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, uniffi::Error)]
|
||||||
|
pub enum MobileHomeFfiError {
|
||||||
|
Failed {
|
||||||
|
kind: MobileHomeErrorKind,
|
||||||
|
title: String,
|
||||||
|
detail: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for MobileHomeFfiError {
|
||||||
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::Failed { title, detail, .. } => write!(formatter, "{title}: {detail}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Error for MobileHomeFfiError {}
|
||||||
|
|
||||||
|
impl From<StorageHomeError> for MobileHomeFfiError {
|
||||||
|
fn from(error: StorageHomeError) -> Self {
|
||||||
|
Self::Failed {
|
||||||
|
kind: error.kind().into(),
|
||||||
|
title: error.title().to_owned(),
|
||||||
|
detail: error.detail().to_owned(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(uniffi::Object)]
|
||||||
|
pub struct MobileHomeOperation {
|
||||||
|
operation: mobile_home::MobileHomeOperation,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[uniffi::export]
|
||||||
|
impl MobileHomeOperation {
|
||||||
|
pub fn progress(&self) -> MobileHomeProgress {
|
||||||
|
let progress = self.operation.progress();
|
||||||
|
MobileHomeProgress {
|
||||||
|
phase: progress.phase().into(),
|
||||||
|
title: progress.title().to_owned(),
|
||||||
|
detail: progress.detail().to_owned(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cached(&self) -> Result<MobileHomePage, MobileHomeFfiError> {
|
||||||
|
self.operation.cached().map(Into::into).map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn refresh_if_stale(&self) -> Result<MobileHomePage, MobileHomeFfiError> {
|
||||||
|
self.operation
|
||||||
|
.refresh_if_stale()
|
||||||
|
.map(Into::into)
|
||||||
|
.map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn refresh(&self) -> Result<MobileHomePage, MobileHomeFfiError> {
|
||||||
|
self.operation.refresh().map(Into::into).map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn pull(&self) -> Result<MobileHomePage, MobileHomeFfiError> {
|
||||||
|
self.operation.pull().map(Into::into).map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cancel(&self) {
|
||||||
|
self.operation.cancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
|
||||||
pub enum MobileOnboardingPhase {
|
pub enum MobileOnboardingPhase {
|
||||||
Validating,
|
Validating,
|
||||||
@@ -301,6 +585,13 @@ pub fn set_selected_mobile_tab(tab: MobileTab) -> Result<(), MobilePreferenceErr
|
|||||||
mobile::store_selected_tab(tab.into()).map_err(Into::into)
|
mobile::store_selected_tab(tab.into()).map_err(Into::into)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[uniffi::export]
|
||||||
|
pub fn mobile_home_operation() -> Arc<MobileHomeOperation> {
|
||||||
|
Arc::new(MobileHomeOperation {
|
||||||
|
operation: mobile_home::MobileHomeOperation::default(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
#[uniffi::export]
|
#[uniffi::export]
|
||||||
pub fn mobile_onboarding_operation(
|
pub fn mobile_onboarding_operation(
|
||||||
server_url: String,
|
server_url: String,
|
||||||
@@ -333,7 +624,10 @@ pub fn replace_configured_mobile_application_token(
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{MobileOnboardingErrorKind, MobileOnboardingFfiError, MobileShellState, MobileTab};
|
use super::{
|
||||||
|
MobileHomePhase, MobileOnboardingErrorKind, MobileOnboardingFfiError, MobileShellState,
|
||||||
|
MobileTab,
|
||||||
|
};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn bridge_reads_product_name_from_storage_crate() {
|
fn bridge_reads_product_name_from_storage_crate() {
|
||||||
@@ -374,4 +668,12 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn home_operation_exposes_typed_progress_and_cancellation() {
|
||||||
|
let operation = super::mobile_home_operation();
|
||||||
|
assert_eq!(operation.progress().phase, MobileHomePhase::Validating);
|
||||||
|
operation.cancel();
|
||||||
|
assert_eq!(operation.progress().phase, MobileHomePhase::Validating);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ pub struct Config {
|
|||||||
clipboard_timeout: ClipboardTimeout,
|
clipboard_timeout: ClipboardTimeout,
|
||||||
authentication_timeout: AuthenticationTimeout,
|
authentication_timeout: AuthenticationTimeout,
|
||||||
mobile_tab: MobileTab,
|
mobile_tab: MobileTab,
|
||||||
|
mobile_home_refreshed_at: Option<i64>,
|
||||||
git_remotes: Vec<GitRemote>,
|
git_remotes: Vec<GitRemote>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,6 +126,10 @@ impl Config {
|
|||||||
self.mobile_tab
|
self.mobile_tab
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn mobile_home_refreshed_at(&self) -> Option<i64> {
|
||||||
|
self.mobile_home_refreshed_at
|
||||||
|
}
|
||||||
|
|
||||||
pub fn git_remotes(&self) -> &[GitRemote] {
|
pub fn git_remotes(&self) -> &[GitRemote] {
|
||||||
&self.git_remotes
|
&self.git_remotes
|
||||||
}
|
}
|
||||||
@@ -168,6 +173,36 @@ impl Config {
|
|||||||
validate_config(self.source.clone(), document, raw)?.persist()
|
validate_config(self.source.clone(), document, raw)?.persist()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn update_mobile_home_refresh(&self, unix_seconds: i64) -> Result<(), ConfigError> {
|
||||||
|
if unix_seconds <= 0 {
|
||||||
|
return Err(ConfigError::InvalidField {
|
||||||
|
field: "ui.home_remote_refreshed_at_unix_seconds",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
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(
|
||||||
|
"home_remote_refreshed_at_unix_seconds".to_owned(),
|
||||||
|
toml::Value::Integer(unix_seconds),
|
||||||
|
);
|
||||||
|
let raw = document
|
||||||
|
.clone()
|
||||||
|
.try_into::<RawConfig>()
|
||||||
|
.map_err(|_| ConfigError::Malformed {
|
||||||
|
path: self.source.clone(),
|
||||||
|
})?;
|
||||||
|
validate_config(self.source.clone(), document, raw)?.persist()
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn create_mobile_clone(
|
pub(crate) fn create_mobile_clone(
|
||||||
source: PathBuf,
|
source: PathBuf,
|
||||||
vault: &Path,
|
vault: &Path,
|
||||||
@@ -806,6 +841,7 @@ struct RawSecurity {
|
|||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
struct RawUi {
|
struct RawUi {
|
||||||
selected_mobile_tab: Option<String>,
|
selected_mobile_tab: Option<String>,
|
||||||
|
home_remote_refreshed_at_unix_seconds: Option<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@@ -887,6 +923,15 @@ fn validate_config(
|
|||||||
.map(MobileTab::from_config)
|
.map(MobileTab::from_config)
|
||||||
.transpose()?
|
.transpose()?
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
let mobile_home_refreshed_at = match raw.ui.home_remote_refreshed_at_unix_seconds {
|
||||||
|
Some(value) if value > 0 => Some(value),
|
||||||
|
Some(_) => {
|
||||||
|
return Err(ConfigError::InvalidField {
|
||||||
|
field: "ui.home_remote_refreshed_at_unix_seconds",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
let git_remotes = validate_remotes(raw.git.remotes)?;
|
let git_remotes = validate_remotes(raw.git.remotes)?;
|
||||||
|
|
||||||
Ok(Config {
|
Ok(Config {
|
||||||
@@ -899,6 +944,7 @@ fn validate_config(
|
|||||||
clipboard_timeout,
|
clipboard_timeout,
|
||||||
authentication_timeout,
|
authentication_timeout,
|
||||||
mobile_tab,
|
mobile_tab,
|
||||||
|
mobile_home_refreshed_at,
|
||||||
git_remotes,
|
git_remotes,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1073,7 +1119,14 @@ fn validate_known_fields(value: &toml::Value, source: &Path) -> Result<(), Confi
|
|||||||
let ui = ui.as_table().ok_or_else(|| ConfigError::Malformed {
|
let ui = ui.as_table().ok_or_else(|| ConfigError::Malformed {
|
||||||
path: source.to_owned(),
|
path: source.to_owned(),
|
||||||
})?;
|
})?;
|
||||||
validate_table(ui, "ui", &["selected_mobile_tab"])?;
|
validate_table(
|
||||||
|
ui,
|
||||||
|
"ui",
|
||||||
|
&[
|
||||||
|
"selected_mobile_tab",
|
||||||
|
"home_remote_refreshed_at_unix_seconds",
|
||||||
|
],
|
||||||
|
)?;
|
||||||
}
|
}
|
||||||
let Some(git) = root.get("git") else {
|
let Some(git) = root.get("git") else {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@@ -1215,6 +1268,8 @@ fn native_config_directory() -> Option<PathBuf> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use std::fs;
|
use std::fs;
|
||||||
|
|
||||||
|
use crate::mobile::MobileTab;
|
||||||
|
|
||||||
use super::{Config, ConfigError, GitRemote};
|
use super::{Config, ConfigError, GitRemote};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1234,8 +1289,16 @@ mod tests {
|
|||||||
"repository-example",
|
"repository-example",
|
||||||
)
|
)
|
||||||
.expect("remote");
|
.expect("remote");
|
||||||
Config::create_mobile_clone(source.clone(), &vault, &keys, "ALICE", &remote)
|
let config = Config::create_mobile_clone(source.clone(), &vault, &keys, "ALICE", &remote)
|
||||||
.expect("create config");
|
.expect("create config");
|
||||||
|
assert_eq!(config.mobile_home_refreshed_at(), None);
|
||||||
|
config
|
||||||
|
.update_mobile_home_refresh(1_789_000_000)
|
||||||
|
.expect("persist refresh time");
|
||||||
|
Config::load(Some(&source))
|
||||||
|
.expect("reload timestamped config")
|
||||||
|
.update_mobile_tab(MobileTab::Preferences)
|
||||||
|
.expect("persist tab after refresh time");
|
||||||
let contents = fs::read_to_string(&source).expect("read config");
|
let contents = fs::read_to_string(&source).expect("read config");
|
||||||
assert!(!contents.contains("token ="));
|
assert!(!contents.contains("token ="));
|
||||||
assert!(!contents.contains("password ="));
|
assert!(!contents.contains("password ="));
|
||||||
@@ -1251,12 +1314,15 @@ mod tests {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
fs::rename(&original, &relocated).expect("relocate app container");
|
fs::rename(&original, &relocated).expect("relocate app container");
|
||||||
|
let relocated_config =
|
||||||
|
Config::load(Some(&relocated.join("config.toml"))).expect("reload config");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
Config::load(Some(&relocated.join("config.toml")))
|
(
|
||||||
.expect("reload config")
|
relocated_config.default_key().as_str(),
|
||||||
.default_key()
|
relocated_config.mobile_home_refreshed_at(),
|
||||||
.as_str(),
|
relocated_config.mobile_tab(),
|
||||||
"ALICE"
|
),
|
||||||
|
("ALICE", Some(1_789_000_000), MobileTab::Preferences)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -180,6 +180,53 @@ pub struct GitSnapshot {
|
|||||||
recent: Vec<GitLogEntry>,
|
recent: Vec<GitLogEntry>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct GitCommitActivity {
|
||||||
|
commit: GitLogEntry,
|
||||||
|
changes: Vec<GitChange>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GitCommitActivity {
|
||||||
|
pub fn commit(&self) -> &GitLogEntry {
|
||||||
|
&self.commit
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn changes(&self) -> &[GitChange] {
|
||||||
|
&self.changes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct GitDivergence {
|
||||||
|
branch: String,
|
||||||
|
status: GitStatus,
|
||||||
|
remote: GitRemoteStatus,
|
||||||
|
incoming: Vec<GitCommitActivity>,
|
||||||
|
outgoing: Vec<GitCommitActivity>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GitDivergence {
|
||||||
|
pub fn branch(&self) -> &str {
|
||||||
|
&self.branch
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn status(&self) -> &GitStatus {
|
||||||
|
&self.status
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn remote(&self) -> &GitRemoteStatus {
|
||||||
|
&self.remote
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn incoming(&self) -> &[GitCommitActivity] {
|
||||||
|
&self.incoming
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn outgoing(&self) -> &[GitCommitActivity] {
|
||||||
|
&self.outgoing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl GitSnapshot {
|
impl GitSnapshot {
|
||||||
pub fn root(&self) -> &Path {
|
pub fn root(&self) -> &Path {
|
||||||
&self.root
|
&self.root
|
||||||
@@ -1698,32 +1745,144 @@ impl GitRepository {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Return commits and typed path changes on each side of the configured
|
||||||
|
/// remote-tracking branch without performing network access.
|
||||||
|
pub fn divergence(
|
||||||
|
&self,
|
||||||
|
configured: &GitRemote,
|
||||||
|
activity_limit: usize,
|
||||||
|
) -> Result<GitDivergence, GitError> {
|
||||||
|
let branch = self.current_branch()?;
|
||||||
|
let status = self.status()?;
|
||||||
|
let name = configured.name().as_str();
|
||||||
|
let actual_url = self.remote_url(name)?;
|
||||||
|
if actual_url != configured.url().as_str() {
|
||||||
|
return Err(GitError::ForbiddenRemoteUrl);
|
||||||
|
}
|
||||||
|
let local_id = self
|
||||||
|
.repository
|
||||||
|
.head_id()
|
||||||
|
.map_err(|_| GitError::UnbornHead)?
|
||||||
|
.detach();
|
||||||
|
let remote_ref = format!("refs/remotes/{name}/{branch}");
|
||||||
|
let remote_id = self
|
||||||
|
.repository
|
||||||
|
.find_reference(&remote_ref)
|
||||||
|
.map_err(|_| GitError::RemoteNotFound {
|
||||||
|
name: remote_ref.clone(),
|
||||||
|
})?
|
||||||
|
.into_fully_peeled_id()
|
||||||
|
.map_err(invalid)?
|
||||||
|
.detach();
|
||||||
|
let local_ids = self.ancestor_ids(local_id)?;
|
||||||
|
let remote_ids = self.ancestor_ids(remote_id)?;
|
||||||
|
let ahead = local_ids.difference(&remote_ids).count();
|
||||||
|
let behind = remote_ids.difference(&local_ids).count();
|
||||||
|
let incoming = self.commit_activities(remote_id, &local_ids, activity_limit)?;
|
||||||
|
let outgoing = self.commit_activities(local_id, &remote_ids, activity_limit)?;
|
||||||
|
Ok(GitDivergence {
|
||||||
|
branch,
|
||||||
|
status,
|
||||||
|
remote: GitRemoteStatus {
|
||||||
|
name: name.to_owned(),
|
||||||
|
url: actual_url,
|
||||||
|
ahead,
|
||||||
|
behind,
|
||||||
|
},
|
||||||
|
incoming,
|
||||||
|
outgoing,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn commit_activities(
|
||||||
|
&self,
|
||||||
|
tip: gix::hash::ObjectId,
|
||||||
|
excluded: &BTreeSet<gix::hash::ObjectId>,
|
||||||
|
limit: usize,
|
||||||
|
) -> Result<Vec<GitCommitActivity>, GitError> {
|
||||||
|
if limit == 0 {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
let commit = self
|
||||||
|
.repository
|
||||||
|
.find_object(tip)
|
||||||
|
.map_err(invalid)?
|
||||||
|
.peel_to_commit()
|
||||||
|
.map_err(invalid)?;
|
||||||
|
let mut output = Vec::new();
|
||||||
|
for info in commit.ancestors().all().map_err(invalid)? {
|
||||||
|
let info = info.map_err(invalid)?;
|
||||||
|
if excluded.contains(&info.id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
output.push(self.commit_activity(info.id)?);
|
||||||
|
if output.len() >= limit {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(output)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn commit_activity(&self, id: gix::hash::ObjectId) -> Result<GitCommitActivity, GitError> {
|
||||||
|
let commit = self
|
||||||
|
.repository
|
||||||
|
.find_object(id)
|
||||||
|
.map_err(invalid)?
|
||||||
|
.peel_to_commit()
|
||||||
|
.map_err(invalid)?;
|
||||||
|
let new_tree = commit.tree_id().map_err(invalid)?.detach();
|
||||||
|
let old_tree = commit
|
||||||
|
.parent_ids()
|
||||||
|
.next()
|
||||||
|
.map(|parent| {
|
||||||
|
self.repository
|
||||||
|
.find_object(parent.detach())
|
||||||
|
.map_err(invalid)?
|
||||||
|
.peel_to_commit()
|
||||||
|
.map_err(invalid)?
|
||||||
|
.tree_id()
|
||||||
|
.map_err(invalid)
|
||||||
|
.map(|tree| tree.detach())
|
||||||
|
})
|
||||||
|
.transpose()?;
|
||||||
|
let old = tree_map_by_id(&self.repository, old_tree)?;
|
||||||
|
let new = tree_map_by_id(&self.repository, Some(new_tree))?;
|
||||||
|
Ok(GitCommitActivity {
|
||||||
|
commit: git_log_entry(&commit)?,
|
||||||
|
changes: compare_maps(&old, &new),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn ahead_behind(
|
fn ahead_behind(
|
||||||
&self,
|
&self,
|
||||||
local: gix::hash::ObjectId,
|
local: gix::hash::ObjectId,
|
||||||
remote: gix::hash::ObjectId,
|
remote: gix::hash::ObjectId,
|
||||||
) -> Result<(usize, usize), GitError> {
|
) -> Result<(usize, usize), GitError> {
|
||||||
let ancestors = |id| -> Result<BTreeSet<gix::hash::ObjectId>, GitError> {
|
let local_ids = self.ancestor_ids(local)?;
|
||||||
let commit = self
|
let remote_ids = self.ancestor_ids(remote)?;
|
||||||
.repository
|
|
||||||
.find_object(id)
|
|
||||||
.map_err(invalid)?
|
|
||||||
.peel_to_commit()
|
|
||||||
.map_err(invalid)?;
|
|
||||||
let mut ids = BTreeSet::from([id]);
|
|
||||||
for info in commit.ancestors().all().map_err(invalid)? {
|
|
||||||
ids.insert(info.map_err(invalid)?.id);
|
|
||||||
}
|
|
||||||
Ok(ids)
|
|
||||||
};
|
|
||||||
let local_ids = ancestors(local)?;
|
|
||||||
let remote_ids = ancestors(remote)?;
|
|
||||||
Ok((
|
Ok((
|
||||||
local_ids.difference(&remote_ids).count(),
|
local_ids.difference(&remote_ids).count(),
|
||||||
remote_ids.difference(&local_ids).count(),
|
remote_ids.difference(&local_ids).count(),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn ancestor_ids(
|
||||||
|
&self,
|
||||||
|
id: gix::hash::ObjectId,
|
||||||
|
) -> Result<BTreeSet<gix::hash::ObjectId>, GitError> {
|
||||||
|
let commit = self
|
||||||
|
.repository
|
||||||
|
.find_object(id)
|
||||||
|
.map_err(invalid)?
|
||||||
|
.peel_to_commit()
|
||||||
|
.map_err(invalid)?;
|
||||||
|
let mut ids = BTreeSet::from([id]);
|
||||||
|
for info in commit.ancestors().all().map_err(invalid)? {
|
||||||
|
ids.insert(info.map_err(invalid)?.id);
|
||||||
|
}
|
||||||
|
Ok(ids)
|
||||||
|
}
|
||||||
|
|
||||||
fn update_remote_tracking(
|
fn update_remote_tracking(
|
||||||
&self,
|
&self,
|
||||||
remote: &str,
|
remote: &str,
|
||||||
@@ -2329,16 +2488,7 @@ impl GitRepository {
|
|||||||
}
|
}
|
||||||
let info = info.map_err(invalid)?;
|
let info = info.map_err(invalid)?;
|
||||||
let commit = info.object().map_err(invalid)?;
|
let commit = info.object().map_err(invalid)?;
|
||||||
let decoded = commit.decode().map_err(invalid)?;
|
output.push(git_log_entry(&commit)?);
|
||||||
let author = decoded.author().map_err(invalid)?;
|
|
||||||
output.push(GitLogEntry {
|
|
||||||
id: commit.id.to_string(),
|
|
||||||
parents: decoded.parents().map(|id| id.to_string()).collect(),
|
|
||||||
author_name: author.name.to_str_lossy().into_owned(),
|
|
||||||
author_email: author.email.to_str_lossy().into_owned(),
|
|
||||||
message: decoded.message.to_str_lossy().into_owned(),
|
|
||||||
timestamp: author.time().map_err(invalid)?.seconds,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
Ok(output)
|
Ok(output)
|
||||||
}
|
}
|
||||||
@@ -2516,6 +2666,19 @@ fn append_diff_lines(output: &mut Vec<u8>, prefix: u8, contents: &[u8]) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn git_log_entry(commit: &gix::Commit<'_>) -> Result<GitLogEntry, GitError> {
|
||||||
|
let decoded = commit.decode().map_err(invalid)?;
|
||||||
|
let author = decoded.author().map_err(invalid)?;
|
||||||
|
Ok(GitLogEntry {
|
||||||
|
id: commit.id.to_string(),
|
||||||
|
parents: decoded.parents().map(|id| id.to_string()).collect(),
|
||||||
|
author_name: author.name.to_str_lossy().into_owned(),
|
||||||
|
author_email: author.email.to_str_lossy().into_owned(),
|
||||||
|
message: decoded.message.to_str_lossy().into_owned(),
|
||||||
|
timestamp: author.time().map_err(invalid)?.seconds,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
impl EntryCommitter for GitRepository {
|
impl EntryCommitter for GitRepository {
|
||||||
fn commit(&mut self, change: &EntryCommit) -> Result<(), EntryCommitError> {
|
fn commit(&mut self, change: &EntryCommit) -> Result<(), EntryCommitError> {
|
||||||
self.stage_and_commit(&[change.path().encrypted_relative_path()], change.message())
|
self.stage_and_commit(&[change.path().encrypted_relative_path()], change.message())
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ pub mod generate;
|
|||||||
pub mod git;
|
pub mod git;
|
||||||
pub mod kdbx;
|
pub mod kdbx;
|
||||||
pub mod mobile;
|
pub mod mobile;
|
||||||
|
pub mod mobile_home;
|
||||||
pub mod mobile_onboarding;
|
pub mod mobile_onboarding;
|
||||||
pub mod mutation;
|
pub mod mutation;
|
||||||
pub mod otp;
|
pub mod otp;
|
||||||
|
|||||||
979
crates/storage/src/mobile_home.rs
Normal file
979
crates/storage/src/mobile_home.rs
Normal file
@@ -0,0 +1,979 @@
|
|||||||
|
//! Storage-owned remote activity and pull-to-refresh models for iPhone Home.
|
||||||
|
|
||||||
|
use std::{
|
||||||
|
collections::BTreeSet,
|
||||||
|
error::Error,
|
||||||
|
fmt,
|
||||||
|
path::Path,
|
||||||
|
sync::{Arc, Mutex},
|
||||||
|
time::{SystemTime, UNIX_EPOCH},
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
config::{Config, ConfigError, GitRemote},
|
||||||
|
git::{
|
||||||
|
GitChangeKind, GitCommitActivity, GitDivergence, GitError, GitIdentity,
|
||||||
|
GitOperationControl, GitProgressPhase, GitRepository, PullOutcome,
|
||||||
|
},
|
||||||
|
repository::{Repository, RepositoryError},
|
||||||
|
secret_store::{
|
||||||
|
NativeSecretStore, SecretCachePolicy, SecretProtectionPolicy, SecretStoreError,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const ACTIVITY_LIMIT: usize = 50;
|
||||||
|
const STALE_AFTER_SECONDS: i64 = 5 * 60;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub enum MobileHomeFreshness {
|
||||||
|
NeverRefreshed,
|
||||||
|
Cached,
|
||||||
|
Current,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub enum MobileHomeChangeKind {
|
||||||
|
PasswordEntry,
|
||||||
|
RecipientPolicy,
|
||||||
|
RecipientSignature,
|
||||||
|
RepositoryFile,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub enum MobileHomeChangeStatus {
|
||||||
|
Added,
|
||||||
|
Modified,
|
||||||
|
Deleted,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<GitChangeKind> for MobileHomeChangeStatus {
|
||||||
|
fn from(kind: GitChangeKind) -> Self {
|
||||||
|
match kind {
|
||||||
|
GitChangeKind::Added => Self::Added,
|
||||||
|
GitChangeKind::Modified => Self::Modified,
|
||||||
|
GitChangeKind::Deleted => Self::Deleted,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct MobileHomeSummaryRow {
|
||||||
|
id: String,
|
||||||
|
title: String,
|
||||||
|
detail: String,
|
||||||
|
system_image: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MobileHomeSummaryRow {
|
||||||
|
pub fn id(&self) -> &str {
|
||||||
|
&self.id
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn title(&self) -> &str {
|
||||||
|
&self.title
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn detail(&self) -> &str {
|
||||||
|
&self.detail
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn system_image(&self) -> &str {
|
||||||
|
&self.system_image
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct MobileHomeChange {
|
||||||
|
id: String,
|
||||||
|
title: String,
|
||||||
|
detail: String,
|
||||||
|
system_image: String,
|
||||||
|
kind: MobileHomeChangeKind,
|
||||||
|
status: MobileHomeChangeStatus,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MobileHomeChange {
|
||||||
|
pub fn id(&self) -> &str {
|
||||||
|
&self.id
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn title(&self) -> &str {
|
||||||
|
&self.title
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn detail(&self) -> &str {
|
||||||
|
&self.detail
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn system_image(&self) -> &str {
|
||||||
|
&self.system_image
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn kind(&self) -> MobileHomeChangeKind {
|
||||||
|
self.kind
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn status(&self) -> MobileHomeChangeStatus {
|
||||||
|
self.status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct MobileHomeCommit {
|
||||||
|
id: String,
|
||||||
|
title: String,
|
||||||
|
detail: String,
|
||||||
|
system_image: String,
|
||||||
|
timestamp: i64,
|
||||||
|
changes: Vec<MobileHomeChange>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MobileHomeCommit {
|
||||||
|
pub fn id(&self) -> &str {
|
||||||
|
&self.id
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn title(&self) -> &str {
|
||||||
|
&self.title
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn detail(&self) -> &str {
|
||||||
|
&self.detail
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn system_image(&self) -> &str {
|
||||||
|
&self.system_image
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn timestamp(&self) -> i64 {
|
||||||
|
self.timestamp
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn changes(&self) -> &[MobileHomeChange] {
|
||||||
|
&self.changes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct MobileHomeNotice {
|
||||||
|
title: String,
|
||||||
|
detail: String,
|
||||||
|
system_image: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MobileHomeNotice {
|
||||||
|
pub fn title(&self) -> &str {
|
||||||
|
&self.title
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn detail(&self) -> &str {
|
||||||
|
&self.detail
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn system_image(&self) -> &str {
|
||||||
|
&self.system_image
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct MobileHomePage {
|
||||||
|
freshness: MobileHomeFreshness,
|
||||||
|
refreshed_at: Option<i64>,
|
||||||
|
summaries: Vec<MobileHomeSummaryRow>,
|
||||||
|
incoming: Vec<MobileHomeCommit>,
|
||||||
|
outgoing: Vec<MobileHomeCommit>,
|
||||||
|
incoming_total: usize,
|
||||||
|
outgoing_total: usize,
|
||||||
|
notice: Option<MobileHomeNotice>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MobileHomePage {
|
||||||
|
pub fn freshness(&self) -> MobileHomeFreshness {
|
||||||
|
self.freshness
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn refreshed_at(&self) -> Option<i64> {
|
||||||
|
self.refreshed_at
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn summaries(&self) -> &[MobileHomeSummaryRow] {
|
||||||
|
&self.summaries
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn incoming(&self) -> &[MobileHomeCommit] {
|
||||||
|
&self.incoming
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn outgoing(&self) -> &[MobileHomeCommit] {
|
||||||
|
&self.outgoing
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn incoming_total(&self) -> usize {
|
||||||
|
self.incoming_total
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn outgoing_total(&self) -> usize {
|
||||||
|
self.outgoing_total
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn notice(&self) -> Option<&MobileHomeNotice> {
|
||||||
|
self.notice.as_ref()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub enum MobileHomePhase {
|
||||||
|
Validating,
|
||||||
|
Authenticating,
|
||||||
|
Receiving,
|
||||||
|
Integrating,
|
||||||
|
Finishing,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<GitProgressPhase> for MobileHomePhase {
|
||||||
|
fn from(phase: GitProgressPhase) -> Self {
|
||||||
|
match phase {
|
||||||
|
GitProgressPhase::Validating => Self::Validating,
|
||||||
|
GitProgressPhase::Authenticating => Self::Authenticating,
|
||||||
|
GitProgressPhase::Receiving => Self::Receiving,
|
||||||
|
GitProgressPhase::Integrating | GitProgressPhase::Sending => Self::Integrating,
|
||||||
|
GitProgressPhase::Refreshing => Self::Finishing,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct MobileHomeProgress {
|
||||||
|
phase: MobileHomePhase,
|
||||||
|
title: String,
|
||||||
|
detail: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MobileHomeProgress {
|
||||||
|
pub fn phase(&self) -> MobileHomePhase {
|
||||||
|
self.phase
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn title(&self) -> &str {
|
||||||
|
&self.title
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn detail(&self) -> &str {
|
||||||
|
&self.detail
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct MobileHomeOperation {
|
||||||
|
control: GitOperationControl,
|
||||||
|
phase: Arc<Mutex<MobileHomePhase>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for MobileHomeOperation {
|
||||||
|
fn default() -> Self {
|
||||||
|
let phase = Arc::new(Mutex::new(MobileHomePhase::Validating));
|
||||||
|
let observed = Arc::clone(&phase);
|
||||||
|
Self {
|
||||||
|
control: GitOperationControl::new(move |phase| {
|
||||||
|
if let Ok(mut current) = observed.lock() {
|
||||||
|
*current = phase.into();
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
phase,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MobileHomeOperation {
|
||||||
|
pub fn cancel(&self) {
|
||||||
|
self.control.cancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn progress(&self) -> MobileHomeProgress {
|
||||||
|
progress_copy(
|
||||||
|
self.phase
|
||||||
|
.lock()
|
||||||
|
.map_or(MobileHomePhase::Validating, |phase| *phase),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cached(&self) -> Result<MobileHomePage, MobileHomeError> {
|
||||||
|
let storage = MobileHomeStorage::load()?;
|
||||||
|
let refreshed_at = storage.config.mobile_home_refreshed_at();
|
||||||
|
storage.page(
|
||||||
|
if refreshed_at.is_some() {
|
||||||
|
MobileHomeFreshness::Cached
|
||||||
|
} else {
|
||||||
|
MobileHomeFreshness::NeverRefreshed
|
||||||
|
},
|
||||||
|
refreshed_at,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn refresh_if_stale(&self) -> Result<MobileHomePage, MobileHomeError> {
|
||||||
|
let storage = MobileHomeStorage::load()?;
|
||||||
|
let now = unix_seconds()?;
|
||||||
|
if !is_stale(storage.config.mobile_home_refreshed_at(), now) {
|
||||||
|
return storage.page(
|
||||||
|
MobileHomeFreshness::Cached,
|
||||||
|
storage.config.mobile_home_refreshed_at(),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
storage.refresh(now, &self.control)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn refresh(&self) -> Result<MobileHomePage, MobileHomeError> {
|
||||||
|
MobileHomeStorage::load()?.refresh(unix_seconds()?, &self.control)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn pull(&self) -> Result<MobileHomePage, MobileHomeError> {
|
||||||
|
MobileHomeStorage::load()?.pull(unix_seconds()?, &self.control)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MobileHomeStorage {
|
||||||
|
config: Config,
|
||||||
|
git: GitRepository,
|
||||||
|
remote: GitRemote,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MobileHomeStorage {
|
||||||
|
fn load() -> Result<Self, MobileHomeError> {
|
||||||
|
let config = Config::load(None).map_err(MobileHomeError::from_config)?;
|
||||||
|
let repository =
|
||||||
|
Repository::open(config.vault()).map_err(MobileHomeError::from_repository)?;
|
||||||
|
let git = GitRepository::open(&repository, GitIdentity::ironstorage())
|
||||||
|
.map_err(MobileHomeError::from_git)?;
|
||||||
|
let remote = config
|
||||||
|
.git_remote(None)
|
||||||
|
.cloned()
|
||||||
|
.ok_or_else(MobileHomeError::missing_remote)?;
|
||||||
|
Ok(Self {
|
||||||
|
config,
|
||||||
|
git,
|
||||||
|
remote,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn refresh(
|
||||||
|
&self,
|
||||||
|
now: i64,
|
||||||
|
control: &GitOperationControl,
|
||||||
|
) -> Result<MobileHomePage, MobileHomeError> {
|
||||||
|
let store = self.credentials()?;
|
||||||
|
let refreshed = self.git.fetch_with_transport_controlled(
|
||||||
|
&self.remote,
|
||||||
|
&store,
|
||||||
|
&crate::git::EmbeddedFetchTransport,
|
||||||
|
control,
|
||||||
|
);
|
||||||
|
let locked = store.lock();
|
||||||
|
refreshed.map_err(MobileHomeError::from_git)?;
|
||||||
|
if let Err(error) = locked {
|
||||||
|
return Err(MobileHomeError::partial_secret(error));
|
||||||
|
}
|
||||||
|
control
|
||||||
|
.report(GitProgressPhase::Refreshing)
|
||||||
|
.map_err(MobileHomeError::from_git)?;
|
||||||
|
self.current_page(now, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pull(
|
||||||
|
&self,
|
||||||
|
now: i64,
|
||||||
|
control: &GitOperationControl,
|
||||||
|
) -> Result<MobileHomePage, MobileHomeError> {
|
||||||
|
let store = self.credentials()?;
|
||||||
|
let pulled = self.git.pull_with_transport_controlled(
|
||||||
|
&self.remote,
|
||||||
|
None,
|
||||||
|
&store,
|
||||||
|
&crate::git::EmbeddedFetchTransport,
|
||||||
|
control,
|
||||||
|
);
|
||||||
|
let locked = store.lock();
|
||||||
|
let outcome = pulled.map_err(MobileHomeError::from_git)?;
|
||||||
|
if let Err(error) = locked {
|
||||||
|
return Err(MobileHomeError::partial_secret(error));
|
||||||
|
}
|
||||||
|
control
|
||||||
|
.report(GitProgressPhase::Refreshing)
|
||||||
|
.map_err(MobileHomeError::from_git)?;
|
||||||
|
self.current_page(now, Some(pull_notice(outcome)))
|
||||||
|
.map_err(MobileHomeError::after_pull)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn credentials(&self) -> Result<NativeSecretStore, MobileHomeError> {
|
||||||
|
let store = NativeSecretStore::system(
|
||||||
|
SecretCachePolicy::Disabled,
|
||||||
|
SecretProtectionPolicy::device_unlocked(),
|
||||||
|
)
|
||||||
|
.map_err(MobileHomeError::from_secret)?;
|
||||||
|
store.unlock().map_err(MobileHomeError::from_secret)?;
|
||||||
|
Ok(store)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn current_page(
|
||||||
|
&self,
|
||||||
|
now: i64,
|
||||||
|
notice: Option<MobileHomeNotice>,
|
||||||
|
) -> Result<MobileHomePage, MobileHomeError> {
|
||||||
|
let persisted = self.config.update_mobile_home_refresh(now).is_ok();
|
||||||
|
let notice = if persisted {
|
||||||
|
notice
|
||||||
|
} else {
|
||||||
|
Some(MobileHomeNotice {
|
||||||
|
title: notice
|
||||||
|
.as_ref()
|
||||||
|
.map_or("Status Refreshed", MobileHomeNotice::title)
|
||||||
|
.to_owned(),
|
||||||
|
detail: "The repository was updated, but the refresh time could not be cached."
|
||||||
|
.to_owned(),
|
||||||
|
system_image: "exclamationmark.triangle".to_owned(),
|
||||||
|
})
|
||||||
|
};
|
||||||
|
self.page(MobileHomeFreshness::Current, Some(now), notice)
|
||||||
|
.map_err(MobileHomeError::after_refresh)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn page(
|
||||||
|
&self,
|
||||||
|
freshness: MobileHomeFreshness,
|
||||||
|
refreshed_at: Option<i64>,
|
||||||
|
notice: Option<MobileHomeNotice>,
|
||||||
|
) -> Result<MobileHomePage, MobileHomeError> {
|
||||||
|
let divergence = self
|
||||||
|
.git
|
||||||
|
.divergence(&self.remote, ACTIVITY_LIMIT)
|
||||||
|
.map_err(MobileHomeError::from_git)?;
|
||||||
|
Ok(page_from_divergence(
|
||||||
|
&divergence,
|
||||||
|
freshness,
|
||||||
|
refreshed_at,
|
||||||
|
notice,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub enum MobileHomeErrorKind {
|
||||||
|
MissingConfiguration,
|
||||||
|
Configuration,
|
||||||
|
Authentication,
|
||||||
|
Conflict,
|
||||||
|
DirtyLocalChanges,
|
||||||
|
Offline,
|
||||||
|
Interrupted,
|
||||||
|
SecureStorage,
|
||||||
|
Repository,
|
||||||
|
PartialProgress,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct MobileHomeError {
|
||||||
|
kind: MobileHomeErrorKind,
|
||||||
|
title: &'static str,
|
||||||
|
detail: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MobileHomeError {
|
||||||
|
pub fn kind(&self) -> MobileHomeErrorKind {
|
||||||
|
self.kind
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn title(&self) -> &str {
|
||||||
|
self.title
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn detail(&self) -> &str {
|
||||||
|
&self.detail
|
||||||
|
}
|
||||||
|
|
||||||
|
fn missing_remote() -> Self {
|
||||||
|
Self {
|
||||||
|
kind: MobileHomeErrorKind::Configuration,
|
||||||
|
title: "Remote Is Not Configured",
|
||||||
|
detail: "Set up an HTTPS password-store remote before refreshing Home.".to_owned(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn from_config(error: ConfigError) -> Self {
|
||||||
|
match error {
|
||||||
|
ConfigError::NotFound { .. } => Self {
|
||||||
|
kind: MobileHomeErrorKind::MissingConfiguration,
|
||||||
|
title: "Set Up Password Store",
|
||||||
|
detail: "Complete first-run setup before loading remote activity.".to_owned(),
|
||||||
|
},
|
||||||
|
_ => Self {
|
||||||
|
kind: MobileHomeErrorKind::Configuration,
|
||||||
|
title: "Configuration Is Unavailable",
|
||||||
|
detail: "Check the local IronStorage configuration and try again.".to_owned(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn from_repository(_error: RepositoryError) -> Self {
|
||||||
|
Self {
|
||||||
|
kind: MobileHomeErrorKind::Repository,
|
||||||
|
title: "Password Store Is Unavailable",
|
||||||
|
detail: "The local password-store clone could not be opened.".to_owned(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn from_git(error: GitError) -> Self {
|
||||||
|
match error {
|
||||||
|
GitError::AuthenticationFailed
|
||||||
|
| GitError::CredentialsUnavailable
|
||||||
|
| GitError::CredentialAccessDenied
|
||||||
|
| GitError::CredentialCancelled => Self {
|
||||||
|
kind: MobileHomeErrorKind::Authentication,
|
||||||
|
title: "Authentication Failed",
|
||||||
|
detail: "Update the application token in Preferences and try again.".to_owned(),
|
||||||
|
},
|
||||||
|
GitError::MergeConflicts { conflicts } => {
|
||||||
|
let names = conflicts
|
||||||
|
.iter()
|
||||||
|
.take(3)
|
||||||
|
.map(|conflict| display_path(conflict.path()))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let suffix = if conflicts.len() > names.len() {
|
||||||
|
format!(" and {} more", conflicts.len() - names.len())
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
Self {
|
||||||
|
kind: MobileHomeErrorKind::Conflict,
|
||||||
|
title: "Pull Needs Conflict Resolution",
|
||||||
|
detail: format!(
|
||||||
|
"Remote changes were fetched, but the local clone was not changed. Resolve {}{} and pull again.",
|
||||||
|
names.join(", "),
|
||||||
|
suffix
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
GitError::DirtyWorktree => Self {
|
||||||
|
kind: MobileHomeErrorKind::DirtyLocalChanges,
|
||||||
|
title: "Local Changes Need Attention",
|
||||||
|
detail: "Commit or discard local changes before pulling remote activity.".to_owned(),
|
||||||
|
},
|
||||||
|
GitError::NetworkUnavailable => Self {
|
||||||
|
kind: MobileHomeErrorKind::Offline,
|
||||||
|
title: "Server Is Offline",
|
||||||
|
detail: "Cached activity remains available. Check the network and try again."
|
||||||
|
.to_owned(),
|
||||||
|
},
|
||||||
|
GitError::TlsFailed => Self {
|
||||||
|
kind: MobileHomeErrorKind::Offline,
|
||||||
|
title: "Secure Connection Failed",
|
||||||
|
detail: "The HTTPS server identity could not be verified. Cached activity was not replaced."
|
||||||
|
.to_owned(),
|
||||||
|
},
|
||||||
|
GitError::Cancelled => Self {
|
||||||
|
kind: MobileHomeErrorKind::Interrupted,
|
||||||
|
title: "Refresh Interrupted",
|
||||||
|
detail: "The local clone remains recoverable. Pull or refresh again when ready."
|
||||||
|
.to_owned(),
|
||||||
|
},
|
||||||
|
GitError::ForbiddenRemoteUrl => Self {
|
||||||
|
kind: MobileHomeErrorKind::Configuration,
|
||||||
|
title: "HTTPS Remote Required",
|
||||||
|
detail: "The configured credential-free HTTPS remote does not match the local clone."
|
||||||
|
.to_owned(),
|
||||||
|
},
|
||||||
|
_ => Self {
|
||||||
|
kind: MobileHomeErrorKind::Repository,
|
||||||
|
title: "Remote Activity Is Unavailable",
|
||||||
|
detail: "The storage-owned Git state could not be loaded.".to_owned(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn from_secret(error: SecretStoreError) -> Self {
|
||||||
|
let detail = match error {
|
||||||
|
SecretStoreError::Denied => "Access to the application token was denied.",
|
||||||
|
SecretStoreError::Cancelled => "Application-token access was cancelled.",
|
||||||
|
SecretStoreError::Missing => {
|
||||||
|
"No application token is stored. Update it in Preferences."
|
||||||
|
}
|
||||||
|
_ => "Protected application-token storage is unavailable.",
|
||||||
|
};
|
||||||
|
Self {
|
||||||
|
kind: MobileHomeErrorKind::SecureStorage,
|
||||||
|
title: "Token Is Unavailable",
|
||||||
|
detail: detail.to_owned(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn partial_secret(_error: SecretStoreError) -> Self {
|
||||||
|
Self {
|
||||||
|
kind: MobileHomeErrorKind::PartialProgress,
|
||||||
|
title: "Remote Updated With a Warning",
|
||||||
|
detail: "Remote data was received, but protected credential state could not be closed cleanly. Restart IronStorage before retrying."
|
||||||
|
.to_owned(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn after_refresh(_error: Self) -> Self {
|
||||||
|
Self {
|
||||||
|
kind: MobileHomeErrorKind::PartialProgress,
|
||||||
|
title: "Remote Refreshed, Activity Unavailable",
|
||||||
|
detail: "Remote tracking data was updated, but the refreshed activity page could not be built. The local clone remains unchanged."
|
||||||
|
.to_owned(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn after_pull(_error: Self) -> Self {
|
||||||
|
Self {
|
||||||
|
kind: MobileHomeErrorKind::PartialProgress,
|
||||||
|
title: "Pull Completed, Activity Unavailable",
|
||||||
|
detail: "The local clone was updated, but the refreshed activity page could not be built. Reload Home to retry."
|
||||||
|
.to_owned(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for MobileHomeError {
|
||||||
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
write!(formatter, "{}: {}", self.title, self.detail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Error for MobileHomeError {}
|
||||||
|
|
||||||
|
fn page_from_divergence(
|
||||||
|
divergence: &GitDivergence,
|
||||||
|
freshness: MobileHomeFreshness,
|
||||||
|
refreshed_at: Option<i64>,
|
||||||
|
notice: Option<MobileHomeNotice>,
|
||||||
|
) -> MobileHomePage {
|
||||||
|
let ahead = divergence.remote().ahead();
|
||||||
|
let behind = divergence.remote().behind();
|
||||||
|
let local_paths = divergence
|
||||||
|
.status()
|
||||||
|
.staged()
|
||||||
|
.iter()
|
||||||
|
.chain(divergence.status().unstaged())
|
||||||
|
.map(|change| change.path().to_owned())
|
||||||
|
.collect::<BTreeSet<_>>();
|
||||||
|
let divergence_title = match (behind, ahead) {
|
||||||
|
(0, 0) => "Up to Date".to_owned(),
|
||||||
|
(behind, 0) => format!("{behind} Incoming"),
|
||||||
|
(0, ahead) => format!("{ahead} Outgoing"),
|
||||||
|
(behind, ahead) => format!("{behind} Incoming · {ahead} Outgoing"),
|
||||||
|
};
|
||||||
|
let divergence_detail = format!(
|
||||||
|
"{} to pull · {} to push",
|
||||||
|
commit_count(behind),
|
||||||
|
commit_count(ahead)
|
||||||
|
);
|
||||||
|
let local_count = local_paths.len();
|
||||||
|
let summaries = vec![
|
||||||
|
MobileHomeSummaryRow {
|
||||||
|
id: "tracked-branch".to_owned(),
|
||||||
|
title: format!("{}/{}", divergence.remote().name(), divergence.branch()),
|
||||||
|
detail: "Tracked HTTPS branch".to_owned(),
|
||||||
|
system_image: "point.3.connected.trianglepath.dotted".to_owned(),
|
||||||
|
},
|
||||||
|
MobileHomeSummaryRow {
|
||||||
|
id: "divergence".to_owned(),
|
||||||
|
title: divergence_title,
|
||||||
|
detail: divergence_detail,
|
||||||
|
system_image: if ahead == 0 && behind == 0 {
|
||||||
|
"checkmark.circle".to_owned()
|
||||||
|
} else {
|
||||||
|
"arrow.triangle.2.circlepath".to_owned()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MobileHomeSummaryRow {
|
||||||
|
id: "working-tree".to_owned(),
|
||||||
|
title: if local_count == 0 {
|
||||||
|
"Working Tree Clean".to_owned()
|
||||||
|
} else {
|
||||||
|
format!("{} Local", change_count(local_count))
|
||||||
|
},
|
||||||
|
detail: if local_count == 0 {
|
||||||
|
"No uncommitted password-store changes".to_owned()
|
||||||
|
} else {
|
||||||
|
"Commit or discard these changes before pulling".to_owned()
|
||||||
|
},
|
||||||
|
system_image: if local_count == 0 {
|
||||||
|
"checkmark.shield".to_owned()
|
||||||
|
} else {
|
||||||
|
"exclamationmark.triangle".to_owned()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
MobileHomePage {
|
||||||
|
freshness,
|
||||||
|
refreshed_at,
|
||||||
|
summaries,
|
||||||
|
incoming: divergence
|
||||||
|
.incoming()
|
||||||
|
.iter()
|
||||||
|
.map(|activity| mobile_commit(activity, true))
|
||||||
|
.collect(),
|
||||||
|
outgoing: divergence
|
||||||
|
.outgoing()
|
||||||
|
.iter()
|
||||||
|
.map(|activity| mobile_commit(activity, false))
|
||||||
|
.collect(),
|
||||||
|
incoming_total: behind,
|
||||||
|
outgoing_total: ahead,
|
||||||
|
notice,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mobile_commit(activity: &GitCommitActivity, incoming: bool) -> MobileHomeCommit {
|
||||||
|
let commit = activity.commit();
|
||||||
|
let title = commit
|
||||||
|
.message()
|
||||||
|
.lines()
|
||||||
|
.find(|line| !line.trim().is_empty())
|
||||||
|
.map(|line| display_text(line.trim(), 160))
|
||||||
|
.filter(|line| !line.is_empty())
|
||||||
|
.unwrap_or_else(|| "Untitled Commit".to_owned());
|
||||||
|
let author = display_text(commit.author_name(), 80);
|
||||||
|
let changes = activity
|
||||||
|
.changes()
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(index, change)| {
|
||||||
|
mobile_change(
|
||||||
|
format!("{}:{index}", commit.id()),
|
||||||
|
change.path(),
|
||||||
|
change.kind(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
MobileHomeCommit {
|
||||||
|
id: commit.id().to_owned(),
|
||||||
|
title,
|
||||||
|
detail: format!(
|
||||||
|
"{} · {}",
|
||||||
|
if author.is_empty() {
|
||||||
|
"Unknown author"
|
||||||
|
} else {
|
||||||
|
&author
|
||||||
|
},
|
||||||
|
change_count(changes.len())
|
||||||
|
),
|
||||||
|
system_image: if incoming {
|
||||||
|
"arrow.down.circle".to_owned()
|
||||||
|
} else {
|
||||||
|
"arrow.up.circle".to_owned()
|
||||||
|
},
|
||||||
|
timestamp: commit.timestamp(),
|
||||||
|
changes,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mobile_change(id: String, path: &Path, kind: GitChangeKind) -> MobileHomeChange {
|
||||||
|
let status = MobileHomeChangeStatus::from(kind);
|
||||||
|
let status_name = match status {
|
||||||
|
MobileHomeChangeStatus::Added => "Added",
|
||||||
|
MobileHomeChangeStatus::Modified => "Modified",
|
||||||
|
MobileHomeChangeStatus::Deleted => "Deleted",
|
||||||
|
};
|
||||||
|
let file_name = path.file_name().and_then(|name| name.to_str());
|
||||||
|
let (title, detail, system_image, change_kind) =
|
||||||
|
if path.extension().is_some_and(|extension| extension == "gpg") {
|
||||||
|
(
|
||||||
|
display_path(&path.with_extension("")),
|
||||||
|
format!("{status_name} password entry"),
|
||||||
|
"key".to_owned(),
|
||||||
|
MobileHomeChangeKind::PasswordEntry,
|
||||||
|
)
|
||||||
|
} else if file_name == Some(".gpg-id") {
|
||||||
|
(
|
||||||
|
policy_title(path, "recipients"),
|
||||||
|
format!("{status_name} recipient policy"),
|
||||||
|
"person.2".to_owned(),
|
||||||
|
MobileHomeChangeKind::RecipientPolicy,
|
||||||
|
)
|
||||||
|
} else if file_name == Some(".gpg-id.sig") {
|
||||||
|
(
|
||||||
|
policy_title(path, "recipient signature"),
|
||||||
|
format!("{status_name} recipient signature"),
|
||||||
|
"signature".to_owned(),
|
||||||
|
MobileHomeChangeKind::RecipientSignature,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
(
|
||||||
|
display_path(path),
|
||||||
|
format!("{status_name} repository file"),
|
||||||
|
"doc".to_owned(),
|
||||||
|
MobileHomeChangeKind::RepositoryFile,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
MobileHomeChange {
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
detail,
|
||||||
|
system_image,
|
||||||
|
kind: change_kind,
|
||||||
|
status,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn policy_title(path: &Path, suffix: &str) -> String {
|
||||||
|
path.parent()
|
||||||
|
.filter(|parent| !parent.as_os_str().is_empty())
|
||||||
|
.map_or_else(
|
||||||
|
|| format!("Root {suffix}"),
|
||||||
|
|parent| format!("{} {suffix}", display_path(parent)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn display_path(path: &Path) -> String {
|
||||||
|
display_text(&path.to_string_lossy(), 240)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn display_text(value: &str, maximum: usize) -> String {
|
||||||
|
let mut output = value
|
||||||
|
.chars()
|
||||||
|
.map(|character| {
|
||||||
|
if character.is_control() {
|
||||||
|
'\u{fffd}'
|
||||||
|
} else {
|
||||||
|
character
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.take(maximum + 1)
|
||||||
|
.collect::<String>();
|
||||||
|
if output.chars().count() > maximum {
|
||||||
|
output = output.chars().take(maximum.saturating_sub(1)).collect();
|
||||||
|
output.push('…');
|
||||||
|
}
|
||||||
|
output
|
||||||
|
}
|
||||||
|
|
||||||
|
fn commit_count(count: usize) -> String {
|
||||||
|
format!("{count} commit{}", if count == 1 { "" } else { "s" })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn change_count(count: usize) -> String {
|
||||||
|
format!("{count} change{}", if count == 1 { "" } else { "s" })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pull_notice(outcome: PullOutcome) -> MobileHomeNotice {
|
||||||
|
let (title, detail, system_image) = match outcome {
|
||||||
|
PullOutcome::UpToDate => (
|
||||||
|
"Already Up to Date",
|
||||||
|
"The local password store already contains every fetched commit.",
|
||||||
|
"checkmark.circle",
|
||||||
|
),
|
||||||
|
PullOutcome::FastForward => (
|
||||||
|
"Password Store Updated",
|
||||||
|
"Remote commits were applied without changing local history.",
|
||||||
|
"arrow.down.circle",
|
||||||
|
),
|
||||||
|
PullOutcome::Merged => (
|
||||||
|
"Remote Changes Merged",
|
||||||
|
"Remote and local commits were merged by storage.",
|
||||||
|
"arrow.triangle.merge",
|
||||||
|
),
|
||||||
|
};
|
||||||
|
MobileHomeNotice {
|
||||||
|
title: title.to_owned(),
|
||||||
|
detail: detail.to_owned(),
|
||||||
|
system_image: system_image.to_owned(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn progress_copy(phase: MobileHomePhase) -> MobileHomeProgress {
|
||||||
|
let (title, detail) = match phase {
|
||||||
|
MobileHomePhase::Validating => ("Checking Home", "Validating local and remote Git state."),
|
||||||
|
MobileHomePhase::Authenticating => (
|
||||||
|
"Authenticating",
|
||||||
|
"Reading the application token from protected storage.",
|
||||||
|
),
|
||||||
|
MobileHomePhase::Receiving => ("Refreshing Remote", "Receiving remote Git objects."),
|
||||||
|
MobileHomePhase::Integrating => (
|
||||||
|
"Updating Password Store",
|
||||||
|
"Integrating fetched commits into the local clone.",
|
||||||
|
),
|
||||||
|
MobileHomePhase::Finishing => {
|
||||||
|
("Updating Home", "Building current activity and divergence.")
|
||||||
|
}
|
||||||
|
};
|
||||||
|
MobileHomeProgress {
|
||||||
|
phase,
|
||||||
|
title: title.to_owned(),
|
||||||
|
detail: detail.to_owned(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unix_seconds() -> Result<i64, MobileHomeError> {
|
||||||
|
let seconds = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map_err(|_| MobileHomeError {
|
||||||
|
kind: MobileHomeErrorKind::Configuration,
|
||||||
|
title: "System Time Is Invalid",
|
||||||
|
detail: "Correct the device time before refreshing remote activity.".to_owned(),
|
||||||
|
})?
|
||||||
|
.as_secs();
|
||||||
|
i64::try_from(seconds).map_err(|_| MobileHomeError {
|
||||||
|
kind: MobileHomeErrorKind::Configuration,
|
||||||
|
title: "System Time Is Invalid",
|
||||||
|
detail: "Correct the device time before refreshing remote activity.".to_owned(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_stale(refreshed_at: Option<i64>, now: i64) -> bool {
|
||||||
|
!matches!(
|
||||||
|
refreshed_at,
|
||||||
|
Some(refreshed_at)
|
||||||
|
if refreshed_at <= now && now.saturating_sub(refreshed_at) < STALE_AFTER_SECONDS
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use crate::git::{GitChangeKind, GitError};
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
MobileHomeChangeKind, MobileHomeChangeStatus, MobileHomeErrorKind, display_text, is_stale,
|
||||||
|
mobile_change,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn freshness_and_display_safety_are_storage_owned() {
|
||||||
|
assert!(is_stale(None, 1_000));
|
||||||
|
assert!(!is_stale(Some(900), 1_000));
|
||||||
|
assert!(is_stale(Some(699), 1_000));
|
||||||
|
assert!(is_stale(Some(1_001), 1_000));
|
||||||
|
assert_eq!(display_text("line\nsecret", 20), "line<EFBFBD>secret");
|
||||||
|
assert_eq!(display_text("abcdef", 4), "abc…");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn changed_password_paths_are_classified_before_presentation() {
|
||||||
|
let entry = mobile_change(
|
||||||
|
"commit:0".to_owned(),
|
||||||
|
Path::new("work/mail.gpg"),
|
||||||
|
GitChangeKind::Modified,
|
||||||
|
);
|
||||||
|
assert_eq!(entry.title(), "work/mail");
|
||||||
|
assert_eq!(entry.kind(), MobileHomeChangeKind::PasswordEntry);
|
||||||
|
assert_eq!(entry.status(), MobileHomeChangeStatus::Modified);
|
||||||
|
let policy = mobile_change(
|
||||||
|
"commit:1".to_owned(),
|
||||||
|
Path::new("team/.gpg-id"),
|
||||||
|
GitChangeKind::Added,
|
||||||
|
);
|
||||||
|
assert_eq!(policy.title(), "team recipients");
|
||||||
|
assert_eq!(policy.kind(), MobileHomeChangeKind::RecipientPolicy);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn failures_are_typed_and_secret_safe() {
|
||||||
|
let offline = super::MobileHomeError::from_git(GitError::NetworkUnavailable);
|
||||||
|
assert_eq!(offline.kind(), MobileHomeErrorKind::Offline);
|
||||||
|
let dirty = super::MobileHomeError::from_git(GitError::DirtyWorktree);
|
||||||
|
assert_eq!(dirty.kind(), MobileHomeErrorKind::DirtyLocalChanges);
|
||||||
|
assert!(!offline.to_string().contains("token-value"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -453,6 +453,23 @@ fn fetched_branches_fast_forward_and_report_typed_conflicts() -> TestResult {
|
|||||||
let snapshot = git.snapshot(Some(remote), 5)?;
|
let snapshot = git.snapshot(Some(remote), 5)?;
|
||||||
assert_eq!(snapshot.remote().expect("remote status").ahead(), 1);
|
assert_eq!(snapshot.remote().expect("remote status").ahead(), 1);
|
||||||
assert_eq!(snapshot.remote().expect("remote status").behind(), 1);
|
assert_eq!(snapshot.remote().expect("remote status").behind(), 1);
|
||||||
|
let divergence = git.divergence(remote, 10)?;
|
||||||
|
assert_eq!(divergence.remote().ahead(), 1);
|
||||||
|
assert_eq!(divergence.remote().behind(), 1);
|
||||||
|
assert_eq!(divergence.incoming().len(), 1);
|
||||||
|
assert_eq!(divergence.incoming()[0].commit().message(), "Remote change");
|
||||||
|
assert_eq!(divergence.incoming()[0].changes().len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
divergence.incoming()[0].changes()[0].path(),
|
||||||
|
Path::new("secret.gpg")
|
||||||
|
);
|
||||||
|
assert_eq!(divergence.outgoing().len(), 1);
|
||||||
|
assert_eq!(divergence.outgoing()[0].commit().message(), "Local change");
|
||||||
|
let totals_only = git.divergence(remote, 0)?;
|
||||||
|
assert_eq!(totals_only.remote().ahead(), 1);
|
||||||
|
assert_eq!(totals_only.remote().behind(), 1);
|
||||||
|
assert!(totals_only.incoming().is_empty());
|
||||||
|
assert!(totals_only.outgoing().is_empty());
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
git.resolve_fetched(
|
git.resolve_fetched(
|
||||||
|
|||||||
Reference in New Issue
Block a user