Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 85 additions & 2 deletions ios/truapi-host/Sources/TrUAPIHost/truapi_platform.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1370,6 +1370,10 @@ public enum AuthState: Equatable, Hashable {
* The last login attempt failed; show the reason and offer a retry.
*/
case loginFailed(
/**
* What kind of failure this was. Hosts branch on this and treat
* `reason` as display copy only.
*/kind: LoginFailureKind,
/**
* Human-readable failure reason.
*/reason: String
Expand Down Expand Up @@ -1409,7 +1413,7 @@ public struct FfiConverterTypeAuthState: FfiConverterRustBuffer {
case 3: return .connected(try FfiConverterTypeSessionUiInfo.read(from: &buf)
)

case 4: return .loginFailed(reason: try FfiConverterString.read(from: &buf)
case 4: return .loginFailed(kind: try FfiConverterTypeLoginFailureKind.read(from: &buf), reason: try FfiConverterString.read(from: &buf)
)

case 5: return .authenticating
Expand All @@ -1436,8 +1440,9 @@ public struct FfiConverterTypeAuthState: FfiConverterRustBuffer {
FfiConverterTypeSessionUiInfo.write(v1, into: &buf)


case let .loginFailed(reason):
case let .loginFailed(kind,reason):
writeInt(&buf, Int32(4))
FfiConverterTypeLoginFailureKind.write(kind, into: &buf)
FfiConverterString.write(reason, into: &buf)


Expand Down Expand Up @@ -1546,6 +1551,84 @@ public func FfiConverterTypeCreateTransactionReview_lower(_ value: CreateTransac



/**
* Why a login attempt failed, for hosts that need to act on the cause rather
* than only display it.
*/

public enum LoginFailureKind: Equatable, Hashable {

/**
* The wallet has no free statement-store allowance slot for this period,
* so it cannot register the device. Deterministic until the period rolls
* over: retrying wastes the user's remaining budget.
*/
case noFreeAllowanceSlots
/**
* Anything else. `reason` carries the detail.
*/
case other





}

#if compiler(>=6)
extension LoginFailureKind: Sendable {}
#endif

#if swift(>=5.8)
@_documentation(visibility: private)
#endif
public struct FfiConverterTypeLoginFailureKind: FfiConverterRustBuffer {
typealias SwiftType = LoginFailureKind

public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> LoginFailureKind {
let variant: Int32 = try readInt(&buf)
switch variant {

case 1: return .noFreeAllowanceSlots

case 2: return .other

default: throw UniffiInternalError.unexpectedEnumCase
}
}

public static func write(_ value: LoginFailureKind, into buf: inout [UInt8]) {
switch value {


case .noFreeAllowanceSlots:
writeInt(&buf, Int32(1))


case .other:
writeInt(&buf, Int32(2))

}
}
}


#if swift(>=5.8)
@_documentation(visibility: private)
#endif
public func FfiConverterTypeLoginFailureKind_lift(_ buf: RustBuffer) throws -> LoginFailureKind {
return try FfiConverterTypeLoginFailureKind.lift(buf)
}

#if swift(>=5.8)
@_documentation(visibility: private)
#endif
public func FfiConverterTypeLoginFailureKind_lower(_ value: LoginFailureKind) -> RustBuffer {
return FfiConverterTypeLoginFailureKind.lower(value)
}



/**
* Permission request whose authorization status can be inspected or updated
* by host administration UI.
Expand Down
32 changes: 25 additions & 7 deletions playground/tests/e2e/dotli-diagnosis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -319,9 +319,9 @@ async function waitForSignedIn(
signingHost: SigningHostCliProcess,
): Promise<string> {
try {
const existingFailure = await latestLoginFailureReason(page);
const existingFailure = await latestLoginFailure(page);
if (existingFailure !== null) {
throw new Error(`Login failed: ${existingFailure}`);
throw new Error(formatLoginFailure(existingFailure));
}
const outcome = await Promise.race([
page
Expand All @@ -334,14 +334,18 @@ async function waitForSignedIn(
const listener = (event: Event): void => {
const state = (
event as CustomEvent<
{ tag?: string; reason?: string } | undefined
{ tag?: string; kind?: string; reason?: string } | undefined
>
).detail;
if (state?.tag !== "LoginFailed") {
return;
}
window.removeEventListener("dotli:truapi-auth-state", listener);
reject(new Error(`Login failed: ${state.reason ?? "unknown"}`));
reject(
new Error(
`Login failed (${state.kind ?? "Other"}): ${state.reason ?? "unknown"}`,
),
);
};
window.addEventListener("dotli:truapi-auth-state", listener);
}),
Expand Down Expand Up @@ -370,15 +374,29 @@ async function waitForSignedIn(
}
}

async function latestLoginFailureReason(page: Page): Promise<string | null> {
/** A login failure the host reported, with the core's typed cause. */
interface LoginFailure {
kind: string;
reason: string;
}

/** Name the cause first: `NoFreeAllowanceSlots` cannot succeed on a retry. */
function formatLoginFailure(failure: LoginFailure): string {
return `Login failed (${failure.kind}): ${failure.reason}`;
}

async function latestLoginFailure(page: Page): Promise<LoginFailure | null> {
return await page.evaluate(() => {
const states = window.__dotliE2eAuthStates ?? [];
for (let i = states.length - 1; i >= 0; i--) {
const candidate = states[i] as {
detail?: { tag?: string; reason?: string };
detail?: { tag?: string; kind?: string; reason?: string };
};
if (candidate.detail?.tag === "LoginFailed") {
return candidate.detail.reason ?? "unknown";
return {
kind: candidate.detail.kind ?? "Other",
reason: candidate.detail.reason ?? "unknown",
};
}
}
return null;
Expand Down
21 changes: 19 additions & 2 deletions rust/crates/truapi-codegen/tests/golden/host-callbacks.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion rust/crates/truapi-host-cli/src/platform.rs
Original file line number Diff line number Diff line change
Expand Up @@ -648,7 +648,7 @@ impl truapi_platform::AuthPresenter for CliPlatform {
AuthState::Disconnected => {
("disconnected".to_string(), SystemEvent::PairingDisconnected)
}
AuthState::LoginFailed { reason } => (
AuthState::LoginFailed { reason, .. } => (
"failed".to_string(),
SystemEvent::PairingFailed {
reason: reason.clone(),
Expand Down
17 changes: 17 additions & 0 deletions rust/crates/truapi-platform/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1052,6 +1052,9 @@ pub enum AuthState {
Connected(SessionUiInfo),
/// The last login attempt failed; show the reason and offer a retry.
LoginFailed {
/// What kind of failure this was. Hosts branch on this and treat
/// `reason` as display copy only.
kind: LoginFailureKind,
/// Human-readable failure reason.
reason: String,
},
Expand All @@ -1061,6 +1064,20 @@ pub enum AuthState {
Authenticating,
}

/// Why a login attempt failed, for hosts that need to act on the cause rather
/// than only display it.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Encode, Decode)]
#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
pub enum LoginFailureKind {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now that one kind is deterministic for the rest of the period, the host-facing docs that call LoginFailed retryable are wrong. Could you add a clause to each saying it is retryable unless kind is NoFreeAllowanceSlots: rust/crates/truapi-server/src/native.rs line 423 (LoginFailed as a retryable error), ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift line 377, ios/truapi-host/README.md line 150, android/truapi-host/src/main/kotlin/io/parity/truapi/TrUAPIHost.kt line 263, and android/truapi-host/README.md line 165. The two copies in the generated truapi_server.swift come from the native.rs doc, so they follow from the bindings sync.

/// The wallet has no free statement-store allowance slot for this period,
/// so it cannot register the device. Deterministic until the period rolls
/// over: retrying wastes the user's remaining budget.
NoFreeAllowanceSlots,
/// Anything else. `reason` carries the detail.
#[default]
Other,
}

/// Host auth UI driven by core-owned [`AuthState`] transitions.
pub trait AuthPresenter: Send + Sync {
/// Observe an auth state change. Emitted only when the state actually
Expand Down
1 change: 1 addition & 0 deletions rust/crates/truapi-server/src/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ mod authority;
pub(crate) mod bulletin_rpc;
mod chat;
mod identity;
pub(crate) mod login_failure;
mod pairing_host;
mod ring_vrf_registry;
/// Role-neutral runtime services shared by product-facing runtimes.
Expand Down
18 changes: 15 additions & 3 deletions rust/crates/truapi-server/src/runtime/auth_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@
use std::sync::{Arc, Mutex};

use futures::channel::oneshot;
use truapi_platform::{AuthPresenter, AuthState, Platform, SessionUiInfo};
use truapi_platform::{AuthPresenter, AuthState, LoginFailureKind, Platform, SessionUiInfo};

use crate::runtime::login_failure::classify_login_failure;

/// Serialized auth-state machine bound to the platform's `auth_state_changed`
/// sink. Each transition mutates under the lock, releases it, then emits the
Expand Down Expand Up @@ -71,6 +73,8 @@ impl AuthStateMachine {
}

/// Active login -> `LoginFailed`: the in-flight login reported a failure.
/// The kind is recovered from `reason`, which is the only form the wallet
/// reports a refusal in.
pub(super) fn login_failed(&self, reason: String) {
self.transition(|inner| {
if !matches!(
Expand All @@ -80,7 +84,10 @@ impl AuthStateMachine {
return None;
}
inner.cancel_tx = None;
inner.state = AuthState::LoginFailed { reason };
inner.state = AuthState::LoginFailed {
kind: classify_login_failure(&reason),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The classifier has three tests, but nothing tests that it is called here, or that login_failed_before_pairing deliberately does not call it. The two pairing tests were widened to AuthState::LoginFailed { reason, .. }, so they ignore kind, and the stub's only wallet failure string classifies as Other, so the NoFreeAllowanceSlots branch is never exercised past the pure function. Could you add two tests to the mod tests right below in this file, which already has stub_platform: one driving pairing_started, authentication_started, login_failed("no free StatementStore slot in period 7 (max 8)") and asserting the emitted state carries LoginFailureKind::NoFreeAllowanceSlots, and one calling login_failed_before_pairing with the same reason and asserting Other.

reason,
};
Some(())
});
}
Expand All @@ -97,7 +104,12 @@ impl AuthStateMachine {
) {
return None;
}
inner.state = AuthState::LoginFailed { reason };
// Pre-pairing failures are the pairing host's own (device identity,
// bootstrap); allowance exhaustion is only ever wallet-reported.
inner.state = AuthState::LoginFailed {
kind: LoginFailureKind::Other,
reason,
};
Some(())
});
}
Expand Down
Loading