Skip to content
16 changes: 10 additions & 6 deletions android/truapi-host/src/main/kotlin/io/parity/truapi/TrUAPIHost.kt
Original file line number Diff line number Diff line change
Expand Up @@ -257,12 +257,16 @@ interface HostBridge {
suspend fun remotePermission(request: RemotePermission): Boolean

/**
* Observe an auth state change. The core emits states only when they
* actually 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. 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.
* 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.
*/
fun authStateChanged(state: AuthState) {}

Expand Down
17 changes: 10 additions & 7 deletions ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift
Original file line number Diff line number Diff line change
Expand Up @@ -371,13 +371,16 @@ public protocol HostBridge: AnyObject, Sendable {
/// stall other TrUAPI traffic.
func remotePermission(request: RemotePermission) async throws -> Bool

/// Observe an auth state change. The core emits states only when they
/// actually change, in transition order: render `.pairing` as the pairing
/// QR UI, `.connected`/`.disconnected` as the account badge, and
/// `.loginFailed` as a retryable error. 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.
/// 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.
func authStateChanged(state: AuthState)

/// Open a JSON-RPC chain connection and return a host-assigned id, or nil if unsupported.
Expand Down
26 changes: 17 additions & 9 deletions ios/truapi-host/Sources/TrUAPIHost/truapi_server.swift
Original file line number Diff line number Diff line change
Expand Up @@ -659,10 +659,14 @@ public protocol HostCallbacks: AnyObject, Sendable {
func remotePermission(request: RemotePermission) async throws -> Bool

/**
* Observe an auth state change. Emitted only when the state actually
* changes, in transition order: render `Pairing` as the pairing QR UI,
* `Connected`/`Disconnected` as the account badge, `LoginFailed` as a
* retryable error. User cancellation is reported through
* 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
* `NativeTrUApiCore.cancel_login()`.
*/
func authStateChanged(state: AuthState)
Expand Down Expand Up @@ -920,10 +924,14 @@ open func remotePermission(request: RemotePermission)async throws -> Bool {
}

/**
* Observe an auth state change. Emitted only when the state actually
* changes, in transition order: render `Pairing` as the pairing QR UI,
* `Connected`/`Disconnected` as the account badge, `LoginFailed` as a
* retryable error. User cancellation is reported through
* 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
* `NativeTrUApiCore.cancel_login()`.
*/
open func authStateChanged(state: AuthState) {try! rustCall() {
Expand Down Expand Up @@ -5291,7 +5299,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() != 48975) {
if (uniffi_truapi_server_checksum_method_hostcallbacks_auth_state_changed() != 46688) {
return InitializationResult.apiChecksumMismatch
}
if (uniffi_truapi_server_checksum_method_hostcallbacks_core_storage_read() != 59238) {
Expand Down
3 changes: 3 additions & 0 deletions js/packages/truapi-host/src/wasm-module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ export interface WorkerPairingHostRuntime extends PermissionAuthorizationRuntime
disconnectSession(): Promise<void>;
cancelPairing(): void;
notifySessionStoreChanged(): void;
activateStoredSession(): Promise<void>;
activateExternalSession(blob: Uint8Array): Promise<void>;
resetSessionState(): Promise<void>;
free(): void;
}

Expand Down
83 changes: 83 additions & 0 deletions js/packages/truapi-host/src/web/create-worker-host-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,25 @@ export interface WorkerPairingHostRuntime {
disconnectSession(): Promise<void>;
cancelPairing(): void;
notifySessionStoreChanged(): void;
/**
* Restore the session persisted in the core's `AuthSession` slot. Resolves
* once product frames may use it, so a host can await this at boot before
* routing. Rejects when the runtime has been disposed or the worker faulted,
* so a host never routes on an activation that did not run.
*/

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.

It would be nice to document these three methods in js/packages/truapi-host/README.md as well.
activateStoredSession, activateExternalSession and resetSessionState are the answer to "how
does a browser host restore a session across a reload", and the README's worker example currently
stops at createProvider, so a host author has to read these types to find them. A few lines
showing the boot order, create the runtime, await activateStoredSession(), then open providers,
would make the fix discoverable. CLAUDE.md asks for README updates after any code change. The same subsection could also cover disconnectSession and notifySessionStoreChanged, which are
undocumented there too.

activateStoredSession(): Promise<void>;
/**
* Install an already-paired session the host holds itself, without copying
* it into core storage. Rejects on a disposed runtime, as
* {@link WorkerPairingHostRuntime.activateStoredSession} does.
*/
activateExternalSession(blob: Uint8Array): Promise<void>;
/**
* Drop the active paired session without notifying the peer. Rejects on a
* disposed runtime, as
* {@link WorkerPairingHostRuntime.activateStoredSession} does.
*/
resetSessionState(): Promise<void>;
getPermissionAuthorizationStatus(
productId: string,
request: PermissionAuthorizationRequest,
Expand Down Expand Up @@ -79,6 +98,10 @@ interface RuntimeState {
number,
{ resolve: () => void; reject: (error: Error) => void }
>;
pendingSessionActivations: Map<
number,
{ resolve: () => void; reject: (error: Error) => void }
>;
pendingPermissionAuthorizationStatuses: Map<
number,
{
Expand Down Expand Up @@ -109,6 +132,7 @@ function debugLoggingEnabled(state: RuntimeState): boolean {

let nextDisconnectRequestId = 0;
let nextPermissionAuthorizationRequestId = 0;
let nextSessionActivationRequestId = 0;
function encodePermissionAuthorizationRequest(
request: PermissionAuthorizationRequest,
): Uint8Array {
Expand Down Expand Up @@ -339,6 +363,19 @@ function handleDisconnectResponse(
);
}

function handleSessionActivationResponse(
state: RuntimeState,
msg:
| { requestId: number; ok: true }
| { requestId: number; ok: false; error: string },
): void {
settlePending(
state.pendingSessionActivations,
msg.requestId,
msg.ok ? { ok: true, value: undefined } : { ok: false, error: msg.error },
);
}

function handlePermissionAuthorizationStatusResponse(
state: RuntimeState,
msg:
Expand Down Expand Up @@ -390,6 +427,7 @@ function handleSetPermissionAuthorizationStatusResponse(

function rejectPendingRuntimeRequests(state: RuntimeState, error: Error): void {
rejectAll(state.pendingDisconnects, error);
rejectAll(state.pendingSessionActivations, error);
rejectAll(state.pendingPermissionAuthorizationStatuses, error);
rejectAll(state.pendingPermissionAuthorizationStatusBatches, error);
rejectAll(state.pendingSetPermissionAuthorizationStatuses, error);
Expand Down Expand Up @@ -419,6 +457,28 @@ function sendWorkerRequest<T>(
});
}

/**
* Send a session activation request, rejecting rather than resolving when the
* runtime is already gone. A host awaits these to learn whether it is signed
* in, so a silent success after a worker fault would route it as if the
* activation had run.
*/
function sendSessionActivationRequest(
state: RuntimeState,
buildMessage: (requestId: number) => MainToWorker,
): Promise<void> {
if (state.disposed) {
return Promise.reject(state.closedError ?? new Error("runtime disposed"));
}
return sendWorkerRequest<void>(
state,
state.pendingSessionActivations,
() => ++nextSessionActivationRequestId,
undefined,
buildMessage,
);
}

function closeCoreState(core: CoreState, error: Error): void {
if (core.disposed) return;
core.disposed = true;
Expand Down Expand Up @@ -489,6 +549,7 @@ export function createWebWorkerPairingHostRuntime(
subscriptionDisposers: new Map(),
chainConnections: new Map(),
pendingDisconnects: new Map(),
pendingSessionActivations: new Map(),
pendingPermissionAuthorizationStatuses: new Map(),
pendingPermissionAuthorizationStatusBatches: new Map(),
pendingSetPermissionAuthorizationStatuses: new Map(),
Expand Down Expand Up @@ -538,6 +599,9 @@ export function createWebWorkerPairingHostRuntime(
case "disconnectSessionResponse":
handleDisconnectResponse(state, msg);
break;
case "sessionActivationResponse":
handleSessionActivationResponse(state, msg);
break;
case "permissionAuthorizationStatusResponse":
handlePermissionAuthorizationStatusResponse(state, msg);
break;
Expand Down Expand Up @@ -743,6 +807,25 @@ function buildRuntime(state: RuntimeState): WorkerPairingHostRuntime {
kind: "notifySessionStoreChanged",
} satisfies MainToWorker);
},
activateStoredSession(): Promise<void> {
Comment thread
filvecchiato marked this conversation as resolved.
return sendSessionActivationRequest(state, (requestId) => ({
kind: "activateStoredSession",
requestId,
}));
},
activateExternalSession(blob: Uint8Array): Promise<void> {
return sendSessionActivationRequest(state, (requestId) => ({
kind: "activateExternalSession",
requestId,
blob,
}));
},
resetSessionState(): Promise<void> {
return sendSessionActivationRequest(state, (requestId) => ({
kind: "resetSessionState",
requestId,
}));
},
getPermissionAuthorizationStatus(productId, request) {
return sendWorkerRequest<PermissionAuthorizationStatus>(
state,
Expand Down
83 changes: 83 additions & 0 deletions js/packages/truapi-host/src/web/worker-provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,17 @@ async function createProviderFromRuntime(
};
}

async function readyRuntime(worker: FakeWorker) {
const runtimePromise = createWebWorkerPairingHostRuntime(
asWorker(worker),
makeHostCallbacks(),
{ hostConfig: hostConfigFromRuntimeConfig(runtimeConfig()) },
);
worker.emit({ kind: "loaded" });
worker.emit({ kind: "ready" });
return runtimePromise;
}

async function readyProvider(worker: FakeWorker, options: ReadyOptions = {}) {
const providerPromise = createProviderFromRuntime(
asWorker(worker),
Expand Down Expand Up @@ -432,6 +443,78 @@ describe("createWebWorkerPairingHostRuntime", () => {
provider.dispose();
});

it("forwards session activation calls and resolves their responses", async () => {
const worker = new FakeWorker();
const runtime = await readyRuntime(worker);
const blob = new Uint8Array([1, 2, 3]);

for (const [kind, call] of [
["activateStoredSession", () => runtime.activateStoredSession()],
["activateExternalSession", () => runtime.activateExternalSession(blob)],
["resetSessionState", () => runtime.resetSessionState()],
] as const) {
const pending = call();
const msg = lastMessageOfKind(worker, kind);
expect(typeof msg.requestId).toBe("number");
worker.emit({
kind: "sessionActivationResponse",
requestId: msg.requestId,
ok: true,
});
await pending;
}

expect(lastMessageOfKind(worker, "activateExternalSession").blob).toEqual(
blob,
);

runtime.dispose();
});

it("rejects a session activation the core could not complete", async () => {
const worker = new FakeWorker();
const runtime = await readyRuntime(worker);

const pending = runtime.activateStoredSession();
const msg = lastMessageOfKind(worker, "activateStoredSession");
worker.emit({
kind: "sessionActivationResponse",
requestId: msg.requestId,
ok: false,
error: "no stored session",
});

await expect(pending).rejects.toThrow("no stored session");

runtime.dispose();
});

it("rejects a session activation still in flight when the worker faults", async () => {
const worker = new FakeWorker();
const runtime = await readyRuntime(worker);

const pending = runtime.activateStoredSession();
worker.emitError("boom");

await expect(pending).rejects.toThrow(/boom/);
});

it("rejects session activation calls made after the runtime is gone", async () => {
const worker = new FakeWorker();
const runtime = await readyRuntime(worker);
const blob = new Uint8Array([1, 2, 3]);
worker.emitError("boom");

// Resolving here would tell a host at boot that the activation ran and
// found no session, when nothing was ever sent to the worker.
await expect(runtime.activateStoredSession()).rejects.toThrow(/boom/);
await expect(runtime.activateExternalSession(blob)).rejects.toThrow(/boom/);
await expect(runtime.resetSessionState()).rejects.toThrow(/boom/);

runtime.dispose();
await expect(runtime.activateStoredSession()).rejects.toThrow();
});

it("dispatches callback requests to host hooks", async () => {
const worker = new FakeWorker();
let clears = 0;
Expand Down
15 changes: 15 additions & 0 deletions js/packages/truapi-host/src/worker-protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,9 @@ export type MainToWorker =
| { kind: "disconnectSession"; requestId: number }
| { kind: "cancelPairing" }
| { kind: "notifySessionStoreChanged" }
| { kind: "activateStoredSession"; requestId: number }
| { kind: "activateExternalSession"; requestId: number; blob: Uint8Array }
| { kind: "resetSessionState"; requestId: number }
| {
kind: "getPermissionAuthorizationStatus";
productId: string;
Expand Down Expand Up @@ -112,6 +115,18 @@ export type WorkerToMain =
ok: false;
error: string;
}
/**
* Shared reply for `activateStoredSession`, `activateExternalSession` and
* `resetSessionState`: all three settle as a bare success or a failure
* reason.
*/
| { kind: "sessionActivationResponse"; requestId: number; ok: true }
| {
kind: "sessionActivationResponse";
requestId: number;
ok: false;
error: string;
}
| {
kind: "permissionAuthorizationStatusResponse";
requestId: number;
Expand Down
Loading