fb8b2ac684
Wires the workspace through `cargo apk build`. After this commit `cargo apk build -p solitaire_app --target x86_64-linux-android` produces a debug-signed APK at `target/debug/apk/solitaire-quest.apk` containing all assets and `lib/x86_64/libsolitaire_app.so` — runnable on the AVD or a physical x86_64 device. The five gating points discovered by iterating compile cycles: 1. solitaire_app split into bin + lib. cargo-apk needs a `cdylib` to bundle as `libmain.so`; pure-bin crates panic with "Bin is not compatible with Cdylib". `src/lib.rs` carries the ECS bootstrap as `pub fn run`; `src/main.rs` is a 3-line shim that delegates for the desktop `cargo run` path. 2. `[package.metadata.android]` pins target SDK 34 / min SDK 26 so cargo-apk doesn't probe for whatever default it ships (which on this machine was an uninstalled API 30). `assets = "../assets"` lets the same asset directory feed both desktop and APK. 3. Workspace `bevy` features add `android-native-activity` (the Bevy-side glue that pairs with cargo-apk's NativeActivity wrapper). The feature is target-gated inside bevy_internal so desktop builds compile it out. 4. `arboard` (clipboard, used by Stats's "Copy share link") has no Android backend — `cargo apk build` fails with E0433 on `platform::Clipboard` if unconditional. Target-gated to `cfg(not(target_os = "android"))`; the system surfaces an informational toast on Android until JNI ClipboardManager is wired in the Phase-Android round. 5. `keyring` + `keyring-core` cannot compile for android — the transitive `rpassword` uses `libc::__errno_location` which bionic doesn't expose. Both crates target-gated; `auth_tokens` ships a stub on Android that returns `KeychainUnavailable` for every call, matching how callers already handle a Linux box without Secret Service. Cosmetic post-pass panic: cargo-apk panics AFTER the APK is signed when it tries to also wrap the bin target. The APK on disk is unaffected. Working around this with `cargo apk build --lib` is the next small step. What's verified: - Desktop `cargo build`, `cargo clippy --workspace --all-targets`, and `cargo test --workspace` all clean. - `cargo apk build -p solitaire_app --target x86_64-linux-android` produces 54 MB debug APK with libsolitaire_app.so + assets. What's NOT yet verified: - Whether the APK actually launches on the AVD / a phone (next step: `adb install` + `adb logcat` against the bevy_test AVD). - Whether `dirs::data_dir()` on Android returns a usable path (sync / persistence will surface this if not). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
166 lines
6.2 KiB
Rust
166 lines
6.2 KiB
Rust
//! Secure storage for JWT access and refresh tokens using the OS keychain.
|
|
//!
|
|
//! Tokens are stored under service name `"solitaire_quest_server"` with entry
|
|
//! keys `"{username}_access"` and `"{username}_refresh"`.
|
|
//!
|
|
//! On Linux this requires a running secret service (GNOME Keyring / KWallet).
|
|
//! If the keychain is unavailable, operations return
|
|
//! [`TokenError::KeychainUnavailable`] — callers should fall back to prompting
|
|
//! the user to log in again.
|
|
//!
|
|
//! Before calling any function in this module the application must initialise
|
|
//! the default keyring store exactly once at startup by calling
|
|
//! `keyring::use_native_store` (e.g. in `solitaire_app::main` before building
|
|
//! the Bevy `App`). If no default store is set, all operations in this module
|
|
//! will return [`TokenError::KeychainUnavailable`].
|
|
//!
|
|
//! # Android stub
|
|
//!
|
|
//! `keyring-core` cannot compile for the android target (its `rpassword`
|
|
//! transitive dep uses `libc::__errno_location`, which Android's bionic
|
|
//! doesn't expose). On Android every function in this module returns
|
|
//! [`TokenError::KeychainUnavailable`] so callers can detect the fallback
|
|
//! the same way they handle a Linux box without Secret Service. The
|
|
//! real Android backend will arrive in the Phase-Android round when we
|
|
//! wire Android Keystore via JNI.
|
|
//!
|
|
//! # Note: no unit tests — requires live OS keychain.
|
|
|
|
#[cfg(not(target_os = "android"))]
|
|
use keyring_core::Entry;
|
|
use thiserror::Error;
|
|
|
|
/// Errors that can occur when reading or writing tokens in the OS keychain.
|
|
#[derive(Debug, Error)]
|
|
pub enum TokenError {
|
|
/// The OS keychain (secret service / keychain daemon) is not available.
|
|
#[error("keychain unavailable: {0}")]
|
|
KeychainUnavailable(String),
|
|
/// No token was found in the keychain for the given username.
|
|
#[error("token not found for user {0}")]
|
|
NotFound(String),
|
|
/// An unexpected keychain error occurred.
|
|
#[error("keychain error: {0}")]
|
|
Keyring(String),
|
|
}
|
|
|
|
/// Service name used to namespace all keychain entries for this application.
|
|
#[cfg(not(target_os = "android"))]
|
|
const SERVICE: &str = "solitaire_quest_server";
|
|
|
|
/// Map a `keyring_core::Error` to the appropriate `TokenError`.
|
|
#[cfg(not(target_os = "android"))]
|
|
fn map_keyring_err(err: keyring_core::Error, username: &str) -> TokenError {
|
|
let msg = err.to_string();
|
|
match err {
|
|
keyring_core::Error::NoStorageAccess(_) | keyring_core::Error::NoDefaultStore => {
|
|
TokenError::KeychainUnavailable(msg)
|
|
}
|
|
keyring_core::Error::NoEntry => TokenError::NotFound(username.to_string()),
|
|
_ => TokenError::Keyring(msg),
|
|
}
|
|
}
|
|
|
|
/// Store the access and refresh tokens for `username` in the OS keychain.
|
|
///
|
|
/// Any previously stored tokens for that username are overwritten.
|
|
#[cfg(not(target_os = "android"))]
|
|
pub fn store_tokens(
|
|
username: &str,
|
|
access_token: &str,
|
|
refresh_token: &str,
|
|
) -> Result<(), TokenError> {
|
|
Entry::new(SERVICE, &format!("{username}_access"))
|
|
.map_err(|e| map_keyring_err(e, username))?
|
|
.set_password(access_token)
|
|
.map_err(|e| map_keyring_err(e, username))?;
|
|
|
|
Entry::new(SERVICE, &format!("{username}_refresh"))
|
|
.map_err(|e| map_keyring_err(e, username))?
|
|
.set_password(refresh_token)
|
|
.map_err(|e| map_keyring_err(e, username))?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Load the stored access token for `username` from the OS keychain.
|
|
///
|
|
/// Returns [`TokenError::NotFound`] if no token has been stored yet.
|
|
#[cfg(not(target_os = "android"))]
|
|
pub fn load_access_token(username: &str) -> Result<String, TokenError> {
|
|
Entry::new(SERVICE, &format!("{username}_access"))
|
|
.map_err(|e| map_keyring_err(e, username))?
|
|
.get_password()
|
|
.map_err(|e| map_keyring_err(e, username))
|
|
}
|
|
|
|
/// Load the stored refresh token for `username` from the OS keychain.
|
|
///
|
|
/// Returns [`TokenError::NotFound`] if no token has been stored yet.
|
|
#[cfg(not(target_os = "android"))]
|
|
pub fn load_refresh_token(username: &str) -> Result<String, TokenError> {
|
|
Entry::new(SERVICE, &format!("{username}_refresh"))
|
|
.map_err(|e| map_keyring_err(e, username))?
|
|
.get_password()
|
|
.map_err(|e| map_keyring_err(e, username))
|
|
}
|
|
|
|
/// Delete the stored access and refresh tokens for `username`.
|
|
///
|
|
/// Intended to be called on logout or account deletion. Missing entries are
|
|
/// silently ignored (the tokens are already gone, which is the desired state).
|
|
#[cfg(not(target_os = "android"))]
|
|
pub fn delete_tokens(username: &str) -> Result<(), TokenError> {
|
|
match Entry::new(SERVICE, &format!("{username}_access"))
|
|
.map_err(|e| map_keyring_err(e, username))?
|
|
.delete_credential()
|
|
{
|
|
Ok(()) | Err(keyring_core::Error::NoEntry) => {}
|
|
Err(e) => return Err(map_keyring_err(e, username)),
|
|
}
|
|
|
|
match Entry::new(SERVICE, &format!("{username}_refresh"))
|
|
.map_err(|e| map_keyring_err(e, username))?
|
|
.delete_credential()
|
|
{
|
|
Ok(()) | Err(keyring_core::Error::NoEntry) => {}
|
|
Err(e) => return Err(map_keyring_err(e, username)),
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
// -------------------------------------------------------------------
|
|
// Android stub — same public API, always returns KeychainUnavailable.
|
|
// Lets `sync_client::*` compile unchanged on Android; the runtime
|
|
// effect is "session login required every launch", same as a Linux
|
|
// box without Secret Service.
|
|
// -------------------------------------------------------------------
|
|
|
|
#[cfg(target_os = "android")]
|
|
const ANDROID_STUB_MSG: &str = "android stub: keychain not yet wired (Phase-Android task)";
|
|
|
|
#[cfg(target_os = "android")]
|
|
pub fn store_tokens(
|
|
_username: &str,
|
|
_access_token: &str,
|
|
_refresh_token: &str,
|
|
) -> Result<(), TokenError> {
|
|
Err(TokenError::KeychainUnavailable(ANDROID_STUB_MSG.to_string()))
|
|
}
|
|
|
|
#[cfg(target_os = "android")]
|
|
pub fn load_access_token(_username: &str) -> Result<String, TokenError> {
|
|
Err(TokenError::KeychainUnavailable(ANDROID_STUB_MSG.to_string()))
|
|
}
|
|
|
|
#[cfg(target_os = "android")]
|
|
pub fn load_refresh_token(_username: &str) -> Result<String, TokenError> {
|
|
Err(TokenError::KeychainUnavailable(ANDROID_STUB_MSG.to_string()))
|
|
}
|
|
|
|
#[cfg(target_os = "android")]
|
|
pub fn delete_tokens(_username: &str) -> Result<(), TokenError> {
|
|
Err(TokenError::KeychainUnavailable(ANDROID_STUB_MSG.to_string()))
|
|
}
|