Prepare Apple App Store distribution (#59)
2
.gitignore
vendored
@@ -1,3 +1,5 @@
|
||||
/target/
|
||||
/apple/IronStorage.xcodeproj/
|
||||
/distribution/
|
||||
.env
|
||||
.DS_Store
|
||||
|
||||
10
Cargo.lock
generated
@@ -5089,6 +5089,16 @@ dependencies = [
|
||||
"uniffi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ironstorage-apple-release"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"toml 0.9.12+spec-1.1.0",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ironstorage-cli"
|
||||
version = "0.1.0"
|
||||
|
||||
@@ -6,6 +6,7 @@ members = [
|
||||
"crates/apple",
|
||||
"crates/storage",
|
||||
"crates/watch-apple",
|
||||
"tools/apple-release",
|
||||
"tools/macos-packager",
|
||||
]
|
||||
resolver = "3"
|
||||
@@ -14,6 +15,8 @@ resolver = "3"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
rust-version = "1.92"
|
||||
license = "MIT"
|
||||
repository = "https://git.rfc1437.de/hugo/IronStorage"
|
||||
|
||||
[workspace.dependencies]
|
||||
apple-native-keyring-store = { version = "1.0", default-features = false, features = ["keychain", "protected"] }
|
||||
@@ -52,6 +55,7 @@ ratatui = { version = "0.30", default-features = false, features = ["crossterm_0
|
||||
security-framework = "3.7"
|
||||
secret-service = { version = "5.1", default-features = false, features = ["rt-tokio-crypto-rust"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
sha1 = "0.10"
|
||||
sha2 = "0.10"
|
||||
shlex = "1.3"
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
# Dependency and license review
|
||||
|
||||
Reviewed 2026-08-09. The OpenPGP backend decision is complete. The project
|
||||
license remains intentionally unset pending the full transitive license audit
|
||||
and packaging review described below.
|
||||
Reviewed 2026-08-16. The OpenPGP backend decision and release packaging review
|
||||
are complete. IronStorage is licensed under the MIT License. Every package in
|
||||
the Rust workspace declares that license, and the iPhone release bundles the
|
||||
exact third-party license files from its Cargo dependency graph.
|
||||
|
||||
## License direction
|
||||
|
||||
The preferred implementation stack permits IronStorage itself to use
|
||||
`MIT OR Apache-2.0`. That is the provisional choice, not yet a final license.
|
||||
The implementation stack permits IronStorage to use the MIT License. The
|
||||
project license is recorded in `LICENSE` and in every workspace package.
|
||||
|
||||
The current direct dependencies are:
|
||||
|
||||
@@ -67,13 +68,17 @@ With this path, the central crate needs no third-party native GPG, Git, OTP, or
|
||||
QR library. Apple Security/LocalAuthentication, Windows Credential Manager,
|
||||
Linux Secret Service, and Apple camera APIs remain operating-system boundaries.
|
||||
|
||||
## Release gate
|
||||
## Release packaging
|
||||
|
||||
Before choosing and adding the project license:
|
||||
|
||||
1. Lock the storage dependencies and run a full transitive license audit.
|
||||
2. Confirm the required notices/source offers for MPL-2.0 dependencies in every
|
||||
distributed app package.
|
||||
`ironstorage-apple-release licenses` traverses the iPhone Rust library's locked,
|
||||
target-filtered Cargo graph and records every dependency's name, version, SPDX
|
||||
license, source, and shipped license/notice files in
|
||||
`apple/Resources/App/ThirdPartyLicenses.txt`. If a published crate omits a
|
||||
separate license file, the report flags that fact and retains its SPDX and
|
||||
upstream source record. The Apple target bundles that attribution alongside
|
||||
IronStorage's MIT license. The committed Cargo lockfile identifies the exact
|
||||
versions, including MPL-2.0 components whose source remains available from the
|
||||
recorded upstream packages.
|
||||
|
||||
The checked-in compatibility suite completes the earlier OpenPGP backend gate:
|
||||
`pgp` imports protected armored and binary exports, decrypts every GnuPG-audited
|
||||
|
||||
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 rfc1437
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -21,8 +21,8 @@ and platform-secure credential orchestration. Applications are presentation
|
||||
and interaction adapters only. Runtime subprocesses—including `pass`, `git`,
|
||||
and `gpg`—are forbidden; compatibility is implemented in Rust.
|
||||
|
||||
Candidate libraries and the pending project-license decision are tracked in
|
||||
[`DEPENDENCIES.md`](DEPENDENCIES.md).
|
||||
IronStorage is MIT licensed. Dependency licenses and the completed packaging
|
||||
review are tracked in [`DEPENDENCIES.md`](DEPENDENCIES.md).
|
||||
|
||||
The shared TOML schema, path rules, editor precedence, and HTTPS remote format
|
||||
are documented in [`docs/configuration.md`](docs/configuration.md).
|
||||
@@ -86,6 +86,10 @@ cd apple
|
||||
xcodegen generate
|
||||
```
|
||||
|
||||
Apple App Store metadata, archive verification, submission, and paired iPhone
|
||||
and Watch release checks are documented in
|
||||
[`apple/AppStore/DISTRIBUTION.md`](apple/AppStore/DISTRIBUTION.md).
|
||||
|
||||
## Building
|
||||
|
||||
Install a stable Rust toolchain satisfying the workspace manifest (Rust 1.92
|
||||
|
||||
69
apple/AppStore/DISTRIBUTION.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# Apple App Store distribution
|
||||
|
||||
Use `distribution/` only as ignored local staging. Never store Apple credentials,
|
||||
signing material, review credentials, GPG material, application tokens, or
|
||||
private diagnostics in the repository or release logs.
|
||||
|
||||
## Release identity
|
||||
|
||||
- iPhone application: `de.rfc1437.ironstorage`
|
||||
- AutoFill extension: `de.rfc1437.ironstorage.autofill`
|
||||
- Apple Watch companion: `de.rfc1437.ironstorage.watch`
|
||||
- Apple Developer team: `MU22FMRGK8`
|
||||
|
||||
The submitted archive must embed the Watch application beneath the iPhone
|
||||
application. An iPhone-only archive is not a releasable IronStorage build.
|
||||
|
||||
## Prepare and submit
|
||||
|
||||
1. Generate the project and release attribution, then run the release verifier:
|
||||
|
||||
```sh
|
||||
cargo metadata --format-version 1 --filter-platform aarch64-apple-ios \
|
||||
> /private/tmp/ironstorage-ios-metadata.json
|
||||
cargo run -p ironstorage-apple-release -- licenses \
|
||||
/private/tmp/ironstorage-ios-metadata.json \
|
||||
apple/Resources/App/ThirdPartyLicenses.txt
|
||||
cargo run -p ironstorage-apple-release -- verify .
|
||||
```
|
||||
|
||||
2. Run the repository gates and build both simulator applications. Validate the
|
||||
local-first flow with synthetic data: generate or import a GPG key, create
|
||||
folders and encrypted entries, read TOTP codes, select Watch entries, and
|
||||
verify the Watch list and selected-code progress display.
|
||||
3. Create the production archive with normal App Store distribution signing:
|
||||
|
||||
```sh
|
||||
cd apple
|
||||
xcodegen generate
|
||||
xcodebuild -project IronStorage.xcodeproj -scheme IronStorage \
|
||||
-configuration Release -destination 'generic/platform=iOS' \
|
||||
-archivePath /private/tmp/IronStorage.xcarchive archive
|
||||
```
|
||||
|
||||
4. Inspect the archive before upload. The iPhone, AutoFill, and Watch bundle
|
||||
identifiers and versions must match the metadata; the Watch app must be
|
||||
embedded; privacy manifests and license resources must be present; the
|
||||
archive must use distribution signing and must not contain `get-task-allow`,
|
||||
simulator artifacts, synthetic demo data, private endpoints, or secrets.
|
||||
5. Export and upload with the checked-in App Store Connect export options:
|
||||
|
||||
```sh
|
||||
xcodebuild -exportArchive \
|
||||
-archivePath /private/tmp/IronStorage.xcarchive \
|
||||
-exportPath /private/tmp/IronStorage-AppStore \
|
||||
-exportOptionsPlist AppStore/ExportOptions.plist
|
||||
```
|
||||
|
||||
6. In App Store Connect, complete the listing from `metadata.toml`, upload the
|
||||
reviewed screenshots, answer privacy and encryption questions, provide
|
||||
local-first review instructions, select the processed build, and submit it
|
||||
for review. Do not provide a production password store or real credentials.
|
||||
7. After approval, install the public release from the App Store on the paired
|
||||
iPhone and Apple Watch. Verify local onboarding, key overwrite confirmation,
|
||||
local Git history, folders, encrypted entries, Face ID, TOTP, optional HTTPS
|
||||
pull and push, Watch snapshot delivery, the Watch entry-only list, and the
|
||||
larger selected code with its validity progress indicator.
|
||||
8. Verify an App Store update in place. Verify the documented uninstall and
|
||||
reinstall Keychain lifecycle only with disposable test data; never erase the
|
||||
paired physical device or its unrelated IronStorage data.
|
||||
20
apple/AppStore/ExportOptions.plist
Normal file
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>destination</key>
|
||||
<string>upload</string>
|
||||
<key>manageAppVersionAndBuildNumber</key>
|
||||
<false/>
|
||||
<key>method</key>
|
||||
<string>app-store-connect</string>
|
||||
<key>signingStyle</key>
|
||||
<string>automatic</string>
|
||||
<key>stripSwiftSymbols</key>
|
||||
<true/>
|
||||
<key>teamID</key>
|
||||
<string>MU22FMRGK8</string>
|
||||
<key>uploadSymbols</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
52
apple/AppStore/apps-index.html
Normal file
@@ -0,0 +1,52 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<title>rfc1437 Apps</title>
|
||||
<link rel="stylesheet" href="/assets/pico.pumpkin.min.css">
|
||||
<link rel="stylesheet" href="/assets/bds.css">
|
||||
<style>.app-header{display:flex;align-items:center;gap:1rem}.app-header img{width:5rem;height:5rem;border-radius:1.1rem}.gallery{display:grid;grid-template-columns:repeat(auto-fit,minmax(14rem,1fr));gap:1rem}figure{margin:0}figure img{display:block;width:100%;height:auto;border-radius:1.5rem}figcaption{margin-top:.5rem;color:var(--pico-muted-color)}</style>
|
||||
</head>
|
||||
<body><main>
|
||||
<header><h1 class="archive-heading">rfc1437 Apps</h1><p>Small, focused apps published by <a href="https://rfc1437.de/">rfc1437</a>.</p></header>
|
||||
<nav class="blog-menu" aria-label="Site navigation"><ul class="blog-menu-list"><li class="blog-menu-item"><a class="blog-menu-link" href="https://rfc1437.de/">Home</a></li><li class="blog-menu-item"><span class="blog-menu-link" aria-current="page">Apps</span></li></ul></nav>
|
||||
<article class="post">
|
||||
<h2>Install</h2>
|
||||
<p>Gotcha is available through the rfc1437 AltStore PAL source at <code>https://rfc1437.de/apps/source.json</code>.</p>
|
||||
<p>IronStorage is being prepared for the Apple App Store so its iPhone app and Apple Watch companion install together through the supported App Store path.</p>
|
||||
</article>
|
||||
<section><h2>Apps</h2>
|
||||
<article class="post">
|
||||
<header class="app-header"><img src="gotcha/icon.png" alt="Gotcha app icon"><div><h3>Gotcha</h3><p>Native Gitea and Forgejo on iPhone.</p></div></header>
|
||||
<p>Follow repositories, issues, pull requests, commits, diffs, and milestones on servers you control.</p>
|
||||
<nav><a href="gotcha/privacy/">Privacy</a> · <a href="https://git.rfc1437.de/hugo/Gotcha">Source</a> · <a href="https://git.rfc1437.de/hugo/Gotcha/issues">Support</a></nav>
|
||||
<div class="gallery">
|
||||
<figure><img src="gotcha/screenshots/01-home-activity.png" alt="Gotcha activity"><figcaption>Activity</figcaption></figure>
|
||||
<figure><img src="gotcha/screenshots/02-issues.png" alt="Gotcha issues"><figcaption>Issues</figcaption></figure>
|
||||
<figure><img src="gotcha/screenshots/03-commit-history.png" alt="Gotcha commits"><figcaption>Commits</figcaption></figure>
|
||||
<figure><img src="gotcha/screenshots/04-diff.png" alt="Gotcha diff"><figcaption>Diffs</figcaption></figure>
|
||||
<figure><img src="gotcha/screenshots/05-pull-request.png" alt="Gotcha pull request"><figcaption>Pull requests</figcaption></figure>
|
||||
<figure><img src="gotcha/screenshots/06-milestones.png" alt="Gotcha milestones"><figcaption>Milestones</figcaption></figure>
|
||||
</div>
|
||||
</article>
|
||||
<article class="post">
|
||||
<header class="app-header"><img src="ironstorage/icon.png" alt="IronStorage app icon"><div><h3>IronStorage</h3><p>Native pass and pass-otp on iPhone and Apple Watch.</p></div></header>
|
||||
<p>Generate or import a protected GPG key, keep local Git history, create encrypted entries and TOTP codes, and add optional HTTPS synchronization later.</p>
|
||||
<p><strong>Apple App Store release in preparation.</strong></p>
|
||||
<nav><a href="ironstorage/privacy/">Privacy</a> · <a href="https://git.rfc1437.de/hugo/IronStorage">Source</a> · <a href="https://git.rfc1437.de/hugo/IronStorage/issues">Support</a></nav>
|
||||
<div class="gallery">
|
||||
<figure><img src="ironstorage/screenshots/01-home.png" alt="IronStorage local Git home"><figcaption>Local Git status</figcaption></figure>
|
||||
<figure><img src="ironstorage/screenshots/02-passwords.png" alt="IronStorage password folders"><figcaption>Passwords and folders</figcaption></figure>
|
||||
<figure><img src="ironstorage/screenshots/03-totp.png" alt="IronStorage TOTP list"><figcaption>TOTP and Watch selection</figcaption></figure>
|
||||
<figure><img src="ironstorage/screenshots/04-totp-detail.png" alt="IronStorage TOTP detail"><figcaption>Live TOTP detail</figcaption></figure>
|
||||
<figure><img src="ironstorage/screenshots/05-preferences.png" alt="IronStorage preferences"><figcaption>Local-first preferences</figcaption></figure>
|
||||
<figure><img src="ironstorage/screenshots/06-search.png" alt="IronStorage search"><figcaption>Password search</figcaption></figure>
|
||||
<figure><img src="ironstorage/screenshots/watch-01-list.png" alt="IronStorage Watch entry list"><figcaption>Watch entry list</figcaption></figure>
|
||||
<figure><img src="ironstorage/screenshots/watch-02-detail.png" alt="IronStorage Watch code detail"><figcaption>Watch code and progress</figcaption></figure>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
<footer><small>Both apps are free and open-source software. Each listing identifies its supported distribution channel.</small></footer>
|
||||
</main></body></html>
|
||||
26
apple/AppStore/index.html
Normal file
@@ -0,0 +1,26 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<title>IronStorage</title>
|
||||
<style>body{font:17px system-ui;line-height:1.55;max-width:64rem;margin:3rem auto;padding:0 1rem;color:#1d1d1f}header{display:flex;align-items:center;gap:1rem}header img{width:6rem;border-radius:1.3rem}.gallery{display:grid;grid-template-columns:repeat(auto-fit,minmax(14rem,1fr));gap:1rem}.gallery img{width:100%;border-radius:1.5rem}nav{margin:1.5rem 0}</style>
|
||||
<header><img src="icon.png" alt="IronStorage app icon"><div><h1>IronStorage</h1><p>Native pass and pass-otp for iPhone and Apple Watch.</p></div></header>
|
||||
<p>Start locally with a protected GPG key and versioned password store. Create folders and encrypted entries on your iPhone, generate TOTP codes, and optionally connect an HTTPS Git remote later.</p>
|
||||
<p>The Apple Watch companion receives only the TOTP entries you explicitly share.</p>
|
||||
<p><strong>Apple App Store release in preparation.</strong></p>
|
||||
<nav><a href="privacy/">Privacy</a> · <a href="https://git.rfc1437.de/hugo/IronStorage">Source code</a> · <a href="https://git.rfc1437.de/hugo/IronStorage/issues">Support</a></nav>
|
||||
<div class="gallery">
|
||||
<img src="screenshots/01-home.png" alt="Local Git status">
|
||||
<img src="screenshots/02-passwords.png" alt="Password entries and folders">
|
||||
<img src="screenshots/03-totp.png" alt="TOTP entries selected for Apple Watch">
|
||||
<img src="screenshots/04-totp-detail.png" alt="Live TOTP detail">
|
||||
<img src="screenshots/05-preferences.png" alt="Local-first preferences">
|
||||
<img src="screenshots/06-search.png" alt="Password search">
|
||||
</div>
|
||||
<h2>Apple Watch</h2>
|
||||
<div class="gallery">
|
||||
<img src="screenshots/watch-01-list.png" alt="Apple Watch TOTP entry list">
|
||||
<img src="screenshots/watch-02-detail.png" alt="Apple Watch selected code and progress bar">
|
||||
</div>
|
||||
</html>
|
||||
74
apple/AppStore/metadata.toml
Normal file
@@ -0,0 +1,74 @@
|
||||
[app]
|
||||
name = "IronStorage"
|
||||
bundle_identifier = "de.rfc1437.ironstorage"
|
||||
developer_name = "rfc1437"
|
||||
subtitle = "Native pass on iPhone"
|
||||
description = "IronStorage is a native iPhone and Apple Watch client for pass and pass-otp password stores. Start entirely on-device with a protected GPG key and local Git history, then add optional HTTPS Git synchronization whenever you want it."
|
||||
keywords = "password,gpg,pass,totp,git,security,watch"
|
||||
icon_file = "apple/Assets.xcassets/AppIcon.appiconset/AppIcon.png"
|
||||
category = "utilities"
|
||||
privacy_url = "https://rfc1437.de/apps/ironstorage/privacy/"
|
||||
support_url = "https://git.rfc1437.de/hugo/IronStorage/issues"
|
||||
marketing_url = "https://rfc1437.de/apps/ironstorage/"
|
||||
age_rating = "4+"
|
||||
|
||||
[release]
|
||||
version = "0.1.0"
|
||||
build = "1"
|
||||
minimum_ios = "17.0"
|
||||
notes = "Initial iPhone and Apple Watch release with local GPG key generation, pass-compatible local Git history, optional HTTPS synchronization, password folders, and explicitly shared TOTP codes on Apple Watch."
|
||||
|
||||
[[screenshots]]
|
||||
file = "apple/AppStore/screenshots/home.png"
|
||||
caption = "Local Git history works before a sync remote is configured"
|
||||
width = 1206
|
||||
height = 2622
|
||||
|
||||
[[screenshots]]
|
||||
file = "apple/AppStore/screenshots/passwords.png"
|
||||
caption = "Password entries and folders stay organized on device"
|
||||
width = 1206
|
||||
height = 2622
|
||||
|
||||
[[screenshots]]
|
||||
file = "apple/AppStore/screenshots/totp.png"
|
||||
caption = "TOTP entries are shared with Apple Watch only when selected"
|
||||
width = 1206
|
||||
height = 2622
|
||||
|
||||
[[screenshots]]
|
||||
file = "apple/AppStore/screenshots/totp-detail.png"
|
||||
caption = "Live TOTP codes include an exact validity indicator"
|
||||
width = 1206
|
||||
height = 2622
|
||||
|
||||
[[screenshots]]
|
||||
file = "apple/AppStore/screenshots/preferences.png"
|
||||
caption = "Generate or import a GPG key and add optional HTTPS sync later"
|
||||
width = 1206
|
||||
height = 2622
|
||||
|
||||
[[screenshots]]
|
||||
file = "apple/AppStore/screenshots/search.png"
|
||||
caption = "Search encrypted password entries by name or folder"
|
||||
width = 1206
|
||||
height = 2622
|
||||
|
||||
[[watch_screenshots]]
|
||||
file = "apple/AppStore/screenshots/watch-list.png"
|
||||
caption = "The Watch list shows only explicitly shared TOTP entries"
|
||||
width = 416
|
||||
height = 496
|
||||
|
||||
[[watch_screenshots]]
|
||||
file = "apple/AppStore/screenshots/watch-detail.png"
|
||||
caption = "The selected Watch code is large with a validity progress bar"
|
||||
width = 416
|
||||
height = 496
|
||||
|
||||
[content]
|
||||
advertising = false
|
||||
account_creation = false
|
||||
digital_purchases = false
|
||||
tracking = false
|
||||
user_generated_content = false
|
||||
18
apple/AppStore/privacy/index.html
Normal file
@@ -0,0 +1,18 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>IronStorage Privacy</title>
|
||||
<style>body{font:17px system-ui;line-height:1.55;max-width:44rem;margin:3rem auto;padding:0 1rem;color:#1d1d1f}h1,h2{line-height:1.2}</style>
|
||||
<h1>IronStorage Privacy</h1>
|
||||
<p>Last updated: 16 August 2026</p>
|
||||
<p>IronStorage does not collect analytics, advertising identifiers, diagnostics, or personal data for the developer, and it does not track you.</p>
|
||||
<h2>Data on your device</h2>
|
||||
<p>Your local Git repository and encrypted password entries remain on your device. Application tokens, imported or generated private-key material, and protected unlock data use Apple Keychain storage. Camera frames used to scan a key-transfer QR code are processed on the device and are not retained by IronStorage.</p>
|
||||
<h2>Network access</h2>
|
||||
<p>IronStorage works without a network service. If you later configure an HTTPS Git server, it connects only to that server using the credentials you provide to pull and push your password store. The developer does not operate an IronStorage service and does not receive this traffic.</p>
|
||||
<h2>Deletion</h2>
|
||||
<p>Remove configured credentials and key material in IronStorage before uninstalling when you want them deleted immediately. Keychain items otherwise follow Apple’s secure-storage lifecycle and may survive an app reinstall.</p>
|
||||
<h2>Support</h2>
|
||||
<p>Questions and issues can be filed at <a href="https://git.rfc1437.de/hugo/IronStorage/issues">the IronStorage issue tracker</a>.</p>
|
||||
</html>
|
||||
BIN
apple/AppStore/screenshots/home.png
Normal file
|
After Width: | Height: | Size: 235 KiB |
BIN
apple/AppStore/screenshots/passwords.png
Normal file
|
After Width: | Height: | Size: 205 KiB |
BIN
apple/AppStore/screenshots/preferences.png
Normal file
|
After Width: | Height: | Size: 364 KiB |
BIN
apple/AppStore/screenshots/search.png
Normal file
|
After Width: | Height: | Size: 148 KiB |
BIN
apple/AppStore/screenshots/totp-detail.png
Normal file
|
After Width: | Height: | Size: 213 KiB |
BIN
apple/AppStore/screenshots/totp.png
Normal file
|
After Width: | Height: | Size: 221 KiB |
BIN
apple/AppStore/screenshots/watch-detail.png
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
apple/AppStore/screenshots/watch-list.png
Normal file
|
After Width: | Height: | Size: 45 KiB |
@@ -17,9 +17,11 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>XPC!</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
|
||||
@@ -619,6 +619,8 @@ public protocol MobileAuthenticationProtocol: AnyObject, Sendable {
|
||||
|
||||
func copyTotpCode(path: String, unixSeconds: UInt64) throws -> MobileEntryCopy
|
||||
|
||||
func createDirectory(parent: String, name: String) throws
|
||||
|
||||
func discardEntryEditor(editor: UInt64) throws
|
||||
|
||||
func entryEditor(editor: UInt64) throws -> MobileEntryEditorPage
|
||||
@@ -823,6 +825,16 @@ open func copyTotpCode(path: String, unixSeconds: UInt64)throws -> MobileEntryC
|
||||
})
|
||||
}
|
||||
|
||||
open func createDirectory(parent: String, name: String)throws {try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) {
|
||||
uniffiCallStatus in
|
||||
uniffi_ironstorage_apple_fn_method_mobileauthentication_create_directory(
|
||||
self.uniffiCloneHandle(),
|
||||
FfiConverterString.lower(parent),
|
||||
FfiConverterString.lower(name),uniffiCallStatus
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
open func discardEntryEditor(editor: UInt64)throws {try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) {
|
||||
uniffiCallStatus in
|
||||
uniffi_ironstorage_apple_fn_method_mobileauthentication_discard_entry_editor(
|
||||
@@ -1551,7 +1563,7 @@ public protocol MobileKeyTransferImportProtocol: AnyObject, Sendable {
|
||||
|
||||
func addFrame(payload: String) throws -> MobileKeyTransferProgress
|
||||
|
||||
func `import`(passphrase: String?, makeDefault: Bool) throws -> MobileKeyTransferOutcome
|
||||
func `import`(passphrase: String?, makeDefault: Bool, overwrite: Bool) throws -> MobileKeyTransferOutcome
|
||||
|
||||
}
|
||||
open class MobileKeyTransferImport: MobileKeyTransferImportProtocol, @unchecked Sendable {
|
||||
@@ -1617,13 +1629,14 @@ open func addFrame(payload: String)throws -> MobileKeyTransferProgress {
|
||||
})
|
||||
}
|
||||
|
||||
open func `import`(passphrase: String?, makeDefault: Bool)throws -> MobileKeyTransferOutcome {
|
||||
open func `import`(passphrase: String?, makeDefault: Bool, overwrite: Bool)throws -> MobileKeyTransferOutcome {
|
||||
return try FfiConverterTypeMobileKeyTransferOutcome_lift(try rustCallWithError(FfiConverterTypeMobileKeyTransferFfiError_lift) {
|
||||
uniffiCallStatus in
|
||||
uniffi_ironstorage_apple_fn_method_mobilekeytransferimport_import(
|
||||
self.uniffiCloneHandle(),
|
||||
FfiConverterOptionString.lower(passphrase),
|
||||
FfiConverterBool.lower(makeDefault),uniffiCallStatus
|
||||
FfiConverterBool.lower(makeDefault),
|
||||
FfiConverterBool.lower(overwrite),uniffiCallStatus
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -1678,10 +1691,142 @@ public func FfiConverterTypeMobileKeyTransferImport_lower(_ value: MobileKeyTran
|
||||
|
||||
|
||||
|
||||
public protocol MobileLocalOnboardingOperationProtocol: AnyObject, Sendable {
|
||||
|
||||
func cancel()
|
||||
|
||||
func generate(userId: String, passphrase: String, replaceExistingKey: Bool) throws -> MobileOnboardingOutcome
|
||||
|
||||
}
|
||||
open class MobileLocalOnboardingOperation: MobileLocalOnboardingOperationProtocol, @unchecked Sendable {
|
||||
fileprivate let handle: UInt64
|
||||
|
||||
/// Used to instantiate a [FFIObject] without an actual handle, for fakes in tests, mostly.
|
||||
#if swift(>=5.8)
|
||||
@_documentation(visibility: private)
|
||||
#endif
|
||||
public struct NoHandle {
|
||||
public init() {}
|
||||
}
|
||||
|
||||
// TODO: We'd like this to be `private` but for Swifty reasons,
|
||||
// we can't implement `FfiConverter` without making this `required` and we can't
|
||||
// make it `required` without making it `public`.
|
||||
#if swift(>=5.8)
|
||||
@_documentation(visibility: private)
|
||||
#endif
|
||||
required public init(unsafeFromHandle handle: UInt64) {
|
||||
self.handle = handle
|
||||
}
|
||||
|
||||
// This constructor can be used to instantiate a fake object.
|
||||
// - Parameter noHandle: Placeholder value so we can have a constructor separate from the default empty one that may be implemented for classes extending [FFIObject].
|
||||
//
|
||||
// - Warning:
|
||||
// Any object instantiated with this constructor cannot be passed to an actual Rust-backed object. Since there isn't a backing handle the FFI lower functions will crash.
|
||||
#if swift(>=5.8)
|
||||
@_documentation(visibility: private)
|
||||
#endif
|
||||
public init(noHandle: NoHandle) {
|
||||
self.handle = 0
|
||||
}
|
||||
|
||||
#if swift(>=5.8)
|
||||
@_documentation(visibility: private)
|
||||
#endif
|
||||
public func uniffiCloneHandle() -> UInt64 {
|
||||
return try! rustCall { uniffi_ironstorage_apple_fn_clone_mobilelocalonboardingoperation(self.handle, $0) }
|
||||
}
|
||||
// No primary constructor declared for this class.
|
||||
|
||||
deinit {
|
||||
if handle == 0 {
|
||||
// Mock objects have handle=0 don't try to free them
|
||||
return
|
||||
}
|
||||
|
||||
try! rustCall { uniffi_ironstorage_apple_fn_free_mobilelocalonboardingoperation(handle, $0) }
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
open func cancel() {try! rustCall() {
|
||||
uniffiCallStatus in
|
||||
uniffi_ironstorage_apple_fn_method_mobilelocalonboardingoperation_cancel(
|
||||
self.uniffiCloneHandle(),uniffiCallStatus
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
open func generate(userId: String, passphrase: String, replaceExistingKey: Bool)throws -> MobileOnboardingOutcome {
|
||||
return try FfiConverterTypeMobileOnboardingOutcome_lift(try rustCallWithError(FfiConverterTypeMobileOnboardingFfiError_lift) {
|
||||
uniffiCallStatus in
|
||||
uniffi_ironstorage_apple_fn_method_mobilelocalonboardingoperation_generate(
|
||||
self.uniffiCloneHandle(),
|
||||
FfiConverterString.lower(userId),
|
||||
FfiConverterString.lower(passphrase),
|
||||
FfiConverterBool.lower(replaceExistingKey),uniffiCallStatus
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
#if swift(>=5.8)
|
||||
@_documentation(visibility: private)
|
||||
#endif
|
||||
public struct FfiConverterTypeMobileLocalOnboardingOperation: FfiConverter {
|
||||
typealias FfiType = UInt64
|
||||
typealias SwiftType = MobileLocalOnboardingOperation
|
||||
|
||||
public static func lift(_ handle: UInt64) throws -> MobileLocalOnboardingOperation {
|
||||
return MobileLocalOnboardingOperation(unsafeFromHandle: handle)
|
||||
}
|
||||
|
||||
public static func lower(_ value: MobileLocalOnboardingOperation) -> UInt64 {
|
||||
return value.uniffiCloneHandle()
|
||||
}
|
||||
|
||||
public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobileLocalOnboardingOperation {
|
||||
let handle: UInt64 = try readInt(&buf)
|
||||
return try lift(handle)
|
||||
}
|
||||
|
||||
public static func write(_ value: MobileLocalOnboardingOperation, into buf: inout [UInt8]) {
|
||||
writeInt(&buf, lower(value))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#if swift(>=5.8)
|
||||
@_documentation(visibility: private)
|
||||
#endif
|
||||
public func FfiConverterTypeMobileLocalOnboardingOperation_lift(_ handle: UInt64) throws -> MobileLocalOnboardingOperation {
|
||||
return try FfiConverterTypeMobileLocalOnboardingOperation.lift(handle)
|
||||
}
|
||||
|
||||
#if swift(>=5.8)
|
||||
@_documentation(visibility: private)
|
||||
#endif
|
||||
public func FfiConverterTypeMobileLocalOnboardingOperation_lower(_ value: MobileLocalOnboardingOperation) -> UInt64 {
|
||||
return FfiConverterTypeMobileLocalOnboardingOperation.lower(value)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
public protocol MobileOnboardingOperationProtocol: AnyObject, Sendable {
|
||||
|
||||
func cancel()
|
||||
|
||||
func connectLocalStore() throws -> MobileOnboardingOutcome
|
||||
|
||||
func discover() throws -> MobileOnboardingDiscovery
|
||||
|
||||
func progress() -> MobileOnboardingProgress
|
||||
@@ -1750,6 +1895,15 @@ open func cancel() {try! rustCall() {
|
||||
}
|
||||
}
|
||||
|
||||
open func connectLocalStore()throws -> MobileOnboardingOutcome {
|
||||
return try FfiConverterTypeMobileOnboardingOutcome_lift(try rustCallWithError(FfiConverterTypeMobileOnboardingFfiError_lift) {
|
||||
uniffiCallStatus in
|
||||
uniffi_ironstorage_apple_fn_method_mobileonboardingoperation_connect_local_store(
|
||||
self.uniffiCloneHandle(),uniffiCallStatus
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
open func discover()throws -> MobileOnboardingDiscovery {
|
||||
return try FfiConverterTypeMobileOnboardingDiscovery_lift(try rustCallWithError(FfiConverterTypeMobileOnboardingFfiError_lift) {
|
||||
uniffiCallStatus in
|
||||
@@ -2929,6 +3083,7 @@ public func FfiConverterTypeMobileHomeNotice_lower(_ value: MobileHomeNotice) ->
|
||||
|
||||
|
||||
public struct MobileHomePage: Equatable, Hashable {
|
||||
public var remoteConfigured: Bool
|
||||
public var freshness: MobileHomeFreshness
|
||||
public var refreshedAt: Int64?
|
||||
public var summaries: [MobileHomeSummaryRow]
|
||||
@@ -2940,7 +3095,8 @@ public struct MobileHomePage: Equatable, Hashable {
|
||||
|
||||
// Default memberwise initializers are never public by default, so we
|
||||
// declare one manually.
|
||||
public init(freshness: MobileHomeFreshness, refreshedAt: Int64?, summaries: [MobileHomeSummaryRow], incoming: [MobileHomeCommit], outgoing: [MobileHomeCommit], incomingTotal: UInt32, outgoingTotal: UInt32, notice: MobileHomeNotice?) {
|
||||
public init(remoteConfigured: Bool, freshness: MobileHomeFreshness, refreshedAt: Int64?, summaries: [MobileHomeSummaryRow], incoming: [MobileHomeCommit], outgoing: [MobileHomeCommit], incomingTotal: UInt32, outgoingTotal: UInt32, notice: MobileHomeNotice?) {
|
||||
self.remoteConfigured = remoteConfigured
|
||||
self.freshness = freshness
|
||||
self.refreshedAt = refreshedAt
|
||||
self.summaries = summaries
|
||||
@@ -2967,6 +3123,7 @@ public struct FfiConverterTypeMobileHomePage: FfiConverterRustBuffer {
|
||||
public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobileHomePage {
|
||||
return
|
||||
try MobileHomePage(
|
||||
remoteConfigured: FfiConverterBool.read(from: &buf),
|
||||
freshness: FfiConverterTypeMobileHomeFreshness.read(from: &buf),
|
||||
refreshedAt: FfiConverterOptionInt64.read(from: &buf),
|
||||
summaries: FfiConverterSequenceTypeMobileHomeSummaryRow.read(from: &buf),
|
||||
@@ -2979,6 +3136,7 @@ public struct FfiConverterTypeMobileHomePage: FfiConverterRustBuffer {
|
||||
}
|
||||
|
||||
public static func write(_ value: MobileHomePage, into buf: inout [UInt8]) {
|
||||
FfiConverterBool.write(value.remoteConfigured, into: &buf)
|
||||
FfiConverterTypeMobileHomeFreshness.write(value.freshness, into: &buf)
|
||||
FfiConverterOptionInt64.write(value.refreshedAt, into: &buf)
|
||||
FfiConverterSequenceTypeMobileHomeSummaryRow.write(value.summaries, into: &buf)
|
||||
@@ -4081,6 +4239,7 @@ public func FfiConverterTypeMobilePasswordRow_lower(_ value: MobilePasswordRow)
|
||||
|
||||
|
||||
public struct MobilePreferences: Equatable, Hashable {
|
||||
public var syncConfigured: Bool
|
||||
public var repositoryTitle: String
|
||||
public var repositoryUrl: String
|
||||
public var serverTitle: String
|
||||
@@ -4097,7 +4256,8 @@ public struct MobilePreferences: Equatable, Hashable {
|
||||
|
||||
// Default memberwise initializers are never public by default, so we
|
||||
// declare one manually.
|
||||
public init(repositoryTitle: String, repositoryUrl: String, serverTitle: String, serverIdentity: String, applicationAccount: String?, defaultKeyTitle: String, defaultKeyFingerprint: String, authenticationTimeoutSeconds: UInt64, biometricUnlockEnabled: Bool, appearance: MobileAppearance, watchState: MobileWatchPreferenceState, watchTitle: String, watchDetail: String) {
|
||||
public init(syncConfigured: Bool, repositoryTitle: String, repositoryUrl: String, serverTitle: String, serverIdentity: String, applicationAccount: String?, defaultKeyTitle: String, defaultKeyFingerprint: String, authenticationTimeoutSeconds: UInt64, biometricUnlockEnabled: Bool, appearance: MobileAppearance, watchState: MobileWatchPreferenceState, watchTitle: String, watchDetail: String) {
|
||||
self.syncConfigured = syncConfigured
|
||||
self.repositoryTitle = repositoryTitle
|
||||
self.repositoryUrl = repositoryUrl
|
||||
self.serverTitle = serverTitle
|
||||
@@ -4129,6 +4289,7 @@ public struct FfiConverterTypeMobilePreferences: FfiConverterRustBuffer {
|
||||
public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobilePreferences {
|
||||
return
|
||||
try MobilePreferences(
|
||||
syncConfigured: FfiConverterBool.read(from: &buf),
|
||||
repositoryTitle: FfiConverterString.read(from: &buf),
|
||||
repositoryUrl: FfiConverterString.read(from: &buf),
|
||||
serverTitle: FfiConverterString.read(from: &buf),
|
||||
@@ -4146,6 +4307,7 @@ public struct FfiConverterTypeMobilePreferences: FfiConverterRustBuffer {
|
||||
}
|
||||
|
||||
public static func write(_ value: MobilePreferences, into buf: inout [UInt8]) {
|
||||
FfiConverterBool.write(value.syncConfigured, into: &buf)
|
||||
FfiConverterString.write(value.repositoryTitle, into: &buf)
|
||||
FfiConverterString.write(value.repositoryUrl, into: &buf)
|
||||
FfiConverterString.write(value.serverTitle, into: &buf)
|
||||
@@ -5674,12 +5836,78 @@ public func FfiConverterTypeMobileHomePhase_lower(_ value: MobileHomePhase) -> R
|
||||
|
||||
|
||||
|
||||
|
||||
public enum MobileKeyTransferErrorKind: Equatable, Hashable {
|
||||
|
||||
case existingKey
|
||||
case failed
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
#if compiler(>=6)
|
||||
extension MobileKeyTransferErrorKind: Sendable {}
|
||||
#endif
|
||||
|
||||
#if swift(>=5.8)
|
||||
@_documentation(visibility: private)
|
||||
#endif
|
||||
public struct FfiConverterTypeMobileKeyTransferErrorKind: FfiConverterRustBuffer {
|
||||
typealias SwiftType = MobileKeyTransferErrorKind
|
||||
|
||||
public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobileKeyTransferErrorKind {
|
||||
let variant: Int32 = try readInt(&buf)
|
||||
switch variant {
|
||||
|
||||
case 1: return .existingKey
|
||||
|
||||
case 2: return .failed
|
||||
|
||||
default: throw UniffiInternalError.unexpectedEnumCase
|
||||
}
|
||||
}
|
||||
|
||||
public static func write(_ value: MobileKeyTransferErrorKind, into buf: inout [UInt8]) {
|
||||
switch value {
|
||||
|
||||
|
||||
case .existingKey:
|
||||
writeInt(&buf, Int32(1))
|
||||
|
||||
|
||||
case .failed:
|
||||
writeInt(&buf, Int32(2))
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#if swift(>=5.8)
|
||||
@_documentation(visibility: private)
|
||||
#endif
|
||||
public func FfiConverterTypeMobileKeyTransferErrorKind_lift(_ buf: RustBuffer) throws -> MobileKeyTransferErrorKind {
|
||||
return try FfiConverterTypeMobileKeyTransferErrorKind.lift(buf)
|
||||
}
|
||||
|
||||
#if swift(>=5.8)
|
||||
@_documentation(visibility: private)
|
||||
#endif
|
||||
public func FfiConverterTypeMobileKeyTransferErrorKind_lower(_ value: MobileKeyTransferErrorKind) -> RustBuffer {
|
||||
return FfiConverterTypeMobileKeyTransferErrorKind.lower(value)
|
||||
}
|
||||
|
||||
|
||||
|
||||
public
|
||||
enum MobileKeyTransferFfiError: Swift.Error, Equatable, Hashable, Foundation.LocalizedError {
|
||||
|
||||
|
||||
|
||||
case Failed(message: String
|
||||
case Failed(kind: MobileKeyTransferErrorKind, message: String
|
||||
)
|
||||
|
||||
|
||||
@@ -5711,6 +5939,7 @@ public struct FfiConverterTypeMobileKeyTransferFfiError: FfiConverterRustBuffer
|
||||
|
||||
|
||||
case 1: return .Failed(
|
||||
kind: try FfiConverterTypeMobileKeyTransferErrorKind.read(from: &buf),
|
||||
message: try FfiConverterString.read(from: &buf)
|
||||
)
|
||||
|
||||
@@ -5725,8 +5954,9 @@ public struct FfiConverterTypeMobileKeyTransferFfiError: FfiConverterRustBuffer
|
||||
|
||||
|
||||
|
||||
case let .Failed(message):
|
||||
case let .Failed(kind,message):
|
||||
writeInt(&buf, Int32(1))
|
||||
FfiConverterTypeMobileKeyTransferErrorKind.write(kind, into: &buf)
|
||||
FfiConverterString.write(message, into: &buf)
|
||||
|
||||
}
|
||||
@@ -5896,6 +6126,7 @@ public enum MobileOnboardingErrorKind: Equatable, Hashable {
|
||||
case authentication
|
||||
case repository
|
||||
case existingClone
|
||||
case existingKey
|
||||
case interrupted
|
||||
case secureStorage
|
||||
case configuration
|
||||
@@ -5931,13 +6162,15 @@ public struct FfiConverterTypeMobileOnboardingErrorKind: FfiConverterRustBuffer
|
||||
|
||||
case 5: return .existingClone
|
||||
|
||||
case 6: return .interrupted
|
||||
case 6: return .existingKey
|
||||
|
||||
case 7: return .secureStorage
|
||||
case 7: return .interrupted
|
||||
|
||||
case 8: return .configuration
|
||||
case 8: return .secureStorage
|
||||
|
||||
case 9: return .alreadyConfigured
|
||||
case 9: return .configuration
|
||||
|
||||
case 10: return .alreadyConfigured
|
||||
|
||||
default: throw UniffiInternalError.unexpectedEnumCase
|
||||
}
|
||||
@@ -5967,21 +6200,25 @@ public struct FfiConverterTypeMobileOnboardingErrorKind: FfiConverterRustBuffer
|
||||
writeInt(&buf, Int32(5))
|
||||
|
||||
|
||||
case .interrupted:
|
||||
case .existingKey:
|
||||
writeInt(&buf, Int32(6))
|
||||
|
||||
|
||||
case .secureStorage:
|
||||
case .interrupted:
|
||||
writeInt(&buf, Int32(7))
|
||||
|
||||
|
||||
case .configuration:
|
||||
case .secureStorage:
|
||||
writeInt(&buf, Int32(8))
|
||||
|
||||
|
||||
case .alreadyConfigured:
|
||||
case .configuration:
|
||||
writeInt(&buf, Int32(9))
|
||||
|
||||
|
||||
case .alreadyConfigured:
|
||||
writeInt(&buf, Int32(10))
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7574,6 +7811,20 @@ public func mobileKeyTransfer()throws -> MobileKeyTransfer {
|
||||
)
|
||||
})
|
||||
}
|
||||
public func mobileLocalKeyTransferImporter() -> MobileKeyTransferImport {
|
||||
return try! FfiConverterTypeMobileKeyTransferImport_lift(try! rustCall() {
|
||||
uniffiCallStatus in
|
||||
uniffi_ironstorage_apple_fn_func_mobile_local_key_transfer_importer(uniffiCallStatus
|
||||
)
|
||||
})
|
||||
}
|
||||
public func mobileLocalOnboardingOperation() -> MobileLocalOnboardingOperation {
|
||||
return try! FfiConverterTypeMobileLocalOnboardingOperation_lift(try! rustCall() {
|
||||
uniffiCallStatus in
|
||||
uniffi_ironstorage_apple_fn_func_mobile_local_onboarding_operation(uniffiCallStatus
|
||||
)
|
||||
})
|
||||
}
|
||||
public func mobileOnboardingOperation(serverUrl: String, account: String, repositoryPath: String, applicationToken: String)throws -> MobileOnboardingOperation {
|
||||
return try FfiConverterTypeMobileOnboardingOperation_lift(try rustCallWithError(FfiConverterTypeMobileOnboardingFfiError_lift) {
|
||||
uniffiCallStatus in
|
||||
@@ -7670,6 +7921,12 @@ private let initializationResult: InitializationResult = {
|
||||
if (uniffi_ironstorage_apple_checksum_func_mobile_key_transfer() != 57389) {
|
||||
return InitializationResult.apiChecksumMismatch
|
||||
}
|
||||
if (uniffi_ironstorage_apple_checksum_func_mobile_local_key_transfer_importer() != 50238) {
|
||||
return InitializationResult.apiChecksumMismatch
|
||||
}
|
||||
if (uniffi_ironstorage_apple_checksum_func_mobile_local_onboarding_operation() != 18004) {
|
||||
return InitializationResult.apiChecksumMismatch
|
||||
}
|
||||
if (uniffi_ironstorage_apple_checksum_func_mobile_onboarding_operation() != 8354) {
|
||||
return InitializationResult.apiChecksumMismatch
|
||||
}
|
||||
@@ -7721,6 +7978,9 @@ private let initializationResult: InitializationResult = {
|
||||
if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_copy_totp_code() != 8344) {
|
||||
return InitializationResult.apiChecksumMismatch
|
||||
}
|
||||
if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_create_directory() != 25476) {
|
||||
return InitializationResult.apiChecksumMismatch
|
||||
}
|
||||
if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_discard_entry_editor() != 28195) {
|
||||
return InitializationResult.apiChecksumMismatch
|
||||
}
|
||||
@@ -7856,12 +8116,21 @@ private let initializationResult: InitializationResult = {
|
||||
if (uniffi_ironstorage_apple_checksum_method_mobilekeytransferimport_add_frame() != 27319) {
|
||||
return InitializationResult.apiChecksumMismatch
|
||||
}
|
||||
if (uniffi_ironstorage_apple_checksum_method_mobilekeytransferimport_import() != 37403) {
|
||||
if (uniffi_ironstorage_apple_checksum_method_mobilekeytransferimport_import() != 44529) {
|
||||
return InitializationResult.apiChecksumMismatch
|
||||
}
|
||||
if (uniffi_ironstorage_apple_checksum_method_mobilelocalonboardingoperation_cancel() != 26455) {
|
||||
return InitializationResult.apiChecksumMismatch
|
||||
}
|
||||
if (uniffi_ironstorage_apple_checksum_method_mobilelocalonboardingoperation_generate() != 58931) {
|
||||
return InitializationResult.apiChecksumMismatch
|
||||
}
|
||||
if (uniffi_ironstorage_apple_checksum_method_mobileonboardingoperation_cancel() != 49755) {
|
||||
return InitializationResult.apiChecksumMismatch
|
||||
}
|
||||
if (uniffi_ironstorage_apple_checksum_method_mobileonboardingoperation_connect_local_store() != 57886) {
|
||||
return InitializationResult.apiChecksumMismatch
|
||||
}
|
||||
if (uniffi_ironstorage_apple_checksum_method_mobileonboardingoperation_discover() != 2309) {
|
||||
return InitializationResult.apiChecksumMismatch
|
||||
}
|
||||
|
||||
@@ -293,6 +293,11 @@ RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_copy_entry_fi
|
||||
RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_copy_totp_code(uint64_t ptr, RustBuffer path, uint64_t unix_seconds, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_CREATE_DIRECTORY
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_CREATE_DIRECTORY
|
||||
void uniffi_ironstorage_apple_fn_method_mobileauthentication_create_directory(uint64_t ptr, RustBuffer parent, RustBuffer name, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_DISCARD_ENTRY_EDITOR
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_DISCARD_ENTRY_EDITOR
|
||||
void uniffi_ironstorage_apple_fn_method_mobileauthentication_discard_entry_editor(uint64_t ptr, uint64_t editor, RustCallStatus *_Nonnull out_status
|
||||
@@ -550,7 +555,27 @@ RustBuffer uniffi_ironstorage_apple_fn_method_mobilekeytransferimport_add_frame(
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEKEYTRANSFERIMPORT_IMPORT
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEKEYTRANSFERIMPORT_IMPORT
|
||||
RustBuffer uniffi_ironstorage_apple_fn_method_mobilekeytransferimport_import(uint64_t ptr, RustBuffer passphrase, int8_t make_default, RustCallStatus *_Nonnull out_status
|
||||
RustBuffer uniffi_ironstorage_apple_fn_method_mobilekeytransferimport_import(uint64_t ptr, RustBuffer passphrase, int8_t make_default, int8_t overwrite, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_CLONE_MOBILELOCALONBOARDINGOPERATION
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_CLONE_MOBILELOCALONBOARDINGOPERATION
|
||||
uint64_t uniffi_ironstorage_apple_fn_clone_mobilelocalonboardingoperation(uint64_t handle, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_FREE_MOBILELOCALONBOARDINGOPERATION
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_FREE_MOBILELOCALONBOARDINGOPERATION
|
||||
void uniffi_ironstorage_apple_fn_free_mobilelocalonboardingoperation(uint64_t handle, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILELOCALONBOARDINGOPERATION_CANCEL
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILELOCALONBOARDINGOPERATION_CANCEL
|
||||
void uniffi_ironstorage_apple_fn_method_mobilelocalonboardingoperation_cancel(uint64_t ptr, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILELOCALONBOARDINGOPERATION_GENERATE
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILELOCALONBOARDINGOPERATION_GENERATE
|
||||
RustBuffer uniffi_ironstorage_apple_fn_method_mobilelocalonboardingoperation_generate(uint64_t ptr, RustBuffer user_id, RustBuffer passphrase, int8_t replace_existing_key, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_CLONE_MOBILEONBOARDINGOPERATION
|
||||
@@ -568,6 +593,11 @@ void uniffi_ironstorage_apple_fn_free_mobileonboardingoperation(uint64_t handle,
|
||||
void uniffi_ironstorage_apple_fn_method_mobileonboardingoperation_cancel(uint64_t ptr, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEONBOARDINGOPERATION_CONNECT_LOCAL_STORE
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEONBOARDINGOPERATION_CONNECT_LOCAL_STORE
|
||||
RustBuffer uniffi_ironstorage_apple_fn_method_mobileonboardingoperation_connect_local_store(uint64_t ptr, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEONBOARDINGOPERATION_DISCOVER
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEONBOARDINGOPERATION_DISCOVER
|
||||
RustBuffer uniffi_ironstorage_apple_fn_method_mobileonboardingoperation_discover(uint64_t ptr, RustCallStatus *_Nonnull out_status
|
||||
@@ -618,6 +648,18 @@ uint64_t uniffi_ironstorage_apple_fn_func_mobile_home_operation(RustBuffer authe
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_FUNC_MOBILE_KEY_TRANSFER
|
||||
uint64_t uniffi_ironstorage_apple_fn_func_mobile_key_transfer(RustCallStatus *_Nonnull out_status
|
||||
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_FUNC_MOBILE_LOCAL_KEY_TRANSFER_IMPORTER
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_FUNC_MOBILE_LOCAL_KEY_TRANSFER_IMPORTER
|
||||
uint64_t uniffi_ironstorage_apple_fn_func_mobile_local_key_transfer_importer(RustCallStatus *_Nonnull out_status
|
||||
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_FUNC_MOBILE_LOCAL_ONBOARDING_OPERATION
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_FUNC_MOBILE_LOCAL_ONBOARDING_OPERATION
|
||||
uint64_t uniffi_ironstorage_apple_fn_func_mobile_local_onboarding_operation(RustCallStatus *_Nonnull out_status
|
||||
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_FUNC_MOBILE_ONBOARDING_OPERATION
|
||||
@@ -944,6 +986,18 @@ uint16_t uniffi_ironstorage_apple_checksum_func_mobile_home_operation(void
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_FUNC_MOBILE_KEY_TRANSFER
|
||||
uint16_t uniffi_ironstorage_apple_checksum_func_mobile_key_transfer(void
|
||||
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_FUNC_MOBILE_LOCAL_KEY_TRANSFER_IMPORTER
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_FUNC_MOBILE_LOCAL_KEY_TRANSFER_IMPORTER
|
||||
uint16_t uniffi_ironstorage_apple_checksum_func_mobile_local_key_transfer_importer(void
|
||||
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_FUNC_MOBILE_LOCAL_ONBOARDING_OPERATION
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_FUNC_MOBILE_LOCAL_ONBOARDING_OPERATION
|
||||
uint16_t uniffi_ironstorage_apple_checksum_func_mobile_local_onboarding_operation(void
|
||||
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_FUNC_MOBILE_ONBOARDING_OPERATION
|
||||
@@ -1046,6 +1100,12 @@ uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_copy_entr
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_COPY_TOTP_CODE
|
||||
uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_copy_totp_code(void
|
||||
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_CREATE_DIRECTORY
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_CREATE_DIRECTORY
|
||||
uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_create_directory(void
|
||||
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_DISCARD_ENTRY_EDITOR
|
||||
@@ -1322,12 +1382,30 @@ uint16_t uniffi_ironstorage_apple_checksum_method_mobilekeytransferimport_add_fr
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEKEYTRANSFERIMPORT_IMPORT
|
||||
uint16_t uniffi_ironstorage_apple_checksum_method_mobilekeytransferimport_import(void
|
||||
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILELOCALONBOARDINGOPERATION_CANCEL
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILELOCALONBOARDINGOPERATION_CANCEL
|
||||
uint16_t uniffi_ironstorage_apple_checksum_method_mobilelocalonboardingoperation_cancel(void
|
||||
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILELOCALONBOARDINGOPERATION_GENERATE
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILELOCALONBOARDINGOPERATION_GENERATE
|
||||
uint16_t uniffi_ironstorage_apple_checksum_method_mobilelocalonboardingoperation_generate(void
|
||||
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEONBOARDINGOPERATION_CANCEL
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEONBOARDINGOPERATION_CANCEL
|
||||
uint16_t uniffi_ironstorage_apple_checksum_method_mobileonboardingoperation_cancel(void
|
||||
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEONBOARDINGOPERATION_CONNECT_LOCAL_STORE
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEONBOARDINGOPERATION_CONNECT_LOCAL_STORE
|
||||
uint16_t uniffi_ironstorage_apple_checksum_method_mobileonboardingoperation_connect_local_store(void
|
||||
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEONBOARDINGOPERATION_DISCOVER
|
||||
|
||||
@@ -17,15 +17,15 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
<key>NSFaceIDUsageDescription</key>
|
||||
<string>Unlock your GPG key for protected password operations.</string>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>Scan GPG key transfer QR codes that you choose to import.</string>
|
||||
<key>NSFaceIDUsageDescription</key>
|
||||
<string>Unlock your GPG key for protected password operations.</string>
|
||||
<key>UILaunchScreen</key>
|
||||
<dict/>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
|
||||
21
apple/Resources/App/License.txt
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 rfc1437
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
17970
apple/Resources/App/ThirdPartyLicenses.txt
Normal file
@@ -20,6 +20,9 @@ extension Notification.Name {
|
||||
static let ironStorageKeyMaterialDidChange = Notification.Name(
|
||||
"de.rfc1437.ironstorage.key-material-did-change"
|
||||
)
|
||||
static let ironStorageConfigurationDidChange = Notification.Name(
|
||||
"de.rfc1437.ironstorage.configuration-did-change"
|
||||
)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@@ -182,6 +185,12 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
name: .ironStorageKeyMaterialDidChange,
|
||||
object: nil
|
||||
)
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(keyMaterialDidChange),
|
||||
name: .ironStorageConfigurationDidChange,
|
||||
object: nil
|
||||
)
|
||||
}
|
||||
|
||||
deinit {
|
||||
@@ -636,7 +645,7 @@ private final class ShellViewController: UITableViewController, MobileTabRoot {
|
||||
}
|
||||
|
||||
@objc private func setupRequested() {
|
||||
navigationController?.pushViewController(OnboardingViewController(), animated: true)
|
||||
navigationController?.pushViewController(SetupChoiceViewController(), animated: true)
|
||||
}
|
||||
|
||||
@objc private func tokenUpdateRequested() {
|
||||
@@ -703,8 +712,10 @@ private final class ShellViewController: UITableViewController, MobileTabRoot {
|
||||
)
|
||||
case .empty:
|
||||
content.image = UIImage(systemName: "checkmark.circle")
|
||||
content.text = "No Remote Activity"
|
||||
content.secondaryText = "There are no commits to pull or push."
|
||||
content.text = homePage.remoteConfigured ? "No Remote Activity" : "Local Store Ready"
|
||||
content.secondaryText = homePage.remoteConfigured
|
||||
? "There are no commits to pull or push."
|
||||
: "Changes are committed to the local Git repository."
|
||||
cell.selectionStyle = .none
|
||||
case .notice:
|
||||
if let notice = homePage.notice {
|
||||
@@ -734,6 +745,11 @@ private final class ShellViewController: UITableViewController, MobileTabRoot {
|
||||
}
|
||||
|
||||
private func freshnessDescription(_ page: MobileHomePage) -> String {
|
||||
if !page.remoteConfigured {
|
||||
return page.freshness == .current
|
||||
? "Current local Git status."
|
||||
: "Local Git status from this device."
|
||||
}
|
||||
let refreshed = page.refreshedAt.map(formattedDate)
|
||||
switch page.freshness {
|
||||
case .neverRefreshed:
|
||||
@@ -867,7 +883,7 @@ private final class ShellViewController: UITableViewController, MobileTabRoot {
|
||||
private func showHomeLoading() {
|
||||
var configuration = UIContentUnavailableConfiguration.loading()
|
||||
configuration.text = "Loading Activity"
|
||||
configuration.secondaryText = "Reading cached remote-branch state from storage."
|
||||
configuration.secondaryText = "Reading Git history and password-store status."
|
||||
contentUnavailableConfiguration = configuration
|
||||
}
|
||||
|
||||
@@ -1078,7 +1094,8 @@ private final class PreferencesViewController: UITableViewController, MobileTabR
|
||||
) -> Int {
|
||||
switch section {
|
||||
case 0: 2
|
||||
case 1, 2, 3: 3
|
||||
case 1: preferences?.syncConfigured == false ? 1 : 3
|
||||
case 2, 3: 3
|
||||
default: 1
|
||||
}
|
||||
}
|
||||
@@ -1089,7 +1106,7 @@ private final class PreferencesViewController: UITableViewController, MobileTabR
|
||||
) -> String? {
|
||||
switch section {
|
||||
case 0: "Password Store"
|
||||
case 1: "Application Token"
|
||||
case 1: preferences?.syncConfigured == false ? "Git Sync" : "Application Token"
|
||||
case 2: "GPG Key & Recovery"
|
||||
case 3: "Authentication"
|
||||
case 4: "Appearance"
|
||||
@@ -1106,7 +1123,9 @@ private final class PreferencesViewController: UITableViewController, MobileTabR
|
||||
case 0:
|
||||
"Non-secret repository and server identities from the shared configuration."
|
||||
case 1:
|
||||
"The application token is stored only in protected system storage and is never displayed."
|
||||
preferences?.syncConfigured == false
|
||||
? "The store has local Git history. An HTTPS remote can be configured later for synchronization."
|
||||
: "The application token is stored only in protected system storage and is never displayed."
|
||||
case 2:
|
||||
"Import provides initial or recovery key setup. Private-key transfers require explicit confirmation and passphrase validation."
|
||||
case 3:
|
||||
@@ -1138,11 +1157,18 @@ private final class PreferencesViewController: UITableViewController, MobileTabR
|
||||
content.secondaryText = preferences?.serverIdentity ?? "Server identity unavailable"
|
||||
cell.selectionStyle = .none
|
||||
case (1, 0):
|
||||
content.image = UIImage(systemName: "key.fill")
|
||||
content.text = preferences?.applicationAccount ?? "No Application Token"
|
||||
content.secondaryText = preferences?.applicationAccount == nil
|
||||
? "Git network operations require a replacement token"
|
||||
: "Token stored in protected system storage"
|
||||
if preferences?.syncConfigured == false {
|
||||
content.image = UIImage(systemName: "network.badge.shield.half.filled")
|
||||
content.text = "Add HTTPS Remote"
|
||||
content.secondaryText = "Keep local history and enable optional synchronization"
|
||||
cell.accessoryType = .disclosureIndicator
|
||||
} else {
|
||||
content.image = UIImage(systemName: "key.fill")
|
||||
content.text = preferences?.applicationAccount ?? "No Application Token"
|
||||
content.secondaryText = preferences?.applicationAccount == nil
|
||||
? "Git network operations require a replacement token"
|
||||
: "Token stored in protected system storage"
|
||||
}
|
||||
cell.selectionStyle = .none
|
||||
case (1, 1):
|
||||
content.image = UIImage(systemName: "arrow.triangle.2.circlepath")
|
||||
@@ -1228,6 +1254,11 @@ private final class PreferencesViewController: UITableViewController, MobileTabR
|
||||
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
switch (indexPath.section, indexPath.row) {
|
||||
case (1, 0) where preferences?.syncConfigured == false:
|
||||
navigationController?.pushViewController(
|
||||
OnboardingViewController(attachLocalStore: true),
|
||||
animated: true
|
||||
)
|
||||
case (1, 1):
|
||||
tokenUpdateRequested()
|
||||
case (1, 2):
|
||||
@@ -1461,8 +1492,9 @@ private final class PreferencesViewController: UITableViewController, MobileTabR
|
||||
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
|
||||
alert.addAction(UIAlertAction(title: "Remove Token", style: .destructive) {
|
||||
[weak self] _ in
|
||||
guard let authentication = self?.authentication else { return }
|
||||
self?.runPreferenceUpdate(announcement: "Application token removed") {
|
||||
try self?.authentication?.removeApplicationToken()
|
||||
try authentication.removeApplicationToken()
|
||||
}
|
||||
})
|
||||
present(alert, animated: true)
|
||||
@@ -1483,8 +1515,9 @@ private final class PreferencesViewController: UITableViewController, MobileTabR
|
||||
alert.addAction(UIAlertAction(title: "Save", style: .default) {
|
||||
[weak self, weak alert] _ in
|
||||
let seconds = UInt64(alert?.textFields?.first?.text ?? "") ?? 0
|
||||
guard let authentication = self?.authentication else { return }
|
||||
self?.runPreferenceUpdate(announcement: "Inactivity timeout updated") {
|
||||
try self?.authentication?.setAuthenticationTimeout(seconds: seconds)
|
||||
try authentication.setAuthenticationTimeout(seconds: seconds)
|
||||
}
|
||||
})
|
||||
present(alert, animated: true)
|
||||
@@ -1498,8 +1531,9 @@ private final class PreferencesViewController: UITableViewController, MobileTabR
|
||||
title: "\(selected ? "✓ " : "")\(appearanceTitle(appearance))",
|
||||
style: .default
|
||||
) { [weak self] _ in
|
||||
guard let authentication = self?.authentication else { return }
|
||||
self?.runPreferenceUpdate(announcement: "Appearance updated") {
|
||||
try self?.authentication?.setMobileAppearance(appearance: appearance)
|
||||
try authentication.setMobileAppearance(appearance: appearance)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1656,6 +1690,14 @@ private final class PreferencesViewController: UITableViewController, MobileTabR
|
||||
watchPaired: watchSnapshot.isPaired,
|
||||
watchAppInstalled: watchSnapshot.isWatchAppInstalled
|
||||
)
|
||||
navigationItem.rightBarButtonItem = preferences?.syncConfigured == true
|
||||
? UIBarButtonItem(
|
||||
title: "Update Token",
|
||||
style: .plain,
|
||||
target: self,
|
||||
action: #selector(tokenUpdateRequested)
|
||||
)
|
||||
: nil
|
||||
if let appearance = preferences?.appearance {
|
||||
tabBarController?.overrideUserInterfaceStyle = switch appearance {
|
||||
case .system: .unspecified
|
||||
@@ -1673,6 +1715,7 @@ private final class KeyImportScannerViewController: UIViewController,
|
||||
DataScannerViewControllerDelegate
|
||||
{
|
||||
private let importer: MobileKeyTransferImport
|
||||
private let localSetup: Bool
|
||||
private let scanner = DataScannerViewController(
|
||||
recognizedDataTypes: [.barcode(symbologies: [.qr])],
|
||||
qualityLevel: .balanced,
|
||||
@@ -1688,11 +1731,20 @@ private final class KeyImportScannerViewController: UIViewController,
|
||||
|
||||
init(transfer: MobileKeyTransfer) {
|
||||
importer = transfer.importer()
|
||||
localSetup = false
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
title = "Import GPG Key"
|
||||
navigationItem.largeTitleDisplayMode = .never
|
||||
}
|
||||
|
||||
init(localImporter: MobileKeyTransferImport) {
|
||||
importer = localImporter
|
||||
localSetup = true
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
title = "Import Local GPG Key"
|
||||
navigationItem.largeTitleDisplayMode = .never
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) is not supported")
|
||||
@@ -1855,11 +1907,20 @@ private final class KeyImportScannerViewController: UIViewController,
|
||||
|
||||
private func confirmImport(_ key: MobileKeyTransferKey) {
|
||||
let privateKey = key.kind == .private
|
||||
if localSetup, !privateKey {
|
||||
completing = false
|
||||
presentFailure("A local store requires a private GPG key with an encryption subkey.") {
|
||||
[weak self] in try? self?.scanner.startScanning()
|
||||
}
|
||||
return
|
||||
}
|
||||
let warning = privateKey
|
||||
? "Import this private key only if you trust the device that displayed it."
|
||||
: "Import this public key?"
|
||||
let alert = UIAlertController(
|
||||
title: privateKey ? "Import Private GPG Key?" : "Import Public GPG Key?",
|
||||
title: localSetup
|
||||
? "Create Local Store with This Key?"
|
||||
: (privateKey ? "Import Private GPG Key?" : "Import Public GPG Key?"),
|
||||
message: "\(warning)\n\n\(key.title)\n\(key.detail)",
|
||||
preferredStyle: .alert
|
||||
)
|
||||
@@ -1874,21 +1935,37 @@ private final class KeyImportScannerViewController: UIViewController,
|
||||
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) { [weak self] _ in
|
||||
self?.navigationController?.popViewController(animated: true)
|
||||
})
|
||||
alert.addAction(UIAlertAction(title: "Import", style: .default) { [weak self, weak alert] _ in
|
||||
self?.finishImport(passphrase: alert?.textFields?.first?.text, makeDefault: false)
|
||||
})
|
||||
if privateKey {
|
||||
alert.addAction(UIAlertAction(title: "Import as Default", style: .default) {
|
||||
if !localSetup {
|
||||
alert.addAction(UIAlertAction(title: "Import", style: .default) {
|
||||
[weak self, weak alert] _ in
|
||||
self?.finishImport(passphrase: alert?.textFields?.first?.text, makeDefault: true)
|
||||
self?.finishImport(
|
||||
passphrase: alert?.textFields?.first?.text,
|
||||
makeDefault: false
|
||||
)
|
||||
})
|
||||
}
|
||||
if privateKey {
|
||||
alert.addAction(UIAlertAction(
|
||||
title: localSetup ? "Create Local Store" : "Import as Default",
|
||||
style: .default
|
||||
) {
|
||||
[weak self, weak alert] _ in
|
||||
self?.finishImport(
|
||||
passphrase: alert?.textFields?.first?.text,
|
||||
makeDefault: true
|
||||
)
|
||||
})
|
||||
}
|
||||
present(alert, animated: true)
|
||||
}
|
||||
|
||||
private func finishImport(passphrase: String?, makeDefault: Bool) {
|
||||
private func finishImport(passphrase: String?, makeDefault: Bool, overwrite: Bool = false) {
|
||||
do {
|
||||
let outcome = try importer.import(passphrase: passphrase, makeDefault: makeDefault)
|
||||
let outcome = try importer.import(
|
||||
passphrase: passphrase,
|
||||
makeDefault: makeDefault,
|
||||
overwrite: overwrite
|
||||
)
|
||||
let alert = UIAlertController(
|
||||
title: outcome.title,
|
||||
message: outcome.detail,
|
||||
@@ -1899,6 +1976,16 @@ private final class KeyImportScannerViewController: UIViewController,
|
||||
NotificationCenter.default.post(name: .ironStorageKeyMaterialDidChange, object: nil)
|
||||
})
|
||||
present(alert, animated: true)
|
||||
} catch let error as MobileKeyTransferFfiError {
|
||||
if case let .Failed(kind, _) = error, kind == .existingKey, !overwrite {
|
||||
confirmOverwrite(passphrase: passphrase, makeDefault: makeDefault)
|
||||
return
|
||||
}
|
||||
completing = false
|
||||
presentFailure(error.localizedDescription) { [weak self] in
|
||||
guard let self else { return }
|
||||
try? scanner.startScanning()
|
||||
}
|
||||
} catch {
|
||||
completing = false
|
||||
presentFailure(error.localizedDescription) { [weak self] in
|
||||
@@ -1908,6 +1995,27 @@ private final class KeyImportScannerViewController: UIViewController,
|
||||
}
|
||||
}
|
||||
|
||||
private func confirmOverwrite(passphrase: String?, makeDefault: Bool) {
|
||||
let alert = UIAlertController(
|
||||
title: "Replace Existing GPG Key?",
|
||||
message: "A key with this fingerprint already exists. Replacing key material can change which private key is used to unlock entries.",
|
||||
preferredStyle: .alert
|
||||
)
|
||||
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) { [weak self] _ in
|
||||
self?.completing = false
|
||||
try? self?.scanner.startScanning()
|
||||
})
|
||||
alert.addAction(UIAlertAction(title: "Replace Key", style: .destructive) {
|
||||
[weak self] _ in
|
||||
self?.finishImport(
|
||||
passphrase: passphrase,
|
||||
makeDefault: makeDefault,
|
||||
overwrite: true
|
||||
)
|
||||
})
|
||||
present(alert, animated: true)
|
||||
}
|
||||
|
||||
private func presentFailure(_ detail: String, completion: (() -> Void)? = nil) {
|
||||
guard presentedViewController == nil else { return }
|
||||
let alert = UIAlertController(
|
||||
@@ -4202,6 +4310,55 @@ private final class PasswordDirectoryViewController: UITableViewController, Mobi
|
||||
present(alert, animated: true)
|
||||
}
|
||||
|
||||
private func createFolderRequested() {
|
||||
let alert = UIAlertController(
|
||||
title: "New Folder",
|
||||
message: "Enter a name for the password folder in this folder.",
|
||||
preferredStyle: .alert
|
||||
)
|
||||
alert.addTextField { field in
|
||||
field.placeholder = "Folder Name"
|
||||
field.autocapitalizationType = .words
|
||||
field.clearButtonMode = .whileEditing
|
||||
field.returnKeyType = .go
|
||||
}
|
||||
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
|
||||
alert.addAction(UIAlertAction(title: "Create", style: .default) { [weak self, weak alert] _ in
|
||||
guard let name = alert?.textFields?.first?.text, !name.isEmpty else { return }
|
||||
self?.beginCreateFolder(name: name)
|
||||
})
|
||||
present(alert, animated: true)
|
||||
}
|
||||
|
||||
private func beginCreateFolder(name: String) {
|
||||
guard let authentication else {
|
||||
presentAuthenticationFailure(.unavailable)
|
||||
return
|
||||
}
|
||||
createTask?.cancel()
|
||||
let parent = path ?? ""
|
||||
createTask = Task { [weak self] in
|
||||
let result = await Task.detached(priority: .userInitiated) {
|
||||
do {
|
||||
try authentication.createDirectory(parent: parent, name: name)
|
||||
return Result<Void, AuthenticationFailure>.success(())
|
||||
} catch let error as MobileAuthenticationFfiError {
|
||||
return .failure(AuthenticationFailure(error))
|
||||
} catch {
|
||||
return .failure(.unexpected)
|
||||
}
|
||||
}.value
|
||||
guard !Task.isCancelled, let self else { return }
|
||||
switch result {
|
||||
case .success:
|
||||
NotificationCenter.default.post(name: .ironStorageLocalStoreDidChange, object: nil)
|
||||
reload()
|
||||
case let .failure(failure):
|
||||
presentAuthenticationFailure(failure)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func beginCreate(name: String, passphrase: String?) {
|
||||
guard let authentication else {
|
||||
presentAuthenticationFailure(.unavailable)
|
||||
@@ -4345,12 +4502,21 @@ private final class PasswordDirectoryViewController: UITableViewController, Mobi
|
||||
case let .success(page):
|
||||
directoryPage = page
|
||||
title = page.title
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||
let add = UIBarButtonItem(
|
||||
barButtonSystemItem: .add,
|
||||
target: self,
|
||||
action: #selector(createRequested)
|
||||
target: nil,
|
||||
action: nil
|
||||
)
|
||||
navigationItem.rightBarButtonItem?.accessibilityLabel = "Create password entry"
|
||||
add.menu = UIMenu(children: [
|
||||
UIAction(title: "New Password", image: UIImage(systemName: "key")) {
|
||||
[weak self] _ in self?.createRequested()
|
||||
},
|
||||
UIAction(title: "New Folder", image: UIImage(systemName: "folder.badge.plus")) {
|
||||
[weak self] _ in self?.createFolderRequested()
|
||||
},
|
||||
])
|
||||
add.accessibilityLabel = "Create password or folder"
|
||||
navigationItem.rightBarButtonItem = add
|
||||
contentUnavailableConfiguration = nil
|
||||
tableView.reloadData()
|
||||
if page.rows.isEmpty {
|
||||
@@ -6058,8 +6224,315 @@ private final class TokenUpdateViewController: UITableViewController {
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class SetupChoiceViewController: UITableViewController {
|
||||
init() {
|
||||
super.init(style: .insetGrouped)
|
||||
title = "Set Up IronStorage"
|
||||
navigationItem.largeTitleDisplayMode = .never
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) is not supported")
|
||||
}
|
||||
|
||||
override func numberOfSections(in tableView: UITableView) -> Int { 2 }
|
||||
|
||||
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
section == 0 ? 2 : 1
|
||||
}
|
||||
|
||||
override func tableView(
|
||||
_ tableView: UITableView,
|
||||
titleForHeaderInSection section: Int
|
||||
) -> String? {
|
||||
section == 0 ? "Local Store" : "Existing Git Store"
|
||||
}
|
||||
|
||||
override func tableView(
|
||||
_ tableView: UITableView,
|
||||
titleForFooterInSection section: Int
|
||||
) -> String? {
|
||||
section == 0
|
||||
? "Creates a local Git repository. Add an HTTPS remote later when you want to sync."
|
||||
: "Clone an existing password-store repository over HTTPS."
|
||||
}
|
||||
|
||||
override func tableView(
|
||||
_ tableView: UITableView,
|
||||
cellForRowAt indexPath: IndexPath
|
||||
) -> UITableViewCell {
|
||||
let cell = UITableViewCell(style: .subtitle, reuseIdentifier: nil)
|
||||
var content = cell.defaultContentConfiguration()
|
||||
switch (indexPath.section, indexPath.row) {
|
||||
case (0, 0):
|
||||
content.image = UIImage(systemName: "key.badge.plus")
|
||||
content.text = "Generate Local GPG Key"
|
||||
content.secondaryText = "Create a protected key and local Git history"
|
||||
case (0, 1):
|
||||
content.image = UIImage(systemName: "qrcode.viewfinder")
|
||||
content.text = "Import Local GPG Key"
|
||||
content.secondaryText = "Scan a private IronStorage key transfer"
|
||||
default:
|
||||
content.image = UIImage(systemName: "network")
|
||||
content.text = "Connect Existing Store"
|
||||
content.secondaryText = "Clone an HTTPS Git repository"
|
||||
}
|
||||
content.secondaryTextProperties.numberOfLines = 0
|
||||
cell.contentConfiguration = content
|
||||
cell.accessoryType = .disclosureIndicator
|
||||
return cell
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
switch (indexPath.section, indexPath.row) {
|
||||
case (0, 0):
|
||||
navigationController?.pushViewController(LocalSetupViewController(), animated: true)
|
||||
case (0, 1):
|
||||
requestLocalKeyScanner()
|
||||
default:
|
||||
navigationController?.pushViewController(OnboardingViewController(), animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
private func requestLocalKeyScanner() {
|
||||
guard DataScannerViewController.isSupported else {
|
||||
presentMessage("This device does not support live QR scanning.")
|
||||
return
|
||||
}
|
||||
switch AVCaptureDevice.authorizationStatus(for: .video) {
|
||||
case .authorized:
|
||||
showLocalKeyScanner()
|
||||
case .notDetermined:
|
||||
AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in
|
||||
Task { @MainActor in
|
||||
if granted {
|
||||
self?.showLocalKeyScanner()
|
||||
} else {
|
||||
self?.presentMessage("Allow camera access in Settings to import a GPG key.")
|
||||
}
|
||||
}
|
||||
}
|
||||
default:
|
||||
presentMessage("Allow camera access in Settings to import a GPG key.")
|
||||
}
|
||||
}
|
||||
|
||||
private func showLocalKeyScanner() {
|
||||
guard DataScannerViewController.isAvailable else {
|
||||
presentMessage("Close other camera apps and try again.")
|
||||
return
|
||||
}
|
||||
navigationController?.pushViewController(
|
||||
KeyImportScannerViewController(localImporter: mobileLocalKeyTransferImporter()),
|
||||
animated: true
|
||||
)
|
||||
}
|
||||
|
||||
private func presentMessage(_ detail: String) {
|
||||
let alert = UIAlertController(
|
||||
title: "GPG Key Import Is Unavailable",
|
||||
message: detail,
|
||||
preferredStyle: .alert
|
||||
)
|
||||
alert.addAction(UIAlertAction(title: "OK", style: .default))
|
||||
present(alert, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class LocalSetupViewController: UITableViewController {
|
||||
private let keyNameField = UITextField()
|
||||
private let passphraseField = UITextField()
|
||||
private let confirmationField = UITextField()
|
||||
private var task: Task<Void, Never>?
|
||||
private var operation: MobileLocalOnboardingOperation?
|
||||
private var isWorking = false
|
||||
|
||||
init() {
|
||||
super.init(style: .insetGrouped)
|
||||
title = "Create Local Store"
|
||||
navigationItem.largeTitleDisplayMode = .never
|
||||
configureFields()
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) is not supported")
|
||||
}
|
||||
|
||||
deinit {
|
||||
operation?.cancel()
|
||||
task?.cancel()
|
||||
}
|
||||
|
||||
override func numberOfSections(in tableView: UITableView) -> Int { 2 }
|
||||
|
||||
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
section == 0 ? 3 : 1
|
||||
}
|
||||
|
||||
override func tableView(
|
||||
_ tableView: UITableView,
|
||||
titleForHeaderInSection section: Int
|
||||
) -> String? {
|
||||
section == 0 ? "Protected GPG Key" : nil
|
||||
}
|
||||
|
||||
override func tableView(
|
||||
_ tableView: UITableView,
|
||||
titleForFooterInSection section: Int
|
||||
) -> String? {
|
||||
section == 0
|
||||
? "The passphrase protects the generated private key. Keep it somewhere safe; IronStorage cannot recover it."
|
||||
: "Creates a pass-compatible password store with local Git versioning and no network connection."
|
||||
}
|
||||
|
||||
override func tableView(
|
||||
_ tableView: UITableView,
|
||||
cellForRowAt indexPath: IndexPath
|
||||
) -> UITableViewCell {
|
||||
let cell = UITableViewCell(style: .default, reuseIdentifier: nil)
|
||||
if indexPath.section == 1 {
|
||||
cell.textLabel?.text = isWorking ? "Creating…" : "Create Local Store"
|
||||
cell.textLabel?.textAlignment = .center
|
||||
cell.textLabel?.textColor = isWorking ? .secondaryLabel : view.tintColor
|
||||
cell.isUserInteractionEnabled = !isWorking
|
||||
return cell
|
||||
}
|
||||
let field = [keyNameField, passphraseField, confirmationField][indexPath.row]
|
||||
field.translatesAutoresizingMaskIntoConstraints = false
|
||||
field.isEnabled = !isWorking
|
||||
cell.contentView.addSubview(field)
|
||||
NSLayoutConstraint.activate([
|
||||
field.leadingAnchor.constraint(equalTo: cell.contentView.layoutMarginsGuide.leadingAnchor),
|
||||
field.trailingAnchor.constraint(equalTo: cell.contentView.layoutMarginsGuide.trailingAnchor),
|
||||
field.topAnchor.constraint(equalTo: cell.contentView.topAnchor, constant: 10),
|
||||
field.bottomAnchor.constraint(equalTo: cell.contentView.bottomAnchor, constant: -10),
|
||||
])
|
||||
return cell
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
guard indexPath.section == 1, !isWorking else { return }
|
||||
create(replaceExistingKey: false)
|
||||
}
|
||||
|
||||
private func configureFields() {
|
||||
for field in [keyNameField, passphraseField, confirmationField] {
|
||||
field.borderStyle = .none
|
||||
field.clearButtonMode = .whileEditing
|
||||
field.autocorrectionType = .no
|
||||
field.adjustsFontForContentSizeCategory = true
|
||||
field.font = .preferredFont(forTextStyle: .body)
|
||||
}
|
||||
keyNameField.placeholder = "GPG key name"
|
||||
keyNameField.text = "IronStorage Local Key"
|
||||
keyNameField.textContentType = .name
|
||||
keyNameField.autocapitalizationType = .words
|
||||
passphraseField.placeholder = "GPG key passphrase"
|
||||
confirmationField.placeholder = "Confirm passphrase"
|
||||
for field in [passphraseField, confirmationField] {
|
||||
field.isSecureTextEntry = true
|
||||
field.textContentType = .newPassword
|
||||
field.autocapitalizationType = .none
|
||||
}
|
||||
}
|
||||
|
||||
private func create(replaceExistingKey: Bool) {
|
||||
let keyName = keyNameField.text ?? ""
|
||||
let passphrase = passphraseField.text ?? ""
|
||||
guard !keyName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
|
||||
!passphrase.isEmpty,
|
||||
passphrase == confirmationField.text
|
||||
else {
|
||||
presentFailure(
|
||||
title: "Check GPG Key Details",
|
||||
detail: "Enter a key name and matching non-empty passphrases."
|
||||
)
|
||||
return
|
||||
}
|
||||
let operation = mobileLocalOnboardingOperation()
|
||||
self.operation = operation
|
||||
isWorking = true
|
||||
navigationItem.prompt = "Generating a protected GPG key and local Git repository."
|
||||
tableView.reloadData()
|
||||
task = Task { [weak self] in
|
||||
let result = await Task.detached(priority: .userInitiated) {
|
||||
do {
|
||||
return Result<MobileOnboardingOutcome, OnboardingFailure>.success(
|
||||
try operation.generate(
|
||||
userId: keyName,
|
||||
passphrase: passphrase,
|
||||
replaceExistingKey: replaceExistingKey
|
||||
)
|
||||
)
|
||||
} catch let error as MobileOnboardingFfiError {
|
||||
return .failure(OnboardingFailure(error))
|
||||
} catch {
|
||||
return .failure(.unexpected)
|
||||
}
|
||||
}.value
|
||||
self?.finish(result, keyName: keyName, passphrase: passphrase)
|
||||
}
|
||||
}
|
||||
|
||||
private func finish(
|
||||
_ result: Result<MobileOnboardingOutcome, OnboardingFailure>,
|
||||
keyName: String,
|
||||
passphrase: String
|
||||
) {
|
||||
operation = nil
|
||||
task = nil
|
||||
isWorking = false
|
||||
navigationItem.prompt = nil
|
||||
tableView.reloadData()
|
||||
switch result {
|
||||
case let .success(outcome):
|
||||
passphraseField.text = nil
|
||||
confirmationField.text = nil
|
||||
let alert = UIAlertController(
|
||||
title: outcome.title,
|
||||
message: outcome.detail,
|
||||
preferredStyle: .alert
|
||||
)
|
||||
alert.addAction(UIAlertAction(title: "Done", style: .default) { _ in
|
||||
NotificationCenter.default.post(name: .ironStorageKeyMaterialDidChange, object: nil)
|
||||
})
|
||||
present(alert, animated: true)
|
||||
case let .failure(failure) where failure.kind == .existingKey:
|
||||
let alert = UIAlertController(
|
||||
title: failure.title,
|
||||
message: failure.detail,
|
||||
preferredStyle: .alert
|
||||
)
|
||||
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
|
||||
alert.addAction(UIAlertAction(title: "Replace Key", style: .destructive) {
|
||||
[weak self] _ in
|
||||
self?.keyNameField.text = keyName
|
||||
self?.passphraseField.text = passphrase
|
||||
self?.confirmationField.text = passphrase
|
||||
self?.create(replaceExistingKey: true)
|
||||
})
|
||||
present(alert, animated: true)
|
||||
case let .failure(failure):
|
||||
presentFailure(title: failure.title, detail: failure.detail)
|
||||
}
|
||||
}
|
||||
|
||||
private func presentFailure(title: String, detail: String) {
|
||||
let alert = UIAlertController(title: title, message: detail, preferredStyle: .alert)
|
||||
alert.addAction(UIAlertAction(title: "OK", style: .default))
|
||||
present(alert, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class OnboardingViewController: UITableViewController {
|
||||
private let attachLocalStore: Bool
|
||||
private let serverField = UITextField()
|
||||
private let accountField = UITextField()
|
||||
private let repositoryField = UITextField()
|
||||
@@ -6072,9 +6545,10 @@ private final class OnboardingViewController: UITableViewController {
|
||||
private var generation = 0
|
||||
private var isWorking = false
|
||||
|
||||
init() {
|
||||
init(attachLocalStore: Bool = false) {
|
||||
self.attachLocalStore = attachLocalStore
|
||||
super.init(style: .insetGrouped)
|
||||
title = "Connect Store"
|
||||
title = attachLocalStore ? "Add Git Remote" : "Connect Store"
|
||||
navigationItem.largeTitleDisplayMode = .never
|
||||
configureFields()
|
||||
}
|
||||
@@ -6096,7 +6570,7 @@ private final class OnboardingViewController: UITableViewController {
|
||||
}
|
||||
|
||||
override func numberOfSections(in tableView: UITableView) -> Int {
|
||||
branches.isEmpty ? 2 : 3
|
||||
attachLocalStore ? 2 : (branches.isEmpty ? 2 : 3)
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
@@ -6113,7 +6587,7 @@ private final class OnboardingViewController: UITableViewController {
|
||||
) -> String? {
|
||||
switch section {
|
||||
case 0: "HTTPS Repository"
|
||||
case 1: branches.isEmpty ? nil : "Remote"
|
||||
case 1: attachLocalStore || branches.isEmpty ? nil : "Remote"
|
||||
default: "Local Clone"
|
||||
}
|
||||
}
|
||||
@@ -6133,7 +6607,10 @@ private final class OnboardingViewController: UITableViewController {
|
||||
return fieldCell(indexPath.row)
|
||||
}
|
||||
let cell = UITableViewCell(style: .value1, reuseIdentifier: nil)
|
||||
if branches.isEmpty {
|
||||
if attachLocalStore {
|
||||
cell.textLabel?.text = "Connect Local Store"
|
||||
cell.textLabel?.textColor = view.tintColor
|
||||
} else if branches.isEmpty {
|
||||
cell.textLabel?.text = "Discover Branches"
|
||||
cell.textLabel?.textColor = view.tintColor
|
||||
cell.accessoryType = .disclosureIndicator
|
||||
@@ -6155,7 +6632,9 @@ private final class OnboardingViewController: UITableViewController {
|
||||
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
guard !isWorking else { return }
|
||||
if branches.isEmpty, indexPath.section == 1 {
|
||||
if attachLocalStore, indexPath.section == 1 {
|
||||
connectLocalStore()
|
||||
} else if branches.isEmpty, indexPath.section == 1 {
|
||||
discoverBranches()
|
||||
} else if indexPath.section == 1 {
|
||||
chooseBranch(from: tableView.cellForRow(at: indexPath))
|
||||
@@ -6221,6 +6700,17 @@ private final class OnboardingViewController: UITableViewController {
|
||||
}
|
||||
}
|
||||
|
||||
private func connectLocalStore() {
|
||||
do {
|
||||
let operation = try makeOperation()
|
||||
begin(operation, title: "Connecting Git Remote") { operation in
|
||||
.completed(try operation.connectLocalStore())
|
||||
}
|
||||
} catch {
|
||||
present(error)
|
||||
}
|
||||
}
|
||||
|
||||
private func runSetup(useExisting: Bool) {
|
||||
guard branches.indices.contains(selectedBranch) else { return }
|
||||
do {
|
||||
@@ -6290,7 +6780,14 @@ private final class OnboardingViewController: UITableViewController {
|
||||
tokenField.text = nil
|
||||
let alert = UIAlertController(title: outcome.title, message: outcome.detail, preferredStyle: .alert)
|
||||
alert.addAction(UIAlertAction(title: "Done", style: .default) { [weak self] _ in
|
||||
self?.navigationController?.popViewController(animated: true)
|
||||
if self?.attachLocalStore == true {
|
||||
NotificationCenter.default.post(
|
||||
name: .ironStorageConfigurationDidChange,
|
||||
object: nil
|
||||
)
|
||||
} else {
|
||||
self?.navigationController?.popViewController(animated: true)
|
||||
}
|
||||
})
|
||||
present(alert, animated: true)
|
||||
case let .failure(failure):
|
||||
|
||||
@@ -9,7 +9,7 @@ actually exercised.
|
||||
|
||||
| Area | Implemented boundary | Automated evidence | Manual release evidence |
|
||||
| --- | --- | --- | --- |
|
||||
| Home tab, remote activity, pull-to-refresh | UIKit renders Rust `MobileHomePage` and typed Commit/Fetch/Pull/Push actions | `mobile_home` unit tests, `git_embedded`, and `crates/apple` bridge tests | Exercise refresh and all four actions against a disposable HTTPS remote, including expired application-token recovery |
|
||||
| Home tab, local history, and optional remote activity | UIKit renders Rust `MobileHomePage` and typed Commit/Fetch/Pull/Push actions | `mobile_home` unit tests, `git_embedded`, and `crates/apple` bridge tests | Verify a clean local store first, then attach a disposable HTTPS remote and exercise refresh and all four actions, including expired application-token recovery |
|
||||
| Passwords tab, navigation, view, and edit | Rust supplies typed paths, fields, drafts, revisions, and mutations; UIKit presents them | `mobile_passwords`, `entry_documents`, `mobile_mutation`, and `mobile_authentication` tests | Open nested entries, edit/save/cancel, and confirm stale-edit recovery |
|
||||
| Swipe move/copy/delete | Rust plans destinations, collisions, dirty-editor handling, and commits; UIKit also exposes accessibility actions | `plans_destinations_collisions_hidden_paths_and_stale_revisions` and authentication serialization tests | Exercise swipe and VoiceOver alternatives; confirm destructive prompts |
|
||||
| TOTP tab and sharing | Rust discovers, caches, parses, selects, and generates OTP data | `mobile_totp`, `otp`, and `mobile_watch` integration tests | Discover entries, read a code, change Watch selection, and verify countdown |
|
||||
@@ -46,8 +46,7 @@ Git, non-TOTP Watch features, and production AutoFill behavior.
|
||||
- Face ID and camera access have purpose strings in `project.yml`. No custom
|
||||
entitlement is currently required: default signing supplies each target's
|
||||
application identifier and Keychain group, WatchConnectivity needs no added
|
||||
capability, and no App Group is used. Alternative-distribution entitlements
|
||||
belong to the final distribution issue.
|
||||
capability, and no App Group is used.
|
||||
|
||||
`cargo test -p ironstorage --test apple_mobile_audit` enforces the source and privacy-manifest
|
||||
parts of this review so those boundaries cannot silently regress.
|
||||
@@ -83,7 +82,8 @@ the tester is ready to enter the passphrase again.
|
||||
## Manual evidence still required
|
||||
|
||||
Record the device model/OS, build commit, and pass/fail result for each manual
|
||||
row above. Physical paired-Watch deployment and WatchConnectivity testing are
|
||||
currently skipped by project direction, so issues closed without that evidence
|
||||
must carry the `untested` label. Do not describe a simulator build or an opened
|
||||
Xcode project as device validation.
|
||||
row above. Simulator validation is required during implementation. The final
|
||||
distribution issue remains open until the approved App Store build is installed
|
||||
and verified on the paired physical iPhone and Apple Watch. Never overwrite that
|
||||
installation with a development-signed build, and do not describe a simulator
|
||||
build or an opened Xcode project as physical-device validation.
|
||||
|
||||
@@ -17,9 +17,11 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
<key>WKApplication</key>
|
||||
<true/>
|
||||
<key>WKCompanionAppBundleIdentifier</key>
|
||||
|
||||
@@ -4,6 +4,10 @@ options:
|
||||
settings:
|
||||
ENABLE_USER_SCRIPT_SANDBOXING: NO
|
||||
SWIFT_VERSION: "5.0"
|
||||
CODE_SIGN_STYLE: Automatic
|
||||
DEVELOPMENT_TEAM: MU22FMRGK8
|
||||
MARKETING_VERSION: "0.1.0"
|
||||
CURRENT_PROJECT_VERSION: "1"
|
||||
targets:
|
||||
IronStorage:
|
||||
type: application
|
||||
@@ -11,8 +15,6 @@ targets:
|
||||
deploymentTarget: "17.0"
|
||||
settings:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: de.rfc1437.ironstorage
|
||||
MARKETING_VERSION: "0.1.0"
|
||||
CURRENT_PROJECT_VERSION: "1"
|
||||
TARGETED_DEVICE_FAMILY: "1"
|
||||
SWIFT_OBJC_BRIDGING_HEADER: IronStorage-Bridging-Header.h
|
||||
LIBRARY_SEARCH_PATHS: "$(inherited) $(DERIVED_FILE_DIR)/rust"
|
||||
@@ -21,6 +23,8 @@ targets:
|
||||
path: Info.plist
|
||||
properties:
|
||||
CFBundleDisplayName: IronStorage
|
||||
CFBundleShortVersionString: $(MARKETING_VERSION)
|
||||
CFBundleVersion: $(CURRENT_PROJECT_VERSION)
|
||||
ITSAppUsesNonExemptEncryption: false
|
||||
NSFaceIDUsageDescription: Unlock your GPG key for protected password operations.
|
||||
NSCameraUsageDescription: Scan GPG key transfer QR codes that you choose to import.
|
||||
@@ -29,10 +33,14 @@ targets:
|
||||
- UIInterfaceOrientationPortrait
|
||||
sources:
|
||||
- Assets.xcassets
|
||||
- Resources/App/License.txt
|
||||
- Resources/App/PrivacyInfo.xcprivacy
|
||||
- Resources/App/ThirdPartyLicenses.txt
|
||||
- Sources/App
|
||||
- Generated/ironstorage_apple.swift
|
||||
dependencies:
|
||||
- target: IronStorageAutoFill
|
||||
embed: true
|
||||
- target: IronStorageWatch
|
||||
embed: true
|
||||
preBuildScripts:
|
||||
@@ -57,6 +65,9 @@ targets:
|
||||
path: AutoFill-Info.plist
|
||||
properties:
|
||||
CFBundleDisplayName: IronStorage AutoFill
|
||||
CFBundleShortVersionString: $(MARKETING_VERSION)
|
||||
CFBundleVersion: $(CURRENT_PROJECT_VERSION)
|
||||
ITSAppUsesNonExemptEncryption: false
|
||||
NSExtension:
|
||||
NSExtensionPointIdentifier: com.apple.authentication-services-credential-provider-ui
|
||||
NSExtensionPrincipalClass: $(PRODUCT_MODULE_NAME).CredentialProviderViewController
|
||||
@@ -87,6 +98,9 @@ targets:
|
||||
path: Watch-Info.plist
|
||||
properties:
|
||||
CFBundleDisplayName: IronStorage
|
||||
CFBundleShortVersionString: $(MARKETING_VERSION)
|
||||
CFBundleVersion: $(CURRENT_PROJECT_VERSION)
|
||||
ITSAppUsesNonExemptEncryption: false
|
||||
WKApplication: true
|
||||
WKCompanionAppBundleIdentifier: de.rfc1437.ironstorage
|
||||
sources:
|
||||
|
||||
@@ -4,6 +4,8 @@ description = "Pass-compatible command-line frontend for IronStorage"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
publish = false
|
||||
|
||||
[[bin]]
|
||||
|
||||
@@ -4,6 +4,8 @@ description = "Iced desktop frontend for IronStorage"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
publish = false
|
||||
|
||||
[package.metadata.packager]
|
||||
|
||||
@@ -4,6 +4,8 @@ description = "Ratatui frontend for IronStorage"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
publish = false
|
||||
|
||||
[[bin]]
|
||||
|
||||
@@ -4,6 +4,8 @@ description = "UniFFI boundary between IronStorage and its Apple frontends"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
|
||||
@@ -270,6 +270,7 @@ pub struct MobileHomeNotice {
|
||||
|
||||
#[derive(Clone, uniffi::Record)]
|
||||
pub struct MobileHomePage {
|
||||
pub remote_configured: bool,
|
||||
pub freshness: MobileHomeFreshness,
|
||||
pub refreshed_at: Option<i64>,
|
||||
pub summaries: Vec<MobileHomeSummaryRow>,
|
||||
@@ -283,6 +284,7 @@ pub struct MobileHomePage {
|
||||
impl From<mobile_home::MobileHomePage> for MobileHomePage {
|
||||
fn from(page: mobile_home::MobileHomePage) -> Self {
|
||||
Self {
|
||||
remote_configured: page.remote_configured(),
|
||||
freshness: page.freshness().into(),
|
||||
refreshed_at: page.refreshed_at(),
|
||||
summaries: page
|
||||
@@ -684,6 +686,7 @@ impl From<StorageWatchPreferenceState> for MobileWatchPreferenceState {
|
||||
|
||||
#[derive(Clone, uniffi::Record)]
|
||||
pub struct MobilePreferences {
|
||||
pub sync_configured: bool,
|
||||
pub repository_title: String,
|
||||
pub repository_url: String,
|
||||
pub server_title: String,
|
||||
@@ -702,6 +705,7 @@ pub struct MobilePreferences {
|
||||
impl From<StorageMobilePreferences> for MobilePreferences {
|
||||
fn from(preferences: StorageMobilePreferences) -> Self {
|
||||
Self {
|
||||
sync_configured: preferences.sync_configured(),
|
||||
repository_title: preferences.repository_title().to_owned(),
|
||||
repository_url: preferences.repository_url().to_owned(),
|
||||
server_title: preferences.server_title().to_owned(),
|
||||
@@ -1351,13 +1355,22 @@ pub struct MobileKeyTransferOutcome {
|
||||
|
||||
#[derive(Debug, uniffi::Error)]
|
||||
pub enum MobileKeyTransferFfiError {
|
||||
Failed { message: String },
|
||||
Failed {
|
||||
kind: MobileKeyTransferErrorKind,
|
||||
message: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
|
||||
pub enum MobileKeyTransferErrorKind {
|
||||
ExistingKey,
|
||||
Failed,
|
||||
}
|
||||
|
||||
impl fmt::Display for MobileKeyTransferFfiError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Failed { message } => formatter.write_str(message),
|
||||
Self::Failed { message, .. } => formatter.write_str(message),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1366,7 +1379,17 @@ impl Error for MobileKeyTransferFfiError {}
|
||||
|
||||
impl From<StorageKeyTransferError> for MobileKeyTransferFfiError {
|
||||
fn from(error: StorageKeyTransferError) -> Self {
|
||||
let kind = match &error {
|
||||
StorageKeyTransferError::ExistingKey => MobileKeyTransferErrorKind::ExistingKey,
|
||||
StorageKeyTransferError::LocalSetup(error)
|
||||
if error.kind() == StorageOnboardingErrorKind::ExistingKey =>
|
||||
{
|
||||
MobileKeyTransferErrorKind::ExistingKey
|
||||
}
|
||||
_ => MobileKeyTransferErrorKind::Failed,
|
||||
};
|
||||
Self::Failed {
|
||||
kind,
|
||||
message: error.to_string(),
|
||||
}
|
||||
}
|
||||
@@ -1440,6 +1463,7 @@ impl MobileKeyTransferImport {
|
||||
self.importer
|
||||
.lock()
|
||||
.map_err(|_| MobileKeyTransferFfiError::Failed {
|
||||
kind: MobileKeyTransferErrorKind::Failed,
|
||||
message: "the key-transfer session is unavailable".to_owned(),
|
||||
})?
|
||||
.add_frame(ironstorage::repository::SecretBytes::new(
|
||||
@@ -1453,17 +1477,20 @@ impl MobileKeyTransferImport {
|
||||
&self,
|
||||
passphrase: Option<String>,
|
||||
make_default: bool,
|
||||
overwrite: bool,
|
||||
) -> Result<MobileKeyTransferOutcome, MobileKeyTransferFfiError> {
|
||||
let outcome = self
|
||||
.importer
|
||||
.lock()
|
||||
.map_err(|_| MobileKeyTransferFfiError::Failed {
|
||||
kind: MobileKeyTransferErrorKind::Failed,
|
||||
message: "the key-transfer session is unavailable".to_owned(),
|
||||
})?
|
||||
.import(
|
||||
passphrase
|
||||
.map(|value| ironstorage::repository::SecretBytes::new(value.into_bytes())),
|
||||
make_default,
|
||||
overwrite,
|
||||
)?;
|
||||
Ok(MobileKeyTransferOutcome {
|
||||
title: outcome.title().to_owned(),
|
||||
@@ -1830,6 +1857,16 @@ impl MobileAuthentication {
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn create_directory(
|
||||
&self,
|
||||
parent: String,
|
||||
name: String,
|
||||
) -> Result<(), MobileAuthenticationFfiError> {
|
||||
self.authentication
|
||||
.create_directory(&parent, &name)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn entry_editor(
|
||||
&self,
|
||||
editor: u64,
|
||||
@@ -1972,6 +2009,7 @@ pub enum MobileOnboardingErrorKind {
|
||||
Authentication,
|
||||
Repository,
|
||||
ExistingClone,
|
||||
ExistingKey,
|
||||
Interrupted,
|
||||
SecureStorage,
|
||||
Configuration,
|
||||
@@ -1986,6 +2024,7 @@ impl From<StorageOnboardingErrorKind> for MobileOnboardingErrorKind {
|
||||
StorageOnboardingErrorKind::Authentication => Self::Authentication,
|
||||
StorageOnboardingErrorKind::Repository => Self::Repository,
|
||||
StorageOnboardingErrorKind::ExistingClone => Self::ExistingClone,
|
||||
StorageOnboardingErrorKind::ExistingKey => Self::ExistingKey,
|
||||
StorageOnboardingErrorKind::Interrupted => Self::Interrupted,
|
||||
StorageOnboardingErrorKind::SecureStorage => Self::SecureStorage,
|
||||
StorageOnboardingErrorKind::Configuration => Self::Configuration,
|
||||
@@ -2060,6 +2099,43 @@ impl MobileOnboardingOperation {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn connect_local_store(&self) -> Result<MobileOnboardingOutcome, MobileOnboardingFfiError> {
|
||||
let outcome = self.operation.connect_local_store(&self.request)?;
|
||||
Ok(MobileOnboardingOutcome {
|
||||
title: outcome.title().to_owned(),
|
||||
detail: outcome.detail().to_owned(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn cancel(&self) {
|
||||
self.operation.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(uniffi::Object)]
|
||||
pub struct MobileLocalOnboardingOperation {
|
||||
operation: mobile_onboarding::MobileOnboardingOperation,
|
||||
}
|
||||
|
||||
#[uniffi::export]
|
||||
impl MobileLocalOnboardingOperation {
|
||||
pub fn generate(
|
||||
&self,
|
||||
user_id: String,
|
||||
passphrase: String,
|
||||
replace_existing_key: bool,
|
||||
) -> Result<MobileOnboardingOutcome, MobileOnboardingFfiError> {
|
||||
let outcome = self.operation.setup_local_generated(
|
||||
&user_id,
|
||||
ironstorage::repository::SecretBytes::new(passphrase.into_bytes()),
|
||||
replace_existing_key,
|
||||
)?;
|
||||
Ok(MobileOnboardingOutcome {
|
||||
title: outcome.title().to_owned(),
|
||||
detail: outcome.detail().to_owned(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn cancel(&self) {
|
||||
self.operation.cancel();
|
||||
}
|
||||
@@ -2177,6 +2253,15 @@ pub fn mobile_key_transfer() -> Result<Arc<MobileKeyTransfer>, MobileKeyTransfer
|
||||
}))
|
||||
}
|
||||
|
||||
#[uniffi::export]
|
||||
pub fn mobile_local_key_transfer_importer() -> Arc<MobileKeyTransferImport> {
|
||||
Arc::new(MobileKeyTransferImport {
|
||||
importer: Mutex::new(
|
||||
ironstorage::mobile_key_transfer::MobileKeyTransferImport::local_setup(),
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
#[uniffi::export]
|
||||
pub fn mobile_onboarding_operation(
|
||||
server_url: String,
|
||||
@@ -2195,6 +2280,13 @@ pub fn mobile_onboarding_operation(
|
||||
}))
|
||||
}
|
||||
|
||||
#[uniffi::export]
|
||||
pub fn mobile_local_onboarding_operation() -> Arc<MobileLocalOnboardingOperation> {
|
||||
Arc::new(MobileLocalOnboardingOperation {
|
||||
operation: mobile_onboarding::MobileOnboardingOperation::default(),
|
||||
})
|
||||
}
|
||||
|
||||
#[uniffi::export]
|
||||
pub fn replace_configured_mobile_application_token(
|
||||
account: String,
|
||||
|
||||
@@ -4,6 +4,8 @@ description = "Pure Rust password-store repository management"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
publish = false
|
||||
|
||||
[features]
|
||||
|
||||
@@ -403,6 +403,58 @@ impl Config {
|
||||
validate_config(self.source.clone(), document, raw)?.persist()
|
||||
}
|
||||
|
||||
pub(crate) fn update_mobile_remote(
|
||||
&self,
|
||||
remote: Option<&GitRemote>,
|
||||
) -> Result<(), ConfigError> {
|
||||
let mut document = self.current_document()?;
|
||||
let root = document
|
||||
.as_table_mut()
|
||||
.ok_or_else(|| ConfigError::Malformed {
|
||||
path: self.source.clone(),
|
||||
})?;
|
||||
let git = root
|
||||
.entry("git")
|
||||
.or_insert_with(|| toml::Value::Table(toml::Table::new()))
|
||||
.as_table_mut()
|
||||
.ok_or(ConfigError::InvalidField { field: "git" })?;
|
||||
match remote {
|
||||
Some(remote) => {
|
||||
let mut configured = toml::Table::new();
|
||||
configured.insert(
|
||||
"name".to_owned(),
|
||||
toml::Value::String(remote.name().as_str().to_owned()),
|
||||
);
|
||||
configured.insert(
|
||||
"url".to_owned(),
|
||||
toml::Value::String(remote.url().to_string()),
|
||||
);
|
||||
configured.insert(
|
||||
"server_id".to_owned(),
|
||||
toml::Value::String(remote.server_id().as_str().to_owned()),
|
||||
);
|
||||
configured.insert(
|
||||
"application_id".to_owned(),
|
||||
toml::Value::String(remote.application_id().as_str().to_owned()),
|
||||
);
|
||||
git.insert(
|
||||
"remotes".to_owned(),
|
||||
toml::Value::Array(vec![toml::Value::Table(configured)]),
|
||||
);
|
||||
}
|
||||
None => {
|
||||
git.remove("remotes");
|
||||
}
|
||||
}
|
||||
let raw = document
|
||||
.clone()
|
||||
.try_into::<RawConfig>()
|
||||
.map_err(|_| ConfigError::Malformed {
|
||||
path: self.source.clone(),
|
||||
})?;
|
||||
validate_config(self.source.clone(), document, raw)?.persist()
|
||||
}
|
||||
|
||||
fn current_document(&self) -> Result<toml::Value, ConfigError> {
|
||||
Self::load(Some(&self.source)).map(|config| config.document)
|
||||
}
|
||||
@@ -413,6 +465,25 @@ impl Config {
|
||||
key_material: &Path,
|
||||
default_key: &str,
|
||||
remote: &GitRemote,
|
||||
) -> Result<Self, ConfigError> {
|
||||
Self::create_mobile(source, vault, key_material, default_key, Some(remote))
|
||||
}
|
||||
|
||||
pub(crate) fn create_mobile_local(
|
||||
source: PathBuf,
|
||||
vault: &Path,
|
||||
key_material: &Path,
|
||||
default_key: &str,
|
||||
) -> Result<Self, ConfigError> {
|
||||
Self::create_mobile(source, vault, key_material, default_key, None)
|
||||
}
|
||||
|
||||
fn create_mobile(
|
||||
source: PathBuf,
|
||||
vault: &Path,
|
||||
key_material: &Path,
|
||||
default_key: &str,
|
||||
remote: Option<&GitRemote>,
|
||||
) -> Result<Self, ConfigError> {
|
||||
let base = source
|
||||
.parent()
|
||||
@@ -439,29 +510,31 @@ impl Config {
|
||||
"key_material".to_owned(),
|
||||
toml::Value::String(path_text(key_material, "key_material")?),
|
||||
);
|
||||
let mut configured = toml::Table::new();
|
||||
configured.insert(
|
||||
"name".to_owned(),
|
||||
toml::Value::String(remote.name().as_str().to_owned()),
|
||||
);
|
||||
configured.insert(
|
||||
"url".to_owned(),
|
||||
toml::Value::String(remote.url().to_string()),
|
||||
);
|
||||
configured.insert(
|
||||
"server_id".to_owned(),
|
||||
toml::Value::String(remote.server_id().as_str().to_owned()),
|
||||
);
|
||||
configured.insert(
|
||||
"application_id".to_owned(),
|
||||
toml::Value::String(remote.application_id().as_str().to_owned()),
|
||||
);
|
||||
let mut git = toml::Table::new();
|
||||
git.insert(
|
||||
"remotes".to_owned(),
|
||||
toml::Value::Array(vec![toml::Value::Table(configured)]),
|
||||
);
|
||||
root.insert("git".to_owned(), toml::Value::Table(git));
|
||||
if let Some(remote) = remote {
|
||||
let mut configured = toml::Table::new();
|
||||
configured.insert(
|
||||
"name".to_owned(),
|
||||
toml::Value::String(remote.name().as_str().to_owned()),
|
||||
);
|
||||
configured.insert(
|
||||
"url".to_owned(),
|
||||
toml::Value::String(remote.url().to_string()),
|
||||
);
|
||||
configured.insert(
|
||||
"server_id".to_owned(),
|
||||
toml::Value::String(remote.server_id().as_str().to_owned()),
|
||||
);
|
||||
configured.insert(
|
||||
"application_id".to_owned(),
|
||||
toml::Value::String(remote.application_id().as_str().to_owned()),
|
||||
);
|
||||
let mut git = toml::Table::new();
|
||||
git.insert(
|
||||
"remotes".to_owned(),
|
||||
toml::Value::Array(vec![toml::Value::Table(configured)]),
|
||||
);
|
||||
root.insert("git".to_owned(), toml::Value::Table(git));
|
||||
}
|
||||
let document = toml::Value::Table(root);
|
||||
let raw = document
|
||||
.clone()
|
||||
|
||||
@@ -11,16 +11,17 @@ use std::{
|
||||
use cap_std::{ambient_authority, fs::Dir};
|
||||
use pgp::{
|
||||
composed::{
|
||||
ArmorOptions, DecryptionOptions, Deserializable, DetachedSignature, Esk, Message,
|
||||
MessageBuilder, PublicOrSecret, SignedPublicKey, SignedPublicSubKey, SignedSecretKey,
|
||||
SubpacketConfig, TheRing,
|
||||
ArmorOptions, DecryptionOptions, Deserializable, DetachedSignature, EncryptionCaps, Esk,
|
||||
KeyType, Message, MessageBuilder, PublicOrSecret, SecretKeyParamsBuilder, SignedPublicKey,
|
||||
SignedPublicSubKey, SignedSecretKey, SubkeyParamsBuilder, SubpacketConfig, TheRing,
|
||||
},
|
||||
crypto::{hash::HashAlgorithm, sym::SymmetricKeyAlgorithm},
|
||||
crypto::{ecc_curve::ECCCurve, hash::HashAlgorithm, sym::SymmetricKeyAlgorithm},
|
||||
packet::{SignatureType, Subpacket, SubpacketData},
|
||||
ser::Serialize as _,
|
||||
types::{KeyDetails as _, Password, SigningKey, Timestamp, VerifyingKey},
|
||||
};
|
||||
use rand::rngs::OsRng;
|
||||
use zeroize::Zeroize as _;
|
||||
|
||||
use crate::repository::{EncryptedEntry, SecretBytes};
|
||||
|
||||
@@ -62,6 +63,21 @@ pub struct KeyInfo {
|
||||
can_sign: bool,
|
||||
}
|
||||
|
||||
pub struct GeneratedKey {
|
||||
info: KeyInfo,
|
||||
armor: SecretBytes,
|
||||
}
|
||||
|
||||
impl GeneratedKey {
|
||||
pub fn info(&self) -> &KeyInfo {
|
||||
&self.info
|
||||
}
|
||||
|
||||
pub fn into_armor(self) -> SecretBytes {
|
||||
self.armor
|
||||
}
|
||||
}
|
||||
|
||||
impl KeyInfo {
|
||||
pub fn fingerprint(&self) -> &KeyFingerprint {
|
||||
&self.fingerprint
|
||||
@@ -168,6 +184,61 @@ impl KeyStore {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Generate one pass-compatible protected signing certificate with a
|
||||
/// Curve25519 encryption subkey and return transferable private armor.
|
||||
pub fn generate(user_id: &str, passphrase: &SecretBytes) -> Result<GeneratedKey, CryptoError> {
|
||||
let user_id = user_id.trim();
|
||||
if user_id.is_empty()
|
||||
|| user_id.len() > 240
|
||||
|| user_id.chars().any(char::is_control)
|
||||
|| passphrase.expose().is_empty()
|
||||
{
|
||||
return Err(CryptoError::InvalidGeneratedKeyInput);
|
||||
}
|
||||
let mut passphrase = String::from_utf8(passphrase.expose().to_vec())
|
||||
.map_err(|_| CryptoError::InvalidGeneratedKeyInput)?;
|
||||
let generated = (|| {
|
||||
let subkey = SubkeyParamsBuilder::default()
|
||||
.key_type(KeyType::ECDH(ECCCurve::Curve25519Legacy))
|
||||
.can_encrypt(EncryptionCaps::All)
|
||||
.passphrase(Some(passphrase.clone()))
|
||||
.build()
|
||||
.map_err(|_| CryptoError::KeyGenerationFailed)?;
|
||||
let secret = SecretKeyParamsBuilder::default()
|
||||
.key_type(KeyType::Ed25519Legacy)
|
||||
.can_certify(true)
|
||||
.can_sign(true)
|
||||
.primary_user_id(user_id.to_owned())
|
||||
.preferred_symmetric_algorithms(
|
||||
[SymmetricKeyAlgorithm::AES256].into_iter().collect(),
|
||||
)
|
||||
.preferred_hash_algorithms([HashAlgorithm::Sha256].into_iter().collect())
|
||||
.preferred_compression_algorithms(Default::default())
|
||||
.passphrase(Some(passphrase.clone()))
|
||||
.subkey(subkey)
|
||||
.build()
|
||||
.map_err(|_| CryptoError::KeyGenerationFailed)?
|
||||
.generate(OsRng)
|
||||
.map_err(|_| CryptoError::KeyGenerationFailed)?;
|
||||
secret
|
||||
.verify_bindings()
|
||||
.map_err(|_| CryptoError::KeyGenerationFailed)?;
|
||||
let armor = secret
|
||||
.to_armored_bytes(ArmorOptions::default())
|
||||
.map_err(|_| CryptoError::KeyGenerationFailed)?;
|
||||
let material = KeyMaterial {
|
||||
public: secret.to_public_key(),
|
||||
secret: Some(secret),
|
||||
};
|
||||
Ok(GeneratedKey {
|
||||
info: key_info(&material),
|
||||
armor: SecretBytes::new(armor),
|
||||
})
|
||||
})();
|
||||
passphrase.zeroize();
|
||||
generated
|
||||
}
|
||||
|
||||
/// Load a regular exported-key file or a directory tree made only of exported-key files.
|
||||
pub fn load(path: impl AsRef<Path>) -> Result<Self, CryptoError> {
|
||||
let path = path.as_ref();
|
||||
@@ -671,6 +742,8 @@ pub enum CryptoError {
|
||||
NoKeyMaterial,
|
||||
CorruptKeyMaterial,
|
||||
InvalidKeyBindings,
|
||||
InvalidGeneratedKeyInput,
|
||||
KeyGenerationFailed,
|
||||
DuplicateFingerprint {
|
||||
fingerprint: KeyFingerprint,
|
||||
},
|
||||
@@ -722,6 +795,10 @@ impl fmt::Display for CryptoError {
|
||||
Self::NoKeyMaterial => formatter.write_str("no OpenPGP key material was found"),
|
||||
Self::CorruptKeyMaterial => formatter.write_str("OpenPGP key material is malformed"),
|
||||
Self::InvalidKeyBindings => formatter.write_str("OpenPGP key bindings are invalid"),
|
||||
Self::InvalidGeneratedKeyInput => {
|
||||
formatter.write_str("the GPG key name and passphrase are required")
|
||||
}
|
||||
Self::KeyGenerationFailed => formatter.write_str("the GPG key could not be generated"),
|
||||
Self::DuplicateFingerprint { fingerprint } => {
|
||||
write!(
|
||||
formatter,
|
||||
|
||||
@@ -150,6 +150,7 @@ pub enum MobileWatchPreferenceState {
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct MobilePreferences {
|
||||
sync_configured: bool,
|
||||
repository_title: String,
|
||||
repository_url: String,
|
||||
server_title: String,
|
||||
@@ -166,6 +167,10 @@ pub struct MobilePreferences {
|
||||
}
|
||||
|
||||
impl MobilePreferences {
|
||||
pub fn sync_configured(&self) -> bool {
|
||||
self.sync_configured
|
||||
}
|
||||
|
||||
pub fn repository_title(&self) -> &str {
|
||||
&self.repository_title
|
||||
}
|
||||
@@ -562,8 +567,7 @@ impl MobileAuthentication {
|
||||
.git_remotes()
|
||||
.iter()
|
||||
.find(|remote| remote.name().as_str() == "origin")
|
||||
.or_else(|| self.config.git_remotes().first())
|
||||
.ok_or_else(|| config_detail("No HTTPS Git remote is configured."))?;
|
||||
.or_else(|| self.config.git_remotes().first());
|
||||
let handle = self
|
||||
.keys
|
||||
.resolve(self.config.default_key().as_str())
|
||||
@@ -587,20 +591,34 @@ impl MobileAuthentication {
|
||||
let biometric_unlock_enabled = status.biometric_unlock_enabled;
|
||||
let appearance = status.appearance;
|
||||
drop(status);
|
||||
let server_title = remote.url().host_str().unwrap_or("HTTPS server").to_owned();
|
||||
let repository_title = remote
|
||||
.url()
|
||||
.path_segments()
|
||||
.and_then(Iterator::last)
|
||||
.unwrap_or("Password Store")
|
||||
.trim_end_matches(".git")
|
||||
.to_owned();
|
||||
let repository_title = remote.map_or_else(
|
||||
|| "Local Password Store".to_owned(),
|
||||
|remote| {
|
||||
remote
|
||||
.url()
|
||||
.path_segments()
|
||||
.and_then(Iterator::last)
|
||||
.unwrap_or("Password Store")
|
||||
.trim_end_matches(".git")
|
||||
.to_owned()
|
||||
},
|
||||
);
|
||||
Ok(MobilePreferences {
|
||||
sync_configured: remote.is_some(),
|
||||
repository_title,
|
||||
repository_url: remote.url().to_string(),
|
||||
server_title,
|
||||
server_identity: remote.server_id().as_str().to_owned(),
|
||||
application_account: application_account(remote)?,
|
||||
repository_url: remote.map_or_else(
|
||||
|| "Stored and versioned on this iPhone".to_owned(),
|
||||
|remote| remote.url().to_string(),
|
||||
),
|
||||
server_title: remote
|
||||
.and_then(|remote| remote.url().host_str())
|
||||
.unwrap_or("Git Sync Not Configured")
|
||||
.to_owned(),
|
||||
server_identity: remote.map_or_else(
|
||||
|| "Add an HTTPS remote when you want to sync".to_owned(),
|
||||
|remote| remote.server_id().as_str().to_owned(),
|
||||
),
|
||||
application_account: remote.map(application_account).transpose()?.flatten(),
|
||||
default_key_title: key
|
||||
.user_ids()
|
||||
.first()
|
||||
@@ -1073,6 +1091,44 @@ impl MobileAuthentication {
|
||||
self.store_editor(MobileEntryDraft::new(document, true))
|
||||
}
|
||||
|
||||
pub fn create_directory(
|
||||
&self,
|
||||
parent: &str,
|
||||
name: &str,
|
||||
) -> Result<(), MobileAuthenticationError> {
|
||||
self.ensure_repository_idle()?;
|
||||
let parent = DirectoryPath::parse(parent).map_err(entry_error)?;
|
||||
let leaf = DirectoryPath::parse(name).map_err(entry_error)?;
|
||||
if leaf
|
||||
.as_path()
|
||||
.parent()
|
||||
.is_some_and(|parent| !parent.as_os_str().is_empty())
|
||||
{
|
||||
return Err(entry_detail(
|
||||
"Folder Name Is Invalid",
|
||||
"enter a name without a folder separator",
|
||||
));
|
||||
}
|
||||
let directory =
|
||||
DirectoryPath::parse(parent.as_path().join(leaf.as_path())).map_err(entry_error)?;
|
||||
if self
|
||||
.repository
|
||||
.snapshot()
|
||||
.map_err(entry_error)?
|
||||
.directories()
|
||||
.any(|record| record.path() == &directory)
|
||||
{
|
||||
return Err(entry_detail(
|
||||
"Folder Already Exists",
|
||||
"Choose a different folder name.",
|
||||
));
|
||||
}
|
||||
self.repository
|
||||
.ensure_directory(&directory)
|
||||
.map_err(|error| entry_detail("Folder Could Not Be Created", error))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn entry_editor(
|
||||
&self,
|
||||
editor: u64,
|
||||
|
||||
@@ -13,7 +13,8 @@ use crate::{
|
||||
config::{Config, ConfigError, GitRemote},
|
||||
git::{
|
||||
FetchOutcome, GitChangeKind, GitCommitActivity, GitDivergence, GitError,
|
||||
GitOperationControl, GitProgressPhase, GitRepository, PullOutcome, PushOutcome,
|
||||
GitOperationControl, GitProgressPhase, GitRepository, GitSnapshot, PullOutcome,
|
||||
PushOutcome,
|
||||
},
|
||||
mobile_authentication::{
|
||||
MobileAuthentication, MobileRepositoryOperation, MobileRepositoryOperationError,
|
||||
@@ -219,6 +220,7 @@ impl MobileHomeNotice {
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct MobileHomePage {
|
||||
remote_configured: bool,
|
||||
freshness: MobileHomeFreshness,
|
||||
refreshed_at: Option<i64>,
|
||||
summaries: Vec<MobileHomeSummaryRow>,
|
||||
@@ -230,6 +232,10 @@ pub struct MobileHomePage {
|
||||
}
|
||||
|
||||
impl MobileHomePage {
|
||||
pub fn remote_configured(&self) -> bool {
|
||||
self.remote_configured
|
||||
}
|
||||
|
||||
pub fn freshness(&self) -> MobileHomeFreshness {
|
||||
self.freshness
|
||||
}
|
||||
@@ -364,6 +370,9 @@ impl MobileHomeOperation {
|
||||
pub fn refresh_if_stale(&self) -> Result<MobileHomePage, MobileHomeError> {
|
||||
let storage = MobileHomeStorage::load()?;
|
||||
let now = unix_seconds()?;
|
||||
if storage.remote.is_none() {
|
||||
return storage.page(MobileHomeFreshness::Current, Some(now), None);
|
||||
}
|
||||
if !is_stale(storage.config.mobile_home_refreshed_at(), now) {
|
||||
return storage.page(
|
||||
MobileHomeFreshness::Cached,
|
||||
@@ -429,7 +438,7 @@ impl MobileHomeOperation {
|
||||
struct MobileHomeStorage {
|
||||
config: Config,
|
||||
git: GitRepository,
|
||||
remote: GitRemote,
|
||||
remote: Option<GitRemote>,
|
||||
}
|
||||
|
||||
impl MobileHomeStorage {
|
||||
@@ -439,10 +448,7 @@ impl MobileHomeStorage {
|
||||
Repository::open(config.vault()).map_err(MobileHomeError::from_repository)?;
|
||||
let git = GitRepository::open(&repository, config.git_identity().clone())
|
||||
.map_err(MobileHomeError::from_git)?;
|
||||
let remote = config
|
||||
.git_remote(None)
|
||||
.cloned()
|
||||
.ok_or_else(MobileHomeError::missing_remote)?;
|
||||
let remote = config.git_remote(None).cloned();
|
||||
Ok(Self {
|
||||
config,
|
||||
git,
|
||||
@@ -478,9 +484,10 @@ impl MobileHomeStorage {
|
||||
now: i64,
|
||||
control: &GitOperationControl,
|
||||
) -> Result<MobileHomePage, MobileHomeError> {
|
||||
let remote = self.remote()?;
|
||||
let store = self.credentials()?;
|
||||
let fetched = self.git.fetch_with_transport_controlled(
|
||||
&self.remote,
|
||||
remote,
|
||||
&store,
|
||||
&crate::git::EmbeddedFetchTransport,
|
||||
control,
|
||||
@@ -501,9 +508,10 @@ impl MobileHomeStorage {
|
||||
now: i64,
|
||||
control: &GitOperationControl,
|
||||
) -> Result<MobileHomePage, MobileHomeError> {
|
||||
let remote = self.remote()?;
|
||||
let store = self.credentials()?;
|
||||
let pulled = self.git.pull_with_transport_controlled(
|
||||
&self.remote,
|
||||
remote,
|
||||
None,
|
||||
&store,
|
||||
&crate::git::EmbeddedFetchTransport,
|
||||
@@ -526,9 +534,10 @@ impl MobileHomeStorage {
|
||||
now: i64,
|
||||
control: &GitOperationControl,
|
||||
) -> Result<MobileHomePage, MobileHomeError> {
|
||||
let remote = self.remote()?;
|
||||
let store = self.credentials()?;
|
||||
let pushed = self.git.push_with_transport_controlled(
|
||||
&self.remote,
|
||||
remote,
|
||||
None,
|
||||
&store,
|
||||
&crate::git::ReqwestGitTransport,
|
||||
@@ -556,6 +565,12 @@ impl MobileHomeStorage {
|
||||
Ok(store)
|
||||
}
|
||||
|
||||
fn remote(&self) -> Result<&GitRemote, MobileHomeError> {
|
||||
self.remote
|
||||
.as_ref()
|
||||
.ok_or_else(MobileHomeError::missing_remote)
|
||||
}
|
||||
|
||||
fn current_page(
|
||||
&self,
|
||||
now: i64,
|
||||
@@ -585,16 +600,29 @@ impl MobileHomeStorage {
|
||||
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,
|
||||
))
|
||||
if let Some(remote) = self.remote.as_ref() {
|
||||
let divergence = self
|
||||
.git
|
||||
.divergence(remote, ACTIVITY_LIMIT)
|
||||
.map_err(MobileHomeError::from_git)?;
|
||||
Ok(page_from_divergence(
|
||||
&divergence,
|
||||
freshness,
|
||||
refreshed_at,
|
||||
notice,
|
||||
))
|
||||
} else {
|
||||
let snapshot = self
|
||||
.git
|
||||
.snapshot(None, ACTIVITY_LIMIT)
|
||||
.map_err(MobileHomeError::from_git)?;
|
||||
Ok(page_from_local_snapshot(
|
||||
&snapshot,
|
||||
freshness,
|
||||
refreshed_at,
|
||||
notice,
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -931,6 +959,7 @@ fn page_from_divergence(
|
||||
},
|
||||
];
|
||||
MobileHomePage {
|
||||
remote_configured: true,
|
||||
freshness,
|
||||
refreshed_at,
|
||||
summaries,
|
||||
@@ -950,6 +979,89 @@ fn page_from_divergence(
|
||||
}
|
||||
}
|
||||
|
||||
fn page_from_local_snapshot(
|
||||
snapshot: &GitSnapshot,
|
||||
freshness: MobileHomeFreshness,
|
||||
refreshed_at: Option<i64>,
|
||||
notice: Option<MobileHomeNotice>,
|
||||
) -> MobileHomePage {
|
||||
let local_paths = snapshot
|
||||
.status()
|
||||
.staged()
|
||||
.iter()
|
||||
.chain(snapshot.status().unstaged())
|
||||
.map(|change| change.path().to_owned())
|
||||
.collect::<BTreeSet<_>>();
|
||||
let commit_count = snapshot.recent().len();
|
||||
let latest = snapshot
|
||||
.recent()
|
||||
.first()
|
||||
.and_then(|commit| {
|
||||
commit
|
||||
.message()
|
||||
.lines()
|
||||
.find(|line| !line.trim().is_empty())
|
||||
})
|
||||
.map(|line| display_text(line.trim(), 160));
|
||||
MobileHomePage {
|
||||
remote_configured: false,
|
||||
freshness,
|
||||
refreshed_at,
|
||||
summaries: vec![
|
||||
MobileHomeSummaryRow {
|
||||
id: "local-branch".to_owned(),
|
||||
title: snapshot.branch().to_owned(),
|
||||
detail: "Local Git branch · no sync remote configured".to_owned(),
|
||||
system_image: "externaldrive.fill".to_owned(),
|
||||
actions: Vec::new(),
|
||||
},
|
||||
MobileHomeSummaryRow {
|
||||
id: "local-history".to_owned(),
|
||||
title: format!(
|
||||
"{} Local Commit{}",
|
||||
commit_count,
|
||||
if commit_count == 1 { "" } else { "s" }
|
||||
),
|
||||
detail: latest.unwrap_or_else(|| "No local history yet".to_owned()),
|
||||
system_image: "clock.arrow.circlepath".to_owned(),
|
||||
actions: Vec::new(),
|
||||
},
|
||||
working_tree_summary(local_paths.len()),
|
||||
],
|
||||
incoming: Vec::new(),
|
||||
outgoing: Vec::new(),
|
||||
incoming_total: 0,
|
||||
outgoing_total: 0,
|
||||
notice,
|
||||
}
|
||||
}
|
||||
|
||||
fn working_tree_summary(local_count: usize) -> MobileHomeSummaryRow {
|
||||
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 these local password-store changes".to_owned()
|
||||
},
|
||||
system_image: if local_count == 0 {
|
||||
"checkmark.shield".to_owned()
|
||||
} else {
|
||||
"exclamationmark.triangle".to_owned()
|
||||
},
|
||||
actions: if local_count > 0 {
|
||||
vec![mobile_action(MobileHomeActionKind::Commit)]
|
||||
} else {
|
||||
Vec::new()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn mobile_commit(activity: &GitCommitActivity, incoming: bool) -> MobileHomeCommit {
|
||||
let commit = activity.commit();
|
||||
let title = commit
|
||||
|
||||
@@ -181,7 +181,7 @@ impl MobileKeyTransferService {
|
||||
}
|
||||
|
||||
pub struct MobileKeyTransferImport {
|
||||
config: Config,
|
||||
destination: MobileKeyTransferDestination,
|
||||
digest: Option<String>,
|
||||
total: Option<usize>,
|
||||
chunks: BTreeMap<usize, Vec<u8>>,
|
||||
@@ -189,10 +189,26 @@ pub struct MobileKeyTransferImport {
|
||||
key: Option<MobileKeyTransferKey>,
|
||||
}
|
||||
|
||||
enum MobileKeyTransferDestination {
|
||||
Configured(Box<Config>),
|
||||
LocalSetup,
|
||||
}
|
||||
|
||||
impl MobileKeyTransferImport {
|
||||
fn new(config: Config) -> Self {
|
||||
Self {
|
||||
config,
|
||||
destination: MobileKeyTransferDestination::Configured(Box::new(config)),
|
||||
digest: None,
|
||||
total: None,
|
||||
chunks: BTreeMap::new(),
|
||||
complete: None,
|
||||
key: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn local_setup() -> Self {
|
||||
Self {
|
||||
destination: MobileKeyTransferDestination::LocalSetup,
|
||||
digest: None,
|
||||
total: None,
|
||||
chunks: BTreeMap::new(),
|
||||
@@ -280,6 +296,7 @@ impl MobileKeyTransferImport {
|
||||
&mut self,
|
||||
passphrase: Option<SecretBytes>,
|
||||
make_default: bool,
|
||||
overwrite: bool,
|
||||
) -> Result<MobileKeyTransferOutcome, MobileKeyTransferError> {
|
||||
let armor = self
|
||||
.complete
|
||||
@@ -298,20 +315,46 @@ impl MobileKeyTransferImport {
|
||||
return Err(MobileKeyTransferError::PublicDefault);
|
||||
}
|
||||
|
||||
let existing = KeyStore::load(self.config.key_material())?;
|
||||
if matches!(self.destination, MobileKeyTransferDestination::LocalSetup) {
|
||||
if key.kind != MobileKeyTransferKind::Private || !make_default {
|
||||
return Err(MobileKeyTransferError::PublicDefault);
|
||||
}
|
||||
let outcome = crate::mobile_onboarding::MobileOnboardingOperation::default()
|
||||
.setup_local_imported(
|
||||
SecretBytes::new(armor.expose().to_vec()),
|
||||
passphrase,
|
||||
overwrite,
|
||||
)
|
||||
.map_err(MobileKeyTransferError::LocalSetup)?;
|
||||
return Ok(MobileKeyTransferOutcome {
|
||||
title: outcome.title().to_owned(),
|
||||
detail: outcome.detail().to_owned(),
|
||||
});
|
||||
}
|
||||
let MobileKeyTransferDestination::Configured(config) = &self.destination else {
|
||||
unreachable!("local setup returned above")
|
||||
};
|
||||
let existing = KeyStore::load(config.key_material())?;
|
||||
if let Some(found) = existing
|
||||
.infos()
|
||||
.find(|info| info.fingerprint().as_str() == key.fingerprint)
|
||||
&& (found.has_secret() || key.kind == MobileKeyTransferKind::Public)
|
||||
&& !overwrite
|
||||
{
|
||||
return Err(MobileKeyTransferError::DuplicateKey);
|
||||
return Err(MobileKeyTransferError::ExistingKey);
|
||||
}
|
||||
|
||||
persist_key_material(self.config.key_material(), key, armor.expose())?;
|
||||
persist_armored_key(
|
||||
config.key_material(),
|
||||
&key.fingerprint,
|
||||
key.kind == MobileKeyTransferKind::Private,
|
||||
armor.expose(),
|
||||
overwrite,
|
||||
)?;
|
||||
if make_default {
|
||||
let mut settings = self.config.settings();
|
||||
let mut settings = config.settings();
|
||||
settings.set_default_key(key.fingerprint.clone());
|
||||
self.config.with_settings(settings)?.persist()?;
|
||||
config.with_settings(settings)?.persist()?;
|
||||
}
|
||||
let kind = if key.kind == MobileKeyTransferKind::Private {
|
||||
"private"
|
||||
@@ -357,12 +400,13 @@ pub enum MobileKeyTransferError {
|
||||
AlreadyComplete,
|
||||
ChecksumMismatch,
|
||||
MismatchedKeys,
|
||||
DuplicateKey,
|
||||
ExistingKey,
|
||||
PublicDefault,
|
||||
InvalidKeyMaterial,
|
||||
IncorrectPassphrase,
|
||||
QrPayload,
|
||||
Write,
|
||||
LocalSetup(crate::mobile_onboarding::MobileOnboardingError),
|
||||
}
|
||||
|
||||
impl fmt::Display for MobileKeyTransferError {
|
||||
@@ -381,12 +425,15 @@ impl fmt::Display for MobileKeyTransferError {
|
||||
"the reconstructed key transfer did not pass its integrity check"
|
||||
}
|
||||
Self::MismatchedKeys => "the transfer contains multiple or mismatched GPG keys",
|
||||
Self::DuplicateKey => "this GPG key is already installed",
|
||||
Self::ExistingKey => {
|
||||
"this GPG key is already installed; confirm replacement to continue"
|
||||
}
|
||||
Self::PublicDefault => "a public-only key cannot become the default GPG key",
|
||||
Self::InvalidKeyMaterial => "the GPG key material is invalid",
|
||||
Self::IncorrectPassphrase => "the GPG key passphrase is incorrect",
|
||||
Self::QrPayload => "the GPG key could not be represented as QR codes",
|
||||
Self::Write => "the GPG key could not be stored safely",
|
||||
Self::LocalSetup(error) => return error.fmt(formatter),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -499,7 +546,7 @@ fn parse_positive(value: Option<&str>) -> Result<usize, MobileKeyTransferError>
|
||||
.ok_or(MobileKeyTransferError::InvalidFrame)
|
||||
}
|
||||
|
||||
fn validate_transfer_armor(bytes: &[u8]) -> Result<(), MobileKeyTransferError> {
|
||||
pub(crate) fn validate_transfer_armor(bytes: &[u8]) -> Result<(), MobileKeyTransferError> {
|
||||
let text =
|
||||
std::str::from_utf8(bytes).map_err(|_| MobileKeyTransferError::InvalidKeyMaterial)?;
|
||||
let end = if text.starts_with("-----BEGIN PGP PUBLIC KEY BLOCK-----") {
|
||||
@@ -522,18 +569,19 @@ fn validate_transfer_armor(bytes: &[u8]) -> Result<(), MobileKeyTransferError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn persist_key_material(
|
||||
pub(crate) fn persist_armored_key(
|
||||
configured: &Path,
|
||||
key: &MobileKeyTransferKey,
|
||||
fingerprint: &str,
|
||||
secret: bool,
|
||||
armor: &[u8],
|
||||
overwrite: bool,
|
||||
) -> Result<(), MobileKeyTransferError> {
|
||||
if configured.is_dir() {
|
||||
let suffix = if key.kind == MobileKeyTransferKind::Private {
|
||||
"secret"
|
||||
} else {
|
||||
"public"
|
||||
};
|
||||
let name = format!("{}-{suffix}.asc", key.fingerprint.to_ascii_lowercase());
|
||||
let suffix = if secret { "secret" } else { "public" };
|
||||
let name = format!("{}-{suffix}.asc", fingerprint.to_ascii_lowercase());
|
||||
if configured.join(&name).exists() && !overwrite {
|
||||
return Err(MobileKeyTransferError::ExistingKey);
|
||||
}
|
||||
atomic_replace(configured, Path::new(&name), armor)
|
||||
} else {
|
||||
let existing = fs::read(configured).map_err(|_| MobileKeyTransferError::Write)?;
|
||||
@@ -689,7 +737,7 @@ mod tests {
|
||||
);
|
||||
assert!(progress.received() < progress.total());
|
||||
assert_eq!(
|
||||
importer.import(None, false),
|
||||
importer.import(None, false, false),
|
||||
Err(MobileKeyTransferError::IncompleteTransfer)
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -706,13 +754,14 @@ mod tests {
|
||||
let mut importer = MobileKeyTransferImport::new(fixture.config.clone());
|
||||
importer.add_frame(SecretBytes::new(ALICE_SECRET.to_vec()))?;
|
||||
assert_eq!(
|
||||
importer.import(Some(SecretBytes::new(b"wrong".to_vec())), true),
|
||||
importer.import(Some(SecretBytes::new(b"wrong".to_vec())), true, false),
|
||||
Err(MobileKeyTransferError::IncorrectPassphrase)
|
||||
);
|
||||
assert_eq!(std::fs::read_dir(&fixture.key_path)?.count(), 1);
|
||||
importer.import(
|
||||
Some(SecretBytes::new(b"fixture-alice-passphrase".to_vec())),
|
||||
true,
|
||||
false,
|
||||
)?;
|
||||
let keys = KeyStore::load(&fixture.key_path)?;
|
||||
assert!(keys.infos().any(|key| key.has_secret()));
|
||||
@@ -722,6 +771,32 @@ mod tests {
|
||||
.as_str(),
|
||||
ALICE_FINGERPRINT
|
||||
);
|
||||
let before = std::fs::read(fixture.key_path.join(format!(
|
||||
"{}-secret.asc",
|
||||
ALICE_FINGERPRINT.to_ascii_lowercase()
|
||||
)))?;
|
||||
let mut replacement = MobileKeyTransferImport::new(fixture.config);
|
||||
replacement.add_frame(SecretBytes::new(ALICE_SECRET.to_vec()))?;
|
||||
assert_eq!(
|
||||
replacement.import(
|
||||
Some(SecretBytes::new(b"fixture-alice-passphrase".to_vec())),
|
||||
true,
|
||||
false,
|
||||
),
|
||||
Err(MobileKeyTransferError::ExistingKey)
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read(fixture.key_path.join(format!(
|
||||
"{}-secret.asc",
|
||||
ALICE_FINGERPRINT.to_ascii_lowercase()
|
||||
)))?,
|
||||
before
|
||||
);
|
||||
replacement.import(
|
||||
Some(SecretBytes::new(b"fixture-alice-passphrase".to_vec())),
|
||||
true,
|
||||
true,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -7,15 +7,20 @@ use std::{
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
use rand::{RngCore as _, rngs::OsRng};
|
||||
use sha2::{Digest as _, Sha256};
|
||||
use url::Url;
|
||||
|
||||
use crate::{
|
||||
command::InitRequest,
|
||||
config::{Config, ConfigError, ConfigLoader, GitRemote},
|
||||
crypto::{CryptoError, KeyInfo, KeyStore, SecretProvider, SecretProviderError},
|
||||
git::{
|
||||
GitCredential, GitCredentialProvider, GitError, GitIdentity, GitOperationControl,
|
||||
GitProgressPhase, GitRepository,
|
||||
},
|
||||
mobile_key_transfer::{persist_armored_key, validate_transfer_armor},
|
||||
recipient::RecipientPolicyManager,
|
||||
repository::{DirectoryPath, Repository, SecretBytes},
|
||||
secret_store::{
|
||||
NativeSecretStore, SecretCachePolicy, SecretProtectionPolicy, SecretStoreError,
|
||||
@@ -189,6 +194,180 @@ impl MobileOnboardingOperation {
|
||||
detail: format!("The {} branch is available locally.", branch),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn connect_local_store(
|
||||
&self,
|
||||
request: &MobileOnboardingRequest,
|
||||
) -> Result<MobileOnboardingOutcome, MobileOnboardingError> {
|
||||
let config = Config::load(None).map_err(MobileOnboardingError::from_config)?;
|
||||
self.connect_local_store_at(&config, request, || store_application_token(request))
|
||||
}
|
||||
|
||||
fn connect_local_store_at<F>(
|
||||
&self,
|
||||
config: &Config,
|
||||
request: &MobileOnboardingRequest,
|
||||
store_token: F,
|
||||
) -> Result<MobileOnboardingOutcome, MobileOnboardingError>
|
||||
where
|
||||
F: FnOnce() -> Result<(), MobileOnboardingError>,
|
||||
{
|
||||
if !config.git_remotes().is_empty() {
|
||||
return Err(MobileOnboardingError::already_configured());
|
||||
}
|
||||
let repository = Repository::open(config.vault())
|
||||
.map_err(|_| MobileOnboardingError::inaccessible_repository())?;
|
||||
let mut git = GitRepository::open(&repository, config.git_identity().clone())
|
||||
.map_err(MobileOnboardingError::from_git)?;
|
||||
git.add_remote(
|
||||
request.remote.name().as_str(),
|
||||
request.remote.url().as_str(),
|
||||
)
|
||||
.map_err(MobileOnboardingError::from_git)?;
|
||||
if let Err(error) = config.update_mobile_remote(Some(&request.remote)) {
|
||||
let _ = git.remove_remote(request.remote.name().as_str());
|
||||
return Err(MobileOnboardingError::from_config(error));
|
||||
}
|
||||
if let Err(error) = store_token() {
|
||||
let _ = config.update_mobile_remote(None);
|
||||
let _ = git.remove_remote(request.remote.name().as_str());
|
||||
return Err(error);
|
||||
}
|
||||
Ok(MobileOnboardingOutcome {
|
||||
title: "Git Sync Configured".to_owned(),
|
||||
detail: "The local Git repository is connected to the HTTPS remote.".to_owned(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn setup_local_generated(
|
||||
&self,
|
||||
user_id: &str,
|
||||
passphrase: SecretBytes,
|
||||
replace_existing_key: bool,
|
||||
) -> Result<MobileOnboardingOutcome, MobileOnboardingError> {
|
||||
let generated =
|
||||
KeyStore::generate(user_id, &passphrase).map_err(MobileOnboardingError::from_crypto)?;
|
||||
let fingerprint = generated.info().fingerprint().as_str().to_owned();
|
||||
self.setup_local_key(
|
||||
generated.into_armor(),
|
||||
Some(passphrase),
|
||||
&fingerprint,
|
||||
replace_existing_key,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn setup_local_imported(
|
||||
&self,
|
||||
armor: SecretBytes,
|
||||
passphrase: Option<SecretBytes>,
|
||||
replace_existing_key: bool,
|
||||
) -> Result<MobileOnboardingOutcome, MobileOnboardingError> {
|
||||
validate_transfer_armor(armor.expose())
|
||||
.map_err(|_| MobileOnboardingError::invalid_gpg_key())?;
|
||||
let mut keys = KeyStore::new();
|
||||
let infos = keys
|
||||
.import(armor.expose())
|
||||
.map_err(MobileOnboardingError::from_crypto)?;
|
||||
let key = infos
|
||||
.first()
|
||||
.filter(|_| infos.len() == 1)
|
||||
.ok_or_else(MobileOnboardingError::invalid_gpg_key)?;
|
||||
validate_local_key(&keys, key, passphrase.as_ref())?;
|
||||
self.setup_local_key(
|
||||
armor,
|
||||
passphrase,
|
||||
key.fingerprint().as_str(),
|
||||
replace_existing_key,
|
||||
)
|
||||
}
|
||||
|
||||
fn setup_local_key(
|
||||
&self,
|
||||
armor: SecretBytes,
|
||||
passphrase: Option<SecretBytes>,
|
||||
fingerprint: &str,
|
||||
replace_existing_key: bool,
|
||||
) -> Result<MobileOnboardingOutcome, MobileOnboardingError> {
|
||||
let paths = MobileOnboardingPaths::system()?;
|
||||
self.setup_local_key_at(paths, armor, passphrase, fingerprint, replace_existing_key)
|
||||
}
|
||||
|
||||
fn setup_local_key_at(
|
||||
&self,
|
||||
paths: MobileOnboardingPaths,
|
||||
armor: SecretBytes,
|
||||
passphrase: Option<SecretBytes>,
|
||||
fingerprint: &str,
|
||||
replace_existing_key: bool,
|
||||
) -> Result<MobileOnboardingOutcome, MobileOnboardingError> {
|
||||
if Config::load(Some(&paths.config)).is_ok() {
|
||||
return Err(MobileOnboardingError::already_configured());
|
||||
}
|
||||
paths.prepare_root()?;
|
||||
if paths.config.exists() {
|
||||
return Err(MobileOnboardingError::already_configured());
|
||||
}
|
||||
require_empty_directory(&paths.vault, false)?;
|
||||
require_empty_directory(&paths.keys, replace_existing_key)?;
|
||||
|
||||
let staging = paths.staging_path();
|
||||
fs::create_dir(&staging).map_err(|_| MobileOnboardingError::configuration())?;
|
||||
set_private_directory(&staging).map_err(|_| MobileOnboardingError::configuration())?;
|
||||
let result = (|| {
|
||||
let staged_vault = staging.join("vault");
|
||||
let staged_keys = staging.join("keys");
|
||||
fs::create_dir(&staged_vault)
|
||||
.and_then(|()| fs::create_dir(&staged_keys))
|
||||
.map_err(|_| MobileOnboardingError::configuration())?;
|
||||
set_private_directory(&staged_vault)
|
||||
.and_then(|()| set_private_directory(&staged_keys))
|
||||
.map_err(|_| MobileOnboardingError::configuration())?;
|
||||
persist_armored_key(&staged_keys, fingerprint, true, armor.expose(), false)
|
||||
.map_err(|_| MobileOnboardingError::configuration())?;
|
||||
let keys = KeyStore::load(&staged_keys).map_err(MobileOnboardingError::from_crypto)?;
|
||||
let key = keys
|
||||
.infos()
|
||||
.find(|key| key.fingerprint().as_str() == fingerprint)
|
||||
.ok_or_else(MobileOnboardingError::invalid_gpg_key)?;
|
||||
validate_local_key(&keys, &key, passphrase.as_ref())?;
|
||||
let repository = Repository::open(&staged_vault)
|
||||
.map_err(|_| MobileOnboardingError::inaccessible_repository())?;
|
||||
let mut git = GitRepository::init(&repository, GitIdentity::ironstorage())
|
||||
.map_err(MobileOnboardingError::from_git)?;
|
||||
RecipientPolicyManager::new(&repository, &keys)
|
||||
.apply_init(
|
||||
&InitRequest {
|
||||
path: None,
|
||||
key_identities: vec![fingerprint.to_owned()],
|
||||
},
|
||||
None,
|
||||
&mut NoSetupSecrets,
|
||||
&mut git,
|
||||
)
|
||||
.map_err(|_| MobileOnboardingError::configuration())?;
|
||||
|
||||
paths.install_staged(&staging)?;
|
||||
if let Err(error) = Config::create_mobile_local(
|
||||
paths.config.clone(),
|
||||
&paths.vault,
|
||||
&paths.keys,
|
||||
fingerprint,
|
||||
) {
|
||||
let _ = paths.restore_staged(&staging);
|
||||
return Err(MobileOnboardingError::from_config(error));
|
||||
}
|
||||
Ok(())
|
||||
})();
|
||||
if staging.exists() {
|
||||
let _ = fs::remove_dir_all(&staging);
|
||||
}
|
||||
result?;
|
||||
Ok(MobileOnboardingOutcome {
|
||||
title: "Local Password Store Ready".to_owned(),
|
||||
detail: "A local Git repository and protected GPG key are ready on this iPhone."
|
||||
.to_owned(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub struct MobileOnboardingRequest {
|
||||
@@ -302,6 +481,7 @@ pub enum MobileOnboardingErrorKind {
|
||||
Authentication,
|
||||
Repository,
|
||||
ExistingClone,
|
||||
ExistingKey,
|
||||
Interrupted,
|
||||
SecureStorage,
|
||||
Configuration,
|
||||
@@ -344,6 +524,25 @@ impl MobileOnboardingError {
|
||||
}
|
||||
}
|
||||
|
||||
fn existing_key() -> Self {
|
||||
Self {
|
||||
kind: MobileOnboardingErrorKind::ExistingKey,
|
||||
title: "GPG Key Already Exists",
|
||||
detail: "Creating this local store would replace existing local key material. Confirm replacement to continue."
|
||||
.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
fn invalid_gpg_key() -> Self {
|
||||
Self {
|
||||
kind: MobileOnboardingErrorKind::InvalidInput,
|
||||
title: "GPG Key Could Not Be Used",
|
||||
detail:
|
||||
"Import one private GPG key with an encryption subkey and the correct passphrase."
|
||||
.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
fn different_existing_clone() -> Self {
|
||||
Self {
|
||||
kind: MobileOnboardingErrorKind::ExistingClone,
|
||||
@@ -432,6 +631,13 @@ impl MobileOnboardingError {
|
||||
}
|
||||
}
|
||||
|
||||
fn from_crypto(error: CryptoError) -> Self {
|
||||
match error {
|
||||
CryptoError::InvalidGeneratedKeyInput => Self::invalid("GPG key name or passphrase"),
|
||||
_ => Self::invalid_gpg_key(),
|
||||
}
|
||||
}
|
||||
|
||||
fn from_secret(error: SecretStoreError) -> Self {
|
||||
let detail = match error {
|
||||
SecretStoreError::Denied => "Access to secure token storage was denied.",
|
||||
@@ -482,6 +688,124 @@ impl MobileOnboardingPaths {
|
||||
fs::create_dir_all(&self.root).map_err(|_| MobileOnboardingError::configuration())?;
|
||||
set_private_directory(&self.root).map_err(|_| MobileOnboardingError::configuration())
|
||||
}
|
||||
|
||||
fn staging_path(&self) -> PathBuf {
|
||||
let mut nonce = [0_u8; 8];
|
||||
OsRng.fill_bytes(&mut nonce);
|
||||
self.root.join(format!(
|
||||
".local-setup-{}",
|
||||
nonce
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect::<String>()
|
||||
))
|
||||
}
|
||||
|
||||
fn install_staged(&self, staging: &Path) -> Result<(), MobileOnboardingError> {
|
||||
let previous_vault = staging.join("previous-vault");
|
||||
let previous_keys = staging.join("previous-keys");
|
||||
let had_vault = self.vault.exists();
|
||||
let had_keys = self.keys.exists();
|
||||
if had_vault {
|
||||
fs::rename(&self.vault, &previous_vault)
|
||||
.map_err(|_| MobileOnboardingError::configuration())?;
|
||||
}
|
||||
if had_keys && fs::rename(&self.keys, &previous_keys).is_err() {
|
||||
if had_vault {
|
||||
let _ = fs::rename(&previous_vault, &self.vault);
|
||||
}
|
||||
return Err(MobileOnboardingError::configuration());
|
||||
}
|
||||
if fs::rename(staging.join("vault"), &self.vault).is_err() {
|
||||
if had_vault {
|
||||
let _ = fs::rename(&previous_vault, &self.vault);
|
||||
}
|
||||
if had_keys {
|
||||
let _ = fs::rename(&previous_keys, &self.keys);
|
||||
}
|
||||
return Err(MobileOnboardingError::configuration());
|
||||
}
|
||||
if fs::rename(staging.join("keys"), &self.keys).is_err() {
|
||||
let _ = fs::rename(&self.vault, staging.join("vault"));
|
||||
if had_vault {
|
||||
let _ = fs::rename(&previous_vault, &self.vault);
|
||||
}
|
||||
if had_keys {
|
||||
let _ = fs::rename(&previous_keys, &self.keys);
|
||||
}
|
||||
return Err(MobileOnboardingError::configuration());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn restore_staged(&self, staging: &Path) -> std::io::Result<()> {
|
||||
let failed_vault = staging.join("failed-vault");
|
||||
let failed_keys = staging.join("failed-keys");
|
||||
if self.vault.exists() {
|
||||
fs::rename(&self.vault, &failed_vault)?;
|
||||
}
|
||||
if self.keys.exists() {
|
||||
fs::rename(&self.keys, &failed_keys)?;
|
||||
}
|
||||
let previous_vault = staging.join("previous-vault");
|
||||
let previous_keys = staging.join("previous-keys");
|
||||
if previous_vault.exists() {
|
||||
fs::rename(previous_vault, &self.vault)?;
|
||||
}
|
||||
if previous_keys.exists() {
|
||||
fs::rename(previous_keys, &self.keys)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn require_empty_directory(
|
||||
path: &Path,
|
||||
replace_existing_key: bool,
|
||||
) -> Result<(), MobileOnboardingError> {
|
||||
let metadata = match fs::symlink_metadata(path) {
|
||||
Ok(metadata) => metadata,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
|
||||
Err(_) => return Err(MobileOnboardingError::configuration()),
|
||||
};
|
||||
if metadata.file_type().is_symlink() || !metadata.is_dir() {
|
||||
return Err(MobileOnboardingError::configuration());
|
||||
}
|
||||
let nonempty = path
|
||||
.read_dir()
|
||||
.map_err(|_| MobileOnboardingError::configuration())?
|
||||
.next()
|
||||
.is_some();
|
||||
if !nonempty {
|
||||
return Ok(());
|
||||
}
|
||||
if path.file_name().is_some_and(|name| name == "vault") {
|
||||
return Err(MobileOnboardingError::existing_clone());
|
||||
}
|
||||
if !replace_existing_key {
|
||||
return Err(MobileOnboardingError::existing_key());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_local_key(
|
||||
keys: &KeyStore,
|
||||
key: &KeyInfo,
|
||||
passphrase: Option<&SecretBytes>,
|
||||
) -> Result<(), MobileOnboardingError> {
|
||||
if !key.has_secret() || !key.can_encrypt() {
|
||||
return Err(MobileOnboardingError::invalid_gpg_key());
|
||||
}
|
||||
keys.validate_secret_passphrase(key.fingerprint().as_str(), passphrase)
|
||||
.map_err(|_| MobileOnboardingError::invalid_gpg_key())
|
||||
}
|
||||
|
||||
struct NoSetupSecrets;
|
||||
|
||||
impl SecretProvider for NoSetupSecrets {
|
||||
fn secret_for(&mut self, _key: &KeyInfo) -> Result<SecretBytes, SecretProviderError> {
|
||||
Err(SecretProviderError::Unavailable)
|
||||
}
|
||||
}
|
||||
|
||||
fn repository_url(server_url: &str, repository_path: &str) -> Result<Url, MobileOnboardingError> {
|
||||
@@ -622,9 +946,18 @@ fn set_private_directory(path: &Path) -> std::io::Result<()> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::fs;
|
||||
|
||||
use crate::{
|
||||
config::Config,
|
||||
crypto::KeyStore,
|
||||
git::{GitIdentity, GitRepository},
|
||||
repository::SecretBytes,
|
||||
};
|
||||
|
||||
use super::{
|
||||
MobileOnboardingErrorKind, MobileOnboardingOperation, MobileOnboardingPhase,
|
||||
MobileOnboardingRequest,
|
||||
MobileOnboardingErrorKind, MobileOnboardingOperation, MobileOnboardingPaths,
|
||||
MobileOnboardingPhase, MobileOnboardingRequest,
|
||||
};
|
||||
|
||||
#[test]
|
||||
@@ -662,4 +995,132 @@ mod tests {
|
||||
operation.cancel();
|
||||
assert!(operation.control.is_cancelled());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_setup_generates_a_versioned_pass_store_without_a_remote() {
|
||||
let temporary = tempfile::tempdir().expect("temporary directory");
|
||||
let root = temporary.path().join("ironstorage");
|
||||
let paths = MobileOnboardingPaths {
|
||||
config: root.join("config.toml"),
|
||||
vault: root.join("vault"),
|
||||
keys: root.join("keys"),
|
||||
root,
|
||||
};
|
||||
let passphrase = b"correct horse battery staple";
|
||||
let generated = KeyStore::generate(
|
||||
"Local Reviewer <reviewer@demo.example.com>",
|
||||
&SecretBytes::new(passphrase.to_vec()),
|
||||
)
|
||||
.expect("generate key");
|
||||
let fingerprint = generated.info().fingerprint().as_str().to_owned();
|
||||
MobileOnboardingOperation::default()
|
||||
.setup_local_key_at(
|
||||
paths,
|
||||
generated.into_armor(),
|
||||
Some(SecretBytes::new(passphrase.to_vec())),
|
||||
&fingerprint,
|
||||
false,
|
||||
)
|
||||
.expect("local setup");
|
||||
|
||||
let config = Config::load(Some(&temporary.path().join("ironstorage/config.toml")))
|
||||
.expect("local config");
|
||||
assert!(config.git_remotes().is_empty());
|
||||
assert_eq!(config.default_key().as_str(), fingerprint);
|
||||
let keys = KeyStore::load(config.key_material()).expect("stored key");
|
||||
keys.validate_secret_passphrase(&fingerprint, Some(&SecretBytes::new(passphrase.to_vec())))
|
||||
.expect("protected key");
|
||||
assert_eq!(
|
||||
fs::read_to_string(config.vault().join(".gpg-id")).expect("recipient policy"),
|
||||
format!("{fingerprint}\n")
|
||||
);
|
||||
let repository = crate::repository::Repository::open(config.vault()).expect("vault");
|
||||
let git = GitRepository::open(&repository, GitIdentity::ironstorage()).expect("git");
|
||||
assert!(git.log(None).expect("history").len() >= 2);
|
||||
|
||||
let request = MobileOnboardingRequest::new(
|
||||
"https://git.demo.example.com".to_owned(),
|
||||
"reviewer".to_owned(),
|
||||
"reviewer/passwords".to_owned(),
|
||||
b"test-token".to_vec(),
|
||||
)
|
||||
.expect("remote request");
|
||||
MobileOnboardingOperation::default()
|
||||
.connect_local_store_at(&config, &request, || Ok(()))
|
||||
.expect("connect remote");
|
||||
let configured = Config::load(Some(&temporary.path().join("ironstorage/config.toml")))
|
||||
.expect("updated config");
|
||||
assert_eq!(configured.git_remotes(), &[request.remote().clone()]);
|
||||
let git = GitRepository::open(&repository, GitIdentity::ironstorage()).expect("git");
|
||||
assert_eq!(
|
||||
git.remote_url("origin").expect("remote URL"),
|
||||
"https://git.demo.example.com/reviewer/passwords.git"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_setup_requires_confirmation_before_replacing_key_material() {
|
||||
let temporary = tempfile::tempdir().expect("temporary directory");
|
||||
let root = temporary.path().join("ironstorage");
|
||||
let paths = MobileOnboardingPaths {
|
||||
config: root.join("config.toml"),
|
||||
vault: root.join("vault"),
|
||||
keys: root.join("keys"),
|
||||
root,
|
||||
};
|
||||
paths.prepare_root().expect("root");
|
||||
fs::create_dir(&paths.vault).expect("vault");
|
||||
fs::create_dir(&paths.keys).expect("keys");
|
||||
fs::write(paths.keys.join("existing.asc"), b"existing key material").expect("existing key");
|
||||
let generated = KeyStore::generate(
|
||||
"Replacement Key <replacement@demo.example.com>",
|
||||
&SecretBytes::new(b"replacement passphrase".to_vec()),
|
||||
)
|
||||
.expect("generate key");
|
||||
let fingerprint = generated.info().fingerprint().as_str().to_owned();
|
||||
let error = MobileOnboardingOperation::default()
|
||||
.setup_local_key_at(
|
||||
paths,
|
||||
generated.into_armor(),
|
||||
Some(SecretBytes::new(b"replacement passphrase".to_vec())),
|
||||
&fingerprint,
|
||||
false,
|
||||
)
|
||||
.expect_err("replacement must require confirmation");
|
||||
assert_eq!(error.kind(), MobileOnboardingErrorKind::ExistingKey);
|
||||
assert_eq!(
|
||||
fs::read(temporary.path().join("ironstorage/keys/existing.asc"))
|
||||
.expect("existing key preserved"),
|
||||
b"existing key material"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_staged_install_restores_existing_key_material() {
|
||||
let temporary = tempfile::tempdir().expect("temporary directory");
|
||||
let root = temporary.path().join("ironstorage");
|
||||
let paths = MobileOnboardingPaths {
|
||||
config: root.join("config.toml"),
|
||||
vault: root.join("vault"),
|
||||
keys: root.join("keys"),
|
||||
root: root.clone(),
|
||||
};
|
||||
paths.prepare_root().expect("root");
|
||||
fs::create_dir(&paths.vault).expect("vault");
|
||||
fs::create_dir(&paths.keys).expect("keys");
|
||||
fs::write(paths.keys.join("existing.asc"), b"existing key material").expect("existing key");
|
||||
let staging = root.join("staging");
|
||||
fs::create_dir(&staging).expect("staging");
|
||||
fs::create_dir(staging.join("vault")).expect("staged vault");
|
||||
|
||||
paths
|
||||
.install_staged(&staging)
|
||||
.expect_err("missing staged keys must fail");
|
||||
|
||||
assert!(paths.vault.is_dir());
|
||||
assert_eq!(
|
||||
fs::read(paths.keys.join("existing.asc")).expect("existing key preserved"),
|
||||
b"existing key material"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ description = "Minimal UniFFI boundary for the IronStorage watchOS TOTP core"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
|
||||
15
tools/apple-release/Cargo.toml
Normal file
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "ironstorage-apple-release"
|
||||
description = "Validate and prepare IronStorage Apple App Store releases"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
toml.workspace = true
|
||||
url.workspace = true
|
||||
395
tools/apple-release/src/main.rs
Normal file
@@ -0,0 +1,395 @@
|
||||
use serde::Deserialize;
|
||||
use std::{
|
||||
collections::{BTreeMap, BTreeSet},
|
||||
env,
|
||||
error::Error,
|
||||
ffi::OsString,
|
||||
fs::{self, OpenOptions},
|
||||
io::Write,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
use url::Url;
|
||||
|
||||
const BUNDLE_ID: &str = "de.rfc1437.ironstorage";
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Metadata {
|
||||
app: App,
|
||||
release: Release,
|
||||
screenshots: Vec<Screenshot>,
|
||||
watch_screenshots: Vec<Screenshot>,
|
||||
content: Content,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct App {
|
||||
name: String,
|
||||
bundle_identifier: String,
|
||||
developer_name: String,
|
||||
subtitle: String,
|
||||
description: String,
|
||||
keywords: String,
|
||||
icon_file: String,
|
||||
category: String,
|
||||
privacy_url: String,
|
||||
support_url: String,
|
||||
marketing_url: String,
|
||||
age_rating: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Release {
|
||||
version: String,
|
||||
build: String,
|
||||
minimum_ios: String,
|
||||
notes: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Screenshot {
|
||||
file: String,
|
||||
caption: String,
|
||||
width: u32,
|
||||
height: u32,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Content {
|
||||
advertising: bool,
|
||||
account_creation: bool,
|
||||
digital_purchases: bool,
|
||||
tracking: bool,
|
||||
user_generated_content: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct CargoMetadata {
|
||||
packages: Vec<CargoPackage>,
|
||||
resolve: CargoResolve,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct CargoPackage {
|
||||
id: String,
|
||||
name: String,
|
||||
version: String,
|
||||
license: Option<String>,
|
||||
license_file: Option<String>,
|
||||
manifest_path: PathBuf,
|
||||
source: Option<String>,
|
||||
repository: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct CargoResolve {
|
||||
nodes: Vec<CargoNode>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct CargoNode {
|
||||
id: String,
|
||||
dependencies: Vec<String>,
|
||||
}
|
||||
|
||||
fn main() -> Result<(), Box<dyn Error>> {
|
||||
let args: Vec<OsString> = env::args_os().collect();
|
||||
match args.as_slice() {
|
||||
[_, command, root] if command == "verify" => verify(Path::new(root)),
|
||||
[_, command, metadata, output] if command == "licenses" => {
|
||||
write_licenses(Path::new(metadata), Path::new(output))
|
||||
}
|
||||
_ => Err("usage:\n ironstorage-apple-release verify <repository-root>\n ironstorage-apple-release licenses <cargo-metadata.json> <output.txt>".into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn read_metadata(root: &Path) -> Result<Metadata, Box<dyn Error>> {
|
||||
Ok(toml::from_str(&fs::read_to_string(
|
||||
root.join("apple/AppStore/metadata.toml"),
|
||||
)?)?)
|
||||
}
|
||||
|
||||
fn verify(root: &Path) -> Result<(), Box<dyn Error>> {
|
||||
let metadata = read_metadata(root)?;
|
||||
if metadata.app.bundle_identifier != BUNDLE_ID {
|
||||
return Err(format!("main bundle identifier must be {BUNDLE_ID}").into());
|
||||
}
|
||||
if metadata.app.age_rating != "4+" || metadata.release.minimum_ios != "17.0" {
|
||||
return Err(
|
||||
"release metadata must retain the reviewed 4+ rating and iOS 17.0 minimum".into(),
|
||||
);
|
||||
}
|
||||
if metadata.release.build.parse::<u64>()? == 0 {
|
||||
return Err("build number must be positive".into());
|
||||
}
|
||||
for (name, value) in [
|
||||
("privacy URL", metadata.app.privacy_url.as_str()),
|
||||
("support URL", metadata.app.support_url.as_str()),
|
||||
("marketing URL", metadata.app.marketing_url.as_str()),
|
||||
] {
|
||||
require_https(name, value)?;
|
||||
}
|
||||
for (name, value) in [
|
||||
("developer name", metadata.app.developer_name.as_str()),
|
||||
("subtitle", metadata.app.subtitle.as_str()),
|
||||
("description", metadata.app.description.as_str()),
|
||||
("keywords", metadata.app.keywords.as_str()),
|
||||
("category", metadata.app.category.as_str()),
|
||||
("release notes", metadata.release.notes.as_str()),
|
||||
] {
|
||||
if value.trim().is_empty() {
|
||||
return Err(format!("{name} must not be empty").into());
|
||||
}
|
||||
}
|
||||
if !metadata.release.notes.contains("Apple Watch") {
|
||||
return Err("release notes must describe the bundled Apple Watch companion".into());
|
||||
}
|
||||
if metadata.content.advertising
|
||||
|| metadata.content.account_creation
|
||||
|| metadata.content.digital_purchases
|
||||
|| metadata.content.tracking
|
||||
|| metadata.content.user_generated_content
|
||||
{
|
||||
return Err("reviewed content declarations must remain false".into());
|
||||
}
|
||||
|
||||
let workspace: toml::Value = toml::from_str(&fs::read_to_string(root.join("Cargo.toml"))?)?;
|
||||
let workspace_version = workspace["workspace"]["package"]["version"]
|
||||
.as_str()
|
||||
.ok_or("workspace version is missing")?;
|
||||
if metadata.release.version != workspace_version {
|
||||
return Err("App Store version must match the Rust workspace version".into());
|
||||
}
|
||||
|
||||
let project = fs::read_to_string(root.join("apple/project.yml"))?;
|
||||
for required in [
|
||||
"PRODUCT_BUNDLE_IDENTIFIER: de.rfc1437.ironstorage\n",
|
||||
"PRODUCT_BUNDLE_IDENTIFIER: de.rfc1437.ironstorage.autofill\n",
|
||||
"PRODUCT_BUNDLE_IDENTIFIER: de.rfc1437.ironstorage.watch\n",
|
||||
"DEVELOPMENT_TEAM: MU22FMRGK8\n",
|
||||
"ITSAppUsesNonExemptEncryption: false\n",
|
||||
"NSFaceIDUsageDescription:",
|
||||
"NSCameraUsageDescription:",
|
||||
"- target: IronStorageAutoFill\n embed: true",
|
||||
"- target: IronStorageWatch\n embed: true",
|
||||
] {
|
||||
if !project.contains(required) {
|
||||
return Err(format!("apple/project.yml is missing {required:?}").into());
|
||||
}
|
||||
}
|
||||
if !project.contains(&format!(
|
||||
"MARKETING_VERSION: \"{}\"",
|
||||
metadata.release.version
|
||||
)) || !project.contains(&format!(
|
||||
"CURRENT_PROJECT_VERSION: \"{}\"",
|
||||
metadata.release.build
|
||||
)) {
|
||||
return Err("Xcode and App Store version/build metadata differ".into());
|
||||
}
|
||||
|
||||
let privacy = fs::read_to_string(root.join("apple/Resources/App/PrivacyInfo.xcprivacy"))?;
|
||||
for required in [
|
||||
"<key>NSPrivacyTracking</key>\n\t<false/>",
|
||||
"<key>NSPrivacyCollectedDataTypes</key>\n\t<array/>",
|
||||
] {
|
||||
if !privacy.contains(required) {
|
||||
return Err(format!("privacy manifest is missing {required:?}").into());
|
||||
}
|
||||
}
|
||||
|
||||
let icon = png_size(&root.join(&metadata.app.icon_file))?;
|
||||
if icon != (1024, 1024) {
|
||||
return Err(format!("app icon must be 1024x1024, found {}x{}", icon.0, icon.1).into());
|
||||
}
|
||||
if metadata.screenshots.len() != 6 {
|
||||
return Err("exactly six reviewed iPhone screenshots are required".into());
|
||||
}
|
||||
if metadata.watch_screenshots.len() != 2 {
|
||||
return Err("exactly the reviewed Watch list and detail screenshots are required".into());
|
||||
}
|
||||
for screenshot in metadata
|
||||
.screenshots
|
||||
.iter()
|
||||
.chain(&metadata.watch_screenshots)
|
||||
{
|
||||
if screenshot.caption.trim().is_empty() {
|
||||
return Err(format!("{} requires a caption", screenshot.file).into());
|
||||
}
|
||||
let actual = png_size(&root.join(&screenshot.file))?;
|
||||
if actual != (screenshot.width, screenshot.height) {
|
||||
return Err(format!("{} dimensions differ from metadata", screenshot.file).into());
|
||||
}
|
||||
}
|
||||
for required in [
|
||||
"LICENSE",
|
||||
"apple/AppStore/privacy/index.html",
|
||||
"apple/AppStore/DISTRIBUTION.md",
|
||||
"apple/AppStore/ExportOptions.plist",
|
||||
"apple/Resources/App/ThirdPartyLicenses.txt",
|
||||
] {
|
||||
if !root.join(required).is_file() {
|
||||
return Err(format!("required release file is missing: {required}").into());
|
||||
}
|
||||
}
|
||||
println!(
|
||||
"verified {} {} ({}) for {} with {} iPhone and {} Watch screenshots",
|
||||
metadata.app.name,
|
||||
metadata.release.version,
|
||||
metadata.release.build,
|
||||
metadata.app.bundle_identifier,
|
||||
metadata.screenshots.len(),
|
||||
metadata.watch_screenshots.len()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_licenses(metadata_path: &Path, output: &Path) -> Result<(), Box<dyn Error>> {
|
||||
let metadata: CargoMetadata = serde_json::from_slice(&fs::read(metadata_path)?)?;
|
||||
let packages: BTreeMap<_, _> = metadata
|
||||
.packages
|
||||
.into_iter()
|
||||
.map(|package| (package.id.clone(), package))
|
||||
.collect();
|
||||
let graph: BTreeMap<_, _> = metadata
|
||||
.resolve
|
||||
.nodes
|
||||
.into_iter()
|
||||
.map(|node| (node.id, node.dependencies))
|
||||
.collect();
|
||||
let root = packages
|
||||
.values()
|
||||
.find(|package| package.name == "ironstorage-apple")
|
||||
.ok_or("cargo metadata has no ironstorage-apple package")?;
|
||||
let mut pending = vec![root.id.clone()];
|
||||
let mut reachable = BTreeSet::new();
|
||||
while let Some(id) = pending.pop() {
|
||||
if reachable.insert(id.clone()) {
|
||||
pending.extend(graph.get(&id).into_iter().flatten().cloned());
|
||||
}
|
||||
}
|
||||
|
||||
let mut package_notices = String::new();
|
||||
let mut license_texts: BTreeMap<String, Vec<String>> = BTreeMap::new();
|
||||
for package in packages
|
||||
.values()
|
||||
.filter(|package| reachable.contains(&package.id) && package.source.is_some())
|
||||
{
|
||||
let directory = package
|
||||
.manifest_path
|
||||
.parent()
|
||||
.ok_or("dependency manifest has no parent directory")?;
|
||||
let mut license_paths = Vec::new();
|
||||
if let Some(path) = &package.license_file {
|
||||
license_paths.push(directory.join(path));
|
||||
} else {
|
||||
for entry in fs::read_dir(directory)? {
|
||||
let path = entry?.path();
|
||||
let name = path
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or("");
|
||||
if path.is_file()
|
||||
&& ["LICENSE", "LICENCE", "COPYING", "NOTICE"]
|
||||
.iter()
|
||||
.any(|prefix| name.to_ascii_uppercase().starts_with(prefix))
|
||||
{
|
||||
license_paths.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
license_paths.sort();
|
||||
license_paths.dedup();
|
||||
package_notices.push_str(&format!(
|
||||
"\n{} {}\nLicense: {}\nSource: {}\n",
|
||||
package.name,
|
||||
package.version,
|
||||
package.license.as_deref().unwrap_or("see included license"),
|
||||
package.repository.as_deref().unwrap_or("see Cargo.lock")
|
||||
));
|
||||
if license_paths.is_empty() {
|
||||
package_notices.push_str(
|
||||
"Notice: the published crate contains SPDX metadata but no separate license file.\n",
|
||||
);
|
||||
}
|
||||
for path in license_paths {
|
||||
let text = fs::read_to_string(&path)?
|
||||
.lines()
|
||||
.map(str::trim_end)
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
license_texts.entry(text).or_default().push(format!(
|
||||
"{} {} ({})",
|
||||
package.name,
|
||||
package.version,
|
||||
path.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or("license")
|
||||
));
|
||||
}
|
||||
}
|
||||
let mut notices = format!(
|
||||
"IronStorage third-party licenses\n================================\n\nGenerated from Cargo metadata for the iPhone Rust library. Source links also satisfy source-availability notice requirements for covered dependencies.\n\nPackages\n--------\n{package_notices}\nLicense texts\n-------------\n"
|
||||
);
|
||||
for (text, used_by) in license_texts {
|
||||
notices.push_str(&format!(
|
||||
"\nUsed by: {}\n\n{}\n",
|
||||
used_by.join(", "),
|
||||
text.trim()
|
||||
));
|
||||
}
|
||||
let mut file = OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.open(output)?;
|
||||
file.write_all(notices.as_bytes())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn require_https(name: &str, value: &str) -> Result<(), Box<dyn Error>> {
|
||||
let url = Url::parse(value)?;
|
||||
if url.scheme() != "https"
|
||||
|| url.host_str().is_none()
|
||||
|| url.username() != ""
|
||||
|| url.password().is_some()
|
||||
{
|
||||
return Err(format!("{name} must be an HTTPS URL without credentials").into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn png_size(path: &Path) -> Result<(u32, u32), Box<dyn Error>> {
|
||||
let bytes = fs::read(path)?;
|
||||
if bytes.len() < 24 || &bytes[..8] != b"\x89PNG\r\n\x1a\n" || &bytes[12..16] != b"IHDR" {
|
||||
return Err(format!("{} is not a PNG with an IHDR", path.display()).into());
|
||||
}
|
||||
Ok((
|
||||
u32::from_be_bytes(bytes[16..20].try_into()?),
|
||||
u32::from_be_bytes(bytes[20..24].try_into()?),
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn metadata() -> Metadata {
|
||||
toml::from_str(include_str!("../../../apple/AppStore/metadata.toml")).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_keeps_the_watch_companion_in_release_scope() {
|
||||
let metadata = metadata();
|
||||
assert_eq!(metadata.app.bundle_identifier, BUNDLE_ID);
|
||||
assert!(metadata.release.notes.contains("Apple Watch"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repository_release_metadata_is_consistent() {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.parent()
|
||||
.unwrap()
|
||||
.parent()
|
||||
.unwrap();
|
||||
verify(root).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@ description = "Build IronStorage macOS release bundles and DMGs"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
|
||||