Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
52 commits
Select commit Hold shift + click to select a range
b2079d1
fix: preserve identity lookup failures and expose notification activa…
replghost Oct 5, 2026
5b11a8f
merge: integrate Tommy platform blockers on Paseo host candidate
replghost Oct 5, 2026
7e6a80f
fix: propagate local identity refresh failures through authority cont…
replghost Oct 5, 2026
a618fba
fix: resolve owned pending usernames without reservation expiry
replghost Oct 5, 2026
e485861
fix: isolate unusable identity labels and preserve root fallback
replghost Oct 5, 2026
a1016a4
feat(media): host-owned Media service (prototype wire trait 218)
replghost Oct 5, 2026
702ab7c
fix(media): deliver the subscription snapshot before signaling is ready
replghost Oct 5, 2026
1d0b2a1
fix(media): accept pictures for admitted participants before their pe…
replghost Oct 6, 2026
38d4953
fix(media): make browser peers relay-only
replghost Oct 6, 2026
0f8d252
fix(container): deny product device enumeration
replghost Oct 6, 2026
89d586f
fix(media): never overflow signaling on one Statement Store page
replghost Oct 6, 2026
d810539
fix(media): keep a waiter's retry request across an in-flight attempt
replghost Oct 6, 2026
ec4a489
Merge approved #1011 parent into Tommy blocker candidate
replghost Oct 6, 2026
6d424c2
merge: integrate host-owned Media into the JamPeerTransport-on-Seity …
replghost Oct 6, 2026
ab66181
Merge approved receiving activation fix into Media integration
replghost Oct 6, 2026
509b854
Merge Tommy blockers #1216 into Media integration #1217
replghost Oct 6, 2026
76fdc79
fix(media): keep the iframe isolation probe under minification
replghost Oct 6, 2026
f22a938
Merge approved Media integration updates into Tommy qualification
replghost Oct 6, 2026
3762b3a
fix(media): retry handshake sends after an ambiguous signaling failure
replghost Oct 6, 2026
674e9a8
Merge branch 'feat/media-sessions' into feat/media-on-jam-seity
replghost Oct 6, 2026
047b9df
fix(sso): correlate replies before decoding foreign payloads
replghost Oct 6, 2026
5af4d5a
fix(notifications): retain OS gates for trusted native products
replghost Oct 6, 2026
5299e54
fix(ios): propagate selected host themes through TrUAPI
replghost Oct 6, 2026
36b99e3
fix(media): mark advertisement lookups on their own connection
replghost Oct 6, 2026
e1808d8
Merge branch 'feat/media-sessions' into feat/media-on-jam-seity
replghost Oct 6, 2026
7527bc4
fix(web): expose Rust-classified product trust to host consent callbacks
replghost Oct 6, 2026
833884e
feat(permissions): bound and revoke automatic Bulletin upload consent
replghost Oct 6, 2026
cf424c9
fix(ios): deliver permission updates on the inherited main actor
replghost Oct 6, 2026
f9197dc
fix(media): never replay superseded viewports to a late event stream
replghost Oct 6, 2026
a65736e
Merge branch 'feat/media-sessions' into feat/media-on-jam-seity
replghost Oct 6, 2026
3d19f1b
Merge Chat notification formatting into Media sessions
replghost Oct 6, 2026
efc24d3
merge: forward Tommy consent and theme fixes into Media integration
replghost Oct 6, 2026
44b87fe
merge: preserve concurrent Media viewport fix in Tommy integration
replghost Oct 6, 2026
91a0f6b
fix(ios): qualify Media structure and broadcast signing configuration
replghost Oct 7, 2026
191ed76
fix(media): keep a picture's video element across layouts
replghost Oct 7, 2026
1bdb3d5
fix(ios): import BulletinChain in the product worker factory
replghost Oct 7, 2026
ba1f865
test(ios): use named Media calling permission arguments
replghost Oct 7, 2026
8e73563
merge: forward Media picture stability fix
replghost Oct 7, 2026
3f840c9
fix(media): allow calls without screen capture
replghost Oct 7, 2026
6142e3d
Merge Mobile Safari Media support
replghost Oct 7, 2026
c5a2267
fix(media): repair observable examples and native qualification gates
replghost Oct 9, 2026
00189a3
Merge reviewed runtime integration and canonical Media permission adm…
replghost Oct 9, 2026
70627ff
Merge final native integration presentation and documentation fixes
replghost Oct 9, 2026
a5da340
test(android): initialize the canonical runtime owner fixture
replghost Oct 9, 2026
3d3efe8
Merge final native platform qualification repairs into open Media int…
replghost Oct 9, 2026
81cf0ee
test(host): remove source and capability forwarding snapshots
replghost Oct 9, 2026
78c6805
Merge parent iOS qualification repairs into Media
replghost Oct 9, 2026
9b623bb
Merge feat/jam-peer-transport-on-seity into feat/media-on-jam-seity
replghost Oct 9, 2026
a62af77
fix(ios): reuse inherited core storage enumeration
replghost Oct 9, 2026
34de877
Merge JAM permission Swift compile repair into Media
replghost Oct 9, 2026
7f57c02
fix(media): repair mobile worker and network policy qualification
replghost Oct 9, 2026
ca5a6bc
fix(ios): repair Media runtime qualification
replghost Oct 9, 2026
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
8 changes: 8 additions & 0 deletions .changeset/host-owned-media.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@parity/truapi": minor
"@parity/truapi-host": minor
---

Add the host-owned `Media` API for real-time sessions, authenticated participant invitations, incoming-offer decisions, local microphone/camera/screen intent, and host-composited surfaces. Sessions belong to the authenticated product, account, network, and runtime. Calling and capture consent remain separate, screen capture uses the trusted picker, and operation identifiers support cancellation and recovery without duplicate effects. Capability discovery reports unsupported hosts without requesting permission or starting capture.

Media TypeScript examples consume observable session snapshots and retain their host listener through mutation admission and cleanup. The mock host preserves product, root-account, and Bulletin-genesis isolation for automatic-preimage consent and quota storage. Android storage fixtures exercise scoped identity and isolation, and Media validation helpers preserve commit, capture, and surface checks without duplicated throw paths.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ generated from [Conventional Commits](https://www.conventionalcommits.org/).

### Fixed

- Resolve paired usernames from already-minted pending dotNS claims regardless of reservation age, while requiring current forward-registry ownership and rejecting conflicting atomic/subnode owners during lookup. Read-only Rust probes resolve the public `session.14`, `session.18`, and `session.42` accounts; legacy names without PoP provenance remain unresolved. Actual mobile-pairing qualification remains outstanding.
- Continue dotNS identity enumeration past conflicting or zero-owner candidates without accepting them, and fall back to the root account when the preferred account has no usable owned username. Preserve first-owned-name precedence by skipping filled slots before further views; transport and ABI failures remain errors, and explicit Chat forward-owner lookups still reject unusable records.
- Browser receiving synchronizes and revokes each authority scope independently, so account or verified-artifact replacement cannot leave the current enrollment in a revoke/register loop.
- Message catch-up receipts preserve an already queued notification activation until the product explicitly acknowledges its sequence.
- Generated WASM bridges preserve owned `String` parameters instead of emitting unsized `str` arguments.
Expand Down
78 changes: 78 additions & 0 deletions Cargo.lock

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

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ futures-util = "0.3"
getrandom = "0.2"
hex = { version = "0.4", default-features = false, features = ["alloc"] }
hkdf = "0.12"
hpke = { version = "0.12", default-features = false }
image = { version = "0.25", default-features = false }
indoc = "2"
js-sys = "0.3"
Expand All @@ -55,6 +56,7 @@ proc-macro2 = "1"
qrcode = { version = "0.14.1", default-features = false }
quote = "1"
rand = "0.8"
rand_core = "0.6"
ratatui = { version = "0.30.2", default-features = false }
reqwest = { version = "0.12", default-features = false }
rqrr = { version = "0.10.1", default-features = false }
Expand Down
40 changes: 30 additions & 10 deletions android/truapi-host/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -241,9 +241,9 @@ The core's `Permissions` platform trait has two methods, and so does the bridge:

Both return `PermissionDecision` (`ALLOW_ONCE`, `ALLOW_ALWAYS`, or `DENY`). Preserve the choice so the core can consume one-use grants without persisting them. OS refusal after app consent should throw rather than record a product denial. `TrUAPIProductExecution.permissionAuthorizationStatus` resolves the current execution's authorization (including the OS gate); settings instead use the process-wide `TrUAPIHostRuntime.permissionAuthorizations(productId)` and suspending `setPermissionAuthorizationStatus(productId, request, status)`. Settings work without opening an execution and preserve product+request, account-neutral authority.

The browser container checks product consent before opening WebSockets or requesting camera and microphone access. After
installing it, the WebChromeClient media callback should check only the Android OS permission, so it does not consume
product consent twice.
The browser container checks product consent before opening WebSockets. Raw WebView camera, microphone,
display capture, WebRTC and fullscreen remain denied even when device or generic network grants exist.
Capture and RTC run only through the host-owned Media backend, with separate core consent and Android OS permission.

To disable WebRTC, await `runtime.setPermissionAuthorizationStatus(productId, request, DENIED)` with a remote `WebRtc` request **before opening** the product execution. Deny/reset invalidates pending prompts and one-use grants and closes the product's existing executions; reopen a fresh execution when the user launches it again. Other products remain open. An explicit `AUTHORIZED` settings choice updates the same authority.

Expand All @@ -259,9 +259,17 @@ signing and other single-action reviews continue to use that Boolean callback.

Implement `HostCoreStorage.keys()` by enumerating the existing core namespace in the real persistent backend, not a separate grant index. The default throws unsupported. Implement `HostBridge.permissionAuthorizationsChanged(productId)` on the process bridge and notify settings observers; every core write, including decisions from native prompts, reaches this callback. After invalidation, check `execution.isClosed()` and dispose that execution's WebView/media resources without automatic renderer recovery. A socket disconnect alone does not stop capture.

Storage adapters retain cancellation-safe durable compare/exchange and deferred cross-root refresh in addition to enumeration.
After a runtime settings setter returns, call `runtime.awaitCoreStorageChanges()` outside session/lifecycle/storage locks;
it reports a failed refresh even if the durable policy edit succeeded. The deferred job also emits the canonical
authorization-change callback after fencing matching Media scopes, including when the requesting coroutine was cancelled.
Keep both callbacks; canonical settings notifications do not replace Media invalidation.
Refresh uses the owning process authority, not execution administration: canonical revocation may already have closed
the execution before the deferred job runs. Storage/read failures still propagate through the drain.

The Android application's permission repository imports Room rows through `importPermissionAuthorizations` before opening an execution or reading settings. Import only fills absent canonical decisions; a stored deny or reset tombstone cannot be resurrected by an old Room grant. Supported legacy reads and both settings toggle directions use canonical records. Legacy-only balance access stays in Room. Historical multi-domain records retain their bundle identity rather than turning a bundle denial into per-domain denials. Malformed or unmappable supported records fail visibly instead of disappearing from settings.

Legacy prompts capture `permissionAuthorizationRevision` before waiting for the user and persist through `setPermissionAuthorizationStatusIfCurrent`; a false result rejects a late answer. One-use legacy grants are invalidated on process notifications. Revocation errors propagate to settings and leave the displayed persistent decision unchanged; there is no optimistic success. These are native host administration APIs, not new guest wire methods.
Legacy prompts capture `permissionAuthorizationRevision` before waiting for the user and persist through `setPermissionAuthorizationStatusIfCurrent`; a false result rejects a late answer. One-use legacy grants are invalidated on process notifications. Revocation errors propagate to settings; a persistence failure retains the old decision, while a later refresh failure reports the error without rolling back a durable denial. These are native host administration APIs, not new guest wire methods.

## Statement-store allowance renewal

Expand Down Expand Up @@ -406,18 +414,29 @@ class MyStorage : HostStorage {
override suspend fun clear(key: String) { map.remove(key) }
}

// Core-owned storage: keyed by SCALE-encoded CoreStorageKey bytes. Back it with
// real persistence (e.g. EncryptedSharedPreferences); an in-memory map is shown
// for brevity.
// Core-owned storage: the bytes remain opaque. Back it with real persistence;
// writes/clears must finish durably before returning. The SDK serializes all
// callbacks and implements cancellation-safe CAS plus cross-core refreshes.
// Every adapter sharing a physical store/prefix MUST use the same identifier.
// This process-wide in-memory store is shown only for brevity.
class MyCoreStorage : HostCoreStorage {
private val map = HashMap<String, ByteArray>()
override val storageIdentifier = "example-process-memory"
private companion object {
val map = java.util.concurrent.ConcurrentHashMap<String, ByteArray>()
}
private fun k(key: ByteArray) = key.joinToString("") { "%02x".format(it) }
@OptIn(ExperimentalStdlibApi::class)
override suspend fun keys(): List<ByteArray> = map.keys.map { it.hexToByteArray() }
override suspend fun read(key: ByteArray) = map[k(key)]
override suspend fun write(key: ByteArray, value: ByteArray) { map[k(key)] = value }
override suspend fun clear(key: ByteArray) { map.remove(k(key)) }
}

class MyBridge(private val webView: WebView) : HostBridge {
class MyBridge(
private val webView: WebView,
private val onPermissionAuthorizationsChanged: (String) -> Unit,
) : HostBridge {
override fun permissionAuthorizationsChanged(productId: String) = onPermissionAuthorizationsChanged(productId)
private val main = Handler(Looper.getMainLooper())

override val storage = MyStorage()
Expand Down Expand Up @@ -489,7 +508,8 @@ class MyBridge(private val webView: WebView) : HostBridge {
}

val webView: WebView = existingWebView
val bridge = MyBridge(webView)
val permissionChanges = kotlinx.coroutines.flow.MutableStateFlow(0L)
val bridge = MyBridge(webView) { permissionChanges.value += 1 }
val runtimeConfig = HostRuntimeConfig(
hostName = "My Host",
hostIcon = "https://host.example/icon.png",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ class AndroidHostCoreStorage(context: Context, namespace: String) : HostCoreStor
}
private val keyAlias = "io.parity.truapi.core.$namespace"
private val databaseFile = File(context.noBackupFilesDir, "truapi-core-$namespace.sqlite")
override val storageIdentifier: String = databaseFile.canonicalPath
private val database = object : SQLiteOpenHelper(
context, databaseFile.absolutePath, 1,
// OpenParams configures every pooled/reopened connection, unlike a
Expand All @@ -53,6 +54,21 @@ class AndroidHostCoreStorage(context: Context, namespace: String) : HostCoreStor
}
private var encryptionKey: SecretKey? = null

override suspend fun keys(): List<ByteArray> = storageCall {
database.readableDatabase.query(
"core_slots", arrayOf("slot"), null, null, null, null, null,
).use { cursor ->
buildList {
while (cursor.moveToNext()) {
val encoded = cursor.getString(0)
val key = android.util.Base64.decode(encoded, android.util.Base64.NO_WRAP)
check(slot(key) == encoded) { "Invalid private core storage key" }
add(key)
}
}
}
}

override suspend fun read(key: ByteArray): ByteArray? = storageCall {
database.readableDatabase.query(
"core_slots", arrayOf("ciphertext"), "slot = ?", arrayOf(slot(key)),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
package io.parity.truapi

import java.util.WeakHashMap
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import uniffi.truapi.HostRejection
import uniffi.truapi.NativeTrUApiHostRuntime
import uniffi.truapi.nativeDescribeCoreStorageKey

/** One coordinator for every adapter in this process, including independent Rust roots. */
internal object CoreStorageAccess {
// Collisions only serialize unrelated slots. The bounded table never forgets a held slot lock.
private val locks = Array(256) { Mutex() }
private data class Owner(
val storageIdentifier: String,
val group: Any,
val productId: String,
val runtime: NativeTrUApiHostRuntime,
)
private val executions = WeakHashMap<TrUAPIProductExecution, Owner>()
private val failures = WeakHashMap<Any, Boolean>()
private sealed interface Notice {
class Changed(
val storage: HostCoreStorage,
val origin: Any,
val key: ByteArray,
val reportFailure: () -> Unit,
val authorizationChanged: (String) -> Unit,
) : Notice
class Barrier(val origin: Any, val completion: CompletableDeferred<Unit>) : Notice
}
// Never closed: foreign CAS jobs may outlive a cancelled requester or closed execution.
private val notices = Channel<Notice>(Channel.UNLIMITED)
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)

init {
scope.launch {
for (notice in notices) {
when (notice) {
is Notice.Changed -> {
val failed = try { refresh(notice) } catch (_: Throwable) { true }
if (failed) {
failures[notice.origin] = true
runCatching { notice.reportFailure() }
}
}
is Notice.Barrier -> {
if (failures.remove(notice.origin) == true) {
notice.completion.completeExceptionally(HostRejection.Rejected("Permission refresh failed"))
} else {
notice.completion.complete(Unit)
}
}
}
}
}
}

// Non-reentrant: `operation` must use the raw [HostCoreStorage], never `serialized` again.
suspend fun <T> serialized(storage: HostCoreStorage, key: ByteArray, operation: suspend () -> T): T {
val hash = 31 * storage.storageIdentifier.hashCode() + key.contentHashCode()
return locks[hash and (locks.size - 1)].withLock { operation() }
}

fun register(
execution: TrUAPIProductExecution,
storageIdentifier: String,
group: Any,
productId: String,
runtime: NativeTrUApiHostRuntime,
) {
synchronized(executions) { executions[execution] = Owner(storageIdentifier, group, productId, runtime) }
}

fun unregister(execution: TrUAPIProductExecution) {
synchronized(executions) { executions.remove(execution) }
}

fun changed(
storage: HostCoreStorage,
origin: Any,
key: ByteArray,
reportFailure: () -> Unit,
authorizationChanged: (String) -> Unit,
) {
notices.trySend(Notice.Changed(storage, origin, key.copyOf(), reportFailure, authorizationChanged)).getOrThrow()
}

suspend fun awaitChanges(origin: Any) {
val completion = CompletableDeferred<Unit>()
notices.trySend(Notice.Barrier(origin, completion)).getOrThrow()
completion.await()
}

private suspend fun refresh(notice: Notice.Changed): Boolean {
// CAS queues before completion to survive cancellation. Its physical write lock must
// nevertheless be released before decoding metadata or entering any Rust core.
serialized(notice.storage, notice.key) { }
val description = nativeDescribeCoreStorageKey(notice.key)
val request = description.permissionRequest ?: return false
val productId = description.productId ?: return false
val targets = synchronized(executions) {
executions.entries.filter { (execution, owner) ->
!execution.isClosed && owner.storageIdentifier == notice.storage.storageIdentifier &&
owner.productId == productId
}.map { it.value }.distinctBy { it.group }
}
var failed = false
for (owner in targets) {
try {
// Canonical revocation may already have closed the execution. Refresh through
// its process authority, which re-reads storage and fences the exact product scope.
// Include the writer: its CAS can outlive the requesting Rust future.
owner.runtime.refreshPermissionAuthorization(productId, request)
} catch (_: Throwable) {
// Do not suppress an unreadable policy or turn it into broader execution closure.
failed = true
}
}
// Notify after refresh so the shell sees canonical closure, including a dropped CAS caller.
runCatching { notice.authorizationChanged(productId) }
return failed
}
}
Loading
Loading