Skip to content
Merged
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
5 changes: 4 additions & 1 deletion android/truapi-host/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,10 @@ class MyBridge(private val webView: WebView) : HostBridge {

// Core-owned auth state stream: render AuthState.Pairing as the pairing
// QR sheet, connected/disconnected as the account badge, and login-failed
// as a retryable error. When the user closes the pairing sheet, report it
// as a retryable error, unless its kind is
// LoginFailureKind.NoFreeAllowanceSlots, which is unlikely to succeed
// before the period rolls over, so retry should not be the primary action.
// When the user closes the pairing sheet, report it
// with `core.cancelLogin()`.
override fun authStateChanged(state: AuthState) {
main.post { /* render the state */ }
Expand Down
19 changes: 11 additions & 8 deletions android/truapi-host/src/main/kotlin/io/parity/truapi/TrUAPIHost.kt
Original file line number Diff line number Diff line change
Expand Up @@ -264,14 +264,17 @@ interface HostBridge {
/**
* Observe an auth state change, in transition order: render
* [AuthState.Pairing] as the pairing QR UI, connected/disconnected as the
* account badge, and login-failed as a retryable error. A pairing host's
* session activation reports its outcome even when it is the default
* disconnected, so a host that awaits activation before routing never has
* to read silence as "signed out"; every other emission, and every emission
* on a host role that has no session activation, happens only when the
* state actually changes. Report a user dismissal of the pairing UI through
* [TrUAPIHostCore.cancelLogin]. Invoked on the dispatcher thread; marshal
* the state to the main thread and return promptly.
* account badge, and login-failed as a retryable error, unless its kind is
* [LoginFailureKind.NoFreeAllowanceSlots], which is unlikely to succeed
* before the period rolls over, so retry should not be the primary action.
* A pairing host's session activation reports its
* outcome even when it is the default disconnected, so a host that awaits
* activation before routing never has to read silence as "signed out";
* every other emission, and every emission on a host role that has no
* session activation, happens only when the state actually changes. Report
* a user dismissal of the pairing UI through [TrUAPIHostCore.cancelLogin].
* Invoked on the dispatcher thread; marshal the state to the main thread
* and return promptly.
*/
fun authStateChanged(state: AuthState) {}

Expand Down
9 changes: 6 additions & 3 deletions ios/truapi-host/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,9 +186,12 @@ final class MyCallbacks: HostCallbacks, @unchecked Sendable {
}

// Core-owned auth state stream: render `.connected`/`.disconnected` as the
// account badge and `.loginFailed` as a retryable error. This core is a
// signing host — it owns the signer and never pairs — so `.pairing` and
// `.authenticating` are not emitted and `core.cancelLogin()` is inert.
// account badge and `.loginFailed` as a retryable error, unless its `kind`
// is `.noFreeAllowanceSlots`, which is unlikely to succeed before the
// period rolls over, so retry should not be the primary action. This core
// is a signing host — it owns the signer and never
// pairs — so `.pairing` and `.authenticating` are not emitted and
// `core.cancelLogin()` is inert.
// Activate the session with `core.activateLocalSession(secret:...)`.
func authStateChanged(state: AuthState) {
DispatchQueue.main.async { /* render the state */ }
Expand Down
19 changes: 11 additions & 8 deletions ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift
Original file line number Diff line number Diff line change
Expand Up @@ -377,14 +377,17 @@ public protocol HostBridge: AnyObject, Sendable {

/// Observe an auth state change, in transition order: render `.pairing` as
/// the pairing QR UI, `.connected`/`.disconnected` as the account badge,
/// and `.loginFailed` as a retryable error. A pairing host's session
/// activation reports its outcome even when it is the default
/// `.disconnected`, so a host that awaits activation before routing never
/// has to read silence as "signed out"; every other emission, and every
/// emission on a host role that has no session activation, happens only
/// when the state actually changes. Report a user dismissal of the pairing
/// UI through ``TrUAPIHostCore/cancelLogin()``. Invoked on the dispatcher
/// thread; hand the state to the main thread and return promptly.
/// and `.loginFailed` as a retryable error, unless its `kind` is
/// `.noFreeAllowanceSlots`, which is unlikely to succeed before the period
/// rolls over, so retry should not be the primary action. A pairing host's
/// session activation reports its outcome even
/// when it is the default `.disconnected`, so a host that awaits activation
/// before routing never has to read silence as "signed out"; every other
/// emission, and every emission on a host role that has no session
/// activation, happens only when the state actually changes. Report a user
/// dismissal of the pairing UI through ``TrUAPIHostCore/cancelLogin()``.
/// Invoked on the dispatcher thread; hand the state to the main thread and
/// return promptly.
func authStateChanged(state: AuthState)

/// Open a JSON-RPC chain connection and return a host-assigned id, or nil if unsupported.
Expand Down
91 changes: 89 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,88 @@ 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 — which normally holds until the period
* rolls over, making a retry a waste of the user's remaining budget.
*
* Recovered heuristically from the wallet's prose, whose wording is not
* this workspace's to pin, so treat it as a strong hint rather than a
* proof: do not make retry the primary action, but leave a way to reach it.
*/
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: 19 additions & 13 deletions ios/truapi-host/Sources/TrUAPIHost/truapi_server.swift
Original file line number Diff line number Diff line change
Expand Up @@ -689,12 +689,15 @@ public protocol HostCallbacks: AnyObject, Sendable {
/**
* Observe an auth state change, in transition order: render `Pairing` as
* the pairing QR UI, `Connected`/`Disconnected` as the account badge,
* `LoginFailed` as a retryable error. A pairing host's session activation
* reports its outcome even when it is the default `Disconnected`, so a
* host that awaits activation before routing never has to read silence as
* "signed out". Every other emission, and every emission on a host role
* that has no session activation, happens only when the state actually
* changes. User cancellation is reported through
* `LoginFailed` as a retryable error unless its `kind` is
* `NoFreeAllowanceSlots`, which is unlikely to succeed before the period
* rolls over, so retry should not be the primary action. A pairing host's
* session activation reports its outcome even
* when it is the default `Disconnected`, so a host that awaits activation
* before routing never has to read silence as "signed out". Every other
* emission, and every emission on a host role that has no session
* activation, happens only when the state actually changes. User
* cancellation is reported through
* `NativeTrUApiCore.cancel_login()`.
*/
func authStateChanged(state: AuthState)
Expand Down Expand Up @@ -954,12 +957,15 @@ open func remotePermission(request: RemotePermission)async throws -> Bool {
/**
* Observe an auth state change, in transition order: render `Pairing` as
* the pairing QR UI, `Connected`/`Disconnected` as the account badge,
* `LoginFailed` as a retryable error. A pairing host's session activation
* reports its outcome even when it is the default `Disconnected`, so a
* host that awaits activation before routing never has to read silence as
* "signed out". Every other emission, and every emission on a host role
* that has no session activation, happens only when the state actually
* changes. User cancellation is reported through
* `LoginFailed` as a retryable error unless its `kind` is
* `NoFreeAllowanceSlots`, which is unlikely to succeed before the period
* rolls over, so retry should not be the primary action. A pairing host's
* session activation reports its outcome even
* when it is the default `Disconnected`, so a host that awaits activation
* before routing never has to read silence as "signed out". Every other
* emission, and every emission on a host role that has no session
* activation, happens only when the state actually changes. User
* cancellation is reported through
* `NativeTrUApiCore.cancel_login()`.
*/
open func authStateChanged(state: AuthState) {try! rustCall() {
Expand Down Expand Up @@ -6154,7 +6160,7 @@ private let initializationResult: InitializationResult = {
if (uniffi_truapi_server_checksum_method_hostcallbacks_remote_permission() != 25245) {
return InitializationResult.apiChecksumMismatch
}
if (uniffi_truapi_server_checksum_method_hostcallbacks_auth_state_changed() != 46688) {
if (uniffi_truapi_server_checksum_method_hostcallbacks_auth_state_changed() != 41678) {
return InitializationResult.apiChecksumMismatch
}
if (uniffi_truapi_server_checksum_method_hostcallbacks_core_storage_read() != 59238) {
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/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1348,7 +1348,7 @@ async fn prepare_pairing_response(session: &mut SigningHostSession, deeplink: &s
}

fn is_statement_slot_exhaustion(err: &anyhow::Error) -> bool {
err.to_string().contains("no free StatementStore slot")
truapi_server::reports_exhausted_period(&err.to_string())
}

/// Best-effort: record the pairing allowance accounts in the renewal ledger so
Expand Down
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 @@ -654,7 +654,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
Loading