mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
Merge pull request #4260 from vitorpamplona/ccr-c9ac5e5e-62o6th
fix(browser): embedded browser/napplet tabs: black screens, NIP-07 routing, Tor proxy, lifecycle
This commit is contained in:
@@ -240,9 +240,15 @@ class Amethyst : Application() {
|
||||
// BACKGROUND means the process is on the system LRU list (real reclaim pressure), while UI_HIDDEN
|
||||
// fires on every app switch — so evict only at BACKGROUND and above, letting a pinned tab survive
|
||||
// a plain backgrounding. R+ only.
|
||||
//
|
||||
// rebuildAll, not a plain eviction: the tab screen the user left stays composed, holding its
|
||||
// controller, and it only re-acquires when the rebuild epoch moves. Tearing the sessions down without
|
||||
// bumping it left that screen with a dead controller and no active tab — blank on return. The epoch
|
||||
// bump is picked up by the next recomposition, which doesn't run while the app is backgrounded, so the
|
||||
// memory stays freed until the user comes back.
|
||||
val pressure = level >= ComponentCallbacks2.TRIM_MEMORY_BACKGROUND
|
||||
if (pressure && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
EmbeddedTabHost.evictAll()
|
||||
EmbeddedTabHost.rebuildAll(keepPages = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
|
||||
import com.vitorpamplona.amethyst.commons.favorites.favoriteCoordinateOf
|
||||
import com.vitorpamplona.amethyst.commons.model.ThemeType
|
||||
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
|
||||
import com.vitorpamplona.amethyst.commons.tor.TorType
|
||||
import com.vitorpamplona.amethyst.napplet.NappletLauncher
|
||||
import com.vitorpamplona.amethyst.napplet.NappletWebViewProfiles
|
||||
import com.vitorpamplona.amethyst.napplet.WebAppNetworkRegistry
|
||||
@@ -87,7 +88,10 @@ object FavoriteAppLauncher {
|
||||
preferTor: Boolean = false,
|
||||
) {
|
||||
val proxyPort = Amethyst.instance.torManager.activePortOrNull.value ?: -1
|
||||
val useTor = proxyPort > 0 && (preferTor || WebAppNetworkRegistry.useTor(url))
|
||||
// Whether Tor is ON, not whether its port is known yet: a surface that wants Tor with no port refuses
|
||||
// to load (fails closed) rather than quietly going out on the open web while Tor is still starting.
|
||||
val torEnabled = Amethyst.instance.torPrefs.torType.value != TorType.OFF
|
||||
val useTor = torEnabled && (preferTor || WebAppNetworkRegistry.useTor(url))
|
||||
val themeType = Amethyst.instance.uiPrefs.value.theme.value
|
||||
val theme =
|
||||
when (themeType) {
|
||||
|
||||
@@ -37,8 +37,10 @@ import com.vitorpamplona.amethyst.commons.browser.BrowserSitePermission
|
||||
import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermissionLedger
|
||||
import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
|
||||
import com.vitorpamplona.amethyst.commons.model.Account
|
||||
import com.vitorpamplona.amethyst.commons.napplet.NappletAttendance
|
||||
import com.vitorpamplona.amethyst.commons.napplet.NappletBroker
|
||||
import com.vitorpamplona.amethyst.commons.napplet.NappletCapability
|
||||
import com.vitorpamplona.amethyst.commons.napplet.NappletHeldRequests
|
||||
import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity
|
||||
import com.vitorpamplona.amethyst.commons.napplet.NappletIdentityWatch
|
||||
import com.vitorpamplona.amethyst.commons.napplet.NappletRequestRouter
|
||||
@@ -93,9 +95,11 @@ class NappletBrokerService : Service() {
|
||||
|
||||
private val incoming by lazy { Messenger(Handler(Looper.getMainLooper(), ::handleMessage)) }
|
||||
|
||||
// Live relay subscriptions, keyed by the applet's subId. The account comes per-open from the
|
||||
// Live relay subscriptions, keyed by the requesting surface plus the applet's subId. The account comes per-open from the
|
||||
// requesting surface's launch token, so a surface's REQs always target the account it acts as.
|
||||
private val liveSubscriptions = NappletLiveSubscriptions(scope)
|
||||
// Which surfaces the user is looking at: relay reads are decrypted for a page only while it is.
|
||||
private val attendance = NappletAttendance<Messenger>()
|
||||
private val liveSubscriptions = NappletLiveSubscriptions(scope, attendance)
|
||||
|
||||
// NAP-RESOURCE cancellation is keyed by the trusted launch token plus the caller's request id.
|
||||
// Cancelling removes the job before it can emit a late terminal envelope to the sandbox.
|
||||
@@ -158,7 +162,20 @@ class NappletBrokerService : Service() {
|
||||
// client's Messenger keeps a binder alive, which pins that surface's whole Activity (and its
|
||||
// WebView) in the `:napplet` process past onDestroy — reclaimable only by killing the process.
|
||||
if (msg.what == NappletIpc.MSG_RELEASE_CLIENT) {
|
||||
msg.replyTo?.let { incBus.removeAll(it) }
|
||||
msg.replyTo?.let {
|
||||
incBus.removeAll(it)
|
||||
liveSubscriptions.closeAllFor(it)
|
||||
attendance.forget(it)
|
||||
}
|
||||
// Tokens the surface will never use again: drop their sessions and whatever runs under them.
|
||||
// Only the surface that minted a token holds it (tokens are unguessable), so it can only ever
|
||||
// give back its own.
|
||||
msg.data?.getStringArray(NappletIpc.KEY_RELEASED_TOKENS)?.forEach { token ->
|
||||
NappletLaunchRegistry.unregister(token)
|
||||
identityWatch.stop(token)
|
||||
val prefix = "$token\u0000"
|
||||
resourceRequests.keys.filter { it.startsWith(prefix) }.forEach { key -> resourceRequests.remove(key)?.cancel() }
|
||||
}
|
||||
// Release its foreground lease too; otherwise a destroyed surface keeps the main process
|
||||
// pinned resumed until the lease watchdog expires it.
|
||||
msg.data?.getString(NappletIpc.KEY_LAUNCH_TOKEN)?.let { token ->
|
||||
@@ -171,6 +188,15 @@ class NappletBrokerService : Service() {
|
||||
|
||||
// A sandbox surface (full-screen :napplet host) entered, renewed, or left the foreground. Hold the
|
||||
// main process resumed while at least one is foreground, so opening it doesn't tear down Tor/relays.
|
||||
if (msg.what == NappletIpc.MSG_SET_ATTENDED) {
|
||||
val owner = msg.replyTo ?: return true
|
||||
val attended = msg.data?.getBoolean(NappletIpc.KEY_ATTENDED, false) ?: false
|
||||
attendance.set(owner, attended)
|
||||
// Encrypted events its subscriptions received meanwhile can be decrypted and delivered now.
|
||||
if (attended) liveSubscriptions.onAttended(owner)
|
||||
return true
|
||||
}
|
||||
|
||||
if (msg.what == NappletIpc.MSG_SET_FOREGROUND) {
|
||||
val data = msg.data ?: return true
|
||||
val token = data.getString(NappletIpc.KEY_LAUNCH_TOKEN) ?: return true
|
||||
@@ -336,8 +362,22 @@ class NappletBrokerService : Service() {
|
||||
val identity = NappletIdentity(authorPubKey = BROWSER_IDENTITY_AUTHOR, identifier = origin)
|
||||
// Bind to the account active at mint time: a browser token minted for one account must
|
||||
// never sign as another if the user switches while the page is still open.
|
||||
val mintAccount = Amethyst.instance.sessionManager.loggedInAccount() ?: return true
|
||||
val token = NappletLaunchRegistry.register(identity, NappletCapability.WEBSITE_CAPABILITIES, mintAccount.pubKey)
|
||||
val mintAccount = Amethyst.instance.sessionManager.loggedInAccount()
|
||||
// The surface names the storage jar it runs in: a page left open across an account switch (its
|
||||
// cookies, its session, belong to the previous account) must not be re-minted a token that acts
|
||||
// as the new one — its token is evicted or released, and it asks again from the old jar.
|
||||
val surfaceProfile = data.getString(NappletIpc.KEY_WEBVIEW_PROFILE)
|
||||
if (mintAccount == null || surfaceProfile != NappletWebViewProfiles.forPubKey(mintAccount.pubKey)) {
|
||||
// No one to act as: answer anyway (with no token), so the page's queued calls fail right away
|
||||
// instead of waiting forever for a token that will never come.
|
||||
val refusal =
|
||||
Message.obtain(null, NappletIpc.MSG_BROWSER_TOKEN).apply {
|
||||
this.data = Bundle().apply { putString(NappletIpc.KEY_BROWSER_ORIGIN, origin) }
|
||||
}
|
||||
runCatching { replyTo.send(refusal) }
|
||||
return true
|
||||
}
|
||||
val token = NappletLaunchRegistry.register(identity, NappletCapability.WEBSITE_CAPABILITIES, mintAccount.pubKey, browserOrigin = true)
|
||||
val response =
|
||||
Message.obtain(null, NappletIpc.MSG_BROWSER_TOKEN).apply {
|
||||
this.data =
|
||||
@@ -366,6 +406,14 @@ class NappletBrokerService : Service() {
|
||||
val session = NappletLaunchRegistry.resolve(launchToken)
|
||||
if (session == null) {
|
||||
reply(replyTo, requestId, NappletProtocolJson.encodeResponse(requestType, NappletResponse.Failed("Unknown napplet session.")))
|
||||
// Tell the surface its token is gone, so a browser tab re-mints instead of failing every call.
|
||||
if (launchToken != null) {
|
||||
val unknown =
|
||||
Message.obtain(null, NappletIpc.MSG_TOKEN_UNKNOWN).apply {
|
||||
this.data = Bundle().apply { putString(NappletIpc.KEY_LAUNCH_TOKEN, launchToken) }
|
||||
}
|
||||
runCatching { replyTo.send(unknown) }
|
||||
}
|
||||
return true
|
||||
}
|
||||
val identity = session.identity
|
||||
@@ -389,6 +437,12 @@ class NappletBrokerService : Service() {
|
||||
reply(replyTo, requestId, NappletProtocolJson.encodeResponse(requestType, NappletResponse.Failed("That account is no longer signed in.")))
|
||||
return@launch
|
||||
}
|
||||
// A query's results are decrypted with the user's key: while nobody is looking at the page it
|
||||
// waits (as its sign / decrypt requests do), and gives up like them.
|
||||
if (requestType == "relay.query" && !attendance.awaitAttended(replyTo, NappletHeldRequests.MAX_AGE_MS)) {
|
||||
reply(replyTo, requestId, NappletProtocolJson.encodeResponse(requestType, NappletResponse.Failed(NappletHeldRequests.EXPIRED)))
|
||||
return@launch
|
||||
}
|
||||
when (val outcome = NappletRequestRouter.route(broker, identity, declared, payload)) {
|
||||
is NappletRequestRouter.Outcome.Ignore -> {}
|
||||
is NappletRequestRouter.Outcome.Reply -> {
|
||||
@@ -401,8 +455,8 @@ class NappletBrokerService : Service() {
|
||||
}
|
||||
}
|
||||
is NappletRequestRouter.Outcome.OpenSubscription ->
|
||||
liveSubscriptions.open(outcome.subId, outcome.filters, accountFor(session.accountPubKey)) { push(replyTo, it) }
|
||||
is NappletRequestRouter.Outcome.CloseSubscription -> liveSubscriptions.close(outcome.subId)
|
||||
liveSubscriptions.open(replyTo, outcome.subId, outcome.filters, accountFor(session.accountPubKey)) { push(replyTo, it) }
|
||||
is NappletRequestRouter.Outcome.CloseSubscription -> liveSubscriptions.close(replyTo, outcome.subId)
|
||||
is NappletRequestRouter.Outcome.Push -> outcome.payloads.forEach { push(replyTo, it) }
|
||||
is NappletRequestRouter.Outcome.SubscribeInc -> incBus.subscribe(replyTo, outcome.topic)
|
||||
is NappletRequestRouter.Outcome.UnsubscribeInc -> incBus.unsubscribe(replyTo, outcome.topic)
|
||||
|
||||
@@ -61,28 +61,37 @@ object NappletLaunchRegistry {
|
||||
val accountPubKey: HexKey,
|
||||
)
|
||||
|
||||
// Access-ordered + capped so tokens from long-closed napplets can't accumulate without bound. The
|
||||
// active napplet always re-touches its token, so only stale sessions are ever evicted.
|
||||
private const val MAX_SESSIONS = 128
|
||||
// Access-ordered + capped so tokens from long-closed napplets can't accumulate without bound. Closed
|
||||
// surfaces give their tokens back ([unregister]), so the cap is only a backstop for ones that never
|
||||
// could (a crashed process); an idle live surface whose token is evicted anyway is told so and re-mints.
|
||||
private const val MAX_SESSIONS = 512
|
||||
|
||||
// LruCache is internally synchronized and access-ordered — the same
|
||||
// touch-on-resolve + evict-eldest-beyond-cap semantics the old access-ordered
|
||||
// LinkedHashMap + @Synchronized pair provided, without JVM-only APIs.
|
||||
private val sessions = LruCache<String, Session>(MAX_SESSIONS)
|
||||
|
||||
// Browser tokens live apart: a page mints one per origin it touches, so a site cycling through
|
||||
// subdomains (a.x.com, b.x.com, …) could otherwise push every open napplet's token out of the cache.
|
||||
private val browserSessions = LruCache<String, Session>(MAX_SESSIONS)
|
||||
|
||||
fun register(
|
||||
identity: NappletIdentity,
|
||||
declared: Set<NappletCapability>,
|
||||
accountPubKey: HexKey,
|
||||
browserOrigin: Boolean = false,
|
||||
): String {
|
||||
val token = RandomInstance.bytes(32).toHexKey()
|
||||
sessions.put(token, Session(identity.copy(instanceId = token), declared, accountPubKey))
|
||||
(if (browserOrigin) browserSessions else sessions).put(token, Session(identity.copy(instanceId = token), declared, accountPubKey))
|
||||
return token
|
||||
}
|
||||
|
||||
fun resolve(token: String?): Session? = token?.let { sessions[it] }
|
||||
fun resolve(token: String?): Session? = token?.let { sessions[it] ?: browserSessions[it] }
|
||||
|
||||
fun unregister(token: String?) {
|
||||
token?.let { sessions.remove(it) }
|
||||
token?.let {
|
||||
sessions.remove(it)
|
||||
browserSessions.remove(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ import com.vitorpamplona.amethyst.commons.model.ThemeType
|
||||
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
|
||||
import com.vitorpamplona.amethyst.commons.napplet.NappletArtifactPolicy
|
||||
import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity
|
||||
import com.vitorpamplona.amethyst.commons.tor.TorType
|
||||
import com.vitorpamplona.amethyst.napplethost.HostProfile
|
||||
import com.vitorpamplona.amethyst.napplethost.NappletHostActivity
|
||||
import com.vitorpamplona.amethyst.napplethost.NappletHostContract
|
||||
@@ -174,7 +175,10 @@ object NappletLauncher {
|
||||
// Resolve the per-site network choice (Tor default; a site can be opted out to the open web).
|
||||
// Locked napplets always keep Tor for their blob fetches — only nSites expose the toggle.
|
||||
NappletNetworkRegistry.init(context.applicationContext)
|
||||
val useTor = if (profile.exposesNetwork) NappletNetworkRegistry.useTor(identity.coordinate) else true
|
||||
// Whether Tor is ON, not whether its port is known yet: with Tor on and no port the host refuses to
|
||||
// fetch anything (fails closed) instead of going out directly while Tor is still starting.
|
||||
val torEnabled = Amethyst.instance.torPrefs.torType.value != TorType.OFF
|
||||
val useTor = torEnabled && (!profile.exposesNetwork || NappletNetworkRegistry.useTor(identity.coordinate))
|
||||
|
||||
// Resolve capability labels here (the app has the resources) so the sandbox module needs none.
|
||||
val capLabels = declared.map { stringRes(context, it.labelResId()) }
|
||||
|
||||
+75
-22
@@ -20,7 +20,9 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.napplet
|
||||
|
||||
import android.os.Messenger
|
||||
import com.vitorpamplona.amethyst.commons.model.Account
|
||||
import com.vitorpamplona.amethyst.commons.napplet.NappletAttendance
|
||||
import com.vitorpamplona.amethyst.commons.napplet.NappletRelayCleartext
|
||||
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletProtocolJson
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
@@ -37,7 +39,10 @@ import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
/**
|
||||
* The registry of live relay subscriptions an applet has open, keyed by its `subId`. Each entry
|
||||
* The registry of live relay subscriptions applets have open, keyed by the requesting surface's reply
|
||||
* [Messenger] plus the applet's own `subId`. One broker serves every surface, and each page numbers its
|
||||
* subs from scratch (`s0`, `s1`, …), so the `subId` alone would let one tab's REQ replace — or its
|
||||
* `relay.close` kill — another tab's feed. Each entry
|
||||
* holds the exact [INostrClient] that opened it, so teardown unsubscribes from the right account
|
||||
* even after an account switch, plus an EOSE latch so a multi-relay subscription emits a single
|
||||
* `relay.eose`. Encodes the `relay.event`/`relay.eose`/`relay.closed` pushes and hands them to the
|
||||
@@ -51,8 +56,14 @@ import java.util.concurrent.atomic.AtomicInteger
|
||||
*/
|
||||
class NappletLiveSubscriptions(
|
||||
private val scope: CoroutineScope,
|
||||
private val attendance: NappletAttendance<Messenger>,
|
||||
) {
|
||||
private val liveSubs = ConcurrentHashMap<String, LiveSub>()
|
||||
private data class Key(
|
||||
val owner: Messenger,
|
||||
val subId: String,
|
||||
)
|
||||
|
||||
private val liveSubs = ConcurrentHashMap<Key, LiveSub>()
|
||||
private val liveSeq = AtomicInteger(0)
|
||||
|
||||
private class LiveSub(
|
||||
@@ -62,6 +73,10 @@ class NappletLiveSubscriptions(
|
||||
val eoseSent = AtomicBoolean(false)
|
||||
val deliveries = Channel<Delivery>(Channel.UNLIMITED)
|
||||
var deliveryJob: Job? = null
|
||||
|
||||
// Encrypted events that arrived while nobody was looking at the page, still encrypted: they are
|
||||
// decrypted and delivered when it is attended again. Touched only by the delivery coroutine.
|
||||
val heldEncrypted = ArrayDeque<Event>()
|
||||
}
|
||||
|
||||
private sealed interface Delivery {
|
||||
@@ -71,17 +86,21 @@ class NappletLiveSubscriptions(
|
||||
|
||||
data object Eose : Delivery
|
||||
|
||||
// The page is being looked at again: deliver what was held.
|
||||
data object Attended : Delivery
|
||||
|
||||
data class Closed(
|
||||
val reason: String,
|
||||
) : Delivery
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a live relay subscription for [nappletSubId], streaming `relay.event`/`relay.eose`/
|
||||
* `relay.closed` envelopes to [push] as events arrive. Replaces any existing subscription for
|
||||
* the same id. With no account/relays/filters it pushes a single empty EOSE to close it.
|
||||
* Opens [owner]'s live relay subscription [nappletSubId], streaming `relay.event`/`relay.eose`/
|
||||
* `relay.closed` envelopes to [push] as events arrive. Replaces any existing subscription [owner] has
|
||||
* under the same id. With no account/relays/filters it pushes a single empty EOSE to close it.
|
||||
*/
|
||||
fun open(
|
||||
owner: Messenger,
|
||||
nappletSubId: String,
|
||||
filters: List<Filter>,
|
||||
account: Account?,
|
||||
@@ -93,19 +112,34 @@ class NappletLiveSubscriptions(
|
||||
return
|
||||
}
|
||||
|
||||
close(nappletSubId)
|
||||
val key = Key(owner, nappletSubId)
|
||||
close(owner, nappletSubId)
|
||||
// liveSeq guarantees a unique client subId, so a rapid re-open of the same applet subId
|
||||
// can't collide with the subscription it's replacing.
|
||||
val sub = LiveSub("napplet-$nappletSubId-${liveSeq.incrementAndGet()}", account.client)
|
||||
liveSubs[nappletSubId] = sub
|
||||
liveSubs[key] = sub
|
||||
sub.deliveryJob =
|
||||
scope.launch {
|
||||
for (delivery in sub.deliveries) {
|
||||
if (liveSubs[nappletSubId] !== sub) break
|
||||
if (liveSubs[key] !== sub) break
|
||||
when (delivery) {
|
||||
is Delivery.RelayEvent ->
|
||||
NappletRelayCleartext.forDelivery(delivery.event, account.signer)?.let {
|
||||
push(NappletProtocolJson.encodeRelayEvent(nappletSubId, it))
|
||||
is Delivery.RelayEvent -> {
|
||||
val event = delivery.event
|
||||
if (NappletRelayCleartext.isEncrypted(event) && !attendance.isAttended(owner)) {
|
||||
// Don't decrypt for a page nobody is watching: keep it (bounded) for later.
|
||||
if (sub.heldEncrypted.size >= MAX_HELD_ENCRYPTED) sub.heldEncrypted.removeFirst()
|
||||
sub.heldEncrypted.addLast(event)
|
||||
} else {
|
||||
NappletRelayCleartext.forDelivery(event, account.signer)?.let {
|
||||
push(NappletProtocolJson.encodeRelayEvent(nappletSubId, it))
|
||||
}
|
||||
}
|
||||
}
|
||||
Delivery.Attended ->
|
||||
while (sub.heldEncrypted.isNotEmpty() && attendance.isAttended(owner)) {
|
||||
NappletRelayCleartext.forDelivery(sub.heldEncrypted.removeFirst(), account.signer)?.let {
|
||||
push(NappletProtocolJson.encodeRelayEvent(nappletSubId, it))
|
||||
}
|
||||
}
|
||||
Delivery.Eose -> push(NappletProtocolJson.encodeRelayEose(nappletSubId))
|
||||
is Delivery.Closed -> push(NappletProtocolJson.encodeRelayClosed(nappletSubId, delivery.reason))
|
||||
@@ -145,21 +179,40 @@ class NappletLiveSubscriptions(
|
||||
runCatching { sub.client.subscribe(sub.clientSubId, relays.associateWith { filters }, listener) }
|
||||
}
|
||||
|
||||
/** Stops the live subscription for [nappletSubId], unsubscribing from the client that opened it. */
|
||||
fun close(nappletSubId: String) {
|
||||
val sub = liveSubs.remove(nappletSubId) ?: return
|
||||
/** [owner] is being looked at again: its subscriptions deliver the encrypted events they held. */
|
||||
fun onAttended(owner: Messenger) {
|
||||
liveSubs.forEach { (key, sub) -> if (key.owner == owner) sub.deliveries.trySend(Delivery.Attended) }
|
||||
}
|
||||
|
||||
/** Stops [owner]'s live subscription [nappletSubId], unsubscribing from the client that opened it. */
|
||||
fun close(
|
||||
owner: Messenger,
|
||||
nappletSubId: String,
|
||||
) {
|
||||
liveSubs.remove(Key(owner, nappletSubId))?.let(::stop)
|
||||
}
|
||||
|
||||
/** Stops every subscription [owner] still has open (its surface went away without closing them). */
|
||||
fun closeAllFor(owner: Messenger) {
|
||||
liveSubs.keys
|
||||
.filter { it.owner == owner }
|
||||
.forEach { key -> liveSubs.remove(key)?.let(::stop) }
|
||||
}
|
||||
|
||||
/** Tears down every open subscription (service teardown). */
|
||||
fun closeAll() {
|
||||
liveSubs.values.forEach(::stop)
|
||||
liveSubs.clear()
|
||||
}
|
||||
|
||||
private fun stop(sub: LiveSub) {
|
||||
sub.deliveries.close()
|
||||
sub.deliveryJob?.cancel()
|
||||
runCatching { sub.client.unsubscribe(sub.clientSubId) }
|
||||
}
|
||||
|
||||
/** Tears down every open subscription (service teardown). */
|
||||
fun closeAll() {
|
||||
liveSubs.values.forEach { sub ->
|
||||
sub.deliveries.close()
|
||||
sub.deliveryJob?.cancel()
|
||||
runCatching { sub.client.unsubscribe(sub.clientSubId) }
|
||||
}
|
||||
liveSubs.clear()
|
||||
private companion object {
|
||||
// Per subscription: past this, the oldest held encrypted event is dropped.
|
||||
const val MAX_HELD_ENCRYPTED = 500
|
||||
}
|
||||
}
|
||||
|
||||
+339
-31
@@ -36,11 +36,12 @@ import android.os.Messenger
|
||||
import android.os.SystemClock
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.privacysandbox.ui.client.SandboxedUiAdapterFactory
|
||||
import androidx.privacysandbox.ui.client.view.SandboxedSdkView
|
||||
import androidx.privacysandbox.ui.client.view.SandboxedSdkViewEventListener
|
||||
import androidx.privacysandbox.ui.core.SandboxedUiAdapter
|
||||
import com.vitorpamplona.amethyst.commons.browser.BrowserChrome
|
||||
import com.vitorpamplona.amethyst.commons.browser.BrowserSitePermission
|
||||
import com.vitorpamplona.amethyst.commons.browser.ui.pill.CertificateInfo
|
||||
import com.vitorpamplona.amethyst.commons.browser.ui.pill.ConsoleLine
|
||||
@@ -49,6 +50,8 @@ import com.vitorpamplona.amethyst.napplet.NappletWebViewProfiles
|
||||
import com.vitorpamplona.amethyst.napplet.WebFileChooserCoordinator
|
||||
import com.vitorpamplona.amethyst.napplethost.NappletBrowserContract
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.ConsoleBridge
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.ConsoleBuffer
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedAutoRecovery
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedImeBridge
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedLoadStatus
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedMagnifierProbe
|
||||
@@ -59,6 +62,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.ImeEvent
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.MagnifierFrame
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.consoleLevelOf
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.parseImeEvent
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
/**
|
||||
@@ -70,7 +74,9 @@ import java.util.concurrent.atomic.AtomicLong
|
||||
@RequiresApi(Build.VERSION_CODES.R)
|
||||
class EmbeddedWebAppController(
|
||||
private val appContext: Context,
|
||||
private val proxyPort: Int,
|
||||
// Read on every create and load rather than once: Tor may still be starting when the tab is made, and a
|
||||
// Tor page loads nothing (fails closed) until its port is known.
|
||||
private val proxyPort: () -> Int,
|
||||
private val initialUseTor: Boolean,
|
||||
private val backgroundColor: Int,
|
||||
private val themeType: String = "SYSTEM",
|
||||
@@ -97,6 +103,36 @@ class EmbeddedWebAppController(
|
||||
private var hasLoadedReal = false
|
||||
private var blankRecovered = false
|
||||
|
||||
// Brings the tab back when its sandbox-side surface dies (see [onSurfaceLost]).
|
||||
private val recovery = EmbeddedAutoRecovery(SystemClock::elapsedRealtime)
|
||||
|
||||
// The remote session behind the current view errored out: only a brand-new session can repaint it.
|
||||
private var sessionDead = false
|
||||
|
||||
// Set after the first connection, so a later onServiceConnected is recognised as `:napplet` coming back.
|
||||
private var everConnected = false
|
||||
|
||||
// Set by [unbind]: nothing that arrives afterwards may act.
|
||||
private var tornDown = false
|
||||
|
||||
// A `:napplet` restart found this tab hidden: its session is re-created when it is next shown.
|
||||
private var createOnShow = false
|
||||
|
||||
// A create is in flight: the view's old session erroring out now is the one being replaced, not news.
|
||||
private var awaitingReady = false
|
||||
|
||||
// The current session's surface has shown in the view at least once (see [retry]).
|
||||
private var uiDisplayed = false
|
||||
|
||||
// What the provider was last told (see [syncPageState]). Remembered so both are replayed right after each
|
||||
// session is created: a parked tab can be hidden before the service even binds.
|
||||
private var wantPaused = false
|
||||
private var wantAttended = false
|
||||
|
||||
// The app is on screen / has been in the background long enough to pause even the visible tab.
|
||||
private var appVisible = true
|
||||
private var backgroundIdle = false
|
||||
|
||||
/** Last known main-frame load state, so the tab layer renders the right overlay immediately. */
|
||||
override var loadStatus: EmbeddedLoadStatus = EmbeddedLoadStatus()
|
||||
private set
|
||||
@@ -105,15 +141,65 @@ class EmbeddedWebAppController(
|
||||
override var onLoadStatusChanged: ((EmbeddedLoadStatus) -> Unit)? = null
|
||||
|
||||
/** JavaScript console output received from the embedded WebView, capped at [MAX_CONSOLE_LOGS] entries. */
|
||||
override val consoleLogs = mutableStateListOf<ConsoleLine>()
|
||||
private val console = ConsoleBuffer(MAX_CONSOLE_LOGS)
|
||||
override val consoleLogs get() = console.lines
|
||||
override val consoleErrorCount get() = console.errorCount
|
||||
|
||||
override fun clearConsoleLogs() = consoleLogs.clear()
|
||||
override fun clearConsoleLogs() = console.clear()
|
||||
|
||||
// The user's per-tab page settings. The provider forgets them whenever the session is re-created (a
|
||||
// `:napplet` restart, a rearm), so they are re-sent with every create — otherwise a site the user
|
||||
// switched onto Tor would silently come back over clearnet while the pill still said Tor.
|
||||
private var useTor = initialUseTor
|
||||
private var textZoom = BrowserChrome.DEFAULT_TEXT_ZOOM
|
||||
private var desktopSite = false
|
||||
|
||||
// The page on screen, kept here rather than in the tab's screen: the screen leaves composition whenever
|
||||
// the user switches bottom-bar tabs, and coming back must show where they were (the right address for
|
||||
// share / favorite / site settings, and a Back that goes back in the page instead of leaving the tab).
|
||||
var lastUrl: String? = null
|
||||
private set
|
||||
var lastTitle: String? = null
|
||||
private set
|
||||
var lastCanGoBack = false
|
||||
private set
|
||||
var lastCanGoForward = false
|
||||
private set
|
||||
|
||||
/** A tab's page and per-tab settings, carried to the controller that replaces this one on a rebuild. */
|
||||
class PageSnapshot(
|
||||
val url: String?,
|
||||
val useTor: Boolean,
|
||||
val textZoom: Int,
|
||||
val desktopSite: Boolean,
|
||||
)
|
||||
|
||||
fun snapshot() = PageSnapshot(lastUrl, useTor, textZoom, desktopSite)
|
||||
|
||||
/** Takes over a torn-down predecessor's page and settings; call before [bind], which creates the session. */
|
||||
fun restore(snapshot: PageSnapshot) {
|
||||
lastUrl = snapshot.url
|
||||
useTor = snapshot.useTor
|
||||
textZoom = snapshot.textZoom
|
||||
desktopSite = snapshot.desktopSite
|
||||
}
|
||||
|
||||
/** The user's per-tab settings as last set, for a screen coming back to this tab. */
|
||||
val isTorOn: Boolean get() = useTor
|
||||
|
||||
// Whether `:napplet` routes through Tor right now: another surface that needs Tor puts every page on it.
|
||||
private val routedOverTor = mutableStateOf(false)
|
||||
|
||||
/** This page is set to the open web but goes through Tor anyway, because another open page needs Tor. */
|
||||
val isTorForced: Boolean get() = !useTor && routedOverTor.value
|
||||
val isDesktopSite: Boolean get() = desktopSite
|
||||
val currentTextZoom: Int get() = textZoom
|
||||
|
||||
// A single NappletBrowserService instance serves every embedded browser tab, so each controller
|
||||
// stamps its own id on every message; the provider uses it to route controls/updates to this tab.
|
||||
// Re-minted whenever the remote session is re-created (see [attachView]), so a late close() from the
|
||||
// previous view can never reap the replacement.
|
||||
private var sessionId: String = "browser-${SESSION_SEQ.incrementAndGet()}"
|
||||
private var sessionId: String = newSessionId()
|
||||
|
||||
/** Invoked on the main thread when the page navigates or retitles: (url, title or null, canGoBack, canGoForward). */
|
||||
var onUrlChanged: ((String, String?, Boolean, Boolean) -> Unit)? = null
|
||||
@@ -153,11 +239,29 @@ class EmbeddedWebAppController(
|
||||
service: IBinder?,
|
||||
) {
|
||||
serviceMessenger = Messenger(service)
|
||||
if (everConnected) {
|
||||
// `:napplet` died and was restarted. Re-creating the session IS the recovery (a fresh
|
||||
// process has no session under any id), so nothing else is left pending; cover the
|
||||
// surface until the new page paints. Only the visible tab rebuilds now: every warm tab
|
||||
// reconnects at once, and rebuilding them all right after the OS reclaimed that memory
|
||||
// would just push it back up. The rest re-create when next shown.
|
||||
recovery.clearPending()
|
||||
sessionDead = false
|
||||
showRecovering()
|
||||
everConnected = true
|
||||
if (recovery.isShown) sendCreateSession() else createOnShow = true
|
||||
return
|
||||
}
|
||||
everConnected = true
|
||||
sendCreateSession()
|
||||
}
|
||||
|
||||
override fun onServiceDisconnected(name: ComponentName?) {
|
||||
// `:napplet` died (the OS reclaimed it, or it crashed). Its WebViews went with it; the
|
||||
// system restarts the bound service and [onServiceConnected] re-creates the session.
|
||||
serviceMessenger = null
|
||||
resetPageState()
|
||||
showRecovering()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,12 +272,17 @@ class EmbeddedWebAppController(
|
||||
}
|
||||
|
||||
fun unbind() {
|
||||
// Tell the provider to drop this tab's session now: one created for a view that was disposed before
|
||||
// it attached never gets the surface close that would otherwise clean it up.
|
||||
send(NappletBrowserContract.MSG_CLOSE_SESSION) {}
|
||||
tornDown = true
|
||||
if (bound) {
|
||||
runCatching { appContext.unbindService(connection) }
|
||||
bound = false
|
||||
}
|
||||
// Drop refs so an evicted controller doesn't pin the surface view or the remote messenger.
|
||||
serviceMessenger = null
|
||||
sandboxedSdkView?.setEventListener(null)
|
||||
sandboxedSdkView = null
|
||||
pendingAdapter = null
|
||||
adapterDelivered = false
|
||||
@@ -181,15 +290,73 @@ class EmbeddedWebAppController(
|
||||
onImeEvent = null
|
||||
onMagnifierFrame = null
|
||||
onLoadStatusChanged = null
|
||||
consoleLogs.clear()
|
||||
console.clear()
|
||||
resetPageState()
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops UI state that belongs to the page on screen: once that page is gone (renderer death, session
|
||||
* lost, `:napplet` restart) nothing will ever close it. A stale fullscreen flag swallowed every Back
|
||||
* press, and a stale dialog or permission prompt auto-refused every new one from the rebuilt page.
|
||||
*/
|
||||
private fun resetPageState() {
|
||||
pendingDialog.value = null
|
||||
pendingPermission.value = null
|
||||
pendingDownload.value = null
|
||||
isFullscreen.value = false
|
||||
_findResult.value = null
|
||||
}
|
||||
|
||||
override fun teardown() = unbind()
|
||||
|
||||
override fun onShown() {
|
||||
val deferredRecovery = recovery.onShown()
|
||||
syncPageState()
|
||||
if (createOnShow) {
|
||||
createOnShow = false
|
||||
sendCreateSession()
|
||||
} else if (deferredRecovery) {
|
||||
recover()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onHidden() {
|
||||
recovery.onHidden()
|
||||
syncPageState()
|
||||
}
|
||||
|
||||
override fun onAppVisibility(visible: Boolean) {
|
||||
appVisible = visible
|
||||
syncPageState()
|
||||
}
|
||||
|
||||
override fun onBackgroundIdle(idle: Boolean) {
|
||||
backgroundIdle = idle
|
||||
syncPageState()
|
||||
}
|
||||
|
||||
/**
|
||||
* Tells the provider what the page may do now:
|
||||
* - paused while parked off-screen, or once the app has sat in the background as long as the relays get
|
||||
* (EmbeddedTabHost.BACKGROUND_PAUSE_MS) — no animations, media or geolocation keep running;
|
||||
* - attended only while it's the visible tab AND the app is on screen. The provider holds the page's
|
||||
* NIP-07 sign / encrypt / decrypt while it isn't, so a parked or backgrounded site can't sign (even with
|
||||
* "allow always") while nobody is looking. That one applies at once: it's about who is watching, not
|
||||
* about saving work.
|
||||
*/
|
||||
private fun syncPageState() {
|
||||
val pause = !recovery.isShown || backgroundIdle
|
||||
if (pause != wantPaused) {
|
||||
wantPaused = pause
|
||||
send(if (pause) NappletBrowserContract.MSG_PAUSE else NappletBrowserContract.MSG_RESUME) {}
|
||||
}
|
||||
val attended = recovery.isShown && appVisible
|
||||
if (attended != wantAttended) {
|
||||
wantAttended = attended
|
||||
send(NappletBrowserContract.MSG_SET_ATTENDED) { putBoolean(NappletBrowserContract.KEY_ENABLED, attended) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hands the surface view to the controller; applies the adapter if it already arrived, and re-arms the
|
||||
* remote session when this controller is being re-used by a *second* view.
|
||||
@@ -212,6 +379,7 @@ class EmbeddedWebAppController(
|
||||
// Paint the surface placeholder in the app's theme background so there's no white flash before
|
||||
// the remote WebView delivers its first frame.
|
||||
view.setBackgroundColor(backgroundColor)
|
||||
view.setEventListener(surfaceListener(view))
|
||||
val adapter = pendingAdapter
|
||||
when {
|
||||
adapter != null -> {
|
||||
@@ -221,30 +389,120 @@ class EmbeddedWebAppController(
|
||||
}
|
||||
// No adapter in hand and one was already spent on a previous (now disposed) view: the session
|
||||
// behind it is gone, so this view would stay blank forever. Re-create it.
|
||||
adapterDelivered -> {
|
||||
// Mint a FRESH session id. The disposed view's Session.close() reaches the sandbox
|
||||
// asynchronously (it posts to the sandbox's main thread) and was measured landing ~1 s
|
||||
// AFTER this create: reusing the id let that late close reap the session we had just asked
|
||||
// for — a new WebView was built, destroyed, and the surface stayed black. A new id makes
|
||||
// the stale close target only the corpse it belongs to.
|
||||
sessionId = "browser-${SESSION_SEQ.incrementAndGet()}"
|
||||
adapterDelivered = false
|
||||
sendCreateSession()
|
||||
}
|
||||
adapterDelivered -> rearmSession()
|
||||
// else: the first session is still in flight; MSG_SESSION_READY will arm this view.
|
||||
}
|
||||
}
|
||||
|
||||
override fun detachView(view: SandboxedSdkView) {
|
||||
if (sandboxedSdkView !== view) return
|
||||
view.setEventListener(null)
|
||||
sandboxedSdkView = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks the sandbox for a brand-new session; the [NappletBrowserContract.MSG_SESSION_READY] reply arms
|
||||
* the current view with its adapter.
|
||||
*
|
||||
* Mints a FRESH session id. A disposed view's Session.close() reaches the sandbox asynchronously (it
|
||||
* posts to the sandbox's main thread) and was measured landing ~1 s AFTER the create: reusing the id let
|
||||
* that late close reap the session we had just asked for — a new WebView was built, destroyed, and the
|
||||
* surface stayed black. A new id makes the stale close target only the corpse it belongs to.
|
||||
*/
|
||||
private fun rearmSession() {
|
||||
// The session being replaced may never have opened a surface (its view went away first), in which
|
||||
// case no surface close will ever reach the provider for it.
|
||||
send(NappletBrowserContract.MSG_CLOSE_SESSION) {}
|
||||
sessionId = newSessionId()
|
||||
// This create IS the re-creation a `:napplet` restart deferred to the next show.
|
||||
createOnShow = false
|
||||
adapterDelivered = false
|
||||
sessionDead = false
|
||||
resetPageState()
|
||||
sendCreateSession()
|
||||
}
|
||||
|
||||
/**
|
||||
* Watches [view]'s remote session. A session that errors out (its provider failed, or `:napplet` died)
|
||||
* leaves the view holding a dead client that never reopens: it paints nothing, forever, until it is
|
||||
* handed a NEW adapter.
|
||||
*/
|
||||
private fun surfaceListener(view: SandboxedSdkView) =
|
||||
object : SandboxedSdkViewEventListener {
|
||||
override fun onUiDisplayed() {
|
||||
// The load state reports when the page itself paints; this only says the surface opened.
|
||||
if (sandboxedSdkView === view) uiDisplayed = true
|
||||
}
|
||||
|
||||
override fun onUiError(error: Throwable) {
|
||||
// A view this controller has since moved past (disposed, replaced) is not ours to revive, and an
|
||||
// error landing while a new session is on its way is the old one dying: that create already
|
||||
// is the rebuild.
|
||||
if (sandboxedSdkView === view && !awaitingReady) onSurfaceLost(sessionDead = true)
|
||||
}
|
||||
|
||||
override fun onUiClosed() {
|
||||
// Nothing to do: closes are ours (a view disposed, or an adapter replaced on purpose).
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The tab's page is gone: its WebView's renderer died ([sessionDead] false — the session lives on, and a
|
||||
* MSG_RELOAD rebuilds the WebView inside it), or the whole remote session errored out ([sessionDead]
|
||||
* true — only a new session can repaint the view). Either way the surface is a black rectangle that would
|
||||
* stay that way, so rebuild it, as [EmbeddedAutoRecovery] allows.
|
||||
*/
|
||||
private fun onSurfaceLost(sessionDead: Boolean) {
|
||||
if (sessionDead) this.sessionDead = true
|
||||
hasLoadedReal = false
|
||||
resetPageState()
|
||||
// `:napplet` itself is down: its restart re-creates the session (see [onServiceConnected]).
|
||||
if (serviceMessenger?.binder?.isBinderAlive != true) {
|
||||
showRecovering()
|
||||
return
|
||||
}
|
||||
when (recovery.onLost()) {
|
||||
EmbeddedAutoRecovery.Decision.RECOVER_NOW -> recover()
|
||||
EmbeddedAutoRecovery.Decision.DEFERRED -> showRecovering()
|
||||
EmbeddedAutoRecovery.Decision.GIVE_UP -> publishLoadStatus(EmbeddedLoadStatus(failed = true))
|
||||
}
|
||||
}
|
||||
|
||||
private fun recover() {
|
||||
showRecovering()
|
||||
if (sessionDead) rearmSession() else reload()
|
||||
}
|
||||
|
||||
/** Covers the surface with the loading spinner until the rebuilt page paints. */
|
||||
private fun showRecovering() {
|
||||
hasLoadedReal = false
|
||||
blankRecovered = false
|
||||
publishLoadStatus(EmbeddedLoadStatus(isLoading = true))
|
||||
}
|
||||
|
||||
// Set by the user's Retry: the next session starts over at [startUrl]. Every other re-creation (a crashed
|
||||
// renderer, a `:napplet` restart, a memory-trim rebuild) resumes the page the user was on.
|
||||
private var restartAtStart = false
|
||||
|
||||
/** Where a new session opens: the page on screen before it was lost, else the tab's own [startUrl]. */
|
||||
private fun sessionUrl(): String {
|
||||
val resume = lastUrl?.takeUnless { restartAtStart || it.isBlankPage() }
|
||||
restartAtStart = false
|
||||
return resume ?: startUrl
|
||||
}
|
||||
|
||||
private fun sendCreateSession() {
|
||||
awaitingReady = true
|
||||
uiDisplayed = false
|
||||
val msg =
|
||||
Message.obtain(null, NappletBrowserContract.MSG_CREATE_SESSION).apply {
|
||||
replyTo = incoming
|
||||
data =
|
||||
Bundle().apply {
|
||||
putString(NappletBrowserContract.KEY_SESSION_ID, sessionId)
|
||||
putString(NappletBrowserContract.KEY_URL, startUrl)
|
||||
putInt(NappletBrowserContract.KEY_PROXY_PORT, proxyPort)
|
||||
putBoolean(NappletBrowserContract.KEY_USE_TOR, initialUseTor)
|
||||
putString(NappletBrowserContract.KEY_URL, sessionUrl())
|
||||
putInt(NappletBrowserContract.KEY_PROXY_PORT, proxyPort())
|
||||
putBoolean(NappletBrowserContract.KEY_USE_TOR, useTor)
|
||||
putInt(NappletBrowserContract.KEY_BG_COLOR, backgroundColor)
|
||||
putString(NappletBrowserContract.KEY_THEME, themeType)
|
||||
// Opaque per-account storage partition, so an embedded site can't carry one
|
||||
@@ -253,12 +511,25 @@ class EmbeddedWebAppController(
|
||||
}
|
||||
}
|
||||
runCatching { serviceMessenger?.send(msg) }
|
||||
// Messenger keeps order, so these land after the CREATE and are stored on the new tab.
|
||||
if (textZoom != BrowserChrome.DEFAULT_TEXT_ZOOM) setTextZoom(textZoom)
|
||||
if (desktopSite) setDesktopSite(true)
|
||||
if (wantPaused) send(NappletBrowserContract.MSG_PAUSE) {}
|
||||
// Always: a new session starts unattended, so a tab created in view must say it is being watched.
|
||||
send(NappletBrowserContract.MSG_SET_ATTENDED) { putBoolean(NappletBrowserContract.KEY_ENABLED, wantAttended) }
|
||||
}
|
||||
|
||||
private fun onServiceMessage(msg: Message): Boolean {
|
||||
// Nothing may act on a torn-down tab (a late file-chooser request would still open a picker), nor on
|
||||
// what a session this controller has since replaced still had in flight — a stale SESSION_READY
|
||||
// would re-arm the view with that dead session's adapter.
|
||||
if (tornDown) return true
|
||||
val from = msg.data?.getString(NappletBrowserContract.KEY_SESSION_ID)
|
||||
if (from != null && from != sessionId) return true
|
||||
when (msg.what) {
|
||||
NappletBrowserContract.MSG_SESSION_READY -> {
|
||||
val coreLibInfo = msg.data?.getBundle(NappletBrowserContract.KEY_CORE_LIB_INFO) ?: return true
|
||||
awaitingReady = false
|
||||
val adapter = SandboxedUiAdapterFactory.createFromCoreLibInfo(coreLibInfo)
|
||||
val view = sandboxedSdkView
|
||||
if (view != null) {
|
||||
@@ -273,6 +544,12 @@ class EmbeddedWebAppController(
|
||||
val canGoBack = msg.data?.getBoolean(NappletBrowserContract.KEY_CAN_GO_BACK, false) ?: false
|
||||
val canGoForward = msg.data?.getBoolean(NappletBrowserContract.KEY_CAN_GO_FORWARD, false) ?: false
|
||||
val title = msg.data?.getString(NappletBrowserContract.KEY_TITLE)
|
||||
if (url != "about:blank") {
|
||||
lastUrl = url
|
||||
lastTitle = title
|
||||
}
|
||||
lastCanGoBack = canGoBack
|
||||
lastCanGoForward = canGoForward
|
||||
onUrlChanged?.invoke(url, title, canGoBack, canGoForward)
|
||||
}
|
||||
NappletBrowserContract.MSG_IME_EVENT -> {
|
||||
@@ -283,15 +560,18 @@ class EmbeddedWebAppController(
|
||||
val isLoading = msg.data?.getBoolean(NappletBrowserContract.KEY_IS_LOADING, false) ?: false
|
||||
val failed = msg.data?.getBoolean(NappletBrowserContract.KEY_LOAD_FAILED, false) ?: false
|
||||
val loadedUrl = msg.data?.getString(NappletBrowserContract.KEY_URL).orEmpty()
|
||||
onLoadState(isLoading, failed, loadedUrl)
|
||||
if (msg.data?.getBoolean(NappletBrowserContract.KEY_RENDERER_GONE, false) == true) {
|
||||
onSurfaceLost(sessionDead = false)
|
||||
} else {
|
||||
onLoadState(isLoading, failed, loadedUrl)
|
||||
}
|
||||
}
|
||||
NappletBrowserContract.MSG_CONSOLE_LOG -> {
|
||||
val level = msg.data?.getString(NappletBrowserContract.KEY_CONSOLE_LEVEL) ?: "LOG"
|
||||
val message = msg.data?.getString(NappletBrowserContract.KEY_CONSOLE_MESSAGE).orEmpty()
|
||||
val source = msg.data?.getString(NappletBrowserContract.KEY_CONSOLE_SOURCE).orEmpty()
|
||||
val line = msg.data?.getInt(NappletBrowserContract.KEY_CONSOLE_LINE, 0) ?: 0
|
||||
if (consoleLogs.size >= MAX_CONSOLE_LOGS) consoleLogs.removeAt(0)
|
||||
consoleLogs.add(ConsoleLine(consoleLevelOf(level), message, source, line))
|
||||
console.add(ConsoleLine(consoleLevelOf(level), message, source, line))
|
||||
}
|
||||
NappletBrowserContract.MSG_FILE_CHOOSER_REQUEST -> {
|
||||
val data = msg.data ?: return true
|
||||
@@ -371,6 +651,7 @@ class EmbeddedWebAppController(
|
||||
val id = msg.data?.getLong(NappletBrowserContract.KEY_PERMISSION_ID)
|
||||
if (pendingPermission.value?.id == id) pendingPermission.value = null
|
||||
}
|
||||
NappletBrowserContract.MSG_ROUTE -> routedOverTor.value = msg.data?.getBoolean(NappletBrowserContract.KEY_USE_TOR, false) ?: false
|
||||
NappletBrowserContract.MSG_DOWNLOAD_CONSENT -> {
|
||||
val data = msg.data ?: return true
|
||||
val id = data.getLong(NappletBrowserContract.KEY_DOWNLOAD_ID)
|
||||
@@ -414,9 +695,13 @@ class EmbeddedWebAppController(
|
||||
return true
|
||||
}
|
||||
|
||||
fun navigate(url: String) = send(NappletBrowserContract.MSG_NAVIGATE) { putString(NappletBrowserContract.KEY_URL, url) }
|
||||
fun navigate(url: String) =
|
||||
send(NappletBrowserContract.MSG_NAVIGATE) {
|
||||
putString(NappletBrowserContract.KEY_URL, url)
|
||||
putInt(NappletBrowserContract.KEY_PROXY_PORT, proxyPort())
|
||||
}
|
||||
|
||||
fun reload() = send(NappletBrowserContract.MSG_RELOAD) {}
|
||||
fun reload() = send(NappletBrowserContract.MSG_RELOAD) { putInt(NappletBrowserContract.KEY_PROXY_PORT, proxyPort()) }
|
||||
|
||||
/**
|
||||
* User-triggered recovery for a stuck, blank, or failed session: reload the canonical [startUrl] from
|
||||
@@ -424,10 +709,15 @@ class EmbeddedWebAppController(
|
||||
* session that never got its URL), this re-navigates to the favorite's real URL.
|
||||
*/
|
||||
override fun retry() {
|
||||
blankRecovered = false
|
||||
hasLoadedReal = false
|
||||
publishLoadStatus(EmbeddedLoadStatus(isLoading = true))
|
||||
navigate(startUrl)
|
||||
recovery.clearPending()
|
||||
showRecovering()
|
||||
// A surface that never opened has nothing to navigate: only a new session can paint it.
|
||||
if (sessionDead || (sandboxedSdkView != null && !uiDisplayed)) {
|
||||
restartAtStart = true
|
||||
rearmSession()
|
||||
} else {
|
||||
navigate(startUrl)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onLoadState(
|
||||
@@ -470,9 +760,15 @@ class EmbeddedWebAppController(
|
||||
|
||||
override fun findNext(forward: Boolean) = send(NappletBrowserContract.MSG_FIND_NEXT) { putBoolean(NappletBrowserContract.KEY_FIND_FORWARD, forward) }
|
||||
|
||||
fun setDesktopSite(enabled: Boolean) = send(NappletBrowserContract.MSG_SET_DESKTOP) { putBoolean(NappletBrowserContract.KEY_ENABLED, enabled) }
|
||||
fun setDesktopSite(enabled: Boolean) {
|
||||
desktopSite = enabled
|
||||
send(NappletBrowserContract.MSG_SET_DESKTOP) { putBoolean(NappletBrowserContract.KEY_ENABLED, enabled) }
|
||||
}
|
||||
|
||||
fun setTextZoom(percent: Int) = send(NappletBrowserContract.MSG_SET_TEXT_ZOOM) { putInt(NappletBrowserContract.KEY_TEXT_ZOOM, percent) }
|
||||
fun setTextZoom(percent: Int) {
|
||||
textZoom = percent
|
||||
send(NappletBrowserContract.MSG_SET_TEXT_ZOOM) { putInt(NappletBrowserContract.KEY_TEXT_ZOOM, percent) }
|
||||
}
|
||||
|
||||
/** Back to the app's home origin ([homeUrl]), Chrome's out-of-scope ✕. */
|
||||
fun backToScope(homeUrl: String) = send(NappletBrowserContract.MSG_BACK_TO_SCOPE) { putString(NappletBrowserContract.KEY_URL, homeUrl) }
|
||||
@@ -526,7 +822,13 @@ class EmbeddedWebAppController(
|
||||
}
|
||||
}
|
||||
|
||||
fun setTor(useTor: Boolean) = send(NappletBrowserContract.MSG_SET_TOR) { putBoolean(NappletBrowserContract.KEY_USE_TOR, useTor) }
|
||||
fun setTor(useTor: Boolean) {
|
||||
this.useTor = useTor
|
||||
send(NappletBrowserContract.MSG_SET_TOR) {
|
||||
putBoolean(NappletBrowserContract.KEY_USE_TOR, useTor)
|
||||
putInt(NappletBrowserContract.KEY_PROXY_PORT, proxyPort())
|
||||
}
|
||||
}
|
||||
|
||||
override fun sendImeOp(json: String) = send(NappletBrowserContract.MSG_IME_OP) { putString(NappletBrowserContract.KEY_IME_PAYLOAD, json) }
|
||||
|
||||
@@ -563,6 +865,12 @@ class EmbeddedWebAppController(
|
||||
|
||||
private companion object {
|
||||
private val SESSION_SEQ = AtomicLong()
|
||||
|
||||
// The provider outlives this process's restarts (and this counter with them): without a per-process
|
||||
// nonce a fresh main process would hand out ids a still-running `:napplet` already holds.
|
||||
private val PROCESS_NONCE = UUID.randomUUID().toString().take(8)
|
||||
private const val MAX_CONSOLE_LOGS = 200
|
||||
|
||||
private fun newSessionId() = "browser-$PROCESS_NONCE-${SESSION_SEQ.incrementAndGet()}"
|
||||
}
|
||||
}
|
||||
|
||||
+34
-18
@@ -55,6 +55,7 @@ import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.net.toUri
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.R
|
||||
@@ -74,6 +75,7 @@ import com.vitorpamplona.amethyst.commons.model.navigation.Route
|
||||
import com.vitorpamplona.amethyst.commons.model.navigation.favoriteIds
|
||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||
import com.vitorpamplona.amethyst.commons.resources.browser_unsupported
|
||||
import com.vitorpamplona.amethyst.commons.tor.TorType
|
||||
import com.vitorpamplona.amethyst.commons.ui.components.PlatformBackHandler
|
||||
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.commons.ui.platform.AppBottomBar
|
||||
@@ -129,23 +131,10 @@ private fun EmbeddedWebAppTab(
|
||||
// Matches FavoriteApp.WebApp.id, so warm-keep membership lines up with the bottom-bar favorites.
|
||||
val id = "url:$url"
|
||||
|
||||
var currentUrl by remember { mutableStateOf(url) }
|
||||
// The page's own <title>; null until the current document reports one (the sheet shows the host).
|
||||
var pageTitle by remember { mutableStateOf<String?>(null) }
|
||||
var canGoBack by remember { mutableStateOf(false) }
|
||||
var canGoForward by remember { mutableStateOf(false) }
|
||||
var desktopSite by remember { mutableStateOf(false) }
|
||||
var textZoom by remember { mutableIntStateOf(BrowserChrome.DEFAULT_TEXT_ZOOM) }
|
||||
var showPageInfo by remember { mutableStateOf(false) }
|
||||
|
||||
val proxyAvailable = remember { Amethyst.instance.torManager.activePortOrNull.value != null }
|
||||
// Start from this site's remembered Tor choice (some sites' servers reject Tor exits, so the user
|
||||
// can opt one out and it must stick). Only meaningful when Tor is actually available.
|
||||
var torOn by remember { mutableStateOf(proxyAvailable && WebAppNetworkRegistry.useTor(url)) }
|
||||
|
||||
val apps by Amethyst.instance.favoriteApps.favorites
|
||||
.collectAsStateWithLifecycle()
|
||||
val isFavorite = remember(apps, currentUrl) { apps.any { it is FavoriteApp.WebApp && it.url == currentUrl } }
|
||||
// Tor is ON (its port may still be coming up: a Tor page then waits, it never falls back to the open web).
|
||||
val proxyAvailable = remember { Amethyst.instance.torPrefs.torType.value != TorType.OFF }
|
||||
|
||||
val backgroundColor = MaterialTheme.colorScheme.background.toArgb()
|
||||
|
||||
@@ -155,6 +144,23 @@ private fun EmbeddedWebAppTab(
|
||||
remember(id, EmbeddedTabHost.rebuildEpoch) {
|
||||
EmbeddedTabFactory.acquireWebApp(context, url, backgroundColor)
|
||||
}
|
||||
|
||||
// Seeded from the controller, which outlives this screen: switching bottom-bar tabs disposes the screen
|
||||
// but keeps the page, so coming back must show where the page is — not the start URL with no history.
|
||||
var currentUrl by remember(controller) { mutableStateOf(controller.lastUrl ?: url) }
|
||||
// The page's own <title>; null until the current document reports one (the sheet shows the host).
|
||||
var pageTitle by remember(controller) { mutableStateOf(controller.lastTitle) }
|
||||
var canGoBack by remember(controller) { mutableStateOf(controller.lastCanGoBack) }
|
||||
var canGoForward by remember(controller) { mutableStateOf(controller.lastCanGoForward) }
|
||||
var desktopSite by remember(controller) { mutableStateOf(controller.isDesktopSite) }
|
||||
var textZoom by remember(controller) { mutableIntStateOf(controller.currentTextZoom) }
|
||||
// The controller starts from this site's remembered Tor choice (some sites' servers reject Tor exits, so
|
||||
// the user can opt one out and it must stick). Only meaningful when Tor is actually available.
|
||||
var torOn by remember(controller) { mutableStateOf(proxyAvailable && controller.isTorOn) }
|
||||
|
||||
val apps by Amethyst.instance.favoriteApps.favorites
|
||||
.collectAsStateWithLifecycle()
|
||||
val isFavorite = remember(apps, currentUrl) { apps.any { it is FavoriteApp.WebApp && it.url == currentUrl } }
|
||||
val isLoading by controller.isLoading
|
||||
|
||||
// Keep the URL/back callback fresh (cheap, needs the latest closure).
|
||||
@@ -239,9 +245,12 @@ private fun EmbeddedWebAppTab(
|
||||
val siteDecisions by WebSitePermissionRegistry.decisions.collectAsStateWithLifecycle()
|
||||
val sitePermissions = remember(siteDecisions, currentUrl) { browserOrigin(currentUrl)?.let { siteDecisions[it] }.orEmpty() }
|
||||
|
||||
// Off for this site, yet on Tor because another open page needs it (Tor always wins in `:napplet`).
|
||||
val torForced = controller.isTorForced
|
||||
|
||||
// Rebuilt only when a displayed value changes, so the tab layer isn't recomposed every frame.
|
||||
val chrome =
|
||||
remember(currentUrl, pageTitle, canGoBack, canGoForward, isLoading, torOn, proxyAvailable, isFavorite, desktopSite, textZoom, sitePermissions, candidates, controller) {
|
||||
remember(currentUrl, pageTitle, canGoBack, canGoForward, isLoading, torOn, torForced, proxyAvailable, isFavorite, desktopSite, textZoom, sitePermissions, candidates, controller) {
|
||||
EmbeddedTabChrome(
|
||||
ui =
|
||||
BrowserPillUi(
|
||||
@@ -256,6 +265,7 @@ private fun EmbeddedWebAppTab(
|
||||
canGoForward = canGoForward,
|
||||
isLoading = isLoading,
|
||||
torOn = if (proxyAvailable) torOn else null,
|
||||
torForced = torForced,
|
||||
hasSiteSettings = browserOrigin(currentUrl) != null,
|
||||
),
|
||||
isFavorite = isFavorite,
|
||||
@@ -289,13 +299,19 @@ private fun EmbeddedWebAppTab(
|
||||
SideEffect { EmbeddedTabHost.setActiveChrome(id, chrome) }
|
||||
|
||||
val bottomBarFlow = accountViewModel.account.settings.syncedSettings.navigation.bottomBarItems
|
||||
val entryLifecycle = LocalLifecycleOwner.current.lifecycle
|
||||
DisposableEffect(id) {
|
||||
val token = EmbeddedTabHost.setActive(id)
|
||||
EmbeddedTabHost.hold(id)
|
||||
onDispose {
|
||||
EmbeddedTabHost.clearActiveIfOwner(token)
|
||||
EmbeddedTabHost.clearActiveChrome(id)
|
||||
// Only bottom-row apps stay warm; anything else restarts when it leaves.
|
||||
if (id !in bottomBarFlow.value.favoriteIds()) EmbeddedTabHost.evict(id)
|
||||
// Only bottom-row apps stay warm; anything else restarts once the user actually leaves it — not
|
||||
// when a screen is merely pushed on top, and not when a re-navigation to this same tab already
|
||||
// composed a new screen on the same session.
|
||||
if (EmbeddedTabHost.release(id)) {
|
||||
EmbeddedTabHost.releaseWhenGone(id, entryLifecycle) { id in bottomBarFlow.value.favoriteIds() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+33
@@ -20,6 +20,9 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.embed
|
||||
|
||||
import androidx.compose.runtime.IntState
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.snapshots.SnapshotStateList
|
||||
import com.vitorpamplona.amethyst.commons.browser.ui.pill.ConsoleLine
|
||||
|
||||
@@ -31,9 +34,39 @@ import com.vitorpamplona.amethyst.commons.browser.ui.pill.ConsoleLine
|
||||
interface ConsoleBridge {
|
||||
val consoleLogs: SnapshotStateList<ConsoleLine>
|
||||
|
||||
/**
|
||||
* How many of [consoleLogs] are errors — the pill's badge. Kept as its own state so the tab layer
|
||||
* reads a single int instead of counting the list, which subscribed it to EVERY log line: a page
|
||||
* logging each frame recomposed the whole layer each frame.
|
||||
*/
|
||||
val consoleErrorCount: IntState
|
||||
|
||||
fun clearConsoleLogs()
|
||||
}
|
||||
|
||||
/**
|
||||
* A capped JavaScript console buffer that keeps its error count as separate state (see
|
||||
* [ConsoleBridge.consoleErrorCount]). Main-thread only.
|
||||
*/
|
||||
class ConsoleBuffer(
|
||||
private val max: Int,
|
||||
) {
|
||||
val lines = mutableStateListOf<ConsoleLine>()
|
||||
private val errors = mutableIntStateOf(0)
|
||||
val errorCount: IntState get() = errors
|
||||
|
||||
fun add(line: ConsoleLine) {
|
||||
if (lines.size >= max && lines.removeAt(0).level == ConsoleLine.Level.ERROR) errors.intValue--
|
||||
lines.add(line)
|
||||
if (line.level == ConsoleLine.Level.ERROR) errors.intValue++
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
lines.clear()
|
||||
errors.intValue = 0
|
||||
}
|
||||
}
|
||||
|
||||
/** Maps a provider's console level (WebView's `ConsoleMessage.MessageLevel` name) onto the chrome's. */
|
||||
fun consoleLevelOf(level: String): ConsoleLine.Level =
|
||||
when (level) {
|
||||
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.embed
|
||||
|
||||
/**
|
||||
* Decides when an embedded tab rebuilds itself after its sandbox-side surface died underneath it.
|
||||
*
|
||||
* Every WebView in `:napplet` shares one renderer process. When the OS reclaims it (memory pressure after
|
||||
* a long session, the app sitting in the background) or it crashes, EVERY warm tab loses its WebView at
|
||||
* once, and a surface whose remote session errored out paints nothing — both leave a black rectangle that
|
||||
* never comes back on its own. The tab was already loaded, so no load overlay covers it either.
|
||||
*
|
||||
* Recovery is automatic but lazy: the visible tab rebuilds right away, a parked one only when it is next
|
||||
* shown, so a renderer death doesn't rebuild every warm tab at once and push memory straight back up. A
|
||||
* page that kills its renderer again right after an automatic rebuild is not rebuilt in a loop — the tab
|
||||
* falls back to the error overlay and its Retry.
|
||||
*
|
||||
* Main-thread only. [now] is injectable for tests.
|
||||
*/
|
||||
class EmbeddedAutoRecovery(
|
||||
private val now: () -> Long,
|
||||
) {
|
||||
enum class Decision {
|
||||
/** Rebuild now: the tab is on screen. */
|
||||
RECOVER_NOW,
|
||||
|
||||
/** The tab is parked; [onShown] will ask for the rebuild. */
|
||||
DEFERRED,
|
||||
|
||||
/** It died again right after an automatic rebuild: stop and let the user retry. */
|
||||
GIVE_UP,
|
||||
}
|
||||
|
||||
private var shown = false
|
||||
|
||||
/** Whether the tab is the visible one (per the last [onShown] / [onHidden]). */
|
||||
val isShown: Boolean get() = shown
|
||||
private var pending = false
|
||||
private var lastAutoRecoveryAt: Long? = null
|
||||
|
||||
/** The tab's surface died. */
|
||||
fun onLost(): Decision {
|
||||
if (!shown) {
|
||||
pending = true
|
||||
return Decision.DEFERRED
|
||||
}
|
||||
val last = lastAutoRecoveryAt
|
||||
if (last != null && now() - last < LOOP_WINDOW_MS) {
|
||||
pending = false
|
||||
return Decision.GIVE_UP
|
||||
}
|
||||
lastAutoRecoveryAt = now()
|
||||
return Decision.RECOVER_NOW
|
||||
}
|
||||
|
||||
/** The tab became visible. Returns true when a deferred rebuild must run now. */
|
||||
fun onShown(): Boolean {
|
||||
shown = true
|
||||
if (!pending) return false
|
||||
pending = false
|
||||
lastAutoRecoveryAt = now()
|
||||
return true
|
||||
}
|
||||
|
||||
fun onHidden() {
|
||||
shown = false
|
||||
}
|
||||
|
||||
/** The surface came back by other means (a fresh session, a user retry): nothing left to rebuild. */
|
||||
fun clearPending() {
|
||||
pending = false
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** A second death this soon after an automatic rebuild means the page itself is killing it. */
|
||||
const val LOOP_WINDOW_MS = 30_000L
|
||||
}
|
||||
}
|
||||
+5
@@ -62,10 +62,15 @@ class MagnifierUiState {
|
||||
var lastRequestUptimeMs: Long = 0L
|
||||
var awaitingFrame: Boolean = false
|
||||
|
||||
// Request stamp of the frame on screen. Frames decode off the main thread and can finish out of order,
|
||||
// so an older one must not replace a newer one.
|
||||
var shownFrameStamp: Long = Long.MIN_VALUE
|
||||
|
||||
fun hide() {
|
||||
visible = false
|
||||
image = null
|
||||
awaitingFrame = false
|
||||
shownFrameStamp = Long.MIN_VALUE
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+23
@@ -40,6 +40,13 @@ import androidx.privacysandbox.ui.client.view.SandboxedSdkView
|
||||
interface EmbeddedSurfaceController {
|
||||
fun attachView(view: SandboxedSdkView)
|
||||
|
||||
/**
|
||||
* [view] left the composition. The controller outlives it (it lives in the process-scoped host), so it
|
||||
* must let go of it — a view holds its Activity, and a warm controller still pointing at the view of an
|
||||
* Activity the user backed out of would keep that whole Activity alive.
|
||||
*/
|
||||
fun detachView(view: SandboxedSdkView)
|
||||
|
||||
/** The session became the visible tab. */
|
||||
fun onShown() {
|
||||
// Optional hook: default no-op. Controllers that don't pause/resume applet JS need no action.
|
||||
@@ -50,6 +57,22 @@ interface EmbeddedSurfaceController {
|
||||
// Optional hook: default no-op. Controllers that don't pause/resume applet JS need no action.
|
||||
}
|
||||
|
||||
/**
|
||||
* The app left the screen ([visible] false) or came back. Even the visible tab has nobody looking at it
|
||||
* while the app is in the background, so a controller stops anything that acts for the user right away.
|
||||
*/
|
||||
fun onAppVisibility(visible: Boolean) {
|
||||
// Optional hook: default no-op (napplet screens pause on their own lifecycle).
|
||||
}
|
||||
|
||||
/**
|
||||
* The app has been in the background long enough that the rest of it winds down too (relays disconnect
|
||||
* at the same point): [idle] true pauses even the visible tab's page; false when the app returns.
|
||||
*/
|
||||
fun onBackgroundIdle(idle: Boolean) {
|
||||
// Optional hook: default no-op (napplet screens pause on their own lifecycle).
|
||||
}
|
||||
|
||||
/** Permanently close the session (unbind the service); used on eviction. */
|
||||
fun teardown()
|
||||
|
||||
|
||||
+15
-3
@@ -30,6 +30,7 @@ import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
|
||||
import com.vitorpamplona.amethyst.commons.model.ThemeType
|
||||
import com.vitorpamplona.amethyst.commons.tor.TorType
|
||||
import com.vitorpamplona.amethyst.favorites.FavoriteAppLauncher
|
||||
import com.vitorpamplona.amethyst.napplet.NappletLaunchRegistry
|
||||
import com.vitorpamplona.amethyst.napplet.WebAppNetworkRegistry
|
||||
import com.vitorpamplona.amethyst.napplethost.NappletHostContract
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.browser.EmbeddedWebAppController
|
||||
@@ -50,6 +51,9 @@ object EmbeddedTabFactory {
|
||||
|
||||
fun nostrAppId(coordinate: String) = "nostr:$coordinate"
|
||||
|
||||
/** Tor's SOCKS port right now, or -1 while it is off or still starting. */
|
||||
fun currentTorPort(): Int = Amethyst.instance.torManager.activePortOrNull.value ?: -1
|
||||
|
||||
/** Acquires (or returns) the warm browser controller for [url], routing over Tor per the site's choice. */
|
||||
fun acquireWebApp(
|
||||
context: Context,
|
||||
@@ -57,8 +61,8 @@ object EmbeddedTabFactory {
|
||||
backgroundColor: Int,
|
||||
): EmbeddedWebAppController =
|
||||
EmbeddedTabHost.acquire(webAppId(url)) {
|
||||
val proxyPort = Amethyst.instance.torManager.activePortOrNull.value ?: -1
|
||||
val initialUseTor = proxyPort > 0 && WebAppNetworkRegistry.useTor(url)
|
||||
// Tor ON, not "port known": the provider blocks a Tor page until the port is there (fails closed).
|
||||
val initialUseTor = Amethyst.instance.torPrefs.torType.value != TorType.OFF && WebAppNetworkRegistry.useTor(url)
|
||||
val themeType = Amethyst.instance.uiPrefs.value.theme.value
|
||||
val theme =
|
||||
when (themeType) {
|
||||
@@ -69,7 +73,10 @@ object EmbeddedTabFactory {
|
||||
if (nightMask == Configuration.UI_MODE_NIGHT_YES) "DARK" else "LIGHT"
|
||||
}
|
||||
}
|
||||
EmbeddedWebAppController(context.applicationContext, proxyPort, initialUseTor, backgroundColor, theme).also { it.bind(url) }
|
||||
EmbeddedWebAppController(context.applicationContext, ::currentTorPort, initialUseTor, backgroundColor, theme).also {
|
||||
EmbeddedTabHost.takePageSnapshot(webAppId(url))?.let(it::restore)
|
||||
it.bind(url)
|
||||
}
|
||||
} as EmbeddedWebAppController
|
||||
|
||||
/**
|
||||
@@ -84,6 +91,11 @@ object EmbeddedTabFactory {
|
||||
backgroundColor: Int,
|
||||
): EmbeddedNostrAppController {
|
||||
params.putInt(NappletHostContract.EXTRA_BG_COLOR, backgroundColor)
|
||||
// The screen mints fresh params on every visit, but a warm tab keeps the session (and token) it was
|
||||
// built with — give the unused fresh token back instead of leaving it registered.
|
||||
if (EmbeddedTabHost.isWarm(nostrAppId(coordinate))) {
|
||||
NappletLaunchRegistry.unregister(params.getString(NappletHostContract.EXTRA_LAUNCH_TOKEN))
|
||||
}
|
||||
return EmbeddedTabHost.acquire(nostrAppId(coordinate)) {
|
||||
EmbeddedNostrAppController(context.applicationContext, params).also { it.bind() }
|
||||
} as EmbeddedNostrAppController
|
||||
|
||||
+170
-16
@@ -21,12 +21,19 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.embed
|
||||
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import com.vitorpamplona.amethyst.napplethost.NappletHostContract
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.browser.EmbeddedWebAppController
|
||||
|
||||
/**
|
||||
* Process-level holder of **warm embedded sessions** — the persistent-surface-layer half of keep-warm.
|
||||
@@ -37,7 +44,7 @@ import androidx.compose.ui.geometry.Rect
|
||||
*
|
||||
* Warm-keep is scoped to **bottom-row apps**: a session is retained only while its app is a bottom-bar
|
||||
* favorite (see [retainOnly], driven by the bottom-bar settings). A favorite opened outside the bottom
|
||||
* row restarts when it leaves, and a low-memory trim ([evictAll]) drops everything.
|
||||
* row restarts when it leaves, and a low-memory trim ([rebuildAll]) drops everything.
|
||||
*
|
||||
* State is Compose snapshot state so [EmbeddedTabLayer] recomposes as sessions / the active id / the
|
||||
* content bounds change. Main-thread only.
|
||||
@@ -109,13 +116,72 @@ object EmbeddedTabHost {
|
||||
): EmbeddedSurfaceController {
|
||||
warm.firstOrNull { it.id == id }?.let { return it.controller }
|
||||
val controller = factory()
|
||||
// A session built while the app is in the background (a rebuild, a preload) starts in that state.
|
||||
if (!appVisible) controller.onAppVisibility(false)
|
||||
if (backgroundIdle) controller.onBackgroundIdle(true)
|
||||
warm.add(Warm(id, controller))
|
||||
return controller
|
||||
}
|
||||
|
||||
// ---- the app in the background ----
|
||||
|
||||
/** How long the app sits in the background before even the visible tab's page is paused (see there). */
|
||||
const val BACKGROUND_PAUSE_MS = NappletHostContract.BACKGROUND_PAUSE_MS
|
||||
|
||||
private var appVisible = true
|
||||
private var backgroundIdle = false
|
||||
private val backgroundTimer = Handler(Looper.getMainLooper())
|
||||
private val goIdle =
|
||||
Runnable {
|
||||
backgroundIdle = true
|
||||
warm.forEach { it.controller.onBackgroundIdle(true) }
|
||||
}
|
||||
|
||||
/** The app's UI stopped (went to the background). */
|
||||
fun onAppStopped() {
|
||||
if (!appVisible) return
|
||||
appVisible = false
|
||||
warm.forEach { it.controller.onAppVisibility(false) }
|
||||
backgroundTimer.postDelayed(goIdle, BACKGROUND_PAUSE_MS)
|
||||
}
|
||||
|
||||
/** The app's UI started again. */
|
||||
fun onAppStarted() {
|
||||
backgroundTimer.removeCallbacks(goIdle)
|
||||
if (appVisible) return
|
||||
appVisible = true
|
||||
warm.forEach { it.controller.onAppVisibility(true) }
|
||||
if (backgroundIdle) {
|
||||
backgroundIdle = false
|
||||
warm.forEach { it.controller.onBackgroundIdle(false) }
|
||||
}
|
||||
}
|
||||
|
||||
/** True if a warm session already exists for [id] (used by the preloader to skip re-acquiring). */
|
||||
fun isWarm(id: String): Boolean = warm.any { it.id == id }
|
||||
|
||||
/** True if [controller] is still the warm session for [id] (not torn down or replaced by a rebuild). */
|
||||
fun isWarm(
|
||||
id: String,
|
||||
controller: Any?,
|
||||
): Boolean = warm.any { it.id == id && it.controller === controller }
|
||||
|
||||
/**
|
||||
* The theme (dark or not) the warm sessions were built in. Kept here, next to the sessions, rather than in
|
||||
* the watcher's `remember`: an Activity recreated while the process lives re-seeds a remembered value to
|
||||
* the CURRENT theme, so a system dark-mode flip that happened meanwhile was never noticed and the warm
|
||||
* pages stayed in the old theme.
|
||||
*/
|
||||
private var builtDark: Boolean? = null
|
||||
|
||||
/** Rebuilds every warm session when the resolved theme differs from the one they were built in. */
|
||||
fun rebuildIfThemeChanged(dark: Boolean) {
|
||||
val previous = builtDark
|
||||
builtDark = dark
|
||||
// The first report only records what the sessions (built from the same preference) already use.
|
||||
if (previous != null && previous != dark) rebuildAll(keepPages = true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Seeds [contentBounds] with an approximate full-content rect when no tab has reported real bounds
|
||||
* yet, so surfaces preloaded before the user visits any tab lay out at a realistic viewport (and so
|
||||
@@ -167,6 +233,7 @@ object EmbeddedTabHost {
|
||||
fun takeKeyboardRestore(id: String): Boolean = keyboardUpOnLeave.remove(id)
|
||||
|
||||
fun evict(id: String) {
|
||||
parked.remove(id)
|
||||
val w = warm.firstOrNull { it.id == id } ?: return
|
||||
if (activeId == id) activeId = null
|
||||
keyboardUpOnLeave.remove(id)
|
||||
@@ -174,36 +241,123 @@ object EmbeddedTabHost {
|
||||
w.controller.teardown()
|
||||
}
|
||||
|
||||
/**
|
||||
* Tears down [id]'s warm session so its screen re-acquires a freshly built one (e.g. an nSite switched
|
||||
* between Tor and the open web). Unlike [evict] this keeps [activeId]: the screen stays composed and
|
||||
* never re-runs its `setActive`, so clearing it would park the new session off-screen — a blank tab.
|
||||
*/
|
||||
fun rebuild(id: String) {
|
||||
val w = warm.firstOrNull { it.id == id } ?: return
|
||||
keyboardUpOnLeave.remove(id)
|
||||
warm.remove(w)
|
||||
w.controller.teardown()
|
||||
}
|
||||
|
||||
// How many composed screens currently show each id. Re-navigating to the same route composes the new
|
||||
// screen (which acquires the SAME warm controller) before the old one disposes; counting lets the old
|
||||
// one's disposal see that the tab is still in use instead of evicting the controller under the new one.
|
||||
private val holders = mutableMapOf<String, Int>()
|
||||
|
||||
/** A screen showing [id] entered composition. Pair with [release]. */
|
||||
fun hold(id: String) {
|
||||
holders[id] = (holders[id] ?: 0) + 1
|
||||
}
|
||||
|
||||
// Non-bar tabs whose screen left composition while their back-stack entry lives on — another screen
|
||||
// was pushed on top (even the tab's own Site settings). They stay warm until EVERY such entry is
|
||||
// destroyed: the same tab can sit in the back stack twice (opened again from inside itself), and popping
|
||||
// the top one must not take the session from under the one still waiting below.
|
||||
private val parked = mutableMapOf<String, MutableSet<Lifecycle>>()
|
||||
|
||||
private fun isParked(id: String) = !parked[id].isNullOrEmpty()
|
||||
|
||||
/**
|
||||
* The last screen showing [id] left composition. A bottom-bar tab ([keepWarm]) stays warm. Any other tab
|
||||
* goes once its back-stack entry ([entry]'s lifecycle) is destroyed — right away when it already is (the
|
||||
* user left it), or later when merely covered by a pushed screen, so coming back doesn't restart the
|
||||
* page.
|
||||
*/
|
||||
fun releaseWhenGone(
|
||||
id: String,
|
||||
entry: Lifecycle,
|
||||
keepWarm: () -> Boolean,
|
||||
) {
|
||||
if (keepWarm()) return
|
||||
|
||||
fun gone() {
|
||||
parked[id]?.let {
|
||||
it.remove(entry)
|
||||
if (it.isEmpty()) parked.remove(id)
|
||||
}
|
||||
// A screen may have come back to this tab meanwhile, another entry may still hold it, or it may
|
||||
// have joined the bottom bar.
|
||||
if ((holders[id] ?: 0) == 0 && !isParked(id) && !keepWarm()) evict(id)
|
||||
}
|
||||
if (entry.currentState == Lifecycle.State.DESTROYED) {
|
||||
gone()
|
||||
return
|
||||
}
|
||||
// Already watched from an earlier time this entry was covered.
|
||||
if (!parked.getOrPut(id) { mutableSetOf() }.add(entry)) return
|
||||
entry.addObserver(
|
||||
object : LifecycleEventObserver {
|
||||
override fun onStateChanged(
|
||||
source: LifecycleOwner,
|
||||
event: Lifecycle.Event,
|
||||
) {
|
||||
if (event != Lifecycle.Event.ON_DESTROY) return
|
||||
entry.removeObserver(this)
|
||||
gone()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** A screen showing [id] left composition. Returns true when no other screen still shows it. */
|
||||
fun release(id: String): Boolean {
|
||||
val left = (holders[id] ?: 1) - 1
|
||||
if (left <= 0) holders.remove(id) else holders[id] = left
|
||||
return left <= 0
|
||||
}
|
||||
|
||||
/** Drops every warm session whose id isn't in [keep] (bottom-row membership + the active tab). */
|
||||
fun retainOnly(keep: Set<String>) {
|
||||
warm
|
||||
.filter { it.id !in keep }
|
||||
.filter { it.id !in keep && !isParked(it.id) }
|
||||
.forEach { evict(it.id) }
|
||||
}
|
||||
|
||||
fun evictAll() {
|
||||
activeId = null
|
||||
keyboardUpOnLeave.clear()
|
||||
val copy = warm.toList()
|
||||
warm.clear()
|
||||
copy.forEach { it.controller.teardown() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Something a WebView can only pick up at construction changed (the theme, or the account): tear down
|
||||
* every warm session and bump [rebuildEpoch] so the visible screen and the preloader re-acquire freshly
|
||||
* built sessions. Unlike [evictAll] this keeps [activeId], so the visible tab re-activates the instant
|
||||
* its screen re-acquires — the user just sees the current tab reload, not a blanked-out surface.
|
||||
* Something a WebView can only pick up at construction changed (the theme, or the account), or the
|
||||
* system asked for memory back: tear down every warm session and bump [rebuildEpoch] so the visible
|
||||
* screen and the preloader re-acquire freshly built sessions. This keeps [activeId], so the visible tab
|
||||
* re-activates the instant its screen re-acquires — the user just sees the current tab reload, not a
|
||||
* blanked-out surface.
|
||||
*
|
||||
* [keepPages]: each browser tab's rebuilt controller resumes the page it showed, with the user's Tor, zoom
|
||||
* and desktop choices ([EmbeddedWebAppController.PageSnapshot]). Android sends the memory trim routinely,
|
||||
* about a minute into the background, so without this every pinned site came back on its start URL. Never
|
||||
* across an account switch: the next account must not open the previous one's pages.
|
||||
*/
|
||||
fun rebuildAll() {
|
||||
fun rebuildAll(keepPages: Boolean) {
|
||||
// Every page is about to be rebuilt from scratch, so no field survives to restore a keyboard onto.
|
||||
keyboardUpOnLeave.clear()
|
||||
val copy = warm.toList()
|
||||
warm.clear()
|
||||
pageSnapshots.clear()
|
||||
if (keepPages) {
|
||||
copy.forEach { w -> (w.controller as? EmbeddedWebAppController)?.let { pageSnapshots[w.id] = it.snapshot() } }
|
||||
}
|
||||
copy.forEach { it.controller.teardown() }
|
||||
rebuildEpoch += 1
|
||||
}
|
||||
|
||||
// Page state carried from a torn-down browser tab to its rebuilt controller (see [rebuildAll]).
|
||||
private val pageSnapshots = mutableMapOf<String, EmbeddedWebAppController.PageSnapshot>()
|
||||
|
||||
/** The page state [rebuildAll] saved for tab [id], handed over once. */
|
||||
fun takePageSnapshot(id: String): EmbeddedWebAppController.PageSnapshot? = pageSnapshots.remove(id)
|
||||
|
||||
/**
|
||||
* Account the warm sessions were built for, as the opaque WebView storage-profile name (null while
|
||||
* logged out). Kept HERE, next to the sessions it describes, rather than in a composable's `remember`:
|
||||
@@ -234,6 +388,6 @@ object EmbeddedTabHost {
|
||||
builtForProfile = profileName
|
||||
// Seeding on the first call (app start) must not bump the epoch: nothing is stale yet, and a
|
||||
// needless bump would restart the preload sweep that is just getting going.
|
||||
if (!isFirstCall) rebuildAll()
|
||||
if (!isFirstCall) rebuildAll(keepPages = false)
|
||||
}
|
||||
}
|
||||
|
||||
+149
-52
@@ -56,13 +56,16 @@ import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.IntState
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
@@ -81,17 +84,24 @@ import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.privacysandbox.ui.client.view.SandboxedSdkView
|
||||
import com.vitorpamplona.amethyst.commons.browser.BrowserChrome
|
||||
import com.vitorpamplona.amethyst.commons.browser.ui.EmbeddedLoadOverlay
|
||||
import com.vitorpamplona.amethyst.commons.browser.ui.pill.BrowserPill
|
||||
import com.vitorpamplona.amethyst.commons.browser.ui.pill.BrowserPillEvent
|
||||
import com.vitorpamplona.amethyst.commons.browser.ui.pill.BrowserPillUi
|
||||
import com.vitorpamplona.amethyst.commons.browser.ui.pill.ConsoleLine
|
||||
import com.vitorpamplona.amethyst.commons.browser.ui.pill.ConsoleSheet
|
||||
import com.vitorpamplona.amethyst.commons.browser.ui.pill.FindInPagePill
|
||||
import com.vitorpamplona.amethyst.commons.ui.components.PlatformBackHandler
|
||||
import com.vitorpamplona.amethyst.napplethost.BrowserWebTools
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import kotlin.math.roundToInt
|
||||
@@ -146,6 +156,22 @@ private fun EmbeddedImeBridge.sendFieldOp(
|
||||
fun EmbeddedTabLayer(barFavoriteIds: List<String>) {
|
||||
val activeId = EmbeddedTabHost.activeId
|
||||
|
||||
// Tell the warm tabs when the app leaves the screen and when it comes back. The host stops them acting for
|
||||
// the user right away and pauses their pages on the same schedule the relays wind down on.
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
DisposableEffect(lifecycleOwner) {
|
||||
val observer =
|
||||
LifecycleEventObserver { _, event ->
|
||||
when (event) {
|
||||
Lifecycle.Event.ON_STOP -> EmbeddedTabHost.onAppStopped()
|
||||
Lifecycle.Event.ON_START -> EmbeddedTabHost.onAppStarted()
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
lifecycleOwner.lifecycle.addObserver(observer)
|
||||
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
|
||||
}
|
||||
|
||||
// Keep only bottom-row apps warm (plus the active tab, even mid-removal). A favorite removed from
|
||||
// the bar drops its warm session here.
|
||||
LaunchedEffect(barFavoriteIds, activeId) {
|
||||
@@ -157,13 +183,10 @@ fun EmbeddedTabLayer(barFavoriteIds: List<String>) {
|
||||
var layerSize by remember { mutableStateOf(IntSize.Zero) }
|
||||
val density = LocalDensity.current
|
||||
|
||||
// While the soft keyboard is up (hosted by [RemoteImeView] in this window), shrink the active
|
||||
// surface so its bottom clears the keyboard — the embedded WebView then reflows and scrolls the
|
||||
// focused field into view. Only the portion of the keyboard that overlaps the surface counts.
|
||||
// Use the *snapped* animation target rather than the animated `ime` inset: the cross-process surface
|
||||
// resize is expensive (a SurfaceControlViewHost reconfigure each frame), so we resize once to the
|
||||
// final height instead of on every frame of the keyboard slide-in/out.
|
||||
val imeBottomPx = WindowInsets.imeAnimationTarget.getBottom(density)
|
||||
// The keyboard's *snapped* target inset (not the animated one). Only the insets object is taken here;
|
||||
// its value is read inside the effect below, so a keyboard showing or hiding doesn't recompose this whole
|
||||
// layer (every surface, the pill, the selection overlay).
|
||||
val imeTarget = WindowInsets.imeAnimationTarget
|
||||
|
||||
Box(
|
||||
Modifier
|
||||
@@ -195,8 +218,6 @@ fun EmbeddedTabLayer(barFavoriteIds: List<String>) {
|
||||
// SurfaceControlViewHost surface, and the first frame presented after that reconfigure
|
||||
// stalls ~1s (the per-focus "freeze"). Keep the surface full-size and let the page bring
|
||||
// the focused field above the keyboard via the shim's scrollIntoView on focus.
|
||||
@Suppress("UNUSED_EXPRESSION")
|
||||
imeBottomPx
|
||||
Modifier
|
||||
.absoluteOffset(left, (bounds.top - layerOrigin.y).toDp())
|
||||
.size(bounds.width.toDp(), bounds.height.toDp())
|
||||
@@ -219,6 +240,9 @@ fun EmbeddedTabLayer(barFavoriteIds: List<String>) {
|
||||
}
|
||||
},
|
||||
modifier = placement,
|
||||
onRelease = { holder ->
|
||||
(holder.getChildAt(0) as? SandboxedSdkView)?.let { session.controller.detachView(it) }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -244,11 +268,13 @@ fun EmbeddedTabLayer(barFavoriteIds: List<String>) {
|
||||
activeController?.onLoadStatusChanged = { loadStatus = it }
|
||||
onDispose { activeController?.onLoadStatusChanged = null }
|
||||
}
|
||||
// Safety net: nothing painted and nothing actively loading after a grace period → offer a retry.
|
||||
// Safety net: nothing painted after a grace period → offer a retry. A load still in flight (a slow
|
||||
// site, one over Tor) gets a longer budget before it's called stuck, instead of flipping to an error
|
||||
// while it's still progressing.
|
||||
LaunchedEffect(activeId, loadStatus) {
|
||||
timedOut = false
|
||||
if (!loadStatus.hasLoadedReal && !loadStatus.failed) {
|
||||
delay(12_000)
|
||||
delay(if (loadStatus.isLoading) 45_000 else 12_000)
|
||||
timedOut = true
|
||||
}
|
||||
}
|
||||
@@ -296,6 +322,12 @@ fun EmbeddedTabLayer(barFavoriteIds: List<String>) {
|
||||
findQuery = ""
|
||||
}
|
||||
|
||||
// Switching tabs drops the find bar (its state is per tab), but the page it searched keeps its
|
||||
// highlights until told otherwise — clear them on the tab being left.
|
||||
DisposableEffect(findBridge) {
|
||||
onDispose { if (findShowing) findBridge?.find("") }
|
||||
}
|
||||
|
||||
val tabModifier =
|
||||
with(density) {
|
||||
Modifier
|
||||
@@ -318,46 +350,48 @@ fun EmbeddedTabLayer(barFavoriteIds: List<String>) {
|
||||
}
|
||||
|
||||
val consoleLogs = consoleBridge?.consoleLogs
|
||||
val ui =
|
||||
val baseUi =
|
||||
chrome.ui.copy(
|
||||
chrome = chrome.ui.chrome.copy(hasFind = chrome.ui.chrome.hasFind && findBridge != null),
|
||||
consoleShowing = consoleShowing,
|
||||
consoleErrors = consoleLogs?.count { it.level == ConsoleLine.Level.ERROR } ?: 0,
|
||||
)
|
||||
|
||||
Box(tabModifier) {
|
||||
BrowserPill(
|
||||
ui = ui,
|
||||
expanded = pillExpanded,
|
||||
onExpandedChange = { pillExpanded = it },
|
||||
onEvent = { event ->
|
||||
val action = (event as? BrowserPillEvent.Action)?.action
|
||||
when {
|
||||
action == BrowserChrome.Action.FIND_IN_PAGE && findBridge != null -> {
|
||||
// One bottom panel at a time: find replaces the console.
|
||||
consoleShowing = false
|
||||
findShowing = true
|
||||
WithConsoleErrors(baseUi, consoleBridge?.consoleErrorCount) { ui ->
|
||||
BrowserPill(
|
||||
ui = ui,
|
||||
expanded = pillExpanded,
|
||||
onExpandedChange = { pillExpanded = it },
|
||||
onEvent = { event ->
|
||||
val action = (event as? BrowserPillEvent.Action)?.action
|
||||
when {
|
||||
action == BrowserChrome.Action.FIND_IN_PAGE && findBridge != null -> {
|
||||
// One bottom panel at a time: find replaces the console.
|
||||
consoleShowing = false
|
||||
findShowing = true
|
||||
}
|
||||
action == BrowserChrome.Action.CONSOLE && consoleBridge != null -> {
|
||||
if (!consoleShowing) closeFind()
|
||||
consoleShowing = !consoleShowing
|
||||
}
|
||||
else -> chrome.onEvent(event)
|
||||
}
|
||||
action == BrowserChrome.Action.CONSOLE && consoleBridge != null -> {
|
||||
if (!consoleShowing) closeFind()
|
||||
consoleShowing = !consoleShowing
|
||||
}
|
||||
else -> chrome.onEvent(event)
|
||||
}
|
||||
},
|
||||
showClose = false,
|
||||
suggestionsFor = chrome.suggestionsFor,
|
||||
onPasteAndGo =
|
||||
if (BrowserWebTools.clipboardHasText(context)) {
|
||||
{
|
||||
pillExpanded = false
|
||||
BrowserWebTools.clipboardText(context)?.let { chrome.onEvent(BrowserPillEvent.Navigate(it)) }
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
modifier = Modifier.align(Alignment.TopCenter),
|
||||
)
|
||||
showClose = false,
|
||||
suggestionsFor = chrome.suggestionsFor,
|
||||
// A clipboard query is a binder call: only make it while the pill is open to use it.
|
||||
onPasteAndGo =
|
||||
if (pillExpanded && BrowserWebTools.clipboardHasText(context)) {
|
||||
{
|
||||
pillExpanded = false
|
||||
BrowserWebTools.clipboardText(context)?.let { chrome.onEvent(BrowserPillEvent.Navigate(it)) }
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
modifier = Modifier.align(Alignment.TopCenter),
|
||||
)
|
||||
}
|
||||
|
||||
// Find in page: opened from the pill's Find tile.
|
||||
if (findShowing && findBridge != null) {
|
||||
@@ -407,8 +441,10 @@ fun EmbeddedTabLayer(barFavoriteIds: List<String>) {
|
||||
// doesn't pop the keyboard back over the page. A tab switch also collapses the insets but does NOT
|
||||
// look like this: measured on device, the switch takes focus off the view in the same frame, so
|
||||
// isMirroringPageField() is already false there and the mark this tab was owed survives.
|
||||
LaunchedEffect(activeId, imeBottomPx) {
|
||||
if (imeBottomPx == 0 && imeView.isMirroringPageField()) imeView.noteKeyboardDismissed()
|
||||
LaunchedEffect(activeId) {
|
||||
snapshotFlow { imeTarget.getBottom(density) }.collect { imeBottomPx ->
|
||||
if (imeBottomPx == 0 && imeView.isMirroringPageField()) imeView.noteKeyboardDismissed()
|
||||
}
|
||||
}
|
||||
DisposableEffect(imeBridge) {
|
||||
val boundId = activeId
|
||||
@@ -489,7 +525,9 @@ fun EmbeddedTabLayer(barFavoriteIds: List<String>) {
|
||||
// mid-typing — which is exactly the case this restore exists for. [wantsKeyboardForPageField]
|
||||
// also answers the other half: only a keyboard THIS mirror holds counts, so typing in the
|
||||
// browser's own address bar never arms a restore for a page field.
|
||||
if (boundId != null) {
|
||||
// Only for the session that is still warm under this id: after a rebuild (theme, account) or an
|
||||
// eviction this disposal runs late, and a mark recorded now would be restored onto a fresh page.
|
||||
if (boundId != null && EmbeddedTabHost.isWarm(boundId, imeBridge)) {
|
||||
EmbeddedTabHost.noteKeyboardOnLeave(boundId, imeView.wantsKeyboardForPageField())
|
||||
}
|
||||
imeView.onPageBlur()
|
||||
@@ -532,11 +570,19 @@ fun EmbeddedTabLayer(barFavoriteIds: List<String>) {
|
||||
// Source rect (surface px) = bubble px / zoom, so the provider-scaled frame lands ≈ bubble-sized.
|
||||
val magSrcW = with(density) { (magBubble.width.toPx() / magZoom).roundToInt() }
|
||||
val magSrcH = with(density) { (magBubble.height.toPx() / magZoom).roundToInt() }
|
||||
val magScope = rememberCoroutineScope()
|
||||
DisposableEffect(magProbe) {
|
||||
magProbe?.onMagnifierFrame = { frame ->
|
||||
if (magnifier.visible) {
|
||||
magnifier.awaitingFrame = false
|
||||
BitmapFactory.decodeByteArray(frame.bytes, 0, frame.bytes.size)?.let { magnifier.image = it.asImageBitmap() }
|
||||
// Decode off the main thread: this runs for every frame of a handle drag.
|
||||
magScope.launch {
|
||||
val image = withContext(Dispatchers.Default) { BitmapFactory.decodeByteArray(frame.bytes, 0, frame.bytes.size)?.asImageBitmap() }
|
||||
if (image != null && magnifier.visible && frame.requestStampNanos > magnifier.shownFrameStamp) {
|
||||
magnifier.shownFrameStamp = frame.requestStampNanos
|
||||
magnifier.image = image
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
onDispose {
|
||||
@@ -783,8 +829,17 @@ private fun SelectionHandle(
|
||||
val color = MaterialTheme.colorScheme.primary
|
||||
val currentTip by rememberUpdatedState(tipPx)
|
||||
val currentMagnify by rememberUpdatedState(onMagnify)
|
||||
// The drag gesture is installed once (pointerInput(Unit)) and outlives recompositions, so everything it
|
||||
// reads that can change mid-life (a rotation or resize moves the origin, the page's zoom changes the
|
||||
// scale, the drag target captures the current bridge) is read through updated state.
|
||||
val currentOriginX by rememberUpdatedState(surfaceOriginX)
|
||||
val currentOriginY by rememberUpdatedState(surfaceOriginY)
|
||||
val currentScale by rememberUpdatedState(scale)
|
||||
val currentLineHalf by rememberUpdatedState(lineHalfPx)
|
||||
val currentDragTo by rememberUpdatedState(onDragTo)
|
||||
var dragTip by remember { mutableStateOf<Offset?>(null) }
|
||||
val tip = dragTip ?: tipPx
|
||||
EndDragOnDispose(isDragging = { dragTip != null }, onMagnify = { currentMagnify })
|
||||
|
||||
// Loupe capture: X follows the finger, Y is locked to the authoritative endpoint's line ([currentTip] is
|
||||
// the foot, so lift by half the line height) — so the bubble shows the line being edited, not wherever the
|
||||
@@ -792,7 +847,7 @@ private fun SelectionHandle(
|
||||
fun magnify(
|
||||
fingerPx: Offset,
|
||||
active: Boolean = true,
|
||||
) = currentMagnify?.invoke(active, fingerPx, fingerPx.x - surfaceOriginX, currentTip.y - surfaceOriginY - lineHalfPx)
|
||||
) = currentMagnify?.invoke(active, fingerPx, fingerPx.x - currentOriginX, currentTip.y - currentOriginY - currentLineHalf)
|
||||
// Place the box so its pointed corner lands on the tip: start = top-right corner, end = top-left corner.
|
||||
val boxLeft = if (isStart) tip.x - sizePx else tip.x
|
||||
Box(
|
||||
@@ -809,7 +864,7 @@ private fun SelectionHandle(
|
||||
change.consume()
|
||||
val np = (dragTip ?: currentTip) + delta
|
||||
dragTip = np
|
||||
onDragTo((np.x - surfaceOriginX) / scale, (np.y - surfaceOriginY) / scale)
|
||||
currentDragTo((np.x - currentOriginX) / currentScale, (np.y - currentOriginY) / currentScale)
|
||||
magnify(np)
|
||||
},
|
||||
onDragEnd = {
|
||||
@@ -870,6 +925,12 @@ private fun InsertionHandle(
|
||||
val currentTip by rememberUpdatedState(tipPx)
|
||||
val currentMagnify by rememberUpdatedState(onMagnify)
|
||||
val currentTap by rememberUpdatedState(onTap)
|
||||
// Read through updated state for the same reason as SelectionHandle: the gesture outlives recompositions.
|
||||
val currentOriginX by rememberUpdatedState(surfaceOriginX)
|
||||
val currentOriginY by rememberUpdatedState(surfaceOriginY)
|
||||
val currentScale by rememberUpdatedState(scale)
|
||||
val currentLineHalf by rememberUpdatedState(lineHalfPx)
|
||||
val currentDragTo by rememberUpdatedState(onDragTo)
|
||||
|
||||
// Loupe capture: X follows the finger, Y is locked to the authoritative caret's line ([currentTip] is the
|
||||
// caret foot, so lift by half the line height) — so the bubble shows the edited line, not wherever the
|
||||
@@ -877,11 +938,12 @@ private fun InsertionHandle(
|
||||
fun magnify(
|
||||
fingerPx: Offset,
|
||||
active: Boolean = true,
|
||||
) = currentMagnify?.invoke(active, fingerPx, fingerPx.x - surfaceOriginX, currentTip.y - surfaceOriginY - lineHalfPx)
|
||||
) = currentMagnify?.invoke(active, fingerPx, fingerPx.x - currentOriginX, currentTip.y - currentOriginY - currentLineHalf)
|
||||
// Track the finger separately from what we draw: the handle renders at the AUTHORITATIVE caret (tipPx,
|
||||
// which snaps to a character position as the move round-trips through the shim), while the finger drives
|
||||
// the move. So the handle stays glued to the line/text and clamps to the field instead of trailing off.
|
||||
var fingerPx by remember { mutableStateOf<Offset?>(null) }
|
||||
EndDragOnDispose(isDragging = { fingerPx != null }, onMagnify = { currentMagnify })
|
||||
Box(
|
||||
Modifier
|
||||
.absoluteOffset { IntOffset((tipPx.x - wPx / 2f).roundToInt(), tipPx.y.roundToInt()) }
|
||||
@@ -909,6 +971,7 @@ private fun InsertionHandle(
|
||||
if (!dragging && (change.position - down.position).getDistance() > viewConfiguration.touchSlop) {
|
||||
dragging = true
|
||||
fp = currentTip
|
||||
fingerPx = fp
|
||||
magnify(currentTip)
|
||||
}
|
||||
if (dragging) {
|
||||
@@ -919,7 +982,7 @@ private fun InsertionHandle(
|
||||
fp += change.positionChangeIgnoreConsumed()
|
||||
change.consume()
|
||||
fingerPx = fp
|
||||
onDragTo((fp.x - surfaceOriginX) / scale, (fp.y - surfaceOriginY) / scale)
|
||||
currentDragTo((fp.x - currentOriginX) / currentScale, (fp.y - currentOriginY) / currentScale)
|
||||
magnify(fp)
|
||||
}
|
||||
}
|
||||
@@ -999,6 +1062,9 @@ private fun SelectionToolbarItem(
|
||||
label: String,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
// The gesture is keyed on the label only, so read the action through updated state: "Copy" captures the
|
||||
// selected text, and a stale first lambda copied the previous selection.
|
||||
val currentOnClick by rememberUpdatedState(onClick)
|
||||
Text(
|
||||
text = label,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
@@ -1012,13 +1078,31 @@ private fun SelectionToolbarItem(
|
||||
val up = waitForUpOrCancellation()
|
||||
if (up != null) {
|
||||
up.consume()
|
||||
onClick()
|
||||
currentOnClick()
|
||||
}
|
||||
}
|
||||
}.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Ends a handle drag that is still in progress when the handle leaves composition — the selection collapsed
|
||||
* or the page blurred under the finger. The gesture's own end/cancel callbacks never run then (its coroutine
|
||||
* is just cancelled), which left the loupe on screen, the toolbar hidden and the drawer's edge swipe disabled
|
||||
* app-wide ([EmbeddedSelectionDrag]).
|
||||
*/
|
||||
@Composable
|
||||
private fun EndDragOnDispose(
|
||||
isDragging: () -> Boolean,
|
||||
onMagnify: () -> OnMagnify?,
|
||||
) {
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
if (isDragging()) onMagnify()?.invoke(false, Offset.Zero, 0f, 0f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One console line as plain text, for copying. */
|
||||
private fun formatConsoleLine(line: ConsoleLine): String =
|
||||
buildString {
|
||||
@@ -1031,3 +1115,16 @@ private fun formatConsoleLine(line: ConsoleLine): String =
|
||||
.append(')')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the page's console error count in a scope of its own: a page that keeps logging errors then
|
||||
* recomposes just the pill, not the whole tab layer around it.
|
||||
*/
|
||||
@Composable
|
||||
private fun WithConsoleErrors(
|
||||
ui: BrowserPillUi,
|
||||
errors: IntState?,
|
||||
content: @Composable (BrowserPillUi) -> Unit,
|
||||
) {
|
||||
content(ui.copy(consoleErrors = errors?.intValue ?: 0))
|
||||
}
|
||||
|
||||
+7
-1
@@ -118,7 +118,13 @@ object EmbeddedTabPreloadSweeper {
|
||||
.associateBy { it.id }
|
||||
var stillPending = false
|
||||
for (id in favoriteIds) {
|
||||
val app = byId[id] ?: continue
|
||||
// Not loaded from disk yet (the favorites store hydrates asynchronously): retry, rather than
|
||||
// letting the sweep end having warmed nothing.
|
||||
val app = byId[id]
|
||||
if (app == null) {
|
||||
stillPending = true
|
||||
continue
|
||||
}
|
||||
if (!EmbeddedTabFactory.preload(context, app, backgroundColor)) stillPending = true
|
||||
// Each preload may build + attach a WebView on this (main) thread; yield between favorites
|
||||
// so the sweep doesn't monopolize the frame and jank the paint that follows.
|
||||
|
||||
+2
-9
@@ -26,8 +26,6 @@ import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.commons.model.ThemeType
|
||||
@@ -55,13 +53,8 @@ fun EmbeddedTabThemeWatcher() {
|
||||
ThemeType.SYSTEM -> systemDark
|
||||
}
|
||||
|
||||
// Holds the theme the warm surfaces were last built in; a mismatch (only after a real flip — the
|
||||
// first composition seeds it equal) triggers exactly one rebuild.
|
||||
val applied = remember { mutableStateOf(resolvedDark) }
|
||||
// The host remembers the theme the warm surfaces were built in; a real flip triggers exactly one rebuild.
|
||||
LaunchedEffect(resolvedDark) {
|
||||
if (applied.value != resolvedDark) {
|
||||
applied.value = resolvedDark
|
||||
EmbeddedTabHost.rebuildAll()
|
||||
}
|
||||
EmbeddedTabHost.rebuildIfThemeChanged(resolvedDark)
|
||||
}
|
||||
}
|
||||
|
||||
+284
-42
@@ -34,29 +34,45 @@ import android.os.Looper
|
||||
import android.os.Message
|
||||
import android.os.Messenger
|
||||
import android.os.SystemClock
|
||||
import android.widget.Toast
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.privacysandbox.ui.client.SandboxedUiAdapterFactory
|
||||
import androidx.privacysandbox.ui.client.view.SandboxedSdkView
|
||||
import androidx.privacysandbox.ui.client.view.SandboxedSdkViewEventListener
|
||||
import androidx.privacysandbox.ui.core.SandboxedUiAdapter
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.commons.browser.BrowserChrome
|
||||
import com.vitorpamplona.amethyst.commons.browser.ui.pill.ConsoleLine
|
||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||
import com.vitorpamplona.amethyst.commons.resources.favorite_notice_paid
|
||||
import com.vitorpamplona.amethyst.commons.resources.favorite_notice_published
|
||||
import com.vitorpamplona.amethyst.commons.resources.favorite_notice_uploaded
|
||||
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
|
||||
import com.vitorpamplona.amethyst.napplet.NappletLaunchRegistry
|
||||
import com.vitorpamplona.amethyst.napplet.NappletWebViewProfiles
|
||||
import com.vitorpamplona.amethyst.napplet.WebFileChooserCoordinator
|
||||
import com.vitorpamplona.amethyst.napplethost.NappletEmbedContract
|
||||
import com.vitorpamplona.amethyst.napplethost.NappletHostContract
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.ConsoleBridge
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.ConsoleBuffer
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedAutoRecovery
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedImeBridge
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedLoadStatus
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedMagnifierProbe
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedSurfaceController
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedTabFactory
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.FindBridge
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.FindResult
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.ImeEvent
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.MagnifierFrame
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.consoleLevelOf
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.parseImeEvent
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
/**
|
||||
@@ -96,21 +112,50 @@ class EmbeddedNostrAppController(
|
||||
// its own id on every message; the provider uses it to route controls/state/IME to this tab.
|
||||
// Re-minted whenever the remote session is re-created (see [attachView]), so a late close() from the
|
||||
// previous view can never reap the replacement.
|
||||
private var sessionId: String = "napplet-${SESSION_SEQ.incrementAndGet()}"
|
||||
private var sessionId: String = newSessionId()
|
||||
|
||||
// A parked tab can be hidden (paused) before the service even binds, so the pause message is
|
||||
// dropped (no messenger yet). Remember the intent and replay it right after the session is created,
|
||||
// otherwise an applet that was never shown comes up running in the background.
|
||||
// What the provider was last told (see [syncPageState]). A parked tab can be hidden before the service
|
||||
// even binds, when the message is dropped (no messenger yet), so both are replayed right after each
|
||||
// session is created — otherwise an applet that was never shown comes up running, and acting, unwatched.
|
||||
private var wantPaused = false
|
||||
private var wantAttended = false
|
||||
|
||||
// The app is on screen / has been in the background long enough to pause even the visible tab.
|
||||
private var appVisible = true
|
||||
private var backgroundIdle = false
|
||||
|
||||
/** (canGoBack) — drives the in-tab back gesture. */
|
||||
var onStateChanged: ((Boolean) -> Unit)? = null
|
||||
|
||||
/** A granted "allow always" sensitive op just ran (one of NappletEmbedContract.NOTICE_*). */
|
||||
var onNotice: ((String) -> Unit)? = null
|
||||
|
||||
private var hasLoadedReal = false
|
||||
|
||||
// Brings the tab back when its sandbox-side surface dies (see [onSurfaceLost]).
|
||||
private val recovery = EmbeddedAutoRecovery(SystemClock::elapsedRealtime)
|
||||
|
||||
// The remote session behind the current view errored out: only a brand-new session can repaint it.
|
||||
private var sessionDead = false
|
||||
|
||||
// Set after the first connection, so a later onServiceConnected is recognised as `:napplet` coming back.
|
||||
private var everConnected = false
|
||||
|
||||
// Set by [unbind]: nothing that arrives afterwards may act.
|
||||
private var tornDown = false
|
||||
|
||||
// A `:napplet` restart found this tab hidden: its session is re-created when it is next shown.
|
||||
private var createOnShow = false
|
||||
|
||||
// A create is in flight: the view's old session erroring out now is the one being replaced, not news.
|
||||
private var awaitingReady = false
|
||||
|
||||
// The current session's surface has shown in the view at least once (see [retry]).
|
||||
private var uiDisplayed = false
|
||||
|
||||
// Whether `:napplet` routes through Tor right now: another surface that needs Tor puts every page on it.
|
||||
private val routedOverTor = mutableStateOf(false)
|
||||
|
||||
/** This nSite is set to the open web but goes through Tor anyway, because another open page needs Tor. */
|
||||
val isTorForced: Boolean get() = !params.getBoolean(NappletHostContract.EXTRA_USE_TOR, true) && routedOverTor.value
|
||||
|
||||
/** Last known main-frame load state, so the tab layer renders the right overlay immediately. */
|
||||
override var loadStatus: EmbeddedLoadStatus = EmbeddedLoadStatus()
|
||||
private set
|
||||
@@ -123,9 +168,23 @@ class EmbeddedNostrAppController(
|
||||
override var onMagnifierFrame: ((MagnifierFrame) -> Unit)? = null
|
||||
|
||||
/** The app's console output, capped at [MAX_CONSOLE_LOGS] entries. */
|
||||
override val consoleLogs = mutableStateListOf<ConsoleLine>()
|
||||
private val console = ConsoleBuffer(MAX_CONSOLE_LOGS)
|
||||
override val consoleLogs get() = console.lines
|
||||
override val consoleErrorCount get() = console.errorCount
|
||||
|
||||
override fun clearConsoleLogs() = consoleLogs.clear()
|
||||
override fun clearConsoleLogs() = console.clear()
|
||||
|
||||
// The user's text zoom. The provider forgets it whenever the session is re-created (a `:napplet`
|
||||
// restart, a rearm), so it is re-sent with every create.
|
||||
private var textZoom = BrowserChrome.DEFAULT_TEXT_ZOOM
|
||||
|
||||
/** The user's text zoom as last set, for a screen coming back to this tab. */
|
||||
val currentTextZoom: Int get() = textZoom
|
||||
|
||||
// Whether the app can go back, kept here rather than in the tab's screen (which leaves composition on
|
||||
// every bottom-bar switch), so coming back keeps Back working inside the app.
|
||||
var lastCanGoBack = false
|
||||
private set
|
||||
|
||||
private val _findResult = mutableStateOf<FindResult?>(null)
|
||||
override val findResult: State<FindResult?> = _findResult
|
||||
@@ -137,11 +196,28 @@ class EmbeddedNostrAppController(
|
||||
service: IBinder?,
|
||||
) {
|
||||
serviceMessenger = Messenger(service)
|
||||
if (everConnected) {
|
||||
// `:napplet` died and was restarted. Re-creating the session IS the recovery (a fresh
|
||||
// process has no session under any id), so nothing else is left pending; cover the
|
||||
// surface until the new page paints. Only the visible tab rebuilds now: every warm tab
|
||||
// reconnects at once, and rebuilding them all right after the OS reclaimed that memory
|
||||
// would just push it back up. The rest re-create when next shown.
|
||||
recovery.clearPending()
|
||||
sessionDead = false
|
||||
showRecovering()
|
||||
everConnected = true
|
||||
if (recovery.isShown) sendCreateSession() else createOnShow = true
|
||||
return
|
||||
}
|
||||
everConnected = true
|
||||
sendCreateSession()
|
||||
}
|
||||
|
||||
override fun onServiceDisconnected(name: ComponentName?) {
|
||||
// `:napplet` died (the OS reclaimed it, or it crashed). Its WebViews went with it; the
|
||||
// system restarts the bound service and [onServiceConnected] re-creates the session.
|
||||
serviceMessenger = null
|
||||
showRecovering()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,17 +227,24 @@ class EmbeddedNostrAppController(
|
||||
}
|
||||
|
||||
fun unbind() {
|
||||
// Tell the provider to drop this tab's session now: one created for a view that was disposed before
|
||||
// it attached never gets the surface close that would otherwise clean it up.
|
||||
send(NappletEmbedContract.MSG_CLOSE_SESSION)
|
||||
tornDown = true
|
||||
if (bound) {
|
||||
runCatching { appContext.unbindService(connection) }
|
||||
bound = false
|
||||
}
|
||||
// This controller's launch token dies with it (every session it re-creates reuses the token, so it
|
||||
// can't be given back any earlier). Left registered, dead tokens crowd live ones out of the registry.
|
||||
NappletLaunchRegistry.unregister(params.getString(NappletHostContract.EXTRA_LAUNCH_TOKEN))
|
||||
// Drop refs so an evicted controller doesn't pin the surface view or the remote messenger.
|
||||
serviceMessenger = null
|
||||
sandboxedSdkView?.setEventListener(null)
|
||||
sandboxedSdkView = null
|
||||
pendingAdapter = null
|
||||
adapterDelivered = false
|
||||
onStateChanged = null
|
||||
onNotice = null
|
||||
onImeEvent = null
|
||||
onMagnifierFrame = null
|
||||
onLoadStatusChanged = null
|
||||
@@ -187,6 +270,7 @@ class EmbeddedNostrAppController(
|
||||
// Paint the surface placeholder in the app's theme background so there's no white flash before
|
||||
// the remote WebView delivers its first frame.
|
||||
view.setBackgroundColor(params.getInt(NappletHostContract.EXTRA_BG_COLOR, android.graphics.Color.WHITE))
|
||||
view.setEventListener(surfaceListener(view))
|
||||
val adapter = pendingAdapter
|
||||
when {
|
||||
adapter != null -> {
|
||||
@@ -196,25 +280,147 @@ class EmbeddedNostrAppController(
|
||||
}
|
||||
// No adapter in hand and one was already spent on a previous (now disposed) view: the session
|
||||
// behind it is gone, so this view would stay blank forever. Re-create it.
|
||||
adapterDelivered -> {
|
||||
// Mint a FRESH session id: the disposed view's Session.close() reaches the sandbox
|
||||
// asynchronously and can land AFTER this create. Reusing the id would let that late close
|
||||
// reap the session we just asked for, leaving the surface black.
|
||||
sessionId = "napplet-${SESSION_SEQ.incrementAndGet()}"
|
||||
adapterDelivered = false
|
||||
sendCreateSession()
|
||||
}
|
||||
adapterDelivered -> rearmSession()
|
||||
// else: the first session is still in flight; MSG_SESSION_READY will arm this view.
|
||||
}
|
||||
}
|
||||
|
||||
override fun onShown() = resume()
|
||||
override fun detachView(view: SandboxedSdkView) {
|
||||
if (sandboxedSdkView !== view) return
|
||||
view.setEventListener(null)
|
||||
sandboxedSdkView = null
|
||||
}
|
||||
|
||||
override fun onHidden() = pause()
|
||||
/**
|
||||
* Asks the sandbox for a brand-new session; the [NappletEmbedContract.MSG_SESSION_READY] reply arms the
|
||||
* current view with its adapter.
|
||||
*
|
||||
* Mints a FRESH session id: a disposed view's Session.close() reaches the sandbox asynchronously and can
|
||||
* land AFTER the create. Reusing the id would let that late close reap the session we just asked for,
|
||||
* leaving the surface black.
|
||||
*/
|
||||
private fun rearmSession() {
|
||||
// The session being replaced may never have opened a surface (its view went away first), in which
|
||||
// case no surface close will ever reach the provider for it.
|
||||
send(NappletEmbedContract.MSG_CLOSE_SESSION)
|
||||
sessionId = newSessionId()
|
||||
// This create IS the re-creation a `:napplet` restart deferred to the next show.
|
||||
createOnShow = false
|
||||
adapterDelivered = false
|
||||
sessionDead = false
|
||||
_findResult.value = null
|
||||
sendCreateSession()
|
||||
}
|
||||
|
||||
/**
|
||||
* Watches [view]'s remote session. A session that errors out (its provider failed, or `:napplet` died)
|
||||
* leaves the view holding a dead client that never reopens: it paints nothing, forever, until it is
|
||||
* handed a NEW adapter.
|
||||
*/
|
||||
private fun surfaceListener(view: SandboxedSdkView) =
|
||||
object : SandboxedSdkViewEventListener {
|
||||
override fun onUiDisplayed() {
|
||||
// The load state reports when the page itself paints; this only says the surface opened.
|
||||
if (sandboxedSdkView === view) uiDisplayed = true
|
||||
}
|
||||
|
||||
override fun onUiError(error: Throwable) {
|
||||
// A view this controller has since moved past (disposed, replaced) is not ours to revive, and an
|
||||
// error landing while a new session is on its way is the old one dying: that create already
|
||||
// is the rebuild.
|
||||
if (sandboxedSdkView === view && !awaitingReady) onSurfaceLost(sessionDead = true)
|
||||
}
|
||||
|
||||
override fun onUiClosed() {
|
||||
// Nothing to do: closes are ours (a view disposed, or an adapter replaced on purpose).
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The tab's page is gone: its WebView's renderer died ([sessionDead] false — the session lives on, and a
|
||||
* MSG_RELOAD rebuilds the WebView inside it), or the whole remote session errored out ([sessionDead]
|
||||
* true — only a new session can repaint the view). Either way the surface is a black rectangle that would
|
||||
* stay that way, so rebuild it, as [EmbeddedAutoRecovery] allows.
|
||||
*/
|
||||
private fun onSurfaceLost(sessionDead: Boolean) {
|
||||
if (sessionDead) this.sessionDead = true
|
||||
hasLoadedReal = false
|
||||
// `:napplet` itself is down: its restart re-creates the session (see [onServiceConnected]).
|
||||
if (serviceMessenger?.binder?.isBinderAlive != true) {
|
||||
showRecovering()
|
||||
return
|
||||
}
|
||||
when (recovery.onLost()) {
|
||||
EmbeddedAutoRecovery.Decision.RECOVER_NOW -> recover()
|
||||
EmbeddedAutoRecovery.Decision.DEFERRED -> showRecovering()
|
||||
EmbeddedAutoRecovery.Decision.GIVE_UP -> publishLoadStatus(EmbeddedLoadStatus(failed = true))
|
||||
}
|
||||
}
|
||||
|
||||
private fun recover() {
|
||||
showRecovering()
|
||||
if (sessionDead) rearmSession() else reload()
|
||||
}
|
||||
|
||||
/** Covers the surface with the loading spinner until the rebuilt page paints. */
|
||||
private fun showRecovering() {
|
||||
hasLoadedReal = false
|
||||
publishLoadStatus(EmbeddedLoadStatus(isLoading = true))
|
||||
}
|
||||
|
||||
override fun onShown() {
|
||||
val deferredRecovery = recovery.onShown()
|
||||
syncPageState()
|
||||
if (createOnShow) {
|
||||
createOnShow = false
|
||||
sendCreateSession()
|
||||
} else if (deferredRecovery) {
|
||||
recover()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onHidden() {
|
||||
recovery.onHidden()
|
||||
syncPageState()
|
||||
}
|
||||
|
||||
override fun onAppVisibility(visible: Boolean) {
|
||||
appVisible = visible
|
||||
syncPageState()
|
||||
}
|
||||
|
||||
override fun onBackgroundIdle(idle: Boolean) {
|
||||
backgroundIdle = idle
|
||||
syncPageState()
|
||||
}
|
||||
|
||||
/**
|
||||
* Tells the provider what the applet may do now — the same schedule as a website tab:
|
||||
* - paused (JS-driven animations, media, geolocation) while parked off-screen, or once the app has sat in
|
||||
* the background as long as the relays get (EmbeddedTabHost.BACKGROUND_PAUSE_MS), so a quick trip to
|
||||
* another app doesn't interrupt it;
|
||||
* - attended only while it's the visible tab AND the app is on screen. The provider holds its requests
|
||||
* that act for the user (publish, pay, upload…) while it isn't — at once, not after the grace: even an
|
||||
* "allow always" napplet can't act on the user's behalf while they aren't looking.
|
||||
*/
|
||||
private fun syncPageState() {
|
||||
val pause = !recovery.isShown || backgroundIdle
|
||||
if (pause != wantPaused) {
|
||||
wantPaused = pause
|
||||
send(if (pause) NappletEmbedContract.MSG_PAUSE else NappletEmbedContract.MSG_RESUME)
|
||||
}
|
||||
val attended = recovery.isShown && appVisible
|
||||
if (attended != wantAttended) {
|
||||
wantAttended = attended
|
||||
send(NappletEmbedContract.MSG_SET_ATTENDED) { putBoolean(NappletEmbedContract.KEY_ATTENDED, attended) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun teardown() = unbind()
|
||||
|
||||
private fun sendCreateSession() {
|
||||
awaitingReady = true
|
||||
uiDisplayed = false
|
||||
val msg =
|
||||
Message.obtain(null, NappletEmbedContract.MSG_CREATE_SESSION).apply {
|
||||
replyTo = incoming
|
||||
@@ -226,19 +432,31 @@ class EmbeddedNostrAppController(
|
||||
// [attachView]) must land in the CURRENT account's jar, never the one this
|
||||
// controller was originally built for.
|
||||
putString(NappletHostContract.EXTRA_WEBVIEW_PROFILE, NappletWebViewProfiles.current())
|
||||
// Likewise Tor's port: it may have come up (or moved) since [params] were minted.
|
||||
putInt(NappletHostContract.EXTRA_PROXY_PORT, EmbeddedTabFactory.currentTorPort())
|
||||
}
|
||||
}
|
||||
runCatching { serviceMessenger?.send(msg) }
|
||||
// Replay a pause that was requested before we had a messenger to send it on (parked-before-bound),
|
||||
// so a never-shown applet doesn't start running. Messenger preserves order, so PAUSE lands after
|
||||
// CREATE in the host.
|
||||
// Replay a pause / the attended state decided before we had a messenger to send it on
|
||||
// (parked-before-bound), so a never-shown applet doesn't start running or acting. Messenger preserves
|
||||
// order, so these land after CREATE in the host.
|
||||
if (wantPaused) send(NappletEmbedContract.MSG_PAUSE)
|
||||
// Always: a new session starts unattended, so a tab created in view must say it is being watched.
|
||||
send(NappletEmbedContract.MSG_SET_ATTENDED) { putBoolean(NappletEmbedContract.KEY_ATTENDED, wantAttended) }
|
||||
if (textZoom != BrowserChrome.DEFAULT_TEXT_ZOOM) setTextZoom(textZoom)
|
||||
}
|
||||
|
||||
private fun onServiceMessage(msg: Message): Boolean {
|
||||
// Nothing may act on a torn-down tab (a late file-chooser request would still open a picker), nor on
|
||||
// what a session this controller has since replaced still had in flight — a stale SESSION_READY
|
||||
// would re-arm the view with that dead session's adapter.
|
||||
if (tornDown) return true
|
||||
val from = msg.data?.getString(NappletEmbedContract.KEY_SESSION_ID)
|
||||
if (from != null && from != sessionId) return true
|
||||
when (msg.what) {
|
||||
NappletEmbedContract.MSG_SESSION_READY -> {
|
||||
val coreLibInfo = msg.data?.getBundle(NappletEmbedContract.KEY_CORE_LIB_INFO) ?: return true
|
||||
awaitingReady = false
|
||||
val adapter = SandboxedUiAdapterFactory.createFromCoreLibInfo(coreLibInfo)
|
||||
val view = sandboxedSdkView
|
||||
if (view != null) {
|
||||
@@ -250,11 +468,12 @@ class EmbeddedNostrAppController(
|
||||
}
|
||||
NappletEmbedContract.MSG_STATE -> {
|
||||
val canGoBack = msg.data?.getBoolean(NappletEmbedContract.KEY_CAN_GO_BACK, false) ?: false
|
||||
lastCanGoBack = canGoBack
|
||||
onStateChanged?.invoke(canGoBack)
|
||||
}
|
||||
NappletEmbedContract.MSG_NOTICE -> {
|
||||
val notice = msg.data?.getString(NappletEmbedContract.KEY_NOTICE) ?: return true
|
||||
onNotice?.invoke(notice)
|
||||
showNotice(notice)
|
||||
}
|
||||
NappletEmbedContract.MSG_IME_EVENT -> {
|
||||
val payload = msg.data?.getString(NappletEmbedContract.KEY_IME_PAYLOAD) ?: return true
|
||||
@@ -263,7 +482,11 @@ class EmbeddedNostrAppController(
|
||||
NappletEmbedContract.MSG_LOAD_STATE -> {
|
||||
val isLoading = msg.data?.getBoolean(NappletEmbedContract.KEY_IS_LOADING, false) ?: false
|
||||
val failed = msg.data?.getBoolean(NappletEmbedContract.KEY_LOAD_FAILED, false) ?: false
|
||||
onLoadState(isLoading, failed)
|
||||
if (msg.data?.getBoolean(NappletEmbedContract.KEY_RENDERER_GONE, false) == true) {
|
||||
onSurfaceLost(sessionDead = false)
|
||||
} else {
|
||||
onLoadState(isLoading, failed)
|
||||
}
|
||||
}
|
||||
NappletEmbedContract.MSG_FILE_CHOOSER_REQUEST -> {
|
||||
val data = msg.data ?: return true
|
||||
@@ -284,14 +507,14 @@ class EmbeddedNostrAppController(
|
||||
}
|
||||
}
|
||||
}
|
||||
NappletEmbedContract.MSG_ROUTE -> routedOverTor.value = msg.data?.getBoolean(NappletEmbedContract.KEY_ROUTE_TOR, false) ?: false
|
||||
NappletEmbedContract.MSG_FIND_RESULT -> {
|
||||
val data = msg.data ?: return true
|
||||
_findResult.value = FindResult(data.getInt(NappletEmbedContract.KEY_FIND_ACTIVE), data.getInt(NappletEmbedContract.KEY_FIND_TOTAL))
|
||||
}
|
||||
NappletEmbedContract.MSG_CONSOLE_LOG -> {
|
||||
val data = msg.data ?: return true
|
||||
if (consoleLogs.size >= MAX_CONSOLE_LOGS) consoleLogs.removeAt(0)
|
||||
consoleLogs.add(
|
||||
console.add(
|
||||
ConsoleLine(
|
||||
consoleLevelOf(data.getString(NappletEmbedContract.KEY_CONSOLE_LEVEL).orEmpty()),
|
||||
data.getString(NappletEmbedContract.KEY_CONSOLE_MESSAGE).orEmpty(),
|
||||
@@ -337,7 +560,7 @@ class EmbeddedNostrAppController(
|
||||
|
||||
fun back() = send(NappletEmbedContract.MSG_BACK)
|
||||
|
||||
fun reload() = send(NappletEmbedContract.MSG_RELOAD)
|
||||
fun reload() = send(NappletEmbedContract.MSG_RELOAD) { putInt(NappletHostContract.EXTRA_PROXY_PORT, EmbeddedTabFactory.currentTorPort()) }
|
||||
|
||||
override fun find(query: String) {
|
||||
if (query.isEmpty()) _findResult.value = null
|
||||
@@ -346,13 +569,17 @@ class EmbeddedNostrAppController(
|
||||
|
||||
override fun findNext(forward: Boolean) = send(NappletEmbedContract.MSG_FIND_NEXT) { putBoolean(NappletEmbedContract.KEY_FIND_FORWARD, forward) }
|
||||
|
||||
fun setTextZoom(percent: Int) = send(NappletEmbedContract.MSG_SET_TEXT_ZOOM) { putInt(NappletEmbedContract.KEY_TEXT_ZOOM, percent) }
|
||||
fun setTextZoom(percent: Int) {
|
||||
textZoom = percent
|
||||
send(NappletEmbedContract.MSG_SET_TEXT_ZOOM) { putInt(NappletEmbedContract.KEY_TEXT_ZOOM, percent) }
|
||||
}
|
||||
|
||||
/** User-triggered recovery for a stuck or failed session: reload the verified content from scratch. */
|
||||
override fun retry() {
|
||||
hasLoadedReal = false
|
||||
publishLoadStatus(EmbeddedLoadStatus(isLoading = true))
|
||||
reload()
|
||||
recovery.clearPending()
|
||||
showRecovering()
|
||||
// A surface that never opened has nothing to reload: only a new session can paint it.
|
||||
if (sessionDead || (sandboxedSdkView != null && !uiDisplayed)) rearmSession() else reload()
|
||||
}
|
||||
|
||||
private fun onLoadState(
|
||||
@@ -368,15 +595,24 @@ class EmbeddedNostrAppController(
|
||||
onLoadStatusChanged?.invoke(status)
|
||||
}
|
||||
|
||||
/** Pause/resume the applet's JS when the tab leaves/returns to the foreground (background gating). */
|
||||
fun pause() {
|
||||
wantPaused = true
|
||||
send(NappletEmbedContract.MSG_PAUSE)
|
||||
}
|
||||
|
||||
fun resume() {
|
||||
wantPaused = false
|
||||
send(NappletEmbedContract.MSG_RESUME)
|
||||
/**
|
||||
* A granted "allow always" sensitive op just ran (one of NappletEmbedContract.NOTICE_*): tell the user.
|
||||
* Shown from here, on the app's own scope, rather than by the tab's screen: the op can complete after
|
||||
* the user has left the tab, when that screen — and the coroutine scope it would have toasted from — is
|
||||
* already gone, and the notice was silently dropped.
|
||||
*/
|
||||
private fun showNotice(notice: String) {
|
||||
val res =
|
||||
when (notice) {
|
||||
NappletEmbedContract.NOTICE_PUBLISHED -> Res.string.favorite_notice_published
|
||||
NappletEmbedContract.NOTICE_UPLOADED -> Res.string.favorite_notice_uploaded
|
||||
NappletEmbedContract.NOTICE_PAID -> Res.string.favorite_notice_paid
|
||||
else -> return
|
||||
}
|
||||
Amethyst.instance.applicationIOScope.launch {
|
||||
val text = loadStringRes(res)
|
||||
withContext(Dispatchers.Main) { Toast.makeText(appContext, text, Toast.LENGTH_SHORT).show() }
|
||||
}
|
||||
}
|
||||
|
||||
private inline fun send(
|
||||
@@ -397,6 +633,12 @@ class EmbeddedNostrAppController(
|
||||
private companion object {
|
||||
private val SESSION_SEQ = AtomicLong()
|
||||
|
||||
// The provider outlives this process's restarts (and this counter with them): without a per-process
|
||||
// nonce a fresh main process would hand out ids a still-running `:napplet` already holds.
|
||||
private val PROCESS_NONCE = UUID.randomUUID().toString().take(8)
|
||||
|
||||
private fun newSessionId() = "napplet-$PROCESS_NONCE-${SESSION_SEQ.incrementAndGet()}"
|
||||
|
||||
private const val MAX_CONSOLE_LOGS = 200
|
||||
}
|
||||
}
|
||||
|
||||
+26
-49
@@ -21,7 +21,6 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.favorites
|
||||
|
||||
import android.os.Build
|
||||
import android.widget.Toast
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
@@ -40,7 +39,6 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -52,8 +50,6 @@ import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
@@ -69,11 +65,8 @@ import com.vitorpamplona.amethyst.commons.resources.browser_unsupported
|
||||
import com.vitorpamplona.amethyst.commons.resources.favorite_app_still_loading
|
||||
import com.vitorpamplona.amethyst.commons.resources.favorite_app_unavailable
|
||||
import com.vitorpamplona.amethyst.commons.resources.favorite_apps
|
||||
import com.vitorpamplona.amethyst.commons.resources.favorite_notice_paid
|
||||
import com.vitorpamplona.amethyst.commons.resources.favorite_notice_published
|
||||
import com.vitorpamplona.amethyst.commons.resources.favorite_notice_uploaded
|
||||
import com.vitorpamplona.amethyst.commons.tor.TorType
|
||||
import com.vitorpamplona.amethyst.commons.ui.components.PlatformBackHandler
|
||||
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
|
||||
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.commons.ui.platform.AppBottomBar
|
||||
import com.vitorpamplona.amethyst.commons.ui.stringRes
|
||||
@@ -81,13 +74,10 @@ import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.favorites.FavoriteAppLauncher
|
||||
import com.vitorpamplona.amethyst.napplet.NappletNetworkRegistry
|
||||
import com.vitorpamplona.amethyst.napplethost.HostProfile
|
||||
import com.vitorpamplona.amethyst.napplethost.NappletEmbedContract
|
||||
import com.vitorpamplona.amethyst.napplethost.NappletHostContract
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedTabChrome
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedTabFactory
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedTabHost
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.StringResource
|
||||
|
||||
/**
|
||||
* A **Nostr app** — an nSite or nApplet, reached by [coordinate] (favorited or not) — rendered as an
|
||||
@@ -149,13 +139,12 @@ private fun EmbeddedNostrAppTab(
|
||||
val capLabels = params.getStringArrayList(NappletHostContract.EXTRA_CAP_LABELS).orEmpty()
|
||||
val profile = HostProfile.fromName(params.getString(NappletHostContract.EXTRA_HOST_PROFILE))
|
||||
val useTor = params.getBoolean(NappletHostContract.EXTRA_USE_TOR, true)
|
||||
// Only nSites have a route of their own to choose, and only when Tor is running.
|
||||
val torOn = if (profile.exposesNetwork && params.getInt(NappletHostContract.EXTRA_PROXY_PORT, -1) > 0) useTor else null
|
||||
// Only nSites have a route of their own to choose, and only when Tor is on.
|
||||
val torType by Amethyst.instance.torPrefs.torType
|
||||
.collectAsStateWithLifecycle()
|
||||
val torOn = if (profile.exposesNetwork && torType != TorType.OFF) useTor else null
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
var canGoBack by remember { mutableStateOf(false) }
|
||||
var showAccess by remember { mutableStateOf(false) }
|
||||
var textZoom by remember(coordinate) { mutableIntStateOf(BrowserChrome.DEFAULT_TEXT_ZOOM) }
|
||||
|
||||
val apps by Amethyst.instance.favoriteApps.favorites
|
||||
.collectAsStateWithLifecycle()
|
||||
@@ -165,24 +154,26 @@ private fun EmbeddedNostrAppTab(
|
||||
remember(id, EmbeddedTabHost.rebuildEpoch, networkEpoch) {
|
||||
EmbeddedTabFactory.acquireNostrApp(context, coordinate, params, backgroundColor)
|
||||
}
|
||||
// Seeded from the controller, which outlives this screen (it leaves composition on every bottom-bar
|
||||
// switch), so coming back keeps Back working inside the app and the pill showing the zoom in effect.
|
||||
var canGoBack by remember(controller) { mutableStateOf(controller.lastCanGoBack) }
|
||||
var textZoom by remember(controller) { mutableIntStateOf(controller.currentTextZoom) }
|
||||
|
||||
// Keep the controller callbacks fresh (cheap, need the latest closures).
|
||||
SideEffect {
|
||||
controller.onStateChanged = { canGoBack = it }
|
||||
controller.onNotice = { notice ->
|
||||
noticeResId(notice)?.let { res ->
|
||||
scope.launch { Toast.makeText(context, loadStringRes(res), Toast.LENGTH_SHORT).show() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The permission-ledger key is the addressable coordinate without its kind prefix (`pubkey:dtag`),
|
||||
// matching how the Connected Apps screen keys napplet/nsite grants (see NappletIdentity.coordinate).
|
||||
val permissionCoordinate = remember(coordinate) { coordinate.substringAfter(':') }
|
||||
|
||||
// Off for this site, yet on Tor because another open page needs it (Tor always wins in `:napplet`).
|
||||
val torForced = controller.isTorForced
|
||||
|
||||
// Stable per app (title/coordinate/isFavorite don't change often), so the tab layer isn't recomposed every frame.
|
||||
val chrome =
|
||||
remember(title, coordinate, isFavorite, torOn, textZoom, controller) {
|
||||
remember(title, coordinate, isFavorite, torOn, torForced, textZoom, controller) {
|
||||
EmbeddedTabChrome(
|
||||
ui =
|
||||
BrowserPillUi(
|
||||
@@ -194,6 +185,7 @@ private fun EmbeddedNostrAppTab(
|
||||
url = "",
|
||||
startUrl = "",
|
||||
torOn = torOn,
|
||||
torForced = torForced,
|
||||
hasAccessInfo = true,
|
||||
),
|
||||
isFavorite = isFavorite,
|
||||
@@ -212,7 +204,7 @@ private fun EmbeddedNostrAppTab(
|
||||
BrowserChrome.Action.TOR -> {
|
||||
// Persist the new route, then rebuild the session so it loads that way.
|
||||
NappletNetworkRegistry.set(permissionCoordinate, !useTor)
|
||||
EmbeddedTabHost.evict(id)
|
||||
EmbeddedTabHost.rebuild(id)
|
||||
networkEpoch++
|
||||
}
|
||||
BrowserChrome.Action.SITE_SETTINGS -> nav.nav(Route.ConnectedAppDetail(permissionCoordinate))
|
||||
@@ -235,32 +227,25 @@ private fun EmbeddedNostrAppTab(
|
||||
SideEffect { EmbeddedTabHost.setActiveChrome(id, chrome) }
|
||||
|
||||
val bottomBarFlow = accountViewModel.account.settings.syncedSettings.navigation.bottomBarItems
|
||||
val entryLifecycle = LocalLifecycleOwner.current.lifecycle
|
||||
DisposableEffect(id) {
|
||||
val token = EmbeddedTabHost.setActive(id)
|
||||
EmbeddedTabHost.hold(id)
|
||||
onDispose {
|
||||
EmbeddedTabHost.clearActiveIfOwner(token)
|
||||
EmbeddedTabHost.clearActiveChrome(id)
|
||||
// Only bottom-row apps stay warm; anything else restarts when it leaves.
|
||||
if (id !in bottomBarFlow.value.favoriteIds()) EmbeddedTabHost.evict(id)
|
||||
// Only bottom-row apps stay warm; anything else restarts once the user actually leaves it — not
|
||||
// when a screen is merely pushed on top, and not when a re-navigation to this same tab already
|
||||
// composed a new screen on the same session.
|
||||
if (EmbeddedTabHost.release(id)) {
|
||||
EmbeddedTabHost.releaseWhenGone(id, entryLifecycle) { id in bottomBarFlow.value.favoriteIds() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pause the applet's JS while the app is backgrounded (parity with NappletHostActivity's onPause):
|
||||
// an "allow always" napplet can't act on the user's behalf when they aren't looking. (The tab layer
|
||||
// separately pauses it whenever it isn't the visible tab.)
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
DisposableEffect(lifecycleOwner, controller) {
|
||||
val observer =
|
||||
LifecycleEventObserver { _, event ->
|
||||
when (event) {
|
||||
Lifecycle.Event.ON_STOP -> controller.pause()
|
||||
Lifecycle.Event.ON_START -> controller.resume()
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
lifecycleOwner.lifecycle.addObserver(observer)
|
||||
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
|
||||
}
|
||||
// No lifecycle handling here: the tab layer tells every warm tab when the app leaves the screen
|
||||
// (EmbeddedTabHost.onAppStopped), which holds the applet's acting requests at once and pauses its page on
|
||||
// the relays' 30 s schedule.
|
||||
|
||||
PlatformBackHandler(enabled = canGoBack) { controller.back() }
|
||||
|
||||
@@ -327,11 +312,3 @@ private fun UnavailableTab(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun noticeResId(notice: String): StringResource? =
|
||||
when (notice) {
|
||||
NappletEmbedContract.NOTICE_PUBLISHED -> Res.string.favorite_notice_published
|
||||
NappletEmbedContract.NOTICE_UPLOADED -> Res.string.favorite_notice_uploaded
|
||||
NappletEmbedContract.NOTICE_PAID -> Res.string.favorite_notice_paid
|
||||
else -> null
|
||||
}
|
||||
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.embed
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.browser.ui.pill.ConsoleLine
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class ConsoleBufferTest {
|
||||
private fun line(level: ConsoleLine.Level) = ConsoleLine(level, "m", "s", 1)
|
||||
|
||||
@Test
|
||||
fun countsErrors() {
|
||||
val buffer = ConsoleBuffer(max = 10)
|
||||
buffer.add(line(ConsoleLine.Level.LOG))
|
||||
buffer.add(line(ConsoleLine.Level.ERROR))
|
||||
buffer.add(line(ConsoleLine.Level.ERROR))
|
||||
assertEquals(2, buffer.errorCount.intValue)
|
||||
assertEquals(3, buffer.lines.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun evictingAnErrorLowersTheCount() {
|
||||
val buffer = ConsoleBuffer(max = 2)
|
||||
buffer.add(line(ConsoleLine.Level.ERROR))
|
||||
buffer.add(line(ConsoleLine.Level.LOG))
|
||||
buffer.add(line(ConsoleLine.Level.LOG))
|
||||
assertEquals(0, buffer.errorCount.intValue)
|
||||
assertEquals(2, buffer.lines.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun evictingANonErrorKeepsTheCount() {
|
||||
val buffer = ConsoleBuffer(max = 2)
|
||||
buffer.add(line(ConsoleLine.Level.LOG))
|
||||
buffer.add(line(ConsoleLine.Level.ERROR))
|
||||
buffer.add(line(ConsoleLine.Level.ERROR))
|
||||
assertEquals(2, buffer.errorCount.intValue)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clearResets() {
|
||||
val buffer = ConsoleBuffer(max = 2)
|
||||
buffer.add(line(ConsoleLine.Level.ERROR))
|
||||
buffer.clear()
|
||||
assertEquals(0, buffer.errorCount.intValue)
|
||||
assertEquals(0, buffer.lines.size)
|
||||
}
|
||||
}
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.embed
|
||||
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedAutoRecovery.Decision
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class EmbeddedAutoRecoveryTest {
|
||||
private var clock = 1_000L
|
||||
private val recovery = EmbeddedAutoRecovery { clock }
|
||||
|
||||
@Test
|
||||
fun visibleTabRebuildsRightAway() {
|
||||
recovery.onShown()
|
||||
assertEquals(Decision.RECOVER_NOW, recovery.onLost())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parkedTabWaitsUntilShown() {
|
||||
assertEquals(Decision.DEFERRED, recovery.onLost())
|
||||
assertTrue(recovery.onShown())
|
||||
// Only once: coming back again doesn't rebuild a live tab.
|
||||
recovery.onHidden()
|
||||
assertFalse(recovery.onShown())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parkedTabRecoveredByOtherMeansDoesNotRebuild() {
|
||||
assertEquals(Decision.DEFERRED, recovery.onLost())
|
||||
recovery.clearPending()
|
||||
assertFalse(recovery.onShown())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun secondDeathRightAfterAnAutoRebuildGivesUp() {
|
||||
recovery.onShown()
|
||||
assertEquals(Decision.RECOVER_NOW, recovery.onLost())
|
||||
clock += 5_000
|
||||
assertEquals(Decision.GIVE_UP, recovery.onLost())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deathAfterADeferredRebuildAlsoGivesUp() {
|
||||
assertEquals(Decision.DEFERRED, recovery.onLost())
|
||||
assertTrue(recovery.onShown())
|
||||
clock += 1_000
|
||||
assertEquals(Decision.GIVE_UP, recovery.onLost())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun laterDeathsRecoverAgain() {
|
||||
recovery.onShown()
|
||||
assertEquals(Decision.RECOVER_NOW, recovery.onLost())
|
||||
clock += EmbeddedAutoRecovery.LOOP_WINDOW_MS
|
||||
assertEquals(Decision.RECOVER_NOW, recovery.onLost())
|
||||
}
|
||||
}
|
||||
+5
@@ -91,6 +91,11 @@ object BrowserChrome {
|
||||
val isLoading: Boolean = false,
|
||||
/** Tor routing state, or null when this surface offers no Tor choice. */
|
||||
val torOn: Boolean? = null,
|
||||
/**
|
||||
* The site is set to the open web ([torOn] false) but still goes through Tor, because another open page
|
||||
* needs Tor and the app's pages share one route (Tor always wins). Shown so the user knows why.
|
||||
*/
|
||||
val torForced: Boolean = false,
|
||||
/** Whether the star is offered at all. */
|
||||
val canFavorite: Boolean = true,
|
||||
/** Whether an editable permissions screen exists for this surface. */
|
||||
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.napplet
|
||||
|
||||
/**
|
||||
* The napplet / website requests that ACT for the user or use their key — publish, pay, upload, notify,
|
||||
* broadcast to other napplets, and a website's NIP-07 sign / encrypt / decrypt — as opposed to reading.
|
||||
* A host holds these while the user isn't looking at the surface (it's parked off-screen, or the app is in
|
||||
* the background): pausing the WebView stops animations and media but not JavaScript, so without this an
|
||||
* "allow always" napplet or site could keep publishing, paying, signing or decrypting while the user looks
|
||||
* elsewhere. Reads (`getPublicKey`, relay queries, …) keep flowing, so a preloaded surface still fills in.
|
||||
*/
|
||||
object NappletActingRequests {
|
||||
private val ACTING =
|
||||
setOf(
|
||||
"relay.publish",
|
||||
"relay.publishEncrypted",
|
||||
"value.payInvoice",
|
||||
"upload.upload",
|
||||
"notify.create",
|
||||
"inc.emit",
|
||||
// NIP-07 (website posture). Decrypt counts too: it hands the page plaintext it couldn't read.
|
||||
"nostr.signEvent",
|
||||
"nostr.nip44Encrypt",
|
||||
"nostr.nip44Decrypt",
|
||||
)
|
||||
|
||||
fun actsForUser(requestType: String?): Boolean = requestType in ACTING
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.napplet
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
|
||||
/**
|
||||
* The broker's view of which surfaces the user is looking at right now, as each surface reports it.
|
||||
*
|
||||
* The surfaces hold a page's acting requests (sign, encrypt, decrypt…) themselves while nobody is looking,
|
||||
* but relay reads decrypt on the broker side: an encrypted event a relay pushes to a parked page's
|
||||
* subscription, or returns for its query, would be decrypted with the user's key and handed over unwatched.
|
||||
* The broker checks here first and waits until the page is attended again.
|
||||
*
|
||||
* Unattended until a surface says otherwise, so one that never reports can't read unwatched.
|
||||
*/
|
||||
class NappletAttendance<K : Any> {
|
||||
private val attended = MutableStateFlow<Set<K>>(emptySet())
|
||||
|
||||
fun set(
|
||||
owner: K,
|
||||
isAttended: Boolean,
|
||||
) = attended.update { if (isAttended) it + owner else it - owner }
|
||||
|
||||
fun isAttended(owner: K): Boolean = owner in attended.value
|
||||
|
||||
/** Waits up to [timeoutMs] for [owner] to be attended; false when it wasn't in time. */
|
||||
suspend fun awaitAttended(
|
||||
owner: K,
|
||||
timeoutMs: Long,
|
||||
): Boolean = withTimeoutOrNull(timeoutMs) { attended.first { owner in it } } != null
|
||||
|
||||
/** [owner] went away. */
|
||||
fun forget(owner: K) = set(owner, false)
|
||||
}
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.napplet
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.util.withString
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
|
||||
/**
|
||||
* Keeps a browser surface's NIP-07 traffic with the document that started it.
|
||||
*
|
||||
* A browser tab has one broker channel but hosts a sequence of documents: every navigation brings a new
|
||||
* page, with a new bridge reply proxy [P], and each page numbers its requests from scratch (`r0`, `r1`, …).
|
||||
* Delivering broker replies to "whichever page posted last" would hand a.com's signature or decrypted
|
||||
* message to b.com once the user navigates while a consent sheet is up (b.com's own `r0` would even
|
||||
* resolve with it).
|
||||
*
|
||||
* So every page gets a document sequence number. [brokerIdFor] stamps it on the page's request id before
|
||||
* the request leaves for the broker, and [resolve] only turns a reply back into the page's id — and hands
|
||||
* back the proxy to post it on — when it belongs to the document on screen now. Replies for a replaced
|
||||
* document are dropped. The stamp is deterministic per (document, page id), so a later message that
|
||||
* reuses a request's id (a cancel) still reaches the same broker-side request.
|
||||
*
|
||||
* Relay subscriptions get the same treatment: a page names its own (`s0`, …), and the broker pushes their
|
||||
* events — decrypted DMs included — keyed by that name. [stampSubscription] stamps the document on the
|
||||
* `subId` of what the page sends, and [resolvePush] only lets a push through, with the page's own name
|
||||
* back, when it is for the document on screen now.
|
||||
*
|
||||
* Single-threaded: call from the WebView's (main) thread.
|
||||
*/
|
||||
class NappletBridgeDocuments<P : Any> {
|
||||
private var current: P? = null
|
||||
private var document = 0L
|
||||
|
||||
/** The bridge reply proxy of the document on screen, or null before the first message. */
|
||||
val currentProxy: P? get() = current
|
||||
|
||||
/**
|
||||
* Records that a main-frame bridge message arrived through [proxy]. Returns true when it came from a
|
||||
* NEW document replacing an earlier one — the caller then drops the old page's broker state.
|
||||
*/
|
||||
fun onMessage(proxy: P): Boolean {
|
||||
if (current === proxy) return false
|
||||
val replaced = current != null
|
||||
current = proxy
|
||||
document++
|
||||
return replaced
|
||||
}
|
||||
|
||||
/** The id to send to the broker for the current document's request [pageId]. */
|
||||
fun brokerIdFor(pageId: String): String = "$document$SEPARATOR$pageId"
|
||||
|
||||
/**
|
||||
* Resolves a broker reply's [brokerId] into the page's own id and the proxy to post it on, or null when
|
||||
* the reply belongs to a document that is gone (or was never stamped by [brokerIdFor]).
|
||||
*/
|
||||
fun resolve(brokerId: String): Pair<String, P>? {
|
||||
val proxy = current ?: return null
|
||||
val cut = brokerId.indexOf(SEPARATOR)
|
||||
if (cut <= 0 || brokerId.substring(0, cut).toLongOrNull() != document) return null
|
||||
return brokerId.substring(cut + 1) to proxy
|
||||
}
|
||||
|
||||
/**
|
||||
* [envelope] with the current document stamped on its `subId` (`relay.subscribe`, `relay.close`), or
|
||||
* null when it carries none and goes to the broker unchanged.
|
||||
*/
|
||||
fun stampSubscription(envelope: JsonObject): JsonObject? {
|
||||
val subId = envelope.quotedString(SUB_ID) ?: return null
|
||||
return envelope.withString(SUB_ID, brokerIdFor(subId))
|
||||
}
|
||||
|
||||
/**
|
||||
* A broker push to hand to the page on screen: unchanged when it isn't for a subscription, with the page's
|
||||
* own `subId` back when it is for one this document opened, or null — drop it — when it is for a
|
||||
* subscription of a document that is gone (or when nothing is on screen).
|
||||
*/
|
||||
fun resolvePush(push: JsonObject): JsonObject? {
|
||||
if (current == null) return null
|
||||
val brokerSubId = push.quotedString(SUB_ID) ?: return push
|
||||
val cut = brokerSubId.indexOf(SEPARATOR)
|
||||
if (cut <= 0 || brokerSubId.substring(0, cut).toLongOrNull() != document) return null
|
||||
return push.withString(SUB_ID, brokerSubId.substring(cut + 1))
|
||||
}
|
||||
|
||||
private fun JsonObject.quotedString(key: String): String? = (this[key] as? JsonPrimitive)?.takeIf { it.isString }?.content
|
||||
|
||||
/**
|
||||
* A main-frame navigation began: whatever document was on screen is on its way out, even if the new one
|
||||
* never talks to the bridge. Returns true when there was one — the caller then drops its broker state.
|
||||
*/
|
||||
fun onNavigation(): Boolean {
|
||||
val had = current != null
|
||||
clear()
|
||||
return had
|
||||
}
|
||||
|
||||
/** The surface went away (session closed, renderer died): nothing on screen can receive a reply. */
|
||||
fun clear() {
|
||||
current = null
|
||||
document++
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val SEPARATOR = ':'
|
||||
const val SUB_ID = "subId"
|
||||
}
|
||||
}
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.napplet
|
||||
|
||||
/**
|
||||
* The requests a page made to act for the user (sign, encrypt, decrypt, publish, pay…) while nobody was
|
||||
* looking at it, held until someone is (see [NappletActingRequests]).
|
||||
*
|
||||
* Bounded both ways, so an unattended page can neither grow the queue without end nor have the user come
|
||||
* back to a pile of stale prompts: past [cap] a new request is handed straight back to be failed, and one
|
||||
* held longer than [maxAgeMs] is failed instead of sent — by [expire], or when [drain] finds it on the user's
|
||||
* return. Either way the page's promise settles with an error rather than hanging.
|
||||
*
|
||||
* Single-threaded: call from the main thread.
|
||||
*/
|
||||
class NappletHeldRequests<T>(
|
||||
private val clock: () -> Long,
|
||||
private val cap: Int = MAX_HELD,
|
||||
private val maxAgeMs: Long = MAX_AGE_MS,
|
||||
) {
|
||||
private class Held<T>(
|
||||
val item: T,
|
||||
val heldAt: Long,
|
||||
)
|
||||
|
||||
private val items = ArrayDeque<Held<T>>()
|
||||
|
||||
val size: Int get() = items.size
|
||||
|
||||
/** Holds [item], or hands it back (for the caller to fail) when [cap] requests are already waiting. */
|
||||
fun hold(item: T): T? {
|
||||
if (items.size >= cap) return item
|
||||
items.addLast(Held(item, clock()))
|
||||
return null
|
||||
}
|
||||
|
||||
/** Removes and returns the requests held longer than [maxAgeMs] (oldest first). */
|
||||
fun expire(): List<T> {
|
||||
val now = clock()
|
||||
val expired = mutableListOf<T>()
|
||||
while (items.isNotEmpty() && now - items.first().heldAt >= maxAgeMs) expired += items.removeFirst().item
|
||||
return expired
|
||||
}
|
||||
|
||||
/** Removes everything held: the requests still fresh enough to send, and the ones to fail instead. */
|
||||
fun drain(): Drained<T> {
|
||||
val expired = expire()
|
||||
val fresh = items.map { it.item }
|
||||
items.clear()
|
||||
return Drained(fresh, expired)
|
||||
}
|
||||
|
||||
/** Drops everything held (the page or surface is gone) and returns it. */
|
||||
fun clear(): List<T> {
|
||||
val all = items.map { it.item }
|
||||
items.clear()
|
||||
return all
|
||||
}
|
||||
|
||||
data class Drained<T>(
|
||||
val send: List<T>,
|
||||
val fail: List<T>,
|
||||
)
|
||||
|
||||
companion object {
|
||||
/** At most this many requests wait for the user at once. */
|
||||
const val MAX_HELD = 32
|
||||
|
||||
/** A held request older than this is failed rather than sent: the moment it was made for has passed. */
|
||||
const val MAX_AGE_MS = 120_000L
|
||||
|
||||
const val TOO_MANY = "Too many requests are waiting for the user."
|
||||
const val EXPIRED = "The request timed out while the user was away."
|
||||
}
|
||||
}
|
||||
+8
@@ -49,6 +49,9 @@ class NappletIdentityWatch(
|
||||
boundPubKey: String,
|
||||
push: (String) -> Unit,
|
||||
) {
|
||||
// A surface re-created under the same launch token (a tab re-arming its session) starts a new watch
|
||||
// with its own sink; replace the old stream rather than keep pushing to the gone surface's Messenger.
|
||||
jobs.remove(watchId)?.cancel()
|
||||
jobs.getOrPut(watchId) {
|
||||
val id = watchId
|
||||
scope
|
||||
@@ -63,6 +66,11 @@ class NappletIdentityWatch(
|
||||
}
|
||||
}
|
||||
|
||||
/** Stops the watch started under [watchId], if any. */
|
||||
fun stop(watchId: String) {
|
||||
jobs.remove(watchId)?.cancel()
|
||||
}
|
||||
|
||||
fun stopAll() {
|
||||
jobs.snapshot().values.forEach { it.cancel() }
|
||||
jobs.clear()
|
||||
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.napplet
|
||||
|
||||
/**
|
||||
* Decides the ONE proxy route shared by every WebView in the sandbox process.
|
||||
*
|
||||
* Android's WebView proxy override is process-global, yet Tor is chosen per surface (per site): a
|
||||
* browser tab, an nSite, a full-screen page. When each surface set or cleared the override for itself,
|
||||
* the last one to do so won for everyone — opening an open-web page silently moved an already-open Tor
|
||||
* page onto the open web, with no reload and nothing on screen to say so.
|
||||
*
|
||||
* So every live surface files a claim, and the route is derived from all of them: **Tor always wins.**
|
||||
* While ANY claim wants Tor, the whole process goes through Tor (the most recent Tor claim's port — a Tor
|
||||
* restart can move it), open-web surfaces included; with no Tor claim at all there is no proxy.
|
||||
*
|
||||
* There are deliberately no per-host exemptions. Exempting an open-web page's host would let a Tor page
|
||||
* reach that host directly — and an attacker who got one page onto the open web (a site opened before Tor
|
||||
* was up, say) could then have a Tor page load `https://x.attacker.com/<id>` and tie the user's real IP to
|
||||
* the Tor session. The cost is that an open-web page goes through Tor while another open page needs it;
|
||||
* surfaces show why (see [Route.usesTor]).
|
||||
*
|
||||
* Not thread-safe: the caller serializes access (the sandbox touches it only on its main thread).
|
||||
*/
|
||||
class NappletProxyClaims {
|
||||
/** The route to apply: through Tor on [torPort] when [usesTor], else no proxy. */
|
||||
data class Route(
|
||||
val torPort: Int,
|
||||
) {
|
||||
val usesTor: Boolean get() = torPort > 0
|
||||
}
|
||||
|
||||
// Insertion-ordered; a re-claim moves the owner to the end, so the last entry is the latest claim.
|
||||
// Each value is the Tor SOCKS port the owner wants, or [NO_PROXY] for the open web.
|
||||
private val claims = LinkedHashMap<Any, Int>()
|
||||
|
||||
/** Files (or replaces) [owner]'s claim — Tor on [torPort] (> 0), or [NO_PROXY] — and returns the route. */
|
||||
fun claim(
|
||||
owner: Any,
|
||||
torPort: Int,
|
||||
): Route {
|
||||
claims.remove(owner)
|
||||
claims[owner] = torPort
|
||||
return route()
|
||||
}
|
||||
|
||||
/** Withdraws [owner]'s claim (the surface is gone) and returns the resulting route. */
|
||||
fun release(owner: Any): Route {
|
||||
claims.remove(owner)
|
||||
return route()
|
||||
}
|
||||
|
||||
fun route(): Route = Route(claims.values.lastOrNull { it > 0 } ?: NO_PROXY)
|
||||
|
||||
companion object {
|
||||
const val NO_PROXY = -1
|
||||
val DIRECT = Route(NO_PROXY)
|
||||
}
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.napplet
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class NappletActingRequestsTest {
|
||||
@Test
|
||||
fun actingRequestsAreHeld() {
|
||||
listOf(
|
||||
"relay.publish",
|
||||
"relay.publishEncrypted",
|
||||
"value.payInvoice",
|
||||
"upload.upload",
|
||||
"notify.create",
|
||||
"inc.emit",
|
||||
"nostr.signEvent",
|
||||
"nostr.nip44Encrypt",
|
||||
"nostr.nip44Decrypt",
|
||||
).forEach { assertTrue(NappletActingRequests.actsForUser(it), it) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun readsFlow() {
|
||||
listOf("identity.getPublicKey", "identity.getRelays", "relay.query", "relay.subscribe", "relay.close", "storage.get", "resource.bytes", "theme.get", null)
|
||||
.forEach { assertFalse(NappletActingRequests.actsForUser(it), it.toString()) }
|
||||
}
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.napplet
|
||||
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class NappletAttendanceTest {
|
||||
private val attendance = NappletAttendance<String>()
|
||||
|
||||
@Test
|
||||
fun unattendedUntilTold() {
|
||||
assertFalse(attendance.isAttended("tab"))
|
||||
attendance.set("tab", true)
|
||||
assertTrue(attendance.isAttended("tab"))
|
||||
attendance.forget("tab")
|
||||
assertFalse(attendance.isAttended("tab"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun awaitReturnsOnceTheUserIsBack() =
|
||||
runTest {
|
||||
val waiting = async { attendance.awaitAttended("tab", 10_000) }
|
||||
testScheduler.advanceTimeBy(1_000)
|
||||
attendance.set("other", true)
|
||||
testScheduler.advanceTimeBy(1_000)
|
||||
attendance.set("tab", true)
|
||||
assertTrue(waiting.await())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun awaitGivesUpAfterTheTimeout() =
|
||||
runTest {
|
||||
assertFalse(attendance.awaitAttended("tab", 5_000))
|
||||
}
|
||||
}
|
||||
+155
@@ -0,0 +1,155 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.napplet
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.util.parseJsonObjectOrNull
|
||||
import com.vitorpamplona.amethyst.commons.util.stringOrNull
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertSame
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class NappletBridgeDocumentsTest {
|
||||
private class Proxy(
|
||||
val name: String,
|
||||
)
|
||||
|
||||
private val docs = NappletBridgeDocuments<Proxy>()
|
||||
private val a = Proxy("a.com")
|
||||
private val b = Proxy("b.com")
|
||||
|
||||
@Test
|
||||
fun replyReachesTheDocumentThatAsked() {
|
||||
assertFalse(docs.onMessage(a))
|
||||
val id = docs.brokerIdFor("r0")
|
||||
val (pageId, proxy) = docs.resolve(id)!!
|
||||
assertEquals("r0", pageId)
|
||||
assertSame(a, proxy)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sameDocumentKeepsItsRequests() {
|
||||
docs.onMessage(a)
|
||||
val id = docs.brokerIdFor("r0")
|
||||
assertFalse(docs.onMessage(a))
|
||||
assertSame(a, docs.resolve(id)!!.second)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun replyForANavigatedAwayDocumentIsDropped() {
|
||||
docs.onMessage(a)
|
||||
val aRequest = docs.brokerIdFor("r0")
|
||||
assertTrue(docs.onMessage(b))
|
||||
// b.com numbers its own requests from r0 too; a.com's late reply must not resolve it.
|
||||
val bRequest = docs.brokerIdFor("r0")
|
||||
assertNull(docs.resolve(aRequest))
|
||||
assertSame(b, docs.resolve(bRequest)!!.second)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun returningToAnEarlierProxyIsStillANewDocument() {
|
||||
docs.onMessage(a)
|
||||
val first = docs.brokerIdFor("r0")
|
||||
docs.onMessage(b)
|
||||
docs.onMessage(a)
|
||||
assertNull(docs.resolve(first))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pageIdsMayContainTheSeparator() {
|
||||
docs.onMessage(a)
|
||||
assertEquals("fire:7", docs.resolve(docs.brokerIdFor("fire:7"))!!.first)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clearedSurfaceResolvesNothing() {
|
||||
docs.onMessage(a)
|
||||
val id = docs.brokerIdFor("r0")
|
||||
docs.clear()
|
||||
assertNull(docs.resolve(id))
|
||||
assertNull(docs.currentProxy)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unstampedIdsAreRejected() {
|
||||
docs.onMessage(a)
|
||||
assertNull(docs.resolve("r0"))
|
||||
assertNull(docs.resolve(":r0"))
|
||||
}
|
||||
|
||||
private fun json(raw: String) = parseJsonObjectOrNull(raw)!!
|
||||
|
||||
@Test
|
||||
fun subscriptionPushesReachTheDocumentThatSubscribed() {
|
||||
docs.onMessage(a)
|
||||
val stamped = docs.stampSubscription(json("""{"type":"relay.subscribe","id":"r0","subId":"s0"}"""))!!
|
||||
val brokerSubId = stamped.stringOrNull("subId")!!
|
||||
val push = docs.resolvePush(json("""{"type":"relay.event","subId":"$brokerSubId"}"""))!!
|
||||
assertEquals("s0", push.stringOrNull("subId"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun subscriptionPushesForANavigatedAwayDocumentAreDropped() {
|
||||
docs.onMessage(a)
|
||||
val brokerSubId = docs.stampSubscription(json("""{"type":"relay.subscribe","subId":"s0"}"""))!!.stringOrNull("subId")!!
|
||||
docs.onMessage(b)
|
||||
// b.com names its own subscription s0 too: a.com's decrypted events must not reach it.
|
||||
docs.stampSubscription(json("""{"type":"relay.subscribe","subId":"s0"}"""))
|
||||
assertNull(docs.resolvePush(json("""{"type":"relay.event","subId":"$brokerSubId"}""")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun closeIsStampedLikeTheSubscribeItEnds() {
|
||||
docs.onMessage(a)
|
||||
val open = docs.stampSubscription(json("""{"type":"relay.subscribe","subId":"s0"}"""))!!
|
||||
val close = docs.stampSubscription(json("""{"type":"relay.close","subId":"s0"}"""))!!
|
||||
assertEquals(open.stringOrNull("subId"), close.stringOrNull("subId"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pushesWithoutASubscriptionGoToThePageOnScreen() {
|
||||
assertNull(docs.resolvePush(json("""{"type":"identity.changed"}""")))
|
||||
docs.onMessage(a)
|
||||
assertEquals("identity.changed", docs.resolvePush(json("""{"type":"identity.changed"}"""))!!.stringOrNull("type"))
|
||||
assertNull(docs.stampSubscription(json("""{"type":"nostr.signEvent","id":"r0"}""")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unstampedSubscriptionPushesAreDropped() {
|
||||
docs.onMessage(a)
|
||||
assertNull(docs.resolvePush(json("""{"type":"relay.event","subId":"s0"}""")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun navigationEndsTheDocumentEvenIfTheNextNeverTalks() {
|
||||
docs.onMessage(a)
|
||||
val request = docs.brokerIdFor("r0")
|
||||
assertTrue(docs.onNavigation())
|
||||
assertNull(docs.resolve(request))
|
||||
assertNull(docs.resolvePush(json("""{"type":"identity.changed"}""")))
|
||||
// Nothing on screen talked yet: a second navigation has nothing to release.
|
||||
assertFalse(docs.onNavigation())
|
||||
// The next page's first message is not a "replacement": its predecessor was already released.
|
||||
assertFalse(docs.onMessage(b))
|
||||
}
|
||||
}
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.napplet
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class NappletHeldRequestsTest {
|
||||
private var now = 0L
|
||||
private val held = NappletHeldRequests<String>(clock = { now }, cap = 3, maxAgeMs = 1_000)
|
||||
|
||||
@Test
|
||||
fun heldRequestsAreSentWhenTheUserIsBack() {
|
||||
assertNull(held.hold("a"))
|
||||
assertNull(held.hold("b"))
|
||||
val drained = held.drain()
|
||||
assertEquals(listOf("a", "b"), drained.send)
|
||||
assertTrue(drained.fail.isEmpty())
|
||||
assertEquals(0, held.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pastTheCapANewRequestIsHandedBack() {
|
||||
held.hold("a")
|
||||
held.hold("b")
|
||||
held.hold("c")
|
||||
assertEquals("d", held.hold("d"))
|
||||
assertEquals(3, held.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun staleRequestsAreFailedNotSent() {
|
||||
held.hold("old")
|
||||
now = 600
|
||||
held.hold("new")
|
||||
now = 1_200
|
||||
val drained = held.drain()
|
||||
assertEquals(listOf("new"), drained.send)
|
||||
assertEquals(listOf("old"), drained.fail)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun expireRemovesOnlyTheStaleOnes() {
|
||||
held.hold("old")
|
||||
now = 600
|
||||
held.hold("new")
|
||||
now = 1_000
|
||||
assertEquals(listOf("old"), held.expire())
|
||||
assertEquals(1, held.size)
|
||||
now = 1_600
|
||||
assertEquals(listOf("new"), held.expire())
|
||||
assertEquals(0, held.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clearReturnsEverything() {
|
||||
held.hold("a")
|
||||
held.hold("b")
|
||||
assertEquals(listOf("a", "b"), held.clear())
|
||||
assertEquals(0, held.size)
|
||||
}
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.napplet
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.napplet.NappletProxyClaims.Companion.DIRECT
|
||||
import com.vitorpamplona.amethyst.commons.napplet.NappletProxyClaims.Companion.NO_PROXY
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class NappletProxyClaimsTest {
|
||||
private val claims = NappletProxyClaims()
|
||||
private val torTab = Any()
|
||||
private val openTab = Any()
|
||||
|
||||
@Test
|
||||
fun noClaimsMeansNoProxy() {
|
||||
assertEquals(DIRECT, claims.route())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anOpenWebSurfaceDoesNotDowngradeATorOne() {
|
||||
claims.claim(torTab, 9050)
|
||||
// The open-web page opening later must not clear Tor for the page already on it.
|
||||
assertEquals(9050, claims.claim(openTab, NO_PROXY).torPort)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun orderDoesNotMatter() {
|
||||
claims.claim(openTab, NO_PROXY)
|
||||
assertTrue(claims.claim(torTab, 9050).usesTor)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun releasingTheLastTorSurfaceGoesDirect() {
|
||||
claims.claim(torTab, 9050)
|
||||
claims.claim(openTab, NO_PROXY)
|
||||
assertEquals(DIRECT, claims.release(torTab))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun switchingASurfaceOffTorReleasesTheProxy() {
|
||||
claims.claim(torTab, 9050)
|
||||
assertEquals(DIRECT, claims.claim(torTab, NO_PROXY))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theLatestTorPortWins() {
|
||||
claims.claim(torTab, 9050)
|
||||
val other = Any()
|
||||
assertEquals(9150, claims.claim(other, 9150).torPort)
|
||||
// Re-claiming moves an owner to the end, making its port the latest.
|
||||
assertEquals(9050, claims.claim(torTab, 9050).torPort)
|
||||
}
|
||||
}
|
||||
@@ -5686,6 +5686,7 @@
|
||||
<string name="browser_pill_tor_title">Onion routing</string>
|
||||
<string name="browser_pill_tor_on">The site can't see your IP address</string>
|
||||
<string name="browser_pill_tor_off">The site can see your IP address</string>
|
||||
<string name="browser_pill_tor_forced">Off for this site, but another open page uses Tor, so this one goes through Tor too</string>
|
||||
<string name="browser_pill_site_settings">Site settings</string>
|
||||
<string name="browser_pill_site_settings_none">Nothing allowed yet</string>
|
||||
<string name="browser_pill_access">What it can access</string>
|
||||
|
||||
+9
-1
@@ -90,6 +90,7 @@ import com.vitorpamplona.amethyst.commons.resources.browser_pill_text_larger
|
||||
import com.vitorpamplona.amethyst.commons.resources.browser_pill_text_reset
|
||||
import com.vitorpamplona.amethyst.commons.resources.browser_pill_text_smaller
|
||||
import com.vitorpamplona.amethyst.commons.resources.browser_pill_text_value
|
||||
import com.vitorpamplona.amethyst.commons.resources.browser_pill_tor_forced
|
||||
import com.vitorpamplona.amethyst.commons.resources.browser_pill_tor_off
|
||||
import com.vitorpamplona.amethyst.commons.resources.browser_pill_tor_on
|
||||
import com.vitorpamplona.amethyst.commons.ui.stringRes
|
||||
@@ -487,7 +488,14 @@ private fun PrivacyCard(
|
||||
icon = { PillActionIcon(Action.TOR, tint = if (on) MaterialTheme.colorScheme.onTertiaryContainer else MaterialTheme.colorScheme.onSurfaceVariant, size = 22.dp) },
|
||||
iconContainer = if (on) MaterialTheme.colorScheme.tertiaryContainer else MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
title = stringRes(pillLabelFor(Action.TOR)),
|
||||
supporting = stringRes(if (on) Res.string.browser_pill_tor_on else Res.string.browser_pill_tor_off),
|
||||
supporting =
|
||||
stringRes(
|
||||
when {
|
||||
on -> Res.string.browser_pill_tor_on
|
||||
ui.chrome.torForced -> Res.string.browser_pill_tor_forced
|
||||
else -> Res.string.browser_pill_tor_off
|
||||
},
|
||||
),
|
||||
onClick = { onAction(Action.TOR) },
|
||||
) { Switch(checked = on, onCheckedChange = { onAction(Action.TOR) }) }
|
||||
}
|
||||
|
||||
+9
-1
@@ -111,6 +111,7 @@ import com.vitorpamplona.amethyst.commons.resources.browser_pill_perm_microphone
|
||||
import com.vitorpamplona.amethyst.commons.resources.browser_pill_perm_once
|
||||
import com.vitorpamplona.amethyst.commons.resources.browser_pill_perm_title
|
||||
import com.vitorpamplona.amethyst.commons.resources.browser_pill_perm_tor_note
|
||||
import com.vitorpamplona.amethyst.commons.resources.browser_pill_tor_forced
|
||||
import com.vitorpamplona.amethyst.commons.resources.browser_pill_tor_off
|
||||
import com.vitorpamplona.amethyst.commons.resources.browser_pill_tor_on
|
||||
import com.vitorpamplona.amethyst.commons.ui.stringRes
|
||||
@@ -372,7 +373,14 @@ fun PageInfoSheet(
|
||||
icon = { PillActionIcon(BrowserChrome.Action.TOR, tint = if (tor) MaterialTheme.colorScheme.onTertiaryContainer else MaterialTheme.colorScheme.onSurfaceVariant, size = 22.dp) },
|
||||
iconContainer = if (tor) MaterialTheme.colorScheme.tertiaryContainer else MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
title = stringRes(if (tor) Res.string.browser_pill_info_tor else Res.string.browser_pill_info_open),
|
||||
supporting = stringRes(if (tor) Res.string.browser_pill_tor_on else Res.string.browser_pill_tor_off),
|
||||
supporting =
|
||||
stringRes(
|
||||
when {
|
||||
tor -> Res.string.browser_pill_tor_on
|
||||
ui.chrome.torForced -> Res.string.browser_pill_tor_forced
|
||||
else -> Res.string.browser_pill_tor_off
|
||||
},
|
||||
),
|
||||
onClick = null,
|
||||
)
|
||||
}
|
||||
|
||||
+22
-10
@@ -57,6 +57,7 @@ import androidx.compose.material3.TextFieldDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -119,6 +120,8 @@ import com.vitorpamplona.quartz.nip5aStaticWebsites.RootSiteEvent
|
||||
import com.vitorpamplona.quartz.nip5dNapplets.NamedNappletEvent
|
||||
import com.vitorpamplona.quartz.nip5dNapplets.RootNappletEvent
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jetbrains.compose.resources.StringResource
|
||||
|
||||
/** How many of the most recent history entries the idle browser home surfaces under "Recent". */
|
||||
@@ -213,19 +216,27 @@ private fun BrowserLauncher(
|
||||
|
||||
// Drop ones already pinned — they show under Favorites, not twice.
|
||||
val favoriteCoordinates = remember(apps) { apps.filterIsInstance<FavoriteApp.NostrApp>().mapTo(HashSet()) { it.coordinate } }
|
||||
val followedNsites =
|
||||
remember(nsiteNotes, nsiteFollows, favoriteCoordinates) {
|
||||
nsiteNotes.toDiscoverApps(nsiteFollows::matchAuthor, favoriteCoordinates)
|
||||
}
|
||||
val followedNapplets =
|
||||
remember(nappletNotes, nappletFollows, favoriteCoordinates) {
|
||||
nappletNotes.toDiscoverApps(nappletFollows::matchAuthor, favoriteCoordinates)
|
||||
}
|
||||
// Built off the main thread: these walk every cached nsite/napplet note, and the note lists re-emit as
|
||||
// relays deliver.
|
||||
val followedNsites by produceState(emptyList<DiscoverNostrApp>(), nsiteNotes, nsiteFollows, favoriteCoordinates) {
|
||||
value = withContext(Dispatchers.Default) { nsiteNotes.toDiscoverApps(nsiteFollows::matchAuthor, favoriteCoordinates) }
|
||||
}
|
||||
val followedNapplets by produceState(emptyList<DiscoverNostrApp>(), nappletNotes, nappletFollows, favoriteCoordinates) {
|
||||
value = withContext(Dispatchers.Default) { nappletNotes.toDiscoverApps(nappletFollows::matchAuthor, favoriteCoordinates) }
|
||||
}
|
||||
|
||||
// What the user actually typed, excluding any selected ghost-completion suffix (selection.min is the
|
||||
// caret when collapsed, or the start of the highlighted suffix when a completion is showing).
|
||||
val typed = field.text.take(field.selection.min.coerceIn(0, field.text.length))
|
||||
val suggestions = remember(typed, candidates) { OmniboxSuggestions.rank(typed, candidates, limit = 12) }
|
||||
// One ranking per typed text: an appended character is ranked in onValueChange (for the inline
|
||||
// completion) and then again for this list on the recomposition that follows — keep the last one.
|
||||
val lastRanking = remember(candidates) { arrayOfNulls<Pair<String, List<OmniboxSuggestions.Suggestion>>>(1) }
|
||||
|
||||
fun ranked(text: String): List<OmniboxSuggestions.Suggestion> =
|
||||
lastRanking[0]?.takeIf { it.first == text }?.second
|
||||
?: OmniboxSuggestions.rank(text, candidates, limit = 12).also { lastRanking[0] = text to it }
|
||||
|
||||
val suggestions = remember(typed, candidates) { ranked(typed) }
|
||||
|
||||
fun open(text: String) {
|
||||
val target = OmniboxInput.resolve(text) ?: return
|
||||
@@ -256,7 +267,8 @@ private fun BrowserLauncher(
|
||||
newText.length > prevTyped.length &&
|
||||
newText.startsWith(prevTyped)
|
||||
if (appended) {
|
||||
val completion = OmniboxSuggestions.completion(newText, OmniboxSuggestions.rank(newText, candidates))
|
||||
// The completion has always looked at the top 8 (rank's default limit).
|
||||
val completion = OmniboxSuggestions.completion(newText, ranked(newText).take(8))
|
||||
if (completion != null) {
|
||||
// Keep the user's own casing for the typed prefix; append only the remaining suffix.
|
||||
val full = newText + completion.substring(newText.length)
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.napplethost
|
||||
|
||||
import android.os.Message
|
||||
import androidx.webkit.JavaScriptReplyProxy
|
||||
import com.vitorpamplona.amethyst.commons.napplet.NappletBridgeDocuments
|
||||
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletProtocolJson
|
||||
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletResponse
|
||||
import com.vitorpamplona.amethyst.commons.util.parseJsonObjectOrNull
|
||||
import com.vitorpamplona.amethyst.commons.util.withString
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
|
||||
/**
|
||||
* Answers a queued broker [request] (a [NappletIpc.MSG_REQUEST] that never left) with a failure, delivered
|
||||
* to the page that made it — the same reply shape the broker uses, so the page's promise rejects with
|
||||
* [reason] instead of waiting forever. Dropped if that page has since been navigated away from.
|
||||
*/
|
||||
fun NappletBridgeDocuments<JavaScriptReplyProxy>.failRequest(
|
||||
request: Message,
|
||||
reason: String,
|
||||
) {
|
||||
val data = request.data ?: return
|
||||
val brokerId = data.getString(NappletIpc.KEY_REQUEST_ID) ?: return
|
||||
val raw = data.getString(NappletIpc.KEY_PAYLOAD) ?: return
|
||||
val (pageId, proxy) = resolve(brokerId) ?: return
|
||||
val type = runCatching { NappletProtocolJson.readType(raw) }.getOrNull() ?: "napplet"
|
||||
val reply = parseJsonObjectOrNull(NappletProtocolJson.encodeResponse(type, NappletResponse.Failed(reason))) ?: JsonObject(emptyMap())
|
||||
runCatching { proxy.postMessage(reply.withString("id", pageId).toString()) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Answers a napplet's queued broker [request] with a failure on [this] proxy. A napplet's request ids aren't
|
||||
* stamped per document (its shell never navigates), so the id goes back as the page sent it.
|
||||
*/
|
||||
fun JavaScriptReplyProxy.failRequest(
|
||||
request: Message,
|
||||
reason: String,
|
||||
) {
|
||||
val data = request.data ?: return
|
||||
val id = data.getString(NappletIpc.KEY_REQUEST_ID) ?: return
|
||||
val raw = data.getString(NappletIpc.KEY_PAYLOAD) ?: return
|
||||
val type = runCatching { NappletProtocolJson.readType(raw) }.getOrNull() ?: "napplet"
|
||||
val reply = parseJsonObjectOrNull(NappletProtocolJson.encodeResponse(type, NappletResponse.Failed(reason))) ?: JsonObject(emptyMap())
|
||||
runCatching { postMessage(reply.withString("id", id).toString()) }
|
||||
}
|
||||
@@ -25,7 +25,9 @@ import android.content.MutableContextWrapper
|
||||
import android.net.Uri
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.webkit.RenderProcessGoneDetail
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import androidx.webkit.JavaScriptReplyProxy
|
||||
import androidx.webkit.WebMessageCompat
|
||||
import androidx.webkit.WebViewCompat
|
||||
@@ -122,6 +124,20 @@ object BrowserPopups {
|
||||
}
|
||||
WebViewCompat.addDocumentStartJavaScript(webView, BrowserWebTools.browserStartScript(shimJs, imeProxy = false), setOf("*"))
|
||||
val token = UUID.randomUUID().toString()
|
||||
// Until an Activity adopts it (and installs its own client), a parked popup still shares the one
|
||||
// `:napplet` renderer. With no client its renderer death falls to the default — returning false, which
|
||||
// kills the whole process and every embedded tab in it. Drop just the popup instead.
|
||||
webView.webViewClient =
|
||||
object : WebViewClient() {
|
||||
override fun onRenderProcessGone(
|
||||
view: WebView,
|
||||
detail: RenderProcessGoneDetail,
|
||||
): Boolean {
|
||||
pending.remove(token)
|
||||
view.destroy()
|
||||
return true
|
||||
}
|
||||
}
|
||||
pending[token] = entry
|
||||
main.postDelayed({
|
||||
pending.remove(token)?.let { orphan ->
|
||||
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.napplethost
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.os.SystemClock
|
||||
import android.webkit.WebView
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.core.graphics.createBitmap
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
/**
|
||||
* Captures the selection loupe's magnified slice of an embedded page for the main process.
|
||||
*
|
||||
* Host-side `PixelCopy` can't read the sandbox surface, so the page is drawn here. That draw has to happen
|
||||
* on the main thread — the one thread every sandboxed surface in `:napplet` renders on — so everything else
|
||||
* is kept off it: the frame is capped at [MAX_SIDE_PX] a side (a large box at high zoom used to allocate
|
||||
* tens of MB and produce a frame too big for the binder, which was silently dropped), and the encode runs on
|
||||
* a background thread as lossy WebP (a quality-100 PNG, per drag frame, on the main thread, was the cost).
|
||||
*/
|
||||
@RequiresApi(Build.VERSION_CODES.R)
|
||||
internal object MagnifierCapture {
|
||||
private const val MAX_SIDE_PX = 512
|
||||
private const val QUALITY = 85
|
||||
|
||||
private val encoder = Executors.newSingleThreadExecutor { Thread(it, "napplet-magnifier").apply { isDaemon = true } }
|
||||
private val main = Handler(Looper.getMainLooper())
|
||||
|
||||
/**
|
||||
* Draws the [boxW]×[boxH] source rect centered on ([cx], [cy]) (view px) of [webView] at [zoom] over
|
||||
* [bgColor], then — on the main thread, once encoded — hands [deliver] the image bytes, their size, and
|
||||
* the capture time in ms. Main thread only.
|
||||
*/
|
||||
fun capture(
|
||||
webView: WebView,
|
||||
bgColor: Int,
|
||||
cx: Float,
|
||||
cy: Float,
|
||||
boxW: Int,
|
||||
boxH: Int,
|
||||
zoom: Float,
|
||||
deliver: (bytes: ByteArray, width: Int, height: Int, captureMs: Double) -> Unit,
|
||||
) {
|
||||
val t0 = SystemClock.elapsedRealtimeNanos()
|
||||
val scale = minOf(zoom, MAX_SIDE_PX.toFloat() / boxW, MAX_SIDE_PX.toFloat() / boxH)
|
||||
val outW = (boxW * scale).toInt().coerceAtLeast(1)
|
||||
val outH = (boxH * scale).toInt().coerceAtLeast(1)
|
||||
val bitmap = createBitmap(outW, outH)
|
||||
val canvas = Canvas(bitmap)
|
||||
canvas.drawColor(bgColor)
|
||||
// Map the source rect (centered on cx,cy in view px) into the scaled output bitmap.
|
||||
canvas.scale(scale, scale)
|
||||
canvas.translate(-(cx - boxW / 2f), -(cy - boxH / 2f))
|
||||
webView.draw(canvas)
|
||||
|
||||
encoder.execute {
|
||||
val bytes =
|
||||
ByteArrayOutputStream().use { out ->
|
||||
bitmap.compress(Bitmap.CompressFormat.WEBP_LOSSY, QUALITY, out)
|
||||
out.toByteArray()
|
||||
}
|
||||
bitmap.recycle()
|
||||
val captureMs = (SystemClock.elapsedRealtimeNanos() - t0) / 1_000_000.0
|
||||
main.post { deliver(bytes, outW, outH, captureMs) }
|
||||
}
|
||||
}
|
||||
}
|
||||
+206
-43
@@ -40,6 +40,7 @@ import android.os.IBinder
|
||||
import android.os.Looper
|
||||
import android.os.Message
|
||||
import android.os.Messenger
|
||||
import android.os.SystemClock
|
||||
import android.util.TypedValue
|
||||
import android.view.ContextMenu
|
||||
import android.view.Gravity
|
||||
@@ -75,8 +76,6 @@ import androidx.core.view.WindowCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.WindowInsetsControllerCompat
|
||||
import androidx.webkit.JavaScriptReplyProxy
|
||||
import androidx.webkit.ProxyConfig
|
||||
import androidx.webkit.ProxyController
|
||||
import androidx.webkit.WebMessageCompat
|
||||
import androidx.webkit.WebViewCompat
|
||||
import androidx.webkit.WebViewFeature
|
||||
@@ -90,7 +89,12 @@ import com.vitorpamplona.amethyst.commons.browser.ui.pill.BrowserPillEvent
|
||||
import com.vitorpamplona.amethyst.commons.browser.ui.pill.BrowserPillUi
|
||||
import com.vitorpamplona.amethyst.commons.browser.ui.pill.ConsoleLine
|
||||
import com.vitorpamplona.amethyst.commons.browser.ui.pill.PageDialogType
|
||||
import com.vitorpamplona.amethyst.commons.napplet.NappletActingRequests
|
||||
import com.vitorpamplona.amethyst.commons.napplet.NappletBridgeDocuments
|
||||
import com.vitorpamplona.amethyst.commons.napplet.NappletHeldRequests
|
||||
import com.vitorpamplona.amethyst.commons.napplet.NappletProxyClaims
|
||||
import com.vitorpamplona.amethyst.commons.napplet.NappletWebContract
|
||||
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletProtocolJson
|
||||
import com.vitorpamplona.amethyst.commons.util.parseJsonObjectOrNull
|
||||
import com.vitorpamplona.amethyst.commons.util.stringOrNull
|
||||
import com.vitorpamplona.amethyst.commons.util.withString
|
||||
@@ -98,7 +102,6 @@ import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.lang.ref.WeakReference
|
||||
import java.util.concurrent.Executor
|
||||
import com.vitorpamplona.amethyst.commons.R as CommonsR
|
||||
|
||||
/**
|
||||
@@ -221,7 +224,10 @@ class NappletBrowserActivity : ComponentActivity() {
|
||||
}
|
||||
|
||||
private val pendingBrokerRequests = mutableListOf<Message>()
|
||||
private var bridgeReplyProxy: JavaScriptReplyProxy? = null
|
||||
|
||||
// The page on screen's bridge reply proxy, and which document each broker reply belongs to: a reply
|
||||
// for a page the user has navigated away from must never land in the next one.
|
||||
private val bridge = NappletBridgeDocuments<JavaScriptReplyProxy>()
|
||||
private var fireSeq = 0
|
||||
private val originTokens = mutableMapOf<String, String>()
|
||||
private val pendingByOrigin = mutableMapOf<String, MutableList<Message>>()
|
||||
@@ -229,6 +235,11 @@ class NappletBrowserActivity : ComponentActivity() {
|
||||
private val downloadCooldown = DownloadCooldown()
|
||||
private var preparingDownload = false
|
||||
|
||||
// The page's requests that act for the user (NIP-07 sign / encrypt / decrypt) made while this window was in
|
||||
// the background, as (origin, request): sent on the next resume, so a site can't sign — even with
|
||||
// "allow always" — while nobody is looking at it.
|
||||
private val heldWhileAway = NappletHeldRequests<Pair<String, Message>>(SystemClock::elapsedRealtime)
|
||||
|
||||
/**
|
||||
* Back walks out of fullscreen video, then the find bar, then the page's history, then leaves. Enabled
|
||||
* only while one of those applies, so the system back (and its predictive animation) otherwise acts
|
||||
@@ -265,6 +276,7 @@ class NappletBrowserActivity : ComponentActivity() {
|
||||
pendingBrokerRequests.forEach { sendToBroker(it) }
|
||||
pendingBrokerRequests.clear()
|
||||
if (resumed) setBrokerForeground(true)
|
||||
reportAttended()
|
||||
}
|
||||
|
||||
override fun onServiceDisconnected(name: ComponentName?) {
|
||||
@@ -303,6 +315,14 @@ class NappletBrowserActivity : ComponentActivity() {
|
||||
}
|
||||
title = intent.getStringExtra(EXTRA_TITLE).orEmpty()
|
||||
|
||||
// Fail closed: Tor is on but its port isn't known yet (still starting). This window can't learn it
|
||||
// later, so refuse now rather than sit blank — or go out on the open web.
|
||||
if (popup == null && useTor && proxyPort <= 0) {
|
||||
Toast.makeText(this, R.string.napplet_route_blocked, Toast.LENGTH_LONG).show()
|
||||
finish()
|
||||
return
|
||||
}
|
||||
|
||||
if (!WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) {
|
||||
Toast.makeText(this, getString(R.string.napplet_webview_too_old), Toast.LENGTH_LONG).show()
|
||||
finish()
|
||||
@@ -310,7 +330,13 @@ class NappletBrowserActivity : ComponentActivity() {
|
||||
}
|
||||
|
||||
shimJs = readContractAsset(NappletWebContract.SHIM_JS_PATH).decodeToString()
|
||||
applyWebViewProxy(if (useTor) proxyPort else -1)
|
||||
// Which route is really in effect: an open-web page goes through Tor while another page needs it.
|
||||
WebViewProxyPolicy.observeRoute(this) {
|
||||
routedOverTor = it
|
||||
updateChromeState { copy(torForced = !useTor && it) }
|
||||
}
|
||||
// A popup's WebView is already loading: its route is claimed now, not before a load.
|
||||
if (popup != null) claimRoute()
|
||||
|
||||
bindService(Intent().setClassName(this, NappletHostContract.BROKER_SERVICE_CLASS), brokerConnection, BIND_AUTO_CREATE)
|
||||
onBackPressedDispatcher.addCallback(this, backCallback)
|
||||
@@ -335,7 +361,8 @@ class NappletBrowserActivity : ComponentActivity() {
|
||||
contentFrame.addView(wv, FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT))
|
||||
if (popup == null) {
|
||||
loadingView = buildLoadingView().also { contentFrame.addView(it) }
|
||||
wv.loadUrl(startUrl)
|
||||
// Wait for this page's route to be in effect before the first request leaves.
|
||||
claimRoute { if (webView === wv) wv.loadUrl(startUrl) }
|
||||
} else {
|
||||
wv.url?.let { if (it.isNotBlank() && it != "about:blank") startUrl = it }
|
||||
}
|
||||
@@ -406,31 +433,59 @@ class NappletBrowserActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
private val expireHeld =
|
||||
Runnable {
|
||||
if (!isDestroyed) heldWhileAway.expire().forEach { (_, request) -> bridge.failRequest(request, NappletHeldRequests.EXPIRED) }
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
webView?.onResume()
|
||||
resumed = true
|
||||
reportAttended()
|
||||
val held = heldWhileAway.drain()
|
||||
held.fail.forEach { (_, request) -> bridge.failRequest(request, NappletHeldRequests.EXPIRED) }
|
||||
held.send.forEach { (origin, request) -> dispatchToBroker(origin, request) }
|
||||
heartbeatHandler.removeCallbacks(heartbeat)
|
||||
heartbeat.run()
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
// Back within the grace: the page was never paused.
|
||||
heartbeatHandler.removeCallbacks(backgroundPause)
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
// Out of sight: pause the page after the same grace the rest of the app gets
|
||||
// (NappletHostContract.BACKGROUND_PAUSE_MS), so a quick trip to another app doesn't interrupt it.
|
||||
// NIP-07 sign / encrypt / decrypt is already held while not resumed (see [heldWhileAway]).
|
||||
heartbeatHandler.postDelayed(backgroundPause, NappletHostContract.BACKGROUND_PAUSE_MS)
|
||||
super.onStop()
|
||||
}
|
||||
|
||||
// Only pause THIS activity's WebView (onPause is per-WebView). Do NOT call pauseTimers(): it is
|
||||
// process-global — it freezes JS/layout/parsing timers for EVERY WebView in `:napplet`, including the
|
||||
// embedded ones in NappletBrowserService, which have no resume of their own. That left the embed frozen
|
||||
// (dead page/connection) after returning from a full-screen excursion.
|
||||
private val backgroundPause = Runnable { if (!isDestroyed) webView?.onPause() }
|
||||
|
||||
override fun onPause() {
|
||||
// Only pause THIS activity's WebView (onPause is per-WebView). Do NOT call pauseTimers(): it is
|
||||
// process-global — it freezes JS/layout/parsing timers for EVERY WebView in `:napplet`, including
|
||||
// the embedded ones in NappletBrowserService, which have no resume of their own. That left the
|
||||
// embed frozen (dead page/connection) after returning from a full-screen excursion.
|
||||
webView?.onPause()
|
||||
resumed = false
|
||||
reportAttended()
|
||||
heartbeatHandler.removeCallbacks(heartbeat)
|
||||
setBrokerForeground(false)
|
||||
super.onPause()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
heartbeatHandler.removeCallbacks(backgroundPause)
|
||||
heartbeatHandler.removeCallbacks(expireHeld)
|
||||
// Tell the broker to drop every reference to our reply Messenger BEFORE unbinding — a retained
|
||||
// Messenger is a binder, and it would pin this Activity (and its WebView) in `:napplet` for the
|
||||
// life of the process. `unbindService` alone does not release it. See [replyMessenger].
|
||||
releaseFromBroker()
|
||||
WebViewProxyPolicy.release(this)
|
||||
runCatching { unbindService(brokerConnection) }
|
||||
// A picker still up when the browser is torn down would otherwise leave its callback unanswered.
|
||||
pendingFileChooser.cancel()
|
||||
@@ -483,7 +538,12 @@ class NappletBrowserActivity : ComponentActivity() {
|
||||
val msg =
|
||||
Message.obtain(null, NappletIpc.MSG_RELEASE_CLIENT).apply {
|
||||
replyTo = replyMessenger
|
||||
data = Bundle().apply { putString(NappletIpc.KEY_LAUNCH_TOKEN, leaseKey) }
|
||||
data =
|
||||
Bundle().apply {
|
||||
putString(NappletIpc.KEY_LAUNCH_TOKEN, leaseKey)
|
||||
// This activity's per-origin tokens die with it (a recreated activity mints its own).
|
||||
if (originTokens.isNotEmpty()) putStringArray(NappletIpc.KEY_RELEASED_TOKENS, originTokens.values.toTypedArray())
|
||||
}
|
||||
}
|
||||
runCatching { broker.send(msg) }
|
||||
}
|
||||
@@ -678,6 +738,9 @@ class NappletBrowserActivity : ComponentActivity() {
|
||||
// A fresh main-frame navigation: arm history gating and show the new address.
|
||||
pendingMainFrameUrl = url
|
||||
mainFrameLoadFailed = false
|
||||
// The page is being replaced: nothing it asked for (replies, subscription pushes) may reach the next
|
||||
// one, even a next one that never talks to the bridge.
|
||||
if (bridge.onNavigation()) releasePage()
|
||||
// A window a page opened takes its first real page as its home ("scope").
|
||||
if (startUrl == "about:blank" && url.startsWith("http")) startUrl = url
|
||||
// Re-arm favicon capture when the host changes, so a same-host in-page nav doesn't re-send.
|
||||
@@ -765,6 +828,9 @@ class NappletBrowserActivity : ComponentActivity() {
|
||||
Log.w(TAG) { "Renderer gone (crashed=${detail.didCrash()}); offering a reload of $lastUrl" }
|
||||
exitFullscreen()
|
||||
destroyWebView()
|
||||
// The page died with its renderer: nothing is left to receive its replies or pushes.
|
||||
releasePage()
|
||||
bridge.clear()
|
||||
showCrashView(lastUrl)
|
||||
return true
|
||||
}
|
||||
@@ -886,12 +952,12 @@ class NappletBrowserActivity : ComponentActivity() {
|
||||
/** Loads a user-typed address from "Edit address", forcing Tor for `.onion` when available. */
|
||||
private fun loadAddress(text: String) {
|
||||
val resolved = OmniboxInput.resolve(text) ?: return
|
||||
if (resolved.forceTor && proxyPort > 0 && !useTor) {
|
||||
if (resolved.forceTor && !useTor) {
|
||||
useTor = true
|
||||
applyWebViewProxy(proxyPort)
|
||||
updateChromeState { copy(torOn = true) }
|
||||
updateChromeState { copy(torOn = true, torForced = false) }
|
||||
}
|
||||
webView?.loadUrl(resolved.url)
|
||||
// An onion must not leave before the Tor route it just claimed is in place.
|
||||
claimRoute { webView?.loadUrl(resolved.url) }
|
||||
}
|
||||
|
||||
// ---- bridge: page <-> native (mirror of NappletBrowserService.onBridgeMessage) ----
|
||||
@@ -904,7 +970,8 @@ class NappletBrowserActivity : ComponentActivity() {
|
||||
replyProxy: JavaScriptReplyProxy,
|
||||
) {
|
||||
if (!isMainFrame) return
|
||||
bridgeReplyProxy = replyProxy
|
||||
// A new document replaced the page: whatever the old one had open with the broker is dead.
|
||||
if (bridge.onMessage(replyProxy)) releasePage()
|
||||
val raw = message.data ?: return
|
||||
val envelope = parseJsonObjectOrNull(raw) ?: return
|
||||
|
||||
@@ -930,17 +997,40 @@ class NappletBrowserActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
val id = envelope.stringOrNull("id").orEmpty().ifEmpty { "fire-${fireSeq++}" }
|
||||
val pageId = envelope.stringOrNull("id").orEmpty().ifEmpty { "fire-${fireSeq++}" }
|
||||
val id = bridge.brokerIdFor(pageId)
|
||||
// A relay subscription is named by the page, and its pushes (decrypted events included) come back
|
||||
// under that name: stamp this document on it so the next page can never receive them.
|
||||
val outgoing = bridge.stampSubscription(envelope)?.toString() ?: raw
|
||||
val msg =
|
||||
Message.obtain(null, NappletIpc.MSG_REQUEST).apply {
|
||||
replyTo = replyMessenger
|
||||
data =
|
||||
Bundle().apply {
|
||||
putString(NappletIpc.KEY_REQUEST_ID, id)
|
||||
putString(NappletIpc.KEY_PAYLOAD, raw)
|
||||
putString(NappletIpc.KEY_PAYLOAD, outgoing)
|
||||
}
|
||||
}
|
||||
|
||||
// In the background: a sign / encrypt / decrypt waits until the user is back on this window.
|
||||
if (!resumed && NappletActingRequests.actsForUser(runCatching { NappletProtocolJson.readType(raw) }.getOrNull())) {
|
||||
val refused = heldWhileAway.hold(origin to msg)
|
||||
if (refused != null) {
|
||||
bridge.failRequest(refused.second, NappletHeldRequests.TOO_MANY)
|
||||
} else {
|
||||
// Settle it with an error if nobody comes back for it, so the page isn't left waiting forever.
|
||||
heartbeatHandler.postDelayed(expireHeld, NappletHeldRequests.MAX_AGE_MS)
|
||||
}
|
||||
return
|
||||
}
|
||||
dispatchToBroker(origin, msg)
|
||||
}
|
||||
|
||||
/** Sends [msg] with [origin]'s launch token, minting the token first if the origin has none yet. */
|
||||
private fun dispatchToBroker(
|
||||
origin: String,
|
||||
msg: Message,
|
||||
) {
|
||||
val token = originTokens[origin]
|
||||
if (token != null) {
|
||||
msg.data.putString(NappletIpc.KEY_LAUNCH_TOKEN, token)
|
||||
@@ -1016,14 +1106,61 @@ class NappletBrowserActivity : ComponentActivity() {
|
||||
|
||||
private fun requestBrowserToken(origin: String) {
|
||||
if (!mintInFlight.add(origin)) return
|
||||
// The broker may never answer (it died mid-mint): fail the origin's queued calls rather than let the
|
||||
// page wait forever.
|
||||
Handler(Looper.getMainLooper()).postDelayed({
|
||||
if (!isDestroyed && origin in mintInFlight) failMint(origin, MINT_TIMED_OUT)
|
||||
}, NappletIpc.MINT_TIMEOUT_MS)
|
||||
val msg =
|
||||
Message.obtain(null, NappletIpc.MSG_MINT_BROWSER_TOKEN).apply {
|
||||
replyTo = replyMessenger
|
||||
data = Bundle().apply { putString(NappletIpc.KEY_BROWSER_ORIGIN, origin) }
|
||||
data =
|
||||
Bundle().apply {
|
||||
putString(NappletIpc.KEY_BROWSER_ORIGIN, origin)
|
||||
putString(NappletIpc.KEY_WEBVIEW_PROFILE, webViewProfile)
|
||||
}
|
||||
}
|
||||
queueToBroker(msg)
|
||||
}
|
||||
|
||||
/**
|
||||
* The page is gone (navigated away, or its renderer died): drop its requests still waiting for a token
|
||||
* or the broker, and have the broker close the live relay / inc subscriptions it opened — otherwise
|
||||
* their events would keep streaming into whatever page comes next. Unlike [releaseFromBroker] this keeps
|
||||
* the surface's foreground lease: the activity itself is still up.
|
||||
*/
|
||||
private fun releasePage() {
|
||||
pendingByOrigin.clear()
|
||||
heldWhileAway.clear()
|
||||
// A mint the broker never answered (none is sent while logged out) would otherwise block the
|
||||
// origin for good; the next page asks again.
|
||||
mintInFlight.clear()
|
||||
pendingBrokerRequests.removeAll { it.what == NappletIpc.MSG_REQUEST }
|
||||
val broker = brokerMessenger ?: return
|
||||
runCatching { broker.send(Message.obtain(null, NappletIpc.MSG_RELEASE_CLIENT).apply { replyTo = replyMessenger }) }
|
||||
// The release forgets this window's attendance along with the rest; the next page is watched the same.
|
||||
if (resumed) reportAttended()
|
||||
}
|
||||
|
||||
/** Tells the broker whether this window is being looked at, which gates decrypting its relay reads. */
|
||||
private fun reportAttended() {
|
||||
queueToBroker(
|
||||
Message.obtain(null, NappletIpc.MSG_SET_ATTENDED).apply {
|
||||
replyTo = replyMessenger
|
||||
data = Bundle().apply { putBoolean(NappletIpc.KEY_ATTENDED, resumed) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** A token for [origin] won't come: answer each call queued behind it with a failure, and allow a retry. */
|
||||
private fun failMint(
|
||||
origin: String,
|
||||
reason: String,
|
||||
) {
|
||||
mintInFlight.remove(origin)
|
||||
pendingByOrigin.remove(origin)?.forEach { queued -> bridge.failRequest(queued, reason) }
|
||||
}
|
||||
|
||||
/** Sends now when the broker is bound, else queues until it is. */
|
||||
private fun queueToBroker(msg: Message) {
|
||||
if (brokerMessenger != null) sendToBroker(msg) else pendingBrokerRequests.add(msg)
|
||||
@@ -1040,15 +1177,25 @@ class NappletBrowserActivity : ComponentActivity() {
|
||||
private fun onBrokerReply(msg: Message): Boolean {
|
||||
val data = msg.data ?: return true
|
||||
when (msg.what) {
|
||||
NappletIpc.MSG_TOKEN_UNKNOWN -> {
|
||||
// The broker no longer knows this token (evicted): forget it so the origin re-mints.
|
||||
val token = data.getString(NappletIpc.KEY_LAUNCH_TOKEN) ?: return true
|
||||
originTokens.values.removeAll { it == token }
|
||||
}
|
||||
NappletIpc.MSG_RESPONSE -> {
|
||||
val id = data.getString(NappletIpc.KEY_REQUEST_ID) ?: return true
|
||||
val brokerId = data.getString(NappletIpc.KEY_REQUEST_ID) ?: return true
|
||||
val payload = data.getString(NappletIpc.KEY_PAYLOAD) ?: return true
|
||||
val result = (parseJsonObjectOrNull(payload) ?: JsonObject(emptyMap())).withString("id", id)
|
||||
bridgeReplyProxy?.postMessage(result.toString())
|
||||
// Null when the page that asked has been navigated away from: drop it rather than hand
|
||||
// one site's answer (a signature, a decryption) to the next.
|
||||
val (pageId, proxy) = bridge.resolve(brokerId) ?: return true
|
||||
val result = (parseJsonObjectOrNull(payload) ?: JsonObject(emptyMap())).withString("id", pageId)
|
||||
runCatching { proxy.postMessage(result.toString()) }
|
||||
}
|
||||
NappletIpc.MSG_PUSH -> {
|
||||
val payload = data.getString(NappletIpc.KEY_PAYLOAD) ?: return true
|
||||
bridgeReplyProxy?.postMessage(payload)
|
||||
// Dropped when it is for a subscription a replaced document opened.
|
||||
val push = parseJsonObjectOrNull(payload)?.let { bridge.resolvePush(it) } ?: return true
|
||||
runCatching { bridge.currentProxy?.postMessage(push.toString()) }
|
||||
}
|
||||
NappletIpc.MSG_WEB_FAVORITE_STATE -> {
|
||||
val url = data.getString(NappletIpc.KEY_FAVORITE_URL) ?: return true
|
||||
@@ -1065,7 +1212,12 @@ class NappletBrowserActivity : ComponentActivity() {
|
||||
}
|
||||
NappletIpc.MSG_BROWSER_TOKEN -> {
|
||||
val origin = data.getString(NappletIpc.KEY_BROWSER_ORIGIN) ?: return true
|
||||
val token = data.getString(NappletIpc.KEY_LAUNCH_TOKEN) ?: return true
|
||||
val token = data.getString(NappletIpc.KEY_LAUNCH_TOKEN)
|
||||
if (token == null) {
|
||||
// Refused (no account signed in): the page's calls fail now instead of hanging.
|
||||
failMint(origin, NOT_SIGNED_IN)
|
||||
return true
|
||||
}
|
||||
originTokens[origin] = token
|
||||
mintInFlight.remove(origin)
|
||||
pendingByOrigin.remove(origin)?.forEach { queued ->
|
||||
@@ -1361,29 +1513,38 @@ class NappletBrowserActivity : ComponentActivity() {
|
||||
|
||||
// ---- network ----
|
||||
|
||||
/** Whether the process route is Tor right now, whatever this page asked for. */
|
||||
private var routedOverTor = false
|
||||
|
||||
/**
|
||||
* Routes WebView traffic through the Tor SOCKS proxy when [port] > 0, else clears the override.
|
||||
* Process-global (this `:napplet` process hosts only sandbox WebViews) and best-effort.
|
||||
* Files this page's Tor / open-web choice with the process-wide [WebViewProxyPolicy] (the override is
|
||||
* shared by every WebView in `:napplet`, so no surface sets it directly); [onReady] runs once the shared
|
||||
* route is in effect. Fails closed: a page that wants Tor loads nothing until Tor's port is known and the
|
||||
* route is really applied.
|
||||
*/
|
||||
private fun applyWebViewProxy(port: Int) {
|
||||
if (!WebViewFeature.isFeatureSupported(WebViewFeature.PROXY_OVERRIDE)) return
|
||||
val executor = Executor { it.run() }
|
||||
runCatching {
|
||||
if (port > 0) {
|
||||
val config = ProxyConfig.Builder().addProxyRule("socks5://127.0.0.1:$port").build()
|
||||
ProxyController.getInstance().setProxyOverride(config, executor) {}
|
||||
} else {
|
||||
ProxyController.getInstance().clearProxyOverride(executor) {}
|
||||
}
|
||||
}.onFailure { Log.w(TAG, "Failed to apply WebView proxy override", it) }
|
||||
private fun claimRoute(onReady: () -> Unit = {}) {
|
||||
if (useTor && proxyPort <= 0) {
|
||||
showRouteBlocked()
|
||||
return
|
||||
}
|
||||
WebViewProxyPolicy.claim(
|
||||
owner = this,
|
||||
torPort = if (useTor) proxyPort else NappletProxyClaims.NO_PROXY,
|
||||
onFailed = { showRouteBlocked() },
|
||||
onReady = onReady,
|
||||
)
|
||||
}
|
||||
|
||||
private fun showRouteBlocked() {
|
||||
if (!isDestroyed) Toast.makeText(this, R.string.napplet_route_blocked, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
|
||||
/** Persists the per-host Tor choice in the main process and re-applies it to the live WebView. */
|
||||
private fun setNetworkMode(newUseTor: Boolean) {
|
||||
useTor = newUseTor
|
||||
applyWebViewProxy(if (useTor) proxyPort else -1)
|
||||
webView?.reload()
|
||||
updateChromeState { copy(torOn = useTor) }
|
||||
// Reload only once the new route is in effect, or the reload would go out the old way.
|
||||
claimRoute { webView?.reload() }
|
||||
updateChromeState { copy(torOn = useTor, torForced = !useTor && routedOverTor) }
|
||||
// Key the persisted choice on the host actually displayed (which may differ from startUrl after
|
||||
// in-page navigation), so the preference sticks to the right site.
|
||||
val host = runCatching { currentUrl().toUri().host }.getOrNull()?.takeIf { it.isNotBlank() } ?: return
|
||||
@@ -1422,6 +1583,7 @@ class NappletBrowserActivity : ComponentActivity() {
|
||||
url = startUrl,
|
||||
startUrl = startUrl,
|
||||
torOn = if (proxyPort > 0) useTor else null,
|
||||
torForced = !useTor && routedOverTor,
|
||||
),
|
||||
isFavorite = intent.getBooleanExtra(EXTRA_IS_FAVORITE, false),
|
||||
defaultBrowserName = DefaultBrowser.label(this),
|
||||
@@ -1669,7 +1831,7 @@ class NappletBrowserActivity : ComponentActivity() {
|
||||
crashView = null
|
||||
val wv = buildWebView()
|
||||
contentFrame.addView(wv, 0, FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT))
|
||||
wv.loadUrl(url)
|
||||
claimRoute { if (webView === wv) wv.loadUrl(url) }
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -1722,7 +1884,8 @@ class NappletBrowserActivity : ComponentActivity() {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "NappletBrowserActivity"
|
||||
|
||||
private const val NOT_SIGNED_IN = "Sign in to Amethyst to use this site's Nostr features."
|
||||
private const val MINT_TIMED_OUT = "Amethyst didn't answer. Reload the page to try again."
|
||||
private const val ACTIVITY_CLASS = "com.vitorpamplona.amethyst.napplethost.NappletBrowserActivity"
|
||||
|
||||
/** How often a resumed browser renews its foreground lease (well under the broker's 90s TTL). */
|
||||
|
||||
+43
-1
@@ -70,7 +70,9 @@ object NappletBrowserContract {
|
||||
* Provider → client: the main-frame load state changed. Carries [KEY_IS_LOADING] (a navigation is in
|
||||
* flight), [KEY_LOAD_FAILED] (the main frame errored), and [KEY_URL] (the page it settled on). Lets
|
||||
* the main process draw a loading spinner / error overlay over the embedded surface, and recover a
|
||||
* favorite whose session came up on a blank page (re-navigate to its real URL).
|
||||
* favorite whose session came up on a blank page (re-navigate to its real URL). [KEY_RENDERER_GONE]
|
||||
* marks a failure caused by the WebView renderer dying: the tab has no WebView left, so the client
|
||||
* must [MSG_RELOAD] (which rebuilds it on the page it was showing).
|
||||
*/
|
||||
const val MSG_LOAD_STATE = 10
|
||||
|
||||
@@ -200,6 +202,45 @@ object NappletBrowserContract {
|
||||
/** Provider → client: withdraw the [MSG_DOWNLOAD_CONSENT] card [KEY_DOWNLOAD_ID] (its tab closed). */
|
||||
const val MSG_DOWNLOAD_CANCEL = 36
|
||||
|
||||
/**
|
||||
* Client → provider: the tab left the screen (parked off-screen by the tab layer). The page's WebView is
|
||||
* paused — animations, media and geolocation stop — so warm tabs in the background don't keep burning
|
||||
* CPU and battery. Mirrors [NappletEmbedContract.MSG_PAUSE] for napplets.
|
||||
*/
|
||||
const val MSG_PAUSE = 39
|
||||
|
||||
/** Client → provider: the tab is the visible one again; resume its WebView. */
|
||||
const val MSG_RESUME = 40
|
||||
|
||||
/**
|
||||
* Client → provider: the tab was torn down (evicted, or rebuilt for a theme/account change). Drops the
|
||||
* session and its WebView even if the surface never opened — a session created for a view that was
|
||||
* disposed before it attached would otherwise sit in the provider forever, pinning its client.
|
||||
*/
|
||||
const val MSG_CLOSE_SESSION = 41
|
||||
|
||||
/**
|
||||
* Client → provider: whether the user is looking at this tab ([KEY_ENABLED]) — it's the visible tab AND
|
||||
* the app is on screen. While not, the provider holds the page's requests that act for the user or use
|
||||
* their key (NIP-07 sign / encrypt / decrypt) and sends them once the user is back, so a parked or
|
||||
* backgrounded site can't sign — even with "allow always" — while nobody is watching. Reads still flow.
|
||||
*/
|
||||
const val MSG_SET_ATTENDED = 37
|
||||
|
||||
/**
|
||||
* Provider → client: whether the process's pages currently go through Tor ([KEY_USE_TOR]). Sent on every
|
||||
* change. Tor always wins process-wide, so a tab set to the open web can still be on Tor because another
|
||||
* open page needs it — the client shows why.
|
||||
*/
|
||||
const val MSG_ROUTE = 38
|
||||
|
||||
/**
|
||||
* On [MSG_LOAD_STATE]: the page was not loaded because its network route can't be honored (Tor wanted but
|
||||
* not running yet, this WebView can't proxy, or applying the proxy failed). Nothing went out; the client
|
||||
* shows the error + Retry even over a page that loaded before.
|
||||
*/
|
||||
const val KEY_ROUTE_BLOCKED = "routeBlocked"
|
||||
|
||||
const val KEY_CAN_GO_FORWARD = "canGoForward"
|
||||
const val KEY_FIND_QUERY = "findQuery"
|
||||
const val KEY_FIND_FORWARD = "findForward"
|
||||
@@ -266,6 +307,7 @@ object NappletBrowserContract {
|
||||
|
||||
const val KEY_IS_LOADING = "isLoading"
|
||||
const val KEY_LOAD_FAILED = "loadFailed"
|
||||
const val KEY_RENDERER_GONE = "rendererGone"
|
||||
|
||||
const val KEY_CONSOLE_LEVEL = "consoleLevel"
|
||||
const val KEY_CONSOLE_MESSAGE = "consoleMessage"
|
||||
|
||||
+338
-114
@@ -26,7 +26,6 @@ import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.ServiceConnection
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
@@ -53,28 +52,28 @@ import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import android.widget.FrameLayout
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.core.graphics.createBitmap
|
||||
import androidx.core.graphics.scale
|
||||
import androidx.core.net.toUri
|
||||
import androidx.privacysandbox.ui.provider.toCoreLibInfo
|
||||
import androidx.webkit.JavaScriptReplyProxy
|
||||
import androidx.webkit.ProxyConfig
|
||||
import androidx.webkit.ProxyController
|
||||
import androidx.webkit.WebMessageCompat
|
||||
import androidx.webkit.WebViewCompat
|
||||
import androidx.webkit.WebViewFeature
|
||||
import com.vitorpamplona.amethyst.commons.browser.BrowserChrome
|
||||
import com.vitorpamplona.amethyst.commons.browser.BrowserSitePermission
|
||||
import com.vitorpamplona.amethyst.commons.browser.DownloadCooldown
|
||||
import com.vitorpamplona.amethyst.commons.browser.OmniboxInput
|
||||
import com.vitorpamplona.amethyst.commons.napplet.NappletActingRequests
|
||||
import com.vitorpamplona.amethyst.commons.napplet.NappletBridgeDocuments
|
||||
import com.vitorpamplona.amethyst.commons.napplet.NappletHeldRequests
|
||||
import com.vitorpamplona.amethyst.commons.napplet.NappletProxyClaims
|
||||
import com.vitorpamplona.amethyst.commons.napplet.NappletWebContract
|
||||
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletProtocolJson
|
||||
import com.vitorpamplona.amethyst.commons.util.parseJsonObjectOrNull
|
||||
import com.vitorpamplona.amethyst.commons.util.stringOrNull
|
||||
import com.vitorpamplona.amethyst.commons.util.withString
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.util.concurrent.Executor
|
||||
|
||||
/**
|
||||
* Provider for the **embedded** in-app browser. Runs in the keyless `:napplet` process: it hosts the
|
||||
@@ -103,7 +102,7 @@ class NappletBrowserService : Service() {
|
||||
val sessionId: String,
|
||||
var clientMessenger: Messenger?,
|
||||
val url: String,
|
||||
val proxyPort: Int,
|
||||
var proxyPort: Int,
|
||||
var useTor: Boolean,
|
||||
val bgColor: Int,
|
||||
val themeType: String,
|
||||
@@ -112,6 +111,19 @@ class NappletBrowserService : Service() {
|
||||
) {
|
||||
var webView: WebView? = null
|
||||
|
||||
// The page the renderer was showing when it died, so the rebuild lands back where the user was.
|
||||
var recoverUrl: String? = null
|
||||
|
||||
// The client's last pause/resume. A parked tab can be paused before its WebView exists (the WebView
|
||||
// is only built when the surface opens), so the flag is applied to every WebView built for the tab.
|
||||
var paused = false
|
||||
|
||||
// Whether the user is looking at this tab (see NappletBrowserContract.MSG_SET_ATTENDED), and the page's
|
||||
// requests that act for the user held while they aren't: (origin, request), sent when they're back.
|
||||
// Unattended until the client says otherwise: it sends its state right after every create.
|
||||
var attended = false
|
||||
val heldWhileAway = NappletHeldRequests<Pair<String, Message>>(SystemClock::elapsedRealtime)
|
||||
|
||||
// The session's root view (holds the WebView, and the page's fullscreen view when it has one).
|
||||
var container: FrameLayout? = null
|
||||
var customView: View? = null
|
||||
@@ -125,7 +137,9 @@ class NappletBrowserService : Service() {
|
||||
var textZoom = BrowserChrome.DEFAULT_TEXT_ZOOM
|
||||
var desktopSite = false
|
||||
|
||||
var bridgeReplyProxy: JavaScriptReplyProxy? = null
|
||||
// The page on screen's bridge reply proxy, and which document each broker reply belongs to: a
|
||||
// reply for a page the tab has navigated away from must never land in the next one.
|
||||
val bridge = NappletBridgeDocuments<JavaScriptReplyProxy>()
|
||||
var fireSeq = 0
|
||||
|
||||
// The in-flight `<input type="file">` pick for this surface. The picker itself runs in the main
|
||||
@@ -153,6 +167,17 @@ class NappletBrowserService : Service() {
|
||||
var preparingDownload = false
|
||||
|
||||
val replyMessenger = Messenger(Handler(Looper.getMainLooper()) { onBrokerReply(this, it) })
|
||||
|
||||
/**
|
||||
* Sends [msg] to this tab's client stamped with the session it belongs to, so the client can drop what
|
||||
* a session it has since replaced still had in flight (a late file-chooser request, a stale
|
||||
* SESSION_READY that would re-arm its view with a dead adapter). Returns whether it was delivered.
|
||||
*/
|
||||
fun toClient(msg: Message): Boolean {
|
||||
val client = clientMessenger ?: return false
|
||||
msg.data.putString(NappletBrowserContract.KEY_SESSION_ID, sessionId)
|
||||
return runCatching { client.send(msg) }.isSuccess
|
||||
}
|
||||
}
|
||||
|
||||
private val tabs = mutableMapOf<String, BrowserTab>()
|
||||
@@ -188,6 +213,8 @@ class NappletBrowserService : Service() {
|
||||
brokerMessenger = Messenger(service)
|
||||
pendingBrokerRequests.forEach { sendToBroker(it) }
|
||||
pendingBrokerRequests.clear()
|
||||
// A broker that restarted knows nothing about who is watching.
|
||||
tabs.values.forEach { if (it.attended) reportAttended(it) }
|
||||
}
|
||||
|
||||
override fun onServiceDisconnected(name: ComponentName?) {
|
||||
@@ -198,19 +225,33 @@ class NappletBrowserService : Service() {
|
||||
override fun onBind(intent: Intent?): IBinder = incoming.binder
|
||||
|
||||
override fun onDestroy() {
|
||||
// Release before unbinding, while the broker can still hear it.
|
||||
tabs.values.forEach {
|
||||
it.fileChooser.cancel()
|
||||
cancelPending(it)
|
||||
releasePage(it, closing = true)
|
||||
WebViewProxyPolicy.release(it)
|
||||
it.webView?.destroy()
|
||||
}
|
||||
tabs.clear()
|
||||
if (brokerBound) {
|
||||
runCatching { unbindService(brokerConnection) }
|
||||
brokerBound = false
|
||||
}
|
||||
tabs.values.forEach {
|
||||
it.fileChooser.cancel()
|
||||
cancelPending(it)
|
||||
it.webView?.destroy()
|
||||
}
|
||||
tabs.clear()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
/** The client re-reads Tor's port on every load it asks for: Tor may have come up (or moved) since the tab was made. */
|
||||
private fun refreshProxyPort(
|
||||
tab: BrowserTab,
|
||||
msg: Message,
|
||||
) {
|
||||
msg.data
|
||||
?.getInt(NappletBrowserContract.KEY_PROXY_PORT, 0)
|
||||
?.takeIf { it != 0 }
|
||||
?.let { tab.proxyPort = it }
|
||||
}
|
||||
|
||||
private fun tabFor(msg: Message): BrowserTab? = msg.data?.getString(NappletBrowserContract.KEY_SESSION_ID)?.let { tabs[it] }
|
||||
|
||||
private fun onClientMessage(msg: Message): Boolean {
|
||||
@@ -218,6 +259,9 @@ class NappletBrowserService : Service() {
|
||||
NappletBrowserContract.MSG_CREATE_SESSION -> {
|
||||
val data = msg.data ?: return true
|
||||
val sessionId = data.getString(NappletBrowserContract.KEY_SESSION_ID) ?: return true
|
||||
// A re-sent create for an id that is still live (a client that lost track of it) must not
|
||||
// strand the old tab's WebView, broker state and proxy claim with nothing left to close them.
|
||||
tabs[sessionId]?.let(::closeTab)
|
||||
val tab =
|
||||
BrowserTab(
|
||||
sessionId = sessionId,
|
||||
@@ -239,10 +283,39 @@ class NappletBrowserService : Service() {
|
||||
}
|
||||
NappletBrowserContract.MSG_NAVIGATE -> {
|
||||
val tab = tabFor(msg) ?: return true
|
||||
refreshProxyPort(tab, msg)
|
||||
val url = normalizeUrl(msg.data?.getString(NappletBrowserContract.KEY_URL).orEmpty())
|
||||
// A renderer crash destroyed this tab's WebView; the user's retry builds a fresh one.
|
||||
if (tab.webView == null) rebuildWebView(tab, url) else tab.webView?.loadUrl(url)
|
||||
val wv = tab.webView
|
||||
if (wv == null) {
|
||||
rebuildWebView(tab, url)
|
||||
} else {
|
||||
// Right after a Tor toggle the new route may still be applying; don't let this load race it
|
||||
// (nor go out at all when Tor is wanted but unavailable).
|
||||
claimRoute(tab) { if (tab.webView === wv) wv.loadUrl(url) }
|
||||
}
|
||||
}
|
||||
NappletBrowserContract.MSG_CLOSE_SESSION -> tabFor(msg)?.let(::closeTab)
|
||||
NappletBrowserContract.MSG_SET_ATTENDED -> {
|
||||
val tab = tabFor(msg) ?: return true
|
||||
tab.attended = msg.data?.getBoolean(NappletBrowserContract.KEY_ENABLED, false) ?: false
|
||||
reportAttended(tab)
|
||||
if (tab.attended) {
|
||||
val held = tab.heldWhileAway.drain()
|
||||
held.fail.forEach { (_, request) -> tab.bridge.failRequest(request, NappletHeldRequests.EXPIRED) }
|
||||
held.send.forEach { (origin, request) -> dispatchToBroker(tab, origin, request) }
|
||||
}
|
||||
}
|
||||
NappletBrowserContract.MSG_PAUSE ->
|
||||
tabFor(msg)?.let {
|
||||
it.paused = true
|
||||
it.webView?.onPause()
|
||||
}
|
||||
NappletBrowserContract.MSG_RESUME ->
|
||||
tabFor(msg)?.let {
|
||||
it.paused = false
|
||||
it.webView?.onResume()
|
||||
}
|
||||
NappletBrowserContract.MSG_FORWARD -> tabFor(msg)?.webView?.let { if (it.canGoForward()) it.goForward() }
|
||||
NappletBrowserContract.MSG_STOP -> tabFor(msg)?.webView?.stopLoading()
|
||||
NappletBrowserContract.MSG_FIND -> {
|
||||
@@ -321,21 +394,25 @@ class NappletBrowserService : Service() {
|
||||
if (allowed) pending.offer.save(this)
|
||||
}
|
||||
NappletBrowserContract.MSG_EXIT_FULLSCREEN -> tabFor(msg)?.let { exitFullscreen(it) }
|
||||
NappletBrowserContract.MSG_RELOAD -> tabFor(msg)?.webView?.reload()
|
||||
NappletBrowserContract.MSG_RELOAD -> {
|
||||
val tab = tabFor(msg) ?: return true
|
||||
refreshProxyPort(tab, msg)
|
||||
// A renderer death destroyed this tab's WebView: rebuild it on the page it was showing.
|
||||
if (tab.webView == null) rebuildWebView(tab, tab.recoverUrl ?: tab.url) else claimRoute(tab) { tab.webView?.reload() }
|
||||
}
|
||||
NappletBrowserContract.MSG_BACK -> tabFor(msg)?.webView?.let { if (it.canGoBack()) it.goBack() }
|
||||
NappletBrowserContract.MSG_IME_OP -> {
|
||||
val tab = tabFor(msg) ?: return true
|
||||
val payload = msg.data?.getString(NappletBrowserContract.KEY_IME_PAYLOAD) ?: return true
|
||||
tab.bridgeReplyProxy?.postMessage(payload)
|
||||
runCatching { tab.bridge.currentProxy?.postMessage(payload) }
|
||||
}
|
||||
NappletBrowserContract.MSG_SET_TOR -> {
|
||||
val tab = tabFor(msg) ?: return true
|
||||
tab.useTor = msg.data?.getBoolean(NappletBrowserContract.KEY_USE_TOR, false) ?: false
|
||||
// Reload only after the proxy override actually applies — setProxyOverride is async, so
|
||||
// reloading immediately would re-fetch through the old route. NB: the override is
|
||||
// process-global (Android has no per-WebView proxy), so it affects every tab; we only
|
||||
// reload the one the user toggled.
|
||||
applyWebViewProxy(if (tab.useTor) tab.proxyPort else -1) { tab.webView?.reload() }
|
||||
refreshProxyPort(tab, msg)
|
||||
// Reload only after the route actually applies — the override is async, so reloading
|
||||
// immediately would re-fetch through the old route.
|
||||
claimRoute(tab) { tab.webView?.reload() }
|
||||
}
|
||||
NappletBrowserContract.MSG_MAGNIFIER_REQUEST -> onMagnifierRequest(msg)
|
||||
NappletBrowserContract.MSG_FILE_CHOOSER_RESULT -> {
|
||||
@@ -352,48 +429,34 @@ class NappletBrowserService : Service() {
|
||||
return true
|
||||
}
|
||||
|
||||
// One reusable output bitmap per tab would be ideal, but loupe size is fixed per drag; createBitmap each
|
||||
// frame is cheap next to the draw. Source rect is in view px (== surface px, the SCVH is 1:1).
|
||||
// Source rect is in view px (== surface px, the SCVH is 1:1). See MagnifierCapture for the threading.
|
||||
private fun onMagnifierRequest(msg: Message) {
|
||||
val tab = tabFor(msg) ?: return
|
||||
val wv = tab.webView ?: return
|
||||
val data = msg.data ?: return
|
||||
val cx = data.getFloat(NappletBrowserContract.KEY_MAG_X)
|
||||
val cy = data.getFloat(NappletBrowserContract.KEY_MAG_Y)
|
||||
val boxW = data.getInt(NappletBrowserContract.KEY_MAG_BOX_W, 150).coerceIn(16, 1024)
|
||||
val boxH = data.getInt(NappletBrowserContract.KEY_MAG_BOX_H, 84).coerceIn(16, 1024)
|
||||
val zoom = data.getFloat(NappletBrowserContract.KEY_MAG_ZOOM, 1.6f).coerceIn(1f, 4f)
|
||||
val reqT = data.getLong(NappletBrowserContract.KEY_MAG_REQ_T)
|
||||
|
||||
val outW = (boxW * zoom).toInt().coerceAtLeast(1)
|
||||
val outH = (boxH * zoom).toInt().coerceAtLeast(1)
|
||||
val t0 = SystemClock.elapsedRealtimeNanos()
|
||||
val bitmap = createBitmap(outW, outH)
|
||||
val canvas = Canvas(bitmap)
|
||||
canvas.drawColor(tab.bgColor)
|
||||
// Map the source rect (centered on cx,cy in view px) into the zoomed output bitmap.
|
||||
canvas.scale(zoom, zoom)
|
||||
canvas.translate(-(cx - boxW / 2f), -(cy - boxH / 2f))
|
||||
wv.draw(canvas)
|
||||
|
||||
val baos = ByteArrayOutputStream()
|
||||
bitmap.compress(Bitmap.CompressFormat.PNG, 100, baos)
|
||||
val bytes = baos.toByteArray()
|
||||
bitmap.recycle()
|
||||
val captureMs = (SystemClock.elapsedRealtimeNanos() - t0) / 1_000_000.0
|
||||
|
||||
val reply =
|
||||
Message.obtain(null, NappletBrowserContract.MSG_MAGNIFIER_FRAME).apply {
|
||||
this.data =
|
||||
Bundle().apply {
|
||||
putByteArray(NappletBrowserContract.KEY_MAG_BYTES, bytes)
|
||||
putInt(NappletBrowserContract.KEY_MAG_W, outW)
|
||||
putInt(NappletBrowserContract.KEY_MAG_H, outH)
|
||||
putDouble(NappletBrowserContract.KEY_MAG_CAPTURE_MS, captureMs)
|
||||
putLong(NappletBrowserContract.KEY_MAG_REQ_T, reqT)
|
||||
}
|
||||
}
|
||||
runCatching { tab.clientMessenger?.send(reply) }
|
||||
MagnifierCapture.capture(
|
||||
webView = wv,
|
||||
bgColor = tab.bgColor,
|
||||
cx = data.getFloat(NappletBrowserContract.KEY_MAG_X),
|
||||
cy = data.getFloat(NappletBrowserContract.KEY_MAG_Y),
|
||||
boxW = data.getInt(NappletBrowserContract.KEY_MAG_BOX_W, 150).coerceIn(16, 1024),
|
||||
boxH = data.getInt(NappletBrowserContract.KEY_MAG_BOX_H, 84).coerceIn(16, 1024),
|
||||
zoom = data.getFloat(NappletBrowserContract.KEY_MAG_ZOOM, 1.6f).coerceIn(1f, 4f),
|
||||
) { bytes, outW, outH, captureMs ->
|
||||
val reply =
|
||||
Message.obtain(null, NappletBrowserContract.MSG_MAGNIFIER_FRAME).apply {
|
||||
this.data =
|
||||
Bundle().apply {
|
||||
putByteArray(NappletBrowserContract.KEY_MAG_BYTES, bytes)
|
||||
putInt(NappletBrowserContract.KEY_MAG_W, outW)
|
||||
putInt(NappletBrowserContract.KEY_MAG_H, outH)
|
||||
putDouble(NappletBrowserContract.KEY_MAG_CAPTURE_MS, captureMs)
|
||||
putLong(NappletBrowserContract.KEY_MAG_REQ_T, reqT)
|
||||
}
|
||||
}
|
||||
tab.toClient(reply)
|
||||
}
|
||||
}
|
||||
|
||||
/** Builds the SandboxedUiAdapter for [tab] and ships its cross-process handle (coreLibInfo) to the client. */
|
||||
@@ -405,7 +468,7 @@ class NappletBrowserService : Service() {
|
||||
Message.obtain(null, NappletBrowserContract.MSG_SESSION_READY).apply {
|
||||
data = Bundle().apply { putBundle(NappletBrowserContract.KEY_CORE_LIB_INFO, coreLibInfo) }
|
||||
}
|
||||
runCatching { tab.clientMessenger?.send(reply) }
|
||||
tab.toClient(reply)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -421,8 +484,56 @@ class NappletBrowserService : Service() {
|
||||
// The session may have been closed between MSG_CREATE_SESSION and this posted call — fail rather
|
||||
// than build a WebView that no tab tracks (it would leak).
|
||||
val tab = tabs[sessionId] ?: error("No browser tab for session $sessionId")
|
||||
// A session re-opened on this tab (the client's view detached and re-attached) before the old one's
|
||||
// close landed: that session's WebView is still here. Destroy it now — its close will be ignored
|
||||
// (see onSessionClosed), and overwriting it would leak it.
|
||||
tab.webView?.let { stale ->
|
||||
(stale.parent as? ViewGroup)?.removeView(stale)
|
||||
stale.destroy()
|
||||
tab.webView = null
|
||||
}
|
||||
tab.container = container
|
||||
return buildTabWebView(context, tab).also { it.loadUrl(tab.url) }
|
||||
val wv = buildTabWebView(context, tab)
|
||||
claimRoute(tab) { if (tab.webView === wv) wv.loadUrl(tab.url) }
|
||||
return wv
|
||||
}
|
||||
|
||||
/**
|
||||
* Files [tab]'s Tor / open-web choice with the process-wide [WebViewProxyPolicy] and runs [onReady] (the
|
||||
* load) once the shared route is in effect. Fails closed: a tab that wants Tor while Tor has no port yet
|
||||
* doesn't claim or load at all, and a route that can't be applied blocks the load too — either way the
|
||||
* client hears [NappletBrowserContract.KEY_ROUTE_BLOCKED] and offers Retry. Also keeps the client told
|
||||
* which route is really in effect ([NappletBrowserContract.MSG_ROUTE]).
|
||||
*/
|
||||
private fun claimRoute(
|
||||
tab: BrowserTab,
|
||||
onReady: () -> Unit = {},
|
||||
) {
|
||||
WebViewProxyPolicy.observeRoute(tab) { usesTor ->
|
||||
if (tabs[tab.sessionId] === tab) sendToClient(tab, NappletBrowserContract.MSG_ROUTE) { putBoolean(NappletBrowserContract.KEY_USE_TOR, usesTor) }
|
||||
}
|
||||
if (tab.useTor && tab.proxyPort <= 0) {
|
||||
reportRouteBlocked(tab)
|
||||
return
|
||||
}
|
||||
WebViewProxyPolicy.claim(
|
||||
owner = tab,
|
||||
torPort = if (tab.useTor) tab.proxyPort else NappletProxyClaims.NO_PROXY,
|
||||
onFailed = { reportRouteBlocked(tab) },
|
||||
onReady = onReady,
|
||||
)
|
||||
}
|
||||
|
||||
/** The page wasn't loaded because its route can't be honored: tell the client, which shows the error. */
|
||||
private fun reportRouteBlocked(tab: BrowserTab) {
|
||||
if (tabs[tab.sessionId] !== tab) return
|
||||
tab.loadFailed = true
|
||||
sendToClient(tab, NappletBrowserContract.MSG_LOAD_STATE) {
|
||||
putBoolean(NappletBrowserContract.KEY_IS_LOADING, false)
|
||||
putBoolean(NappletBrowserContract.KEY_LOAD_FAILED, true)
|
||||
putBoolean(NappletBrowserContract.KEY_ROUTE_BLOCKED, true)
|
||||
putString(NappletBrowserContract.KEY_URL, tab.webView?.url ?: tab.url)
|
||||
}
|
||||
}
|
||||
|
||||
/** Builds [tab]'s WebView with every client, bridge and script wired, without loading anything. */
|
||||
@@ -439,7 +550,6 @@ class NappletBrowserService : Service() {
|
||||
// Theme the pre-load background so a blank/loading page shows Amethyst's background, not white.
|
||||
wv.setBackgroundColor(tab.bgColor)
|
||||
wv.dropSystemBarInsets()
|
||||
applyWebViewProxy(if (tab.useTor) tab.proxyPort else -1)
|
||||
WebViewCompat.addWebMessageListener(wv, NappletWebContract.BRIDGE_NAME, setOf("*")) { view, message, sourceOrigin, isMainFrame, replyProxy ->
|
||||
onBridgeMessage(tab, view, message, sourceOrigin, isMainFrame, replyProxy)
|
||||
}
|
||||
@@ -450,6 +560,7 @@ class NappletBrowserService : Service() {
|
||||
BrowserWebTools.setTextZoom(wv, tab.textZoom)
|
||||
if (tab.desktopSite) BrowserWebTools.setDesktopMode(wv, true)
|
||||
tab.webView = wv
|
||||
if (tab.paused) wv.onPause()
|
||||
return wv
|
||||
}
|
||||
|
||||
@@ -461,19 +572,33 @@ class NappletBrowserService : Service() {
|
||||
val container = tab.container ?: return
|
||||
val wv = buildTabWebView(container.context, tab)
|
||||
container.addView(wv, 0, FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT))
|
||||
wv.loadUrl(url)
|
||||
claimRoute(tab) { if (tab.webView === wv) wv.loadUrl(url) }
|
||||
}
|
||||
|
||||
/** A session closed: drop the tab and destroy its own WebView (never a sibling's). */
|
||||
fun onSessionClosed(sessionId: String) {
|
||||
val tab = tabs.remove(sessionId) ?: return
|
||||
tab.bridgeReplyProxy = null
|
||||
fun onSessionClosed(
|
||||
sessionId: String,
|
||||
container: FrameLayout,
|
||||
) {
|
||||
// Only the session that currently owns the tab may close it. A late close from a session that was
|
||||
// already replaced by a re-open would otherwise reap the live one — its WebView destroyed under a
|
||||
// client that had just been told the session opened, leaving the surface black for good.
|
||||
tabs[sessionId]?.takeIf { it.container === container }?.let(::closeTab)
|
||||
}
|
||||
|
||||
/** Drops [tab] and everything it holds (its WebView, broker state, proxy claim). */
|
||||
private fun closeTab(tab: BrowserTab) {
|
||||
// By identity: a replaced tab closing late must not take its replacement (same id) with it.
|
||||
tabs.remove(tab.sessionId, tab)
|
||||
WebViewProxyPolicy.release(tab)
|
||||
releasePage(tab, closing = true)
|
||||
tab.bridge.clear()
|
||||
// Release a picker still waiting on this surface before its WebView goes away.
|
||||
tab.fileChooser.cancel()
|
||||
cancelPending(tab)
|
||||
// An unanswered download card dies with its tab: nothing is saved, its bytes are freed, and the
|
||||
// client is told so its card doesn't linger with a Save that does nothing.
|
||||
pendingDownloads.entries.filter { it.value.sessionId == sessionId }.forEach { (id, _) ->
|
||||
pendingDownloads.entries.filter { it.value.sessionId == tab.sessionId }.forEach { (id, _) ->
|
||||
pendingDownloads.remove(id)
|
||||
sendToClient(tab, NappletBrowserContract.MSG_DOWNLOAD_CANCEL) { putLong(NappletBrowserContract.KEY_DOWNLOAD_ID, id) }
|
||||
}
|
||||
@@ -519,9 +644,8 @@ class NappletBrowserService : Service() {
|
||||
what: Int,
|
||||
crossinline block: Bundle.() -> Unit,
|
||||
): Boolean {
|
||||
val client = tab.clientMessenger ?: return false
|
||||
val message = Message.obtain(null, what).apply { data = Bundle().apply(block) }
|
||||
return runCatching { client.send(message) }.isSuccess
|
||||
return tab.toClient(message)
|
||||
}
|
||||
|
||||
private fun pushFindResult(
|
||||
@@ -723,10 +847,14 @@ class NappletBrowserService : Service() {
|
||||
val wanted = request.resources.mapNotNull(::sitePermissionFor).toSet()
|
||||
val id =
|
||||
relayPermissionRequest(tab, BrowserChrome.originOf(request.origin.toString()), wanted) { granted ->
|
||||
// Answered: nothing left for a cancellation to withdraw.
|
||||
pendingWebPermissions.remove(request)
|
||||
val resources = request.resources.filter { sitePermissionFor(it) in granted }.toTypedArray()
|
||||
if (resources.isEmpty()) request.deny() else request.grant(resources)
|
||||
}
|
||||
if (id != null) pendingWebPermissions[request] = id
|
||||
// Only track it while it is still waiting on the user — it may already have been answered inline
|
||||
// (nothing to ask, or the client unreachable), and an entry kept after that would never be removed.
|
||||
if (id != null && tab?.permissionRequests?.containsKey(id) == true) pendingWebPermissions[request] = id
|
||||
}
|
||||
|
||||
override fun onPermissionRequestCanceled(request: PermissionRequest) {
|
||||
@@ -796,7 +924,7 @@ class NappletBrowserService : Service() {
|
||||
putString(NappletBrowserContract.KEY_FILE_CHOOSER_TITLE, params.title?.toString())
|
||||
}
|
||||
}
|
||||
if (runCatching { client.send(msg) }.isFailure) tab.fileChooser.cancel()
|
||||
if (!tab.toClient(msg)) tab.fileChooser.cancel()
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -817,7 +945,7 @@ class NappletBrowserService : Service() {
|
||||
putInt(NappletBrowserContract.KEY_CONSOLE_LINE, line)
|
||||
}
|
||||
}
|
||||
runCatching { tab.clientMessenger?.send(msg) }
|
||||
tab.toClient(msg)
|
||||
}
|
||||
|
||||
/** Loads live web pages in-WebView (http/https) and hands other schemes to the system on a user tap. */
|
||||
@@ -841,6 +969,9 @@ class NappletBrowserService : Service() {
|
||||
) {
|
||||
// A new main-frame navigation cleared any prior error, and lifts "block this page's dialogs".
|
||||
tab?.loadFailed = false
|
||||
// The page is being replaced: nothing it asked for (replies, subscription pushes) may reach the next
|
||||
// one, even a next one that never talks to the bridge.
|
||||
if (tab != null && tab.bridge.onNavigation()) releasePage(tab)
|
||||
tab?.jsDialogsOnPage = 0
|
||||
tab?.jsDialogsBlocked = false
|
||||
// Re-arm favicon capture when the host changes, so a same-host in-page nav doesn't re-send.
|
||||
@@ -879,19 +1010,26 @@ class NappletBrowserService : Service() {
|
||||
|
||||
/**
|
||||
* The renderer died. It is shared by every WebView in `:napplet`, and an unhandled crash kills the
|
||||
* whole process — every other tab included. Drop just this tab's WebView and report the load as
|
||||
* failed; the tab's retry (MSG_NAVIGATE) builds a fresh WebView in the same surface.
|
||||
* whole process — every other tab included. Drop just this tab's WebView and report it gone
|
||||
* ([NappletBrowserContract.KEY_RENDERER_GONE]); the client's MSG_RELOAD (or a MSG_NAVIGATE) builds
|
||||
* a fresh WebView in the same surface.
|
||||
*/
|
||||
override fun onRenderProcessGone(
|
||||
view: WebView,
|
||||
detail: RenderProcessGoneDetail,
|
||||
): Boolean {
|
||||
Log.w(TAG) { "Renderer gone (crashed=${detail.didCrash()}) for an embedded tab" }
|
||||
val lastUrl = view.url
|
||||
(view.parent as? ViewGroup)?.removeView(view)
|
||||
view.destroy()
|
||||
val tab = tab ?: return true
|
||||
if (tab.webView === view) {
|
||||
tab.webView = null
|
||||
tab.recoverUrl = lastUrl?.takeIf { it.isNotBlank() && it != ABOUT_BLANK } ?: tab.recoverUrl
|
||||
// The page died with its renderer: its broker subscriptions and pick have no one to serve.
|
||||
releasePage(tab)
|
||||
tab.bridge.clear()
|
||||
tab.fileChooser.cancel()
|
||||
tab.customView?.let { tab.container?.removeView(it) }
|
||||
tab.customView = null
|
||||
tab.customViewCallback = null
|
||||
@@ -900,6 +1038,7 @@ class NappletBrowserService : Service() {
|
||||
sendToClient(tab, NappletBrowserContract.MSG_LOAD_STATE) {
|
||||
putBoolean(NappletBrowserContract.KEY_IS_LOADING, false)
|
||||
putBoolean(NappletBrowserContract.KEY_LOAD_FAILED, true)
|
||||
putBoolean(NappletBrowserContract.KEY_RENDERER_GONE, true)
|
||||
putString(NappletBrowserContract.KEY_URL, tab.url)
|
||||
}
|
||||
}
|
||||
@@ -922,7 +1061,7 @@ class NappletBrowserService : Service() {
|
||||
putString(NappletBrowserContract.KEY_URL, view.url.orEmpty())
|
||||
}
|
||||
}
|
||||
runCatching { tab?.clientMessenger?.send(message) }
|
||||
tab?.toClient(message)
|
||||
}
|
||||
|
||||
private fun pushUrl(
|
||||
@@ -943,35 +1082,7 @@ class NappletBrowserService : Service() {
|
||||
title?.let { putString(NappletBrowserContract.KEY_TITLE, it) }
|
||||
}
|
||||
}
|
||||
runCatching { tab?.clientMessenger?.send(message) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Routes WebView traffic through the Tor SOCKS proxy when [port] > 0, else clears the override.
|
||||
* [onApplied] runs on the main thread once the override is in effect (the WebKit callback is async,
|
||||
* so callers that reload must wait for it). Process-global (this `:napplet` process hosts only
|
||||
* sandbox WebViews) and best-effort.
|
||||
*/
|
||||
private fun applyWebViewProxy(
|
||||
port: Int,
|
||||
onApplied: () -> Unit = {},
|
||||
) {
|
||||
if (!WebViewFeature.isFeatureSupported(WebViewFeature.PROXY_OVERRIDE)) {
|
||||
onApplied()
|
||||
return
|
||||
}
|
||||
val executor = Executor { it.run() }
|
||||
runCatching {
|
||||
if (port > 0) {
|
||||
val config = ProxyConfig.Builder().addProxyRule("socks5://127.0.0.1:$port").build()
|
||||
ProxyController.getInstance().setProxyOverride(config, executor) { onApplied() }
|
||||
} else {
|
||||
ProxyController.getInstance().clearProxyOverride(executor) { onApplied() }
|
||||
}
|
||||
}.onFailure {
|
||||
Log.w(TAG, "Failed to apply WebView proxy override", it)
|
||||
onApplied()
|
||||
}
|
||||
tab?.toClient(message)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -987,7 +1098,8 @@ class NappletBrowserService : Service() {
|
||||
replyProxy: JavaScriptReplyProxy,
|
||||
) {
|
||||
if (!isMainFrame) return
|
||||
tab.bridgeReplyProxy = replyProxy
|
||||
// A new document replaced the page: whatever the old one had open with the broker is dead.
|
||||
if (tab.bridge.onMessage(replyProxy)) releasePage(tab)
|
||||
val raw = message.data ?: return
|
||||
val envelope = parseJsonObjectOrNull(raw) ?: return
|
||||
|
||||
@@ -1015,21 +1127,53 @@ class NappletBrowserService : Service() {
|
||||
Message.obtain(null, NappletBrowserContract.MSG_IME_EVENT).apply {
|
||||
data = Bundle().apply { putString(NappletBrowserContract.KEY_IME_PAYLOAD, raw) }
|
||||
}
|
||||
runCatching { tab.clientMessenger?.send(reply) }
|
||||
tab.toClient(reply)
|
||||
return
|
||||
}
|
||||
|
||||
val id = envelope.stringOrNull("id").orEmpty().ifEmpty { "fire-${tab.fireSeq++}" }
|
||||
val pageId = envelope.stringOrNull("id").orEmpty().ifEmpty { "fire-${tab.fireSeq++}" }
|
||||
val id = tab.bridge.brokerIdFor(pageId)
|
||||
// A relay subscription is named by the page, and its pushes (decrypted events included) come back
|
||||
// under that name: stamp this document on it so the next page can never receive them.
|
||||
val outgoing = tab.bridge.stampSubscription(envelope)?.toString() ?: raw
|
||||
val msg =
|
||||
Message.obtain(null, NappletIpc.MSG_REQUEST).apply {
|
||||
replyTo = tab.replyMessenger
|
||||
data =
|
||||
Bundle().apply {
|
||||
putString(NappletIpc.KEY_REQUEST_ID, id)
|
||||
putString(NappletIpc.KEY_PAYLOAD, raw)
|
||||
putString(NappletIpc.KEY_PAYLOAD, outgoing)
|
||||
}
|
||||
}
|
||||
|
||||
// Nobody is looking at this tab (it's parked, or the app is in the background): a sign / encrypt /
|
||||
// decrypt waits until they are, even when "allow always" would let it through without a prompt.
|
||||
if (!tab.attended && NappletActingRequests.actsForUser(runCatching { NappletProtocolJson.readType(raw) }.getOrNull())) {
|
||||
val refused = tab.heldWhileAway.hold(origin to msg)
|
||||
if (refused != null) {
|
||||
tab.bridge.failRequest(refused.second, NappletHeldRequests.TOO_MANY)
|
||||
} else {
|
||||
// Settle it with an error if nobody comes back for it, so the page isn't left waiting forever.
|
||||
heldExpiry.postDelayed({ expireHeld(tab) }, NappletHeldRequests.MAX_AGE_MS)
|
||||
}
|
||||
return
|
||||
}
|
||||
dispatchToBroker(tab, origin, msg)
|
||||
}
|
||||
|
||||
private val heldExpiry = Handler(Looper.getMainLooper())
|
||||
|
||||
private fun expireHeld(tab: BrowserTab) {
|
||||
if (tabs[tab.sessionId] !== tab) return
|
||||
tab.heldWhileAway.expire().forEach { (_, request) -> tab.bridge.failRequest(request, NappletHeldRequests.EXPIRED) }
|
||||
}
|
||||
|
||||
/** Sends [msg] with [origin]'s launch token, minting the token first if the origin has none yet. */
|
||||
private fun dispatchToBroker(
|
||||
tab: BrowserTab,
|
||||
origin: String,
|
||||
msg: Message,
|
||||
) {
|
||||
val token = tab.originTokens[origin]
|
||||
if (token != null) {
|
||||
msg.data.putString(NappletIpc.KEY_LAUNCH_TOKEN, token)
|
||||
@@ -1040,6 +1184,60 @@ class NappletBrowserService : Service() {
|
||||
}
|
||||
}
|
||||
|
||||
private val mintTimeouts = Handler(Looper.getMainLooper())
|
||||
|
||||
/** A token for [origin] won't come: answer each call queued behind it with a failure, and allow a retry. */
|
||||
private fun failMint(
|
||||
tab: BrowserTab,
|
||||
origin: String,
|
||||
reason: String,
|
||||
) {
|
||||
tab.mintInFlight.remove(origin)
|
||||
tab.pendingByOrigin.remove(origin)?.forEach { queued -> tab.bridge.failRequest(queued, reason) }
|
||||
}
|
||||
|
||||
/**
|
||||
* The page on [tab] is gone (navigated away, renderer died, session closed): drop its requests still
|
||||
* waiting for a token or the broker, and have the broker close the live relay / inc subscriptions it
|
||||
* opened — otherwise their events would keep streaming into whatever page comes next.
|
||||
*
|
||||
* [closing]: the whole tab is going away, so its per-origin launch tokens are given back too (a closed
|
||||
* tab never re-uses them; a new session mints its own). Otherwise they'd sit in the broker's registry
|
||||
* until evicted, pushing live tabs' tokens out first.
|
||||
*/
|
||||
private fun releasePage(
|
||||
tab: BrowserTab,
|
||||
closing: Boolean = false,
|
||||
) {
|
||||
tab.pendingByOrigin.clear()
|
||||
tab.heldWhileAway.clear()
|
||||
// A mint the broker never answered (none is sent while logged out) would otherwise block the
|
||||
// origin for the tab's life; the next page asks again.
|
||||
tab.mintInFlight.clear()
|
||||
pendingBrokerRequests.removeAll { it.what == NappletIpc.MSG_REQUEST && it.replyTo == tab.replyMessenger }
|
||||
val release =
|
||||
Message.obtain(null, NappletIpc.MSG_RELEASE_CLIENT).apply {
|
||||
replyTo = tab.replyMessenger
|
||||
if (closing && tab.originTokens.isNotEmpty()) {
|
||||
data = Bundle().apply { putStringArray(NappletIpc.KEY_RELEASED_TOKENS, tab.originTokens.values.toTypedArray()) }
|
||||
}
|
||||
}
|
||||
if (closing) tab.originTokens.clear()
|
||||
if (brokerMessenger != null) sendToBroker(release)
|
||||
// The release forgets the tab's attendance along with the rest; the next page is watched just the same.
|
||||
if (!closing && tab.attended) reportAttended(tab)
|
||||
}
|
||||
|
||||
/** Tells the broker whether [tab] is being looked at, which gates decrypting its relay reads. */
|
||||
private fun reportAttended(tab: BrowserTab) {
|
||||
val msg =
|
||||
Message.obtain(null, NappletIpc.MSG_SET_ATTENDED).apply {
|
||||
replyTo = tab.replyMessenger
|
||||
data = Bundle().apply { putBoolean(NappletIpc.KEY_ATTENDED, tab.attended) }
|
||||
}
|
||||
if (brokerMessenger == null) pendingBrokerRequests.add(msg) else sendToBroker(msg)
|
||||
}
|
||||
|
||||
/** `scheme://host[:port]` of the WebView-reported origin, or null when it has no usable one. */
|
||||
private fun trustedOrigin(sourceOrigin: Uri): String? {
|
||||
val scheme = sourceOrigin.scheme ?: return null
|
||||
@@ -1117,10 +1315,19 @@ class NappletBrowserService : Service() {
|
||||
origin: String,
|
||||
) {
|
||||
if (!tab.mintInFlight.add(origin)) return
|
||||
// The broker may never answer (it died mid-mint): fail the origin's queued calls rather than let the
|
||||
// page wait forever.
|
||||
mintTimeouts.postDelayed({
|
||||
if (tabs[tab.sessionId] === tab && origin in tab.mintInFlight) failMint(tab, origin, MINT_TIMED_OUT)
|
||||
}, NappletIpc.MINT_TIMEOUT_MS)
|
||||
val msg =
|
||||
Message.obtain(null, NappletIpc.MSG_MINT_BROWSER_TOKEN).apply {
|
||||
replyTo = tab.replyMessenger
|
||||
data = Bundle().apply { putString(NappletIpc.KEY_BROWSER_ORIGIN, origin) }
|
||||
data =
|
||||
Bundle().apply {
|
||||
putString(NappletIpc.KEY_BROWSER_ORIGIN, origin)
|
||||
putString(NappletIpc.KEY_WEBVIEW_PROFILE, tab.webViewProfile)
|
||||
}
|
||||
}
|
||||
if (brokerMessenger == null) pendingBrokerRequests.add(msg) else sendToBroker(msg)
|
||||
}
|
||||
@@ -1198,18 +1405,33 @@ class NappletBrowserService : Service() {
|
||||
val data = msg.data ?: return true
|
||||
when (msg.what) {
|
||||
NappletIpc.MSG_RESPONSE -> {
|
||||
val id = data.getString(NappletIpc.KEY_REQUEST_ID) ?: return true
|
||||
val brokerId = data.getString(NappletIpc.KEY_REQUEST_ID) ?: return true
|
||||
val payload = data.getString(NappletIpc.KEY_PAYLOAD) ?: return true
|
||||
val result = (parseJsonObjectOrNull(payload) ?: JsonObject(emptyMap())).withString("id", id)
|
||||
runCatching { tab.bridgeReplyProxy?.postMessage(result.toString()) }
|
||||
// Null when the page that asked has been navigated away from: drop it rather than hand
|
||||
// one site's answer (a signature, a decryption) to the next.
|
||||
val (pageId, proxy) = tab.bridge.resolve(brokerId) ?: return true
|
||||
val result = (parseJsonObjectOrNull(payload) ?: JsonObject(emptyMap())).withString("id", pageId)
|
||||
runCatching { proxy.postMessage(result.toString()) }
|
||||
}
|
||||
NappletIpc.MSG_PUSH -> {
|
||||
val payload = data.getString(NappletIpc.KEY_PAYLOAD) ?: return true
|
||||
runCatching { tab.bridgeReplyProxy?.postMessage(payload) }
|
||||
// Dropped when it is for a subscription a replaced document opened.
|
||||
val push = parseJsonObjectOrNull(payload)?.let { tab.bridge.resolvePush(it) } ?: return true
|
||||
runCatching { tab.bridge.currentProxy?.postMessage(push.toString()) }
|
||||
}
|
||||
NappletIpc.MSG_TOKEN_UNKNOWN -> {
|
||||
// The broker no longer knows this token (evicted): forget it so the origin re-mints.
|
||||
val token = data.getString(NappletIpc.KEY_LAUNCH_TOKEN) ?: return true
|
||||
tab.originTokens.values.removeAll { it == token }
|
||||
}
|
||||
NappletIpc.MSG_BROWSER_TOKEN -> {
|
||||
val origin = data.getString(NappletIpc.KEY_BROWSER_ORIGIN) ?: return true
|
||||
val token = data.getString(NappletIpc.KEY_LAUNCH_TOKEN) ?: return true
|
||||
val token = data.getString(NappletIpc.KEY_LAUNCH_TOKEN)
|
||||
if (token == null) {
|
||||
// Refused (no account signed in): the page's calls fail now instead of hanging.
|
||||
failMint(tab, origin, NOT_SIGNED_IN)
|
||||
return true
|
||||
}
|
||||
tab.originTokens[origin] = token
|
||||
tab.mintInFlight.remove(origin)
|
||||
tab.pendingByOrigin.remove(origin)?.forEach { queued ->
|
||||
@@ -1229,6 +1451,8 @@ class NappletBrowserService : Service() {
|
||||
|
||||
private companion object {
|
||||
private const val TAG = "NappletBrowserService"
|
||||
private const val NOT_SIGNED_IN = "Sign in to Amethyst to use this site's Nostr features."
|
||||
private const val MINT_TIMED_OUT = "Amethyst didn't answer. Reload the page to try again."
|
||||
private const val ABOUT_BLANK = "about:blank"
|
||||
|
||||
/** Max favicon edge (px) before sending over IPC — keeps the PNG tiny, well under the Binder limit. */
|
||||
|
||||
+8
-3
@@ -111,8 +111,13 @@ private class BrowserSession(
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
// The library may call close() off the main thread; WebView.destroy() (and the tabs mutation)
|
||||
// must run on the main thread.
|
||||
Handler(Looper.getMainLooper()).post { service.onSessionClosed(sessionId) }
|
||||
// WebView.destroy() (and the tabs mutation) must run on the main thread. The library usually calls
|
||||
// this there already; post only when it doesn't. The container identifies THIS session, so a close
|
||||
// that lands after the tab was re-opened can't tear down its successor.
|
||||
if (Looper.myLooper() == Looper.getMainLooper()) {
|
||||
service.onSessionClosed(sessionId, container)
|
||||
} else {
|
||||
Handler(Looper.getMainLooper()).post { service.onSessionClosed(sessionId, container) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+36
@@ -81,6 +81,8 @@ object NappletEmbedContract {
|
||||
* Provider → client: the main-frame load state changed. Carries [KEY_IS_LOADING] (a load is in
|
||||
* flight) and [KEY_LOAD_FAILED] (the main frame errored). Lets the main process draw a loading
|
||||
* spinner / error+retry overlay over the embedded surface instead of a bare black/white void.
|
||||
* [KEY_RENDERER_GONE] marks a failure caused by the WebView renderer dying: the tab has no WebView
|
||||
* left, so the client must [MSG_RELOAD] (which rebuilds it) — the page on screen is gone, not failed.
|
||||
*/
|
||||
const val MSG_LOAD_STATE = 15
|
||||
|
||||
@@ -133,6 +135,29 @@ object NappletEmbedContract {
|
||||
*/
|
||||
const val MSG_CONSOLE_LOG = 24
|
||||
|
||||
/**
|
||||
* Client → provider: the tab was torn down (evicted, or rebuilt for a theme/account change). Drops the
|
||||
* session and its WebView even if the surface never opened — a session created for a view that was
|
||||
* disposed before it attached would otherwise sit in the provider forever, pinning its client.
|
||||
*/
|
||||
const val MSG_CLOSE_SESSION = 25
|
||||
|
||||
/**
|
||||
* Client → provider: whether the user is looking at this napplet ([KEY_ATTENDED]) — it's the visible tab
|
||||
* AND the app is on screen. While not, the provider holds the napplet's requests that act for the user
|
||||
* (publish, pay, upload, notify, `inc.emit`) and sends them once the user is back. Separate from
|
||||
* [MSG_PAUSE]: the page itself is only paused a while after the app leaves the screen, but nothing may
|
||||
* act on the user's behalf the moment they stop watching.
|
||||
*/
|
||||
const val MSG_SET_ATTENDED = 26
|
||||
|
||||
/**
|
||||
* Provider → client: whether the process's pages currently go through Tor ([KEY_ROUTE_TOR]). Sent on every
|
||||
* change for an nSite (it has off-origin traffic of its own). Tor always wins process-wide, so an nSite
|
||||
* set to the open web can still be on Tor because another open page needs it — the client shows why.
|
||||
*/
|
||||
const val MSG_ROUTE = 27
|
||||
|
||||
const val KEY_FIND_QUERY = "findQuery"
|
||||
const val KEY_FIND_FORWARD = "findForward"
|
||||
const val KEY_FIND_ACTIVE = "findActive"
|
||||
@@ -171,6 +196,16 @@ object NappletEmbedContract {
|
||||
const val KEY_CAN_GO_BACK = "canGoBack"
|
||||
const val KEY_IS_LOADING = "isLoading"
|
||||
const val KEY_LOAD_FAILED = "loadFailed"
|
||||
const val KEY_RENDERER_GONE = "rendererGone"
|
||||
const val KEY_ROUTE_TOR = "routeTor"
|
||||
|
||||
/**
|
||||
* On [MSG_LOAD_STATE]: the applet was not loaded because its network route can't be honored (Tor wanted
|
||||
* but not running yet, this WebView can't proxy, or applying the proxy failed). Nothing went out; the
|
||||
* client offers Retry, whose [MSG_RELOAD] carries the current Tor port
|
||||
* ([NappletHostContract.EXTRA_PROXY_PORT]).
|
||||
*/
|
||||
const val KEY_ROUTE_BLOCKED = "routeBlocked"
|
||||
const val KEY_NOTICE = "notice"
|
||||
const val KEY_IME_PAYLOAD = "imePayload"
|
||||
|
||||
@@ -180,6 +215,7 @@ object NappletEmbedContract {
|
||||
* so this scopes a control to the right surface and routes state/notices/IME back to the right tab.
|
||||
*/
|
||||
const val KEY_SESSION_ID = "sessionId"
|
||||
const val KEY_ATTENDED = "attended"
|
||||
|
||||
const val NOTICE_PUBLISHED = "published"
|
||||
const val NOTICE_UPLOADED = "uploaded"
|
||||
|
||||
+101
-34
@@ -33,6 +33,7 @@ import android.os.IBinder
|
||||
import android.os.Looper
|
||||
import android.os.Message
|
||||
import android.os.Messenger
|
||||
import android.os.SystemClock
|
||||
import android.util.TypedValue
|
||||
import android.view.Gravity
|
||||
import android.view.KeyEvent
|
||||
@@ -58,8 +59,6 @@ import androidx.activity.ComponentActivity
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.webkit.JavaScriptReplyProxy
|
||||
import androidx.webkit.ProxyConfig
|
||||
import androidx.webkit.ProxyController
|
||||
import androidx.webkit.WebMessageCompat
|
||||
import androidx.webkit.WebViewCompat
|
||||
import androidx.webkit.WebViewFeature
|
||||
@@ -68,6 +67,9 @@ import com.vitorpamplona.amethyst.commons.browser.BrowserSitePermission
|
||||
import com.vitorpamplona.amethyst.commons.browser.ui.pill.BrowserPillEvent
|
||||
import com.vitorpamplona.amethyst.commons.browser.ui.pill.BrowserPillUi
|
||||
import com.vitorpamplona.amethyst.commons.browser.ui.pill.ConsoleLine
|
||||
import com.vitorpamplona.amethyst.commons.napplet.NappletActingRequests
|
||||
import com.vitorpamplona.amethyst.commons.napplet.NappletHeldRequests
|
||||
import com.vitorpamplona.amethyst.commons.napplet.NappletProxyClaims
|
||||
import com.vitorpamplona.amethyst.commons.napplet.NappletWebContract
|
||||
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletProtocolJson
|
||||
import com.vitorpamplona.amethyst.commons.util.booleanOrNull
|
||||
@@ -89,7 +91,6 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import java.lang.ref.WeakReference
|
||||
import java.util.concurrent.Executor
|
||||
import com.vitorpamplona.amethyst.commons.R as CommonsR
|
||||
|
||||
/**
|
||||
@@ -155,6 +156,9 @@ class NappletHostActivity : ComponentActivity() {
|
||||
|
||||
private var proxyPort: Int = -1
|
||||
|
||||
/** Whether the process route is Tor right now, whatever this nSite asked for. */
|
||||
private var routedOverTor = false
|
||||
|
||||
// The resource edge (shell + verified blobs); built in onCreate once the manifest is parsed.
|
||||
private lateinit var contentServer: NappletContentServer
|
||||
|
||||
@@ -233,6 +237,15 @@ class NappletHostActivity : ComponentActivity() {
|
||||
// the broker binds after this surface is already resumed (bindService is async).
|
||||
private var resumed = false
|
||||
|
||||
// Requests that act for the user (publish, pay, upload…) made while this napplet was in the background.
|
||||
// Pausing the WebView doesn't stop JavaScript, so they are held here and sent on the next resume.
|
||||
private val heldWhilePaused = NappletHeldRequests<Message>(SystemClock::elapsedRealtime)
|
||||
|
||||
private val expireHeld =
|
||||
Runnable {
|
||||
if (!isDestroyed) heldWhilePaused.expire().forEach { bridgeReplyProxy?.failRequest(it, NappletHeldRequests.EXPIRED) }
|
||||
}
|
||||
|
||||
// Renews the broker's foreground lease while resumed. If this process dies, the heartbeat stops and
|
||||
// the broker reaps the stale lease, so a crash can't pin the main process's network up forever.
|
||||
private var foregroundHeartbeat: Job? = null
|
||||
@@ -249,6 +262,7 @@ class NappletHostActivity : ComponentActivity() {
|
||||
// If we're already foreground by the time the broker binds, report it now so the
|
||||
// main-process resource hold is acquired for this session.
|
||||
if (resumed) setBrokerForeground(true)
|
||||
reportAttended()
|
||||
}
|
||||
|
||||
override fun onServiceDisconnected(name: ComponentName?) {
|
||||
@@ -265,6 +279,12 @@ class NappletHostActivity : ComponentActivity() {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
// Fail closed: Tor is on but its port isn't known yet, so nothing (blobs or web traffic) may go out.
|
||||
if (useTor && proxyPort <= 0) {
|
||||
Toast.makeText(this, R.string.napplet_route_blocked, Toast.LENGTH_LONG).show()
|
||||
finish()
|
||||
return
|
||||
}
|
||||
|
||||
if (!WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) {
|
||||
Toast.makeText(this, getString(R.string.napplet_webview_too_old), Toast.LENGTH_LONG).show()
|
||||
@@ -303,7 +323,15 @@ class NappletHostActivity : ComponentActivity() {
|
||||
// Route the WebView's own (off-origin) traffic through Tor for an nSite, unless this site was
|
||||
// opted out to the open web. Set process-wide before any page navigation; the shell + blobs are
|
||||
// served from cache via shouldInterceptRequest, so only the site's external requests hit this.
|
||||
if (profile.exposesNetwork) applyWebViewProxy(effectiveProxy)
|
||||
if (profile.exposesNetwork) {
|
||||
// An open-web nSite still goes through Tor while another surface needs it: say so in the chrome.
|
||||
WebViewProxyPolicy.observeRoute(this) {
|
||||
routedOverTor = it
|
||||
chrome?.let { c -> c.ui = c.ui.copy(chrome = c.ui.chrome.copy(torForced = !useTor && it)) }
|
||||
}
|
||||
// Start applying now, overlapping the index probe; the load itself waits in mountWebView.
|
||||
WebViewProxyPolicy.claim(this, effectiveProxy)
|
||||
}
|
||||
// Origin-restricted bridge: only the trusted shell page (main frame) can reach native.
|
||||
WebViewCompat.addWebMessageListener(
|
||||
webView,
|
||||
@@ -368,10 +396,30 @@ class NappletHostActivity : ComponentActivity() {
|
||||
contentFrame.addView(webView, 0, FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT))
|
||||
if (!started) {
|
||||
started = true
|
||||
webView.loadUrl(NappletWebContract.SHELL_URL)
|
||||
// Wait for the route to be in effect: a Tor nSite's first off-origin request must not leave early,
|
||||
// and must not leave at all if the route can't be applied.
|
||||
if (profile.exposesNetwork) {
|
||||
WebViewProxyPolicy.claim(
|
||||
owner = this,
|
||||
torPort = if (useTor) proxyPort else NappletProxyClaims.NO_PROXY,
|
||||
onFailed = { if (!isDestroyed) Toast.makeText(this, R.string.napplet_route_blocked, Toast.LENGTH_LONG).show() },
|
||||
) { if (!isDestroyed) webView.loadUrl(NappletWebContract.SHELL_URL) }
|
||||
} else {
|
||||
webView.loadUrl(NappletWebContract.SHELL_URL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val backgroundPauseHandler = Handler(Looper.getMainLooper())
|
||||
|
||||
// webView.onPause() pauses THIS WebView (animations, media, geolocation). Do NOT call pauseTimers(): it's
|
||||
// process-global and freezes EVERY WebView in `:napplet`, including the embedded browser/napplet surfaces,
|
||||
// which never resume.
|
||||
private val backgroundPause =
|
||||
Runnable {
|
||||
if (!isDestroyed && this::webView.isInitialized && !webViewGone) webView.onPause()
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
if (this::webView.isInitialized && !webViewGone) {
|
||||
@@ -381,21 +429,34 @@ class NappletHostActivity : ComponentActivity() {
|
||||
// hold the main process resumed (Tor/relays/AUTH) while this napplet/nSite is in front, and
|
||||
// keep renewing that lease so a crash here can't pin the network up forever.
|
||||
resumed = true
|
||||
reportAttended()
|
||||
startForegroundHeartbeat()
|
||||
val held = heldWhilePaused.drain()
|
||||
held.fail.forEach { bridgeReplyProxy?.failRequest(it, NappletHeldRequests.EXPIRED) }
|
||||
held.send.forEach { if (brokerMessenger == null) pendingRequests.add(it) else sendToBroker(it) }
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
// Back within the grace: the page was never paused.
|
||||
backgroundPauseHandler.removeCallbacks(backgroundPause)
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
// Out of sight: pause the page after the same grace the rest of the app gets
|
||||
// (NappletHostContract.BACKGROUND_PAUSE_MS), so a quick trip to another app doesn't interrupt it.
|
||||
// Anything that acts for the user is already held (see [resumed]).
|
||||
backgroundPauseHandler.postDelayed(backgroundPause, NappletHostContract.BACKGROUND_PAUSE_MS)
|
||||
super.onStop()
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
// Foreground-only: stop the applet's JS/timers in the background so it cannot fire a
|
||||
// sign/decrypt/pay request whose consent prompt would surface over (and be confused with)
|
||||
// Amethyst's own UI. Requests only happen while the user is looking at this napplet.
|
||||
if (this::webView.isInitialized && !webViewGone) {
|
||||
// webView.onPause() pauses THIS WebView's JS/DOM (the security goal — a backgrounded napplet can't
|
||||
// fire a sign/decrypt/pay request). Do NOT call pauseTimers(): it's process-global and freezes
|
||||
// EVERY WebView in `:napplet`, including the embedded browser/napplet surfaces, which never resume.
|
||||
webView.onPause()
|
||||
}
|
||||
// No longer foreground: stop renewing and let the main process resume normal background scaling.
|
||||
// Foreground-only for requests: while not resumed, the applet's requests that act for the user
|
||||
// (sign/publish/pay…) are held until the user is back, so their consent prompt can't surface over
|
||||
// (and be confused with) Amethyst's own UI and an "allow always" napplet can't act unwatched. The page
|
||||
// itself keeps running until onStop's grace runs out.
|
||||
resumed = false
|
||||
reportAttended()
|
||||
stopForegroundHeartbeat()
|
||||
setBrokerForeground(false)
|
||||
super.onPause()
|
||||
@@ -433,6 +494,16 @@ class NappletHostActivity : ComponentActivity() {
|
||||
runCatching { broker.send(msg) }
|
||||
}
|
||||
|
||||
/** Tells the broker whether this napplet is being looked at, which gates decrypting its relay reads. */
|
||||
private fun reportAttended() {
|
||||
val msg =
|
||||
Message.obtain(null, NappletIpc.MSG_SET_ATTENDED).apply {
|
||||
replyTo = replyMessenger
|
||||
data = Bundle().apply { putBoolean(NappletIpc.KEY_ATTENDED, resumed) }
|
||||
}
|
||||
if (brokerMessenger == null) pendingRequests.add(msg) else sendToBroker(msg)
|
||||
}
|
||||
|
||||
/** Reports this surface's foreground state to the broker so it can hold the main process resumed. */
|
||||
|
||||
private fun setBrokerForeground(foreground: Boolean) {
|
||||
@@ -451,10 +522,13 @@ class NappletHostActivity : ComponentActivity() {
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
backgroundPauseHandler.removeCallbacks(backgroundPause)
|
||||
backgroundPauseHandler.removeCallbacks(expireHeld)
|
||||
uiScope.cancel()
|
||||
// Drop the broker's references to our reply Messenger BEFORE unbinding — a retained Messenger is a
|
||||
// binder and would pin this Activity (and its WebView) for the life of the `:napplet` process.
|
||||
releaseFromBroker()
|
||||
WebViewProxyPolicy.release(this)
|
||||
// unbind is in runCatching: if the index never resolved we never bound the broker.
|
||||
runCatching { unbindService(brokerConnection) }
|
||||
keyActions.clear()
|
||||
@@ -569,25 +643,6 @@ class NappletHostActivity : ComponentActivity() {
|
||||
webView.webChromeClient = NappletWebChromeClient()
|
||||
}
|
||||
|
||||
/**
|
||||
* Routes this process's WebView traffic through the Tor SOCKS proxy when [port] > 0, else clears any
|
||||
* override so the site loads over the open web. Process-global (this `:napplet` process hosts only
|
||||
* applet/site WebViews) and best-effort: a device whose WebView can't honor a SOCKS proxy falls back
|
||||
* to direct — verified on-device, since SOCKS-over-WebView support varies by WebView version.
|
||||
*/
|
||||
private fun applyWebViewProxy(port: Int) {
|
||||
if (!WebViewFeature.isFeatureSupported(WebViewFeature.PROXY_OVERRIDE)) return
|
||||
val executor = Executor { it.run() }
|
||||
runCatching {
|
||||
if (port > 0) {
|
||||
val config = ProxyConfig.Builder().addProxyRule("socks5://127.0.0.1:$port").build()
|
||||
ProxyController.getInstance().setProxyOverride(config, executor) {}
|
||||
} else {
|
||||
ProxyController.getInstance().clearProxyOverride(executor) {}
|
||||
}
|
||||
}.onFailure { Log.w(TAG, "Failed to apply WebView proxy override", it) }
|
||||
}
|
||||
|
||||
/** Serves only the trusted shell and the manifest's verified blobs; everything else 404s. */
|
||||
private inner class NappletWebViewClient : WebViewClient() {
|
||||
override fun shouldInterceptRequest(
|
||||
@@ -787,6 +842,17 @@ class NappletHostActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
// In the background: an act on the user's behalf waits until they're looking at this napplet again.
|
||||
if (!resumed && NappletActingRequests.actsForUser(runCatching { NappletProtocolJson.readType(raw) }.getOrNull())) {
|
||||
val refused = heldWhilePaused.hold(msg)
|
||||
if (refused != null) {
|
||||
bridgeReplyProxy?.failRequest(refused, NappletHeldRequests.TOO_MANY)
|
||||
} else {
|
||||
// Settle it with an error if nobody comes back for it, so the applet isn't left waiting forever.
|
||||
backgroundPauseHandler.postDelayed(expireHeld, NappletHeldRequests.MAX_AGE_MS)
|
||||
}
|
||||
return
|
||||
}
|
||||
val messenger = brokerMessenger
|
||||
if (messenger == null) {
|
||||
pendingRequests.add(msg)
|
||||
@@ -940,6 +1006,7 @@ class NappletHostActivity : ComponentActivity() {
|
||||
// Website-mode nSites can re-route over Tor; switching rebuilds the session, so the
|
||||
// row taps through to a full relaunch rather than toggling inline.
|
||||
torOn = if (profile.exposesNetwork && proxyPort > 0) useTor else null,
|
||||
torForced = !useTor && routedOverTor,
|
||||
canFavorite = false,
|
||||
hasAccessInfo = true,
|
||||
),
|
||||
|
||||
+15
@@ -26,6 +26,21 @@ package com.vitorpamplona.amethyst.napplethost
|
||||
* `:amethyst` depends on `:nappletHost`, never the other way around.
|
||||
*/
|
||||
object NappletHostContract {
|
||||
/**
|
||||
* How long a napplet or website page keeps running after the app leaves the screen before its WebView is
|
||||
* paused — embedded tabs and the full-screen hosts alike. The same grace the rest of the app gets: relays
|
||||
* disconnect 30 s after the UI stops (RelayProxyClientConnector's `WhileSubscribed(30000)`), so a quick
|
||||
* trip to another app (a 2FA code, a password manager) doesn't interrupt a page, while one left behind
|
||||
* stops running. Requests that act for the user are held from the first moment regardless.
|
||||
*
|
||||
* An upper bound, not a promise: on Android 14+ the cached-app freezer suspends `:napplet` (and its
|
||||
* renderers) about 10 s after the app leaves the screen, since nothing keeps that process in the
|
||||
* foreground. Measured on an API 36 emulator: frozen 12 s after Home, media stopped with it. So on those
|
||||
* devices a page stops within seconds, and this pause lands (on the next unfreeze) on a page that
|
||||
* already stopped.
|
||||
*/
|
||||
const val BACKGROUND_PAUSE_MS = 30_000L
|
||||
|
||||
const val EXTRA_PATHS = "napplet_paths"
|
||||
const val EXTRA_HASHES = "napplet_hashes"
|
||||
const val EXTRA_SERVERS = "napplet_servers"
|
||||
|
||||
+221
-79
@@ -26,7 +26,6 @@ import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.ServiceConnection
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
@@ -52,17 +51,17 @@ import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import android.widget.FrameLayout
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.core.graphics.createBitmap
|
||||
import androidx.core.net.toUri
|
||||
import androidx.privacysandbox.ui.provider.toCoreLibInfo
|
||||
import androidx.webkit.JavaScriptReplyProxy
|
||||
import androidx.webkit.ProxyConfig
|
||||
import androidx.webkit.ProxyController
|
||||
import androidx.webkit.WebMessageCompat
|
||||
import androidx.webkit.WebViewCompat
|
||||
import androidx.webkit.WebViewFeature
|
||||
import com.vitorpamplona.amethyst.commons.browser.BrowserChrome
|
||||
import com.vitorpamplona.amethyst.commons.napplet.NappletActingRequests
|
||||
import com.vitorpamplona.amethyst.commons.napplet.NappletHeldRequests
|
||||
import com.vitorpamplona.amethyst.commons.napplet.NappletWebContract
|
||||
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletProtocolJson
|
||||
import com.vitorpamplona.amethyst.commons.util.booleanOrNull
|
||||
import com.vitorpamplona.amethyst.commons.util.parseJsonObjectOrNull
|
||||
import com.vitorpamplona.amethyst.commons.util.stringOrNull
|
||||
@@ -72,8 +71,6 @@ import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import com.vitorpamplona.quartz.utils.sha256.sha256
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.util.concurrent.Executor
|
||||
|
||||
/**
|
||||
* Provider for an **embedded** nsite/napplet tab — the in-app-tab counterpart of [NappletHostActivity].
|
||||
@@ -107,7 +104,7 @@ class NappletHostService : Service() {
|
||||
val launchToken: String,
|
||||
val profile: HostProfile,
|
||||
val useTor: Boolean,
|
||||
val proxyPort: Int,
|
||||
var proxyPort: Int,
|
||||
val bgColor: Int,
|
||||
val themeType: String,
|
||||
// Opaque per-account WebView storage-profile name (see NappletWebViewProfile).
|
||||
@@ -121,6 +118,22 @@ class NappletHostService : Service() {
|
||||
|
||||
// The session's root view; a WebView lost to a renderer crash is rebuilt inside it on retry.
|
||||
var container: FrameLayout? = null
|
||||
|
||||
// The client's last pause/resume. A parked tab is paused before its WebView exists (the WebView is
|
||||
// only built when the surface opens), so the flag is applied to every WebView built for the tab.
|
||||
var paused = false
|
||||
|
||||
// The applet wasn't built because its route can't be honored (Tor wanted, no port): a retry rebuilds it.
|
||||
var routeBlocked = false
|
||||
|
||||
// Whether the user is looking at this napplet (NappletEmbedContract.MSG_SET_ATTENDED). Requests that act
|
||||
// for the user (publish, pay, upload…) made while they aren't — or while the page is paused — are held
|
||||
// here and sent when they're back: pausing the WebView doesn't stop JavaScript. Unattended until the
|
||||
// client says otherwise: it sends its state right after every create.
|
||||
var attended = false
|
||||
val heldWhilePaused = NappletHeldRequests<Message>(SystemClock::elapsedRealtime)
|
||||
|
||||
val mayAct: Boolean get() = attended && !paused
|
||||
var bridgeReplyProxy: JavaScriptReplyProxy? = null
|
||||
var fireSeq = 0
|
||||
|
||||
@@ -135,6 +148,17 @@ class NappletHostService : Service() {
|
||||
// The user's text size, re-applied when a renderer crash forces a fresh WebView.
|
||||
var textZoom = BrowserChrome.DEFAULT_TEXT_ZOOM
|
||||
val replyMessenger = Messenger(Handler(Looper.getMainLooper()) { onBrokerReply(this, it) })
|
||||
|
||||
/**
|
||||
* Sends [msg] to this tab's client stamped with the session it belongs to, so the client can drop what
|
||||
* a session it has since replaced still had in flight (a late file-chooser request, a stale
|
||||
* SESSION_READY that would re-arm its view with a dead adapter). Returns whether it was delivered.
|
||||
*/
|
||||
fun toClient(msg: Message): Boolean {
|
||||
val client = clientMessenger ?: return false
|
||||
msg.data.putString(NappletEmbedContract.KEY_SESSION_ID, sessionId)
|
||||
return runCatching { client.send(msg) }.isSuccess
|
||||
}
|
||||
}
|
||||
|
||||
private val tabs = mutableMapOf<String, NappletTab>()
|
||||
@@ -157,6 +181,8 @@ class NappletHostService : Service() {
|
||||
brokerMessenger = Messenger(service)
|
||||
pendingBrokerRequests.forEach { sendToBroker(it) }
|
||||
pendingBrokerRequests.clear()
|
||||
// A broker that restarted knows nothing about who is watching.
|
||||
tabs.values.forEach { if (it.attended) reportAttended(it) }
|
||||
}
|
||||
|
||||
override fun onServiceDisconnected(name: ComponentName?) {
|
||||
@@ -167,16 +193,19 @@ class NappletHostService : Service() {
|
||||
override fun onBind(intent: Intent?): IBinder = incoming.binder
|
||||
|
||||
override fun onDestroy() {
|
||||
if (brokerBound) {
|
||||
runCatching { unbindService(brokerConnection) }
|
||||
brokerBound = false
|
||||
}
|
||||
// Release before unbinding, while the broker can still hear it.
|
||||
tabs.values.forEach {
|
||||
WebViewProxyPolicy.release(it)
|
||||
releaseFromBroker(it)
|
||||
it.fileChooser.cancel()
|
||||
it.contentServer?.close()
|
||||
it.webView?.destroy()
|
||||
}
|
||||
tabs.clear()
|
||||
if (brokerBound) {
|
||||
runCatching { unbindService(brokerConnection) }
|
||||
brokerBound = false
|
||||
}
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
@@ -188,6 +217,9 @@ class NappletHostService : Service() {
|
||||
when (msg.what) {
|
||||
NappletEmbedContract.MSG_CREATE_SESSION -> {
|
||||
val tab = buildTab(msg) ?: return true
|
||||
// A re-sent create for an id that is still live must not strand the old tab's WebView, content
|
||||
// server and broker state with nothing left to close them.
|
||||
tabs[tab.sessionId]?.let(::closeTab)
|
||||
tabs[tab.sessionId] = tab
|
||||
// Bind the broker once; a re-sent MSG_CREATE_SESSION must not leak a second binding.
|
||||
if (!brokerBound) {
|
||||
@@ -198,9 +230,15 @@ class NappletHostService : Service() {
|
||||
NappletEmbedContract.MSG_BACK -> tabFor(msg)?.webView?.let { if (it.canGoBack()) it.goBack() }
|
||||
NappletEmbedContract.MSG_RELOAD -> {
|
||||
val tab = tabFor(msg) ?: return true
|
||||
// The client re-reads Tor's port on a retry: it may have come up (or moved) since the tab was made.
|
||||
msg.data
|
||||
?.getInt(NappletHostContract.EXTRA_PROXY_PORT, 0)
|
||||
?.takeIf { it != 0 }
|
||||
?.let { tab.proxyPort = it }
|
||||
val container = tab.container
|
||||
// After a renderer crash the tab has no WebView: the retry builds a fresh one.
|
||||
if (tab.webView == null && container != null) {
|
||||
// After a renderer crash the tab has no WebView, and a tab blocked on its route has only a blank
|
||||
// placeholder: the retry builds a fresh one.
|
||||
if ((tab.webView == null || tab.routeBlocked) && container != null) {
|
||||
val wv = createHostWebView(container.context, tab.sessionId, container)
|
||||
container.addView(wv, 0, FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT))
|
||||
} else {
|
||||
@@ -210,12 +248,29 @@ class NappletHostService : Service() {
|
||||
// onPause()/onResume() are per-WebView (pause/resume THIS surface's JS/DOM). Do NOT call
|
||||
// pauseTimers()/resumeTimers(): they are process-global and would freeze/thaw every WebView in
|
||||
// `:napplet` (the browser embed + other napplets), whose lifecycles are independent of this one.
|
||||
NappletEmbedContract.MSG_PAUSE -> tabFor(msg)?.webView?.onPause()
|
||||
NappletEmbedContract.MSG_RESUME -> tabFor(msg)?.webView?.onResume()
|
||||
NappletEmbedContract.MSG_CLOSE_SESSION -> tabFor(msg)?.let(::closeTab)
|
||||
NappletEmbedContract.MSG_PAUSE ->
|
||||
tabFor(msg)?.let {
|
||||
it.paused = true
|
||||
it.webView?.onPause()
|
||||
}
|
||||
NappletEmbedContract.MSG_RESUME ->
|
||||
tabFor(msg)?.let {
|
||||
it.paused = false
|
||||
it.webView?.onResume()
|
||||
releaseHeld(it)
|
||||
}
|
||||
NappletEmbedContract.MSG_SET_ATTENDED ->
|
||||
tabFor(msg)?.let {
|
||||
it.attended = msg.data?.getBoolean(NappletEmbedContract.KEY_ATTENDED, false) ?: false
|
||||
reportAttended(it)
|
||||
releaseHeld(it)
|
||||
}
|
||||
NappletEmbedContract.MSG_IME_OP -> {
|
||||
val tab = tabFor(msg) ?: return true
|
||||
val payload = msg.data?.getString(NappletEmbedContract.KEY_IME_PAYLOAD) ?: return true
|
||||
tab.bridgeReplyProxy?.postMessage(payload)
|
||||
// The proxy can belong to a page that has already gone away; that must not crash the sandbox.
|
||||
runCatching { tab.bridgeReplyProxy?.postMessage(payload) }
|
||||
}
|
||||
NappletEmbedContract.MSG_MAGNIFIER_REQUEST -> onMagnifierRequest(msg)
|
||||
NappletEmbedContract.MSG_FIND -> {
|
||||
@@ -286,41 +341,29 @@ class NappletHostService : Service() {
|
||||
val tab = tabFor(msg) ?: return
|
||||
val wv = tab.webView ?: return
|
||||
val data = msg.data ?: return
|
||||
val cx = data.getFloat(NappletEmbedContract.KEY_MAG_X)
|
||||
val cy = data.getFloat(NappletEmbedContract.KEY_MAG_Y)
|
||||
val boxW = data.getInt(NappletEmbedContract.KEY_MAG_BOX_W, 150).coerceIn(16, 1024)
|
||||
val boxH = data.getInt(NappletEmbedContract.KEY_MAG_BOX_H, 84).coerceIn(16, 1024)
|
||||
val zoom = data.getFloat(NappletEmbedContract.KEY_MAG_ZOOM, 1.6f).coerceIn(1f, 4f)
|
||||
val reqT = data.getLong(NappletEmbedContract.KEY_MAG_REQ_T)
|
||||
|
||||
val outW = (boxW * zoom).toInt().coerceAtLeast(1)
|
||||
val outH = (boxH * zoom).toInt().coerceAtLeast(1)
|
||||
val t0 = SystemClock.elapsedRealtimeNanos()
|
||||
val bitmap = createBitmap(outW, outH)
|
||||
val canvas = Canvas(bitmap)
|
||||
canvas.drawColor(tab.bgColor)
|
||||
canvas.scale(zoom, zoom)
|
||||
canvas.translate(-(cx - boxW / 2f), -(cy - boxH / 2f))
|
||||
wv.draw(canvas)
|
||||
|
||||
val baos = ByteArrayOutputStream()
|
||||
bitmap.compress(Bitmap.CompressFormat.PNG, 100, baos)
|
||||
val bytes = baos.toByteArray()
|
||||
bitmap.recycle()
|
||||
val captureMs = (SystemClock.elapsedRealtimeNanos() - t0) / 1_000_000.0
|
||||
|
||||
val reply =
|
||||
Message.obtain(null, NappletEmbedContract.MSG_MAGNIFIER_FRAME).apply {
|
||||
this.data =
|
||||
Bundle().apply {
|
||||
putByteArray(NappletEmbedContract.KEY_MAG_BYTES, bytes)
|
||||
putInt(NappletEmbedContract.KEY_MAG_W, outW)
|
||||
putInt(NappletEmbedContract.KEY_MAG_H, outH)
|
||||
putDouble(NappletEmbedContract.KEY_MAG_CAPTURE_MS, captureMs)
|
||||
putLong(NappletEmbedContract.KEY_MAG_REQ_T, reqT)
|
||||
}
|
||||
}
|
||||
runCatching { tab.clientMessenger?.send(reply) }
|
||||
MagnifierCapture.capture(
|
||||
webView = wv,
|
||||
bgColor = tab.bgColor,
|
||||
cx = data.getFloat(NappletEmbedContract.KEY_MAG_X),
|
||||
cy = data.getFloat(NappletEmbedContract.KEY_MAG_Y),
|
||||
boxW = data.getInt(NappletEmbedContract.KEY_MAG_BOX_W, 150).coerceIn(16, 1024),
|
||||
boxH = data.getInt(NappletEmbedContract.KEY_MAG_BOX_H, 84).coerceIn(16, 1024),
|
||||
zoom = data.getFloat(NappletEmbedContract.KEY_MAG_ZOOM, 1.6f).coerceIn(1f, 4f),
|
||||
) { bytes, outW, outH, captureMs ->
|
||||
val reply =
|
||||
Message.obtain(null, NappletEmbedContract.MSG_MAGNIFIER_FRAME).apply {
|
||||
this.data =
|
||||
Bundle().apply {
|
||||
putByteArray(NappletEmbedContract.KEY_MAG_BYTES, bytes)
|
||||
putInt(NappletEmbedContract.KEY_MAG_W, outW)
|
||||
putInt(NappletEmbedContract.KEY_MAG_H, outH)
|
||||
putDouble(NappletEmbedContract.KEY_MAG_CAPTURE_MS, captureMs)
|
||||
putLong(NappletEmbedContract.KEY_MAG_REQ_T, reqT)
|
||||
}
|
||||
}
|
||||
tab.toClient(reply)
|
||||
}
|
||||
}
|
||||
|
||||
/** Builds the SandboxedUiAdapter for [tab] and ships its cross-process handle (coreLibInfo) to the client. */
|
||||
@@ -332,7 +375,7 @@ class NappletHostService : Service() {
|
||||
Message.obtain(null, NappletEmbedContract.MSG_SESSION_READY).apply {
|
||||
data = Bundle().apply { putBundle(NappletEmbedContract.KEY_CORE_LIB_INFO, coreLibInfo) }
|
||||
}
|
||||
runCatching { tab.clientMessenger?.send(reply) }
|
||||
tab.toClient(reply)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -348,9 +391,29 @@ class NappletHostService : Service() {
|
||||
// The session may have been closed between MSG_CREATE_SESSION and this posted call — fail rather
|
||||
// than build a WebView that no tab tracks (it would leak).
|
||||
val tab = tabs[sessionId] ?: error("No napplet tab for session $sessionId")
|
||||
// A session re-opened on this tab (the client's view detached and re-attached) before the old one's
|
||||
// close landed: that session's WebView is still here. Destroy it now — its close will be ignored
|
||||
// (see onSessionClosed), and overwriting it would leak it.
|
||||
tab.webView?.let { stale ->
|
||||
(stale.parent as? ViewGroup)?.removeView(stale)
|
||||
stale.destroy()
|
||||
tab.webView = null
|
||||
tab.bridgeReplyProxy = null
|
||||
}
|
||||
tab.container = container
|
||||
// A rebuild after a renderer crash: release the previous content server first.
|
||||
tab.contentServer?.close()
|
||||
tab.contentServer = null
|
||||
// Fail closed: Tor is wanted but has no port yet. Nothing may be fetched — not the applet's blobs (the
|
||||
// content server would fetch them directly) and not its own traffic — so leave a blank placeholder and
|
||||
// tell the client, whose Retry sends the port once Tor is up.
|
||||
if (tab.useTor && tab.proxyPort <= 0) {
|
||||
val blank = WebView(nightThemedContext(context, tab.themeType)).apply { setBackgroundColor(tab.bgColor) }
|
||||
tab.webView = blank
|
||||
reportRouteBlocked(tab)
|
||||
return blank
|
||||
}
|
||||
tab.routeBlocked = false
|
||||
val wv = WebView(nightThemedContext(context, tab.themeType))
|
||||
// FIRST touch after construction: setProfile throws once the WebView has loaded content (or its
|
||||
// profile has otherwise been used), so the storage partition must be chosen before the
|
||||
@@ -376,19 +439,48 @@ class NappletHostService : Service() {
|
||||
// Theme the pre-load background so the shell/app loading shows Amethyst's background, not white.
|
||||
wv.setBackgroundColor(tab.bgColor)
|
||||
wv.dropSystemBarInsets()
|
||||
if (tab.profile.exposesNetwork) applyWebViewProxy(effectiveProxy)
|
||||
WebViewCompat.addWebMessageListener(wv, NappletWebContract.BRIDGE_NAME, setOf(NappletWebContract.ORIGIN), ::onShellMessage)
|
||||
wv.setFindListener { active, total, _ -> pushFindResult(tab, active, total) }
|
||||
if (tab.textZoom != BrowserChrome.DEFAULT_TEXT_ZOOM) BrowserWebTools.setTextZoom(wv, tab.textZoom)
|
||||
tab.webView = wv
|
||||
wv.loadUrl(NappletWebContract.SHELL_URL)
|
||||
if (tab.paused) wv.onPause()
|
||||
if (tab.profile.exposesNetwork) {
|
||||
// The site's own off-origin traffic follows the process-wide route; load once it's in place so
|
||||
// a Tor nSite's first request can't leave over the open web — and not at all if it can't be.
|
||||
WebViewProxyPolicy.observeRoute(tab) { usesTor ->
|
||||
if (tabs[tab.sessionId] === tab) {
|
||||
tab.toClient(Message.obtain(null, NappletEmbedContract.MSG_ROUTE).apply { data = Bundle().apply { putBoolean(NappletEmbedContract.KEY_ROUTE_TOR, usesTor) } })
|
||||
}
|
||||
}
|
||||
WebViewProxyPolicy.claim(
|
||||
owner = tab,
|
||||
torPort = effectiveProxy,
|
||||
onFailed = { reportRouteBlocked(tab) },
|
||||
) { if (tab.webView === wv) wv.loadUrl(NappletWebContract.SHELL_URL) }
|
||||
} else {
|
||||
wv.loadUrl(NappletWebContract.SHELL_URL)
|
||||
}
|
||||
return wv
|
||||
}
|
||||
|
||||
/** A session closed: drop the tab, release its resources, and destroy its own WebView (never a sibling's). */
|
||||
fun onSessionClosed(sessionId: String) {
|
||||
val tab = tabs.remove(sessionId) ?: return
|
||||
fun onSessionClosed(
|
||||
sessionId: String,
|
||||
container: FrameLayout,
|
||||
) {
|
||||
// Only the session that currently owns the tab may close it. A late close from a session that was
|
||||
// already replaced by a re-open would otherwise reap the live one — its WebView destroyed under a
|
||||
// client that had just been told the session opened, leaving the surface black for good.
|
||||
tabs[sessionId]?.takeIf { it.container === container }?.let(::closeTab)
|
||||
}
|
||||
|
||||
/** Drops [tab] and everything it holds (its WebView, content server, broker state, proxy claim). */
|
||||
private fun closeTab(tab: NappletTab) {
|
||||
// By identity: a replaced tab closing late must not take its replacement (same id) with it.
|
||||
tabs.remove(tab.sessionId, tab)
|
||||
tab.bridgeReplyProxy = null
|
||||
WebViewProxyPolicy.release(tab)
|
||||
releaseFromBroker(tab)
|
||||
// Release a picker still waiting on this surface before its WebView goes away.
|
||||
tab.fileChooser.cancel()
|
||||
tab.contentServer?.close()
|
||||
@@ -515,23 +607,10 @@ class NappletHostService : Service() {
|
||||
putString(NappletEmbedContract.KEY_FILE_CHOOSER_TITLE, params.title?.toString())
|
||||
}
|
||||
}
|
||||
if (runCatching { client.send(msg) }.isFailure) tab.fileChooser.cancel()
|
||||
if (!tab.toClient(msg)) tab.fileChooser.cancel()
|
||||
return true
|
||||
}
|
||||
|
||||
private fun applyWebViewProxy(port: Int) {
|
||||
if (!WebViewFeature.isFeatureSupported(WebViewFeature.PROXY_OVERRIDE)) return
|
||||
val executor = Executor { it.run() }
|
||||
runCatching {
|
||||
if (port > 0) {
|
||||
val config = ProxyConfig.Builder().addProxyRule("socks5://127.0.0.1:$port").build()
|
||||
ProxyController.getInstance().setProxyOverride(config, executor) {}
|
||||
} else {
|
||||
ProxyController.getInstance().clearProxyOverride(executor) {}
|
||||
}
|
||||
}.onFailure { Log.w(TAG, "Failed to apply WebView proxy override", it) }
|
||||
}
|
||||
|
||||
/** Serves only the trusted shell and the manifest's verified blobs; external links go to the system. */
|
||||
private inner class HostClient(
|
||||
private val tab: NappletTab,
|
||||
@@ -544,7 +623,7 @@ class NappletHostService : Service() {
|
||||
override fun onPageStarted(
|
||||
view: WebView,
|
||||
url: String,
|
||||
favicon: android.graphics.Bitmap?,
|
||||
favicon: Bitmap?,
|
||||
) {
|
||||
// A new main-frame navigation cleared any prior error.
|
||||
tab.loadFailed = false
|
||||
@@ -588,8 +667,8 @@ class NappletHostService : Service() {
|
||||
|
||||
/**
|
||||
* The renderer died. It is shared by every WebView in `:napplet`, and an unhandled crash kills the
|
||||
* whole process — every other tab included. Drop just this tab's WebView and report the load as
|
||||
* failed; the tab's retry (MSG_RELOAD) rebuilds it in the same surface.
|
||||
* whole process — every other tab included. Drop just this tab's WebView and report it gone
|
||||
* ([NappletEmbedContract.KEY_RENDERER_GONE]); the client's MSG_RELOAD rebuilds it in the same surface.
|
||||
*/
|
||||
override fun onRenderProcessGone(
|
||||
view: WebView,
|
||||
@@ -602,7 +681,7 @@ class NappletHostService : Service() {
|
||||
tab.webView = null
|
||||
tab.bridgeReplyProxy = null
|
||||
tab.loadFailed = true
|
||||
pushLoadState(tab, isLoading = false)
|
||||
pushLoadState(tab, isLoading = false, rendererGone = true)
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -628,7 +707,7 @@ class NappletHostService : Service() {
|
||||
Message.obtain(null, NappletEmbedContract.MSG_STATE).apply {
|
||||
data = Bundle().apply { putBoolean(NappletEmbedContract.KEY_CAN_GO_BACK, view.canGoBack()) }
|
||||
}
|
||||
runCatching { tab.clientMessenger?.send(message) }
|
||||
tab.toClient(message)
|
||||
}
|
||||
|
||||
private fun pushFindResult(
|
||||
@@ -644,7 +723,7 @@ class NappletHostService : Service() {
|
||||
putInt(NappletEmbedContract.KEY_FIND_TOTAL, total)
|
||||
}
|
||||
}
|
||||
runCatching { tab.clientMessenger?.send(message) }
|
||||
tab.toClient(message)
|
||||
}
|
||||
|
||||
private fun pushConsoleLog(
|
||||
@@ -664,13 +743,15 @@ class NappletHostService : Service() {
|
||||
putInt(NappletEmbedContract.KEY_CONSOLE_LINE, line)
|
||||
}
|
||||
}
|
||||
runCatching { tab.clientMessenger?.send(message) }
|
||||
tab.toClient(message)
|
||||
}
|
||||
|
||||
/** Tells the client whether a main-frame load is in flight and whether it failed, so it can overlay a spinner/retry. */
|
||||
private fun pushLoadState(
|
||||
tab: NappletTab,
|
||||
isLoading: Boolean,
|
||||
rendererGone: Boolean = false,
|
||||
routeBlocked: Boolean = false,
|
||||
) {
|
||||
val message =
|
||||
Message.obtain(null, NappletEmbedContract.MSG_LOAD_STATE).apply {
|
||||
@@ -678,9 +759,20 @@ class NappletHostService : Service() {
|
||||
Bundle().apply {
|
||||
putBoolean(NappletEmbedContract.KEY_IS_LOADING, isLoading)
|
||||
putBoolean(NappletEmbedContract.KEY_LOAD_FAILED, tab.loadFailed)
|
||||
putBoolean(NappletEmbedContract.KEY_RENDERER_GONE, rendererGone)
|
||||
putBoolean(NappletEmbedContract.KEY_ROUTE_BLOCKED, routeBlocked)
|
||||
}
|
||||
}
|
||||
runCatching { tab.clientMessenger?.send(message) }
|
||||
tab.toClient(message)
|
||||
}
|
||||
|
||||
/** The applet wasn't loaded because its route can't be honored: tell the client, which shows the error. */
|
||||
private fun reportRouteBlocked(tab: NappletTab) {
|
||||
if (tabs[tab.sessionId] !== tab) return
|
||||
// Whatever WebView the tab has never loaded the applet: the retry must rebuild it, not reload it.
|
||||
tab.routeBlocked = true
|
||||
tab.loadFailed = true
|
||||
pushLoadState(tab, isLoading = false, routeBlocked = true)
|
||||
}
|
||||
|
||||
// ---- bridge: shell <-> native (mirror of NappletHostActivity.onShellMessage) ----
|
||||
@@ -705,7 +797,7 @@ class NappletHostService : Service() {
|
||||
Message.obtain(null, NappletEmbedContract.MSG_IME_EVENT).apply {
|
||||
data = Bundle().apply { putString(NappletEmbedContract.KEY_IME_PAYLOAD, raw) }
|
||||
}
|
||||
runCatching { tab.clientMessenger?.send(reply) }
|
||||
tab.toClient(reply)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -720,9 +812,59 @@ class NappletHostService : Service() {
|
||||
putString(NappletIpc.KEY_LAUNCH_TOKEN, tab.launchToken)
|
||||
}
|
||||
}
|
||||
// Nobody is looking (parked off-screen, or the app is in the background): an act on the user's behalf
|
||||
// waits until they're looking at this napplet again.
|
||||
if (!tab.mayAct && NappletActingRequests.actsForUser(runCatching { NappletProtocolJson.readType(raw) }.getOrNull())) {
|
||||
val refused = tab.heldWhilePaused.hold(msg)
|
||||
if (refused != null) {
|
||||
tab.bridgeReplyProxy?.failRequest(refused, NappletHeldRequests.TOO_MANY)
|
||||
} else {
|
||||
// Settle it with an error if nobody comes back for it, so the applet isn't left waiting forever.
|
||||
heldExpiry.postDelayed({ expireHeld(tab) }, NappletHeldRequests.MAX_AGE_MS)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (brokerMessenger == null) pendingBrokerRequests.add(msg) else sendToBroker(msg)
|
||||
}
|
||||
|
||||
/** Sends [tab]'s held acting requests once it may act again (attended and not paused). */
|
||||
private fun releaseHeld(tab: NappletTab) {
|
||||
if (!tab.mayAct) return
|
||||
val held = tab.heldWhilePaused.drain()
|
||||
held.fail.forEach { tab.bridgeReplyProxy?.failRequest(it, NappletHeldRequests.EXPIRED) }
|
||||
held.send.forEach { request -> if (brokerMessenger == null) pendingBrokerRequests.add(request) else sendToBroker(request) }
|
||||
}
|
||||
|
||||
private val heldExpiry = Handler(Looper.getMainLooper())
|
||||
|
||||
/** Tells the broker whether [tab] is being looked at, which gates decrypting its relay reads. */
|
||||
private fun reportAttended(tab: NappletTab) {
|
||||
val msg =
|
||||
Message.obtain(null, NappletIpc.MSG_SET_ATTENDED).apply {
|
||||
replyTo = tab.replyMessenger
|
||||
data = Bundle().apply { putBoolean(NappletIpc.KEY_ATTENDED, tab.attended) }
|
||||
}
|
||||
if (brokerMessenger == null) pendingBrokerRequests.add(msg) else sendToBroker(msg)
|
||||
}
|
||||
|
||||
private fun expireHeld(tab: NappletTab) {
|
||||
if (tabs[tab.sessionId] !== tab) return
|
||||
tab.heldWhilePaused.expire().forEach { tab.bridgeReplyProxy?.failRequest(it, NappletHeldRequests.EXPIRED) }
|
||||
}
|
||||
|
||||
/**
|
||||
* [tab] is gone: have the broker close the live relay / inc subscriptions it opened and drop its reply
|
||||
* Messenger (a binder the main process would otherwise hold, keeping the tab alive). The launch token is
|
||||
* NOT given back: the main-process controller re-creates sessions with the same token, and gives it
|
||||
* back itself when it is torn down.
|
||||
*/
|
||||
private fun releaseFromBroker(tab: NappletTab) {
|
||||
tab.heldWhilePaused.clear()
|
||||
pendingBrokerRequests.removeAll { it.replyTo == tab.replyMessenger }
|
||||
if (brokerMessenger == null) return
|
||||
sendToBroker(Message.obtain(null, NappletIpc.MSG_RELEASE_CLIENT).apply { replyTo = tab.replyMessenger })
|
||||
}
|
||||
|
||||
private fun sendToBroker(msg: Message) {
|
||||
try {
|
||||
brokerMessenger?.send(msg)
|
||||
@@ -774,7 +916,7 @@ class NappletHostService : Service() {
|
||||
Message.obtain(null, NappletEmbedContract.MSG_NOTICE).apply {
|
||||
data = Bundle().apply { putString(NappletEmbedContract.KEY_NOTICE, notice) }
|
||||
}
|
||||
runCatching { tab.clientMessenger?.send(message) }
|
||||
tab.toClient(message)
|
||||
}
|
||||
|
||||
private fun readContractAsset(path: String): ByteArray = assets.open(NappletWebContract.RESOURCE_ASSET_ROOT + path).use { it.readBytes() }
|
||||
|
||||
+8
-3
@@ -112,8 +112,13 @@ private class HostSession(
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
// The library may call close() off the main thread; WebView.destroy() (and the tabs mutation)
|
||||
// must run on the main thread.
|
||||
Handler(Looper.getMainLooper()).post { service.onSessionClosed(sessionId) }
|
||||
// WebView.destroy() (and the tabs mutation) must run on the main thread. The library usually calls
|
||||
// this there already; post only when it doesn't. The container identifies THIS session, so a close
|
||||
// that lands after the tab was re-opened can't tear down its successor.
|
||||
if (Looper.myLooper() == Looper.getMainLooper()) {
|
||||
service.onSessionClosed(sessionId, container)
|
||||
} else {
|
||||
Handler(Looper.getMainLooper()).post { service.onSessionClosed(sessionId, container) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +57,9 @@ object NappletIpc {
|
||||
/** Broker → host: the [KEY_LAUNCH_TOKEN] minted for [KEY_BROWSER_ORIGIN]. */
|
||||
const val MSG_BROWSER_TOKEN = 6
|
||||
|
||||
/** How long a host waits for [MSG_BROWSER_TOKEN] before failing the origin's queued calls. */
|
||||
const val MINT_TIMEOUT_MS = 20_000L
|
||||
|
||||
/**
|
||||
* Host → broker: this sandbox surface entered ([KEY_FOREGROUND] true) or left ([KEY_FOREGROUND]
|
||||
* false) the foreground. The `:napplet` host runs in its own process and so can't touch the main
|
||||
@@ -109,8 +112,10 @@ object NappletIpc {
|
||||
|
||||
/**
|
||||
* Host → broker: this surface is being destroyed — drop every reference the broker holds to its
|
||||
* `replyTo` [android.os.Messenger] (inc-bus topic subscriptions, and its foreground lease when
|
||||
* [KEY_LAUNCH_TOKEN] is supplied).
|
||||
* `replyTo` [android.os.Messenger] (inc-bus topic subscriptions, live relay subscriptions, and its
|
||||
* foreground lease when [KEY_LAUNCH_TOKEN] is supplied). [KEY_RELEASED_TOKENS], when present, lists
|
||||
* launch tokens the surface minted and will never use again (a closed browser tab's per-origin
|
||||
* tokens); the broker unregisters them so dead sessions stop crowding live ones out of the registry.
|
||||
*
|
||||
* A `Messenger` handed to the broker is a **binder**, so the main process holding it keeps a JNI
|
||||
* global reference alive in `:napplet`. Because the sandbox's reply handler is a bound method
|
||||
@@ -157,6 +162,24 @@ object NappletIpc {
|
||||
*/
|
||||
const val MSG_ADD_TO_HOME_SCREEN = 19
|
||||
|
||||
/**
|
||||
* Broker → host: a request carried [KEY_LAUNCH_TOKEN] the registry no longer knows (it was evicted, or
|
||||
* released). The request itself was answered with a failure; a browser host drops that token so the
|
||||
* origin's next call mints a fresh one instead of failing forever.
|
||||
*/
|
||||
const val MSG_TOKEN_UNKNOWN = 20
|
||||
|
||||
/**
|
||||
* Host → broker: whether the user is looking at the surface behind [android.os.Message.replyTo]
|
||||
* ([KEY_ATTENDED]). The broker decrypts relay reads for a page — events pushed to its subscriptions, and
|
||||
* `relay.query` results — only while it is; until then encrypted events wait. A surface is unattended
|
||||
* until it says otherwise, and after each [MSG_RELEASE_CLIENT].
|
||||
*/
|
||||
const val MSG_SET_ATTENDED = 21
|
||||
|
||||
/** Boolean for [MSG_SET_ATTENDED]. */
|
||||
const val KEY_ATTENDED = "attended"
|
||||
|
||||
const val KEY_REQUEST_ID = "requestId"
|
||||
const val KEY_PAYLOAD = "payload"
|
||||
|
||||
@@ -199,6 +222,9 @@ object NappletIpc {
|
||||
/** The visited web origin (e.g. `https://example.com`) a browser-mode request belongs to. */
|
||||
const val KEY_BROWSER_ORIGIN = "browserOrigin"
|
||||
|
||||
/** [MSG_MINT_BROWSER_TOKEN]: the opaque storage profile the asking surface runs in (its account's jar). */
|
||||
const val KEY_WEBVIEW_PROFILE = "webViewProfile"
|
||||
|
||||
/** Boolean: route this site through Tor (true) or over the open web (false). */
|
||||
const val KEY_NETWORK_USE_TOR = "networkUseTor"
|
||||
|
||||
@@ -208,4 +234,7 @@ object NappletIpc {
|
||||
* the trusted identity + declared capability set the launch was registered with.
|
||||
*/
|
||||
const val KEY_LAUNCH_TOKEN = "launchToken"
|
||||
|
||||
/** Launch tokens a [MSG_RELEASE_CLIENT] gives back (a string array). */
|
||||
const val KEY_RELEASED_TOKENS = "releasedTokens"
|
||||
}
|
||||
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.napplethost
|
||||
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import androidx.webkit.ProxyConfig
|
||||
import androidx.webkit.ProxyController
|
||||
import androidx.webkit.WebViewFeature
|
||||
import com.vitorpamplona.amethyst.commons.napplet.NappletProxyClaims
|
||||
import com.vitorpamplona.amethyst.commons.napplet.NappletProxyClaims.Route
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import java.util.concurrent.Executor
|
||||
|
||||
/**
|
||||
* The single owner of the `:napplet` process's WebView proxy override. Every surface — embedded browser
|
||||
* tab, embedded nSite, full-screen browser or host — files a claim here instead of setting the override
|
||||
* itself; [NappletProxyClaims] derives the one route they all share (Tor always wins, see there).
|
||||
*
|
||||
* It fails CLOSED. A surface's page load waits for [claim]'s `onReady`, which only runs once the route is
|
||||
* actually in effect: if applying it fails, or this WebView can't take a proxy override at all while Tor is
|
||||
* wanted, the waiting loads get `onFailed` instead of going out directly, and the next claim tries again.
|
||||
* (A surface that wants Tor but has no Tor port yet must not claim at all — it blocks its own load.)
|
||||
*
|
||||
* Main thread only; the WebKit callback is delivered back to the main thread too.
|
||||
*/
|
||||
object WebViewProxyPolicy {
|
||||
private const val TAG = "WebViewProxyPolicy"
|
||||
|
||||
/** Why a surface's load can't go ahead. */
|
||||
enum class Failure {
|
||||
/** This device's WebView can't route through a proxy, so Tor can't be honored. */
|
||||
TOR_UNSUPPORTED,
|
||||
|
||||
/** Setting the proxy override failed. */
|
||||
APPLY_FAILED,
|
||||
}
|
||||
|
||||
private class Waiter(
|
||||
val wantsTor: Boolean,
|
||||
val onReady: () -> Unit,
|
||||
val onFailed: (Failure) -> Unit,
|
||||
)
|
||||
|
||||
private val claims = NappletProxyClaims()
|
||||
|
||||
// What the WebView currently runs with (a fresh process has no override), and what is being applied.
|
||||
private var applied: Route = NappletProxyClaims.DIRECT
|
||||
private var applying: Route? = null
|
||||
private val waiting = mutableListOf<Waiter>()
|
||||
|
||||
// Surfaces told which route is really in effect (an open-web page can be on Tor because another needs it).
|
||||
private val routeListeners = LinkedHashMap<Any, (Boolean) -> Unit>()
|
||||
|
||||
private val main = Handler(Looper.getMainLooper())
|
||||
private val mainExecutor = Executor { if (Looper.myLooper() == Looper.getMainLooper()) it.run() else main.post(it) }
|
||||
|
||||
private val supported by lazy { WebViewFeature.isFeatureSupported(WebViewFeature.PROXY_OVERRIDE) }
|
||||
|
||||
/**
|
||||
* Files [owner]'s route: through Tor on [torPort] (> 0), or the open web ([NappletProxyClaims.NO_PROXY]).
|
||||
* [onReady] runs on the main thread once the resulting process route is in effect — immediately when
|
||||
* nothing had to change; [onFailed] instead when it can't be.
|
||||
*/
|
||||
fun claim(
|
||||
owner: Any,
|
||||
torPort: Int,
|
||||
onFailed: (Failure) -> Unit = {},
|
||||
onReady: () -> Unit = {},
|
||||
) {
|
||||
claims.claim(owner, torPort)
|
||||
sync(Waiter(torPort > 0, onReady, onFailed))
|
||||
}
|
||||
|
||||
/** Withdraws [owner]'s claim and route listener; the route relaxes once no remaining surface needs it. */
|
||||
fun release(owner: Any) {
|
||||
claims.release(owner)
|
||||
routeListeners.remove(owner)
|
||||
sync(null)
|
||||
}
|
||||
|
||||
/** Tells [listener] (now, and on every change) whether the process currently routes through Tor. */
|
||||
fun observeRoute(
|
||||
owner: Any,
|
||||
listener: (usesTor: Boolean) -> Unit,
|
||||
) {
|
||||
routeListeners[owner] = listener
|
||||
listener(applied.usesTor)
|
||||
}
|
||||
|
||||
private fun sync(waiter: Waiter?) {
|
||||
val target = claims.route()
|
||||
if (!supported) {
|
||||
// Nothing can be proxied. The open web still works; a surface that wants Tor fails closed.
|
||||
if (waiter?.wantsTor == true) waiter.onFailed(Failure.TOR_UNSUPPORTED) else waiter?.onReady?.invoke()
|
||||
return
|
||||
}
|
||||
if (target == applied && applying == null) {
|
||||
waiter?.onReady?.invoke()
|
||||
return
|
||||
}
|
||||
waiter?.let { waiting += it }
|
||||
if (target == applying) return
|
||||
applying = target
|
||||
apply(target) { ok ->
|
||||
// A newer route superseded this one while it applied: its own callback settles the waiters.
|
||||
if (applying != target) {
|
||||
if (ok) applied = target
|
||||
return@apply
|
||||
}
|
||||
applying = null
|
||||
val settled = waiting.toList()
|
||||
waiting.clear()
|
||||
if (ok) {
|
||||
applied = target
|
||||
routeListeners.values.toList().forEach { it(target.usesTor) }
|
||||
settled.forEach { it.onReady() }
|
||||
} else {
|
||||
// Keep `applied` as it was, so the next claim tries again; nothing waiting goes out unrouted.
|
||||
settled.forEach { it.onFailed(Failure.APPLY_FAILED) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun apply(
|
||||
route: Route,
|
||||
done: (ok: Boolean) -> Unit,
|
||||
) {
|
||||
runCatching {
|
||||
if (route.usesTor) {
|
||||
val config = ProxyConfig.Builder().addProxyRule("socks5://127.0.0.1:${route.torPort}").build()
|
||||
ProxyController.getInstance().setProxyOverride(config, mainExecutor) { done(true) }
|
||||
} else {
|
||||
ProxyController.getInstance().clearProxyOverride(mainExecutor) { done(true) }
|
||||
}
|
||||
}.onFailure {
|
||||
Log.w(TAG, "Failed to apply WebView proxy override", it)
|
||||
done(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,5 +17,8 @@
|
||||
<!-- Developer console: page-load failures surfaced as console errors -->
|
||||
<string name="napplet_console_load_error">Failed to load (%1$d): %2$s</string>
|
||||
<string name="napplet_console_http_error">HTTP %1$d %2$s</string>
|
||||
<!-- Shown by the full-screen browser / napplet (in the sandbox process, where compose resources can't be
|
||||
read outside composition) when a page is not loaded because its Tor route can't be honored. -->
|
||||
<string name="napplet_route_blocked">Not loaded: Tor isn\'t available for this page yet. Try again in a moment.</string>
|
||||
|
||||
</resources>
|
||||
|
||||
Reference in New Issue
Block a user