diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedPageRequests.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedPageRequests.kt index 65eedabf6d..b7ed7888e8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedPageRequests.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedPageRequests.kt @@ -40,3 +40,17 @@ data class EmbeddedPermissionRequest( val origin: String, val permissions: Set, ) + +/** + * A download an embedded page started, waiting for consent before anything is fetched or written into + * the shared Downloads collection. [fileName]/[sizeBytes] are the sanitized name and size (-1 when + * unknown) the sandbox reports; [origin] is the WebView-reported origin, not a page field. + */ +data class EmbeddedDownloadRequest( + val id: Long, + val origin: String, + val fileName: String, + val sizeBytes: Long, + val sourceHost: String?, + val risky: Boolean, +) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedWebAppController.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedWebAppController.kt index 8df5a2000f..2e56d97b8a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedWebAppController.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedWebAppController.kt @@ -129,6 +129,9 @@ class EmbeddedWebAppController( /** The camera / microphone / location request the page is waiting on, if any. */ val pendingPermission = mutableStateOf(null) + /** The download the page started that awaits the user's consent, if any. */ + val pendingDownload = mutableStateOf(null) + /** The certificate of the page on screen, once page info asked for it (null for none, or not yet). */ val pageCertificate = mutableStateOf(null) @@ -181,6 +184,7 @@ class EmbeddedWebAppController( consoleLogs.clear() pendingDialog.value = null pendingPermission.value = null + pendingDownload.value = null isFullscreen.value = false } @@ -367,6 +371,30 @@ class EmbeddedWebAppController( val id = msg.data?.getLong(NappletBrowserContract.KEY_PERMISSION_ID) if (pendingPermission.value?.id == id) pendingPermission.value = null } + NappletBrowserContract.MSG_DOWNLOAD_CONSENT -> { + val data = msg.data ?: return true + val id = data.getLong(NappletBrowserContract.KEY_DOWNLOAD_ID) + val origin = data.getString(NappletBrowserContract.KEY_BROWSER_ORIGIN) + val name = data.getString(NappletBrowserContract.KEY_DOWNLOAD_NAME).orEmpty() + // An unnamed/absent origin means the sandbox couldn't even state who is asking: refuse. + if (origin == null || name.isEmpty() || pendingDownload.value != null || pendingDialog.value != null || pendingPermission.value != null) { + answerDownload(id, allowed = false) + return true + } + pendingDownload.value = + EmbeddedDownloadRequest( + id = id, + origin = origin, + fileName = name, + sizeBytes = data.getLong(NappletBrowserContract.KEY_DOWNLOAD_SIZE, -1L), + sourceHost = data.getString(NappletBrowserContract.KEY_DOWNLOAD_SOURCE), + risky = data.getBoolean(NappletBrowserContract.KEY_DOWNLOAD_RISKY, false), + ) + } + NappletBrowserContract.MSG_DOWNLOAD_CANCEL -> { + val id = msg.data?.getLong(NappletBrowserContract.KEY_DOWNLOAD_ID) + if (pendingDownload.value?.id == id) pendingDownload.value = null + } NappletBrowserContract.MSG_FULLSCREEN -> isFullscreen.value = msg.data?.getBoolean(NappletBrowserContract.KEY_ENABLED, false) ?: false NappletBrowserContract.MSG_MAGNIFIER_FRAME -> { val data = msg.data ?: return true @@ -486,6 +514,18 @@ class EmbeddedWebAppController( } } + /** Answers the download-consent card for [id]: true lets the sandbox fetch or write the file it described. */ + fun answerDownload( + id: Long, + allowed: Boolean, + ) { + if (pendingDownload.value?.id == id) pendingDownload.value = null + send(NappletBrowserContract.MSG_DOWNLOAD_CONSENT_RESULT) { + putLong(NappletBrowserContract.KEY_DOWNLOAD_ID, id) + putBoolean(NappletBrowserContract.KEY_DOWNLOAD_ALLOWED, allowed) + } + } + fun setTor(useTor: Boolean) = send(NappletBrowserContract.MSG_SET_TOR) { putBoolean(NappletBrowserContract.KEY_USE_TOR, useTor) } override fun sendImeOp(json: String) = send(NappletBrowserContract.MSG_IME_OP) { putString(NappletBrowserContract.KEY_IME_PAYLOAD, json) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/WebAppScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/WebAppScreen.kt index d2947e9dfc..b66cedceca 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/WebAppScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/WebAppScreen.kt @@ -65,6 +65,7 @@ import com.vitorpamplona.amethyst.commons.browser.OmniboxSuggestions import com.vitorpamplona.amethyst.commons.browser.ui.pill.AddressSuggestion 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.DownloadPromptCard import com.vitorpamplona.amethyst.commons.browser.ui.pill.PageDialogCard import com.vitorpamplona.amethyst.commons.browser.ui.pill.PageInfoSheet import com.vitorpamplona.amethyst.commons.browser.ui.pill.PermissionPromptCard @@ -324,8 +325,9 @@ private fun EmbeddedWebAppTab( /** * Everything an embedded page asks the user for, drawn by the main process because the provider has no * window: JS dialogs, camera / microphone / location prompts (remembered per origin in - * [WebSitePermissionRegistry], then Android's own runtime permission), and page info — the shared - * [PageDialogCard], [PermissionPromptCard] and [PageInfoSheet]. + * [WebSitePermissionRegistry], then Android's own runtime permission), inline-download consent, and + * page info — the shared [PageDialogCard], [PermissionPromptCard], [DownloadPromptCard] and + * [PageInfoSheet]. */ @RequiresApi(Build.VERSION_CODES.R) @Composable @@ -408,6 +410,25 @@ private fun EmbeddedPageUi( } } + // A download the page started: nothing is fetched or saved until this is answered. The card shows the + // file name that would be saved, its size and source host, headed by the WebView-reported origin (never + // a page-supplied field), with a warning for names that can be installed or run ("invoice.apk"). + val downloadRequest by controller.pendingDownload + downloadRequest?.let { request -> + Dialog(onDismissRequest = { controller.answerDownload(request.id, allowed = false) }) { + DownloadPromptCard( + host = hostLabel(request.origin), + security = ui.security, + fileName = request.fileName, + sizeBytes = request.sizeBytes, + sourceHost = request.sourceHost, + risky = request.risky, + onAllow = { controller.answerDownload(request.id, allowed = true) }, + onDeny = { controller.answerDownload(request.id, allowed = false) }, + ) + } + } + if (showPageInfo) { val certificate by controller.pageCertificate val origin = browserOrigin(ui.chrome.url) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/BrowserDownloadRules.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/BrowserDownloadRules.kt new file mode 100644 index 0000000000..fe22abfa1c --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/BrowserDownloadRules.kt @@ -0,0 +1,275 @@ +/* + * 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.browser + +import kotlin.io.encoding.Base64 +import kotlin.time.Duration +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds +import kotlin.time.TimeMark +import kotlin.time.TimeSource + +/** + * The platform-free rules behind the in-app browser's download consent: what a saved file may be named, + * which names deserve a warning, how a page-supplied `data:` URL turns into the bytes a consent card + * describes, and how often one site may ask. + * + * A page can start a download without any gesture (a navigation to an attachment, a script `.click()` + * on an ``, or a hand-forged bridge envelope), so every page-initiated save asks the user + * first. The card shows exactly the name and size these rules produce, and the save writes exactly that. + */ +object BrowserDownloadRules { + /** Cap for bytes a page hands over inline (a `blob:`/`data:` download travels as base64 over the bridge). */ + const val MAX_INLINE_BYTES = 25 * 1024 * 1024 + + /** Longest file name kept; longer ones are shortened in the middle so the extension survives. */ + const val MAX_NAME_LENGTH = 120 + + /** + * Extensions something can install, run, or open as a live page from. The consent card flags them; + * the name itself is never rewritten, so the user judges the real one. + */ + val RISKY_EXTENSIONS = + setOf( + // Android + "apk", + "apks", + "apkm", + "xapk", + "aab", + "jar", + "dex", + "so", + // Windows + "exe", + "msi", + "msix", + "appx", + "bat", + "cmd", + "com", + "cpl", + "pif", + "reg", + "scr", + "hta", + "ps1", + "js", + "jse", + "vbs", + "vbe", + "wsf", + "lnk", + "url", + // Apple + "dmg", + "pkg", + "app", + "ipa", + "command", + // Linux + "deb", + "rpm", + "appimage", + "run", + "sh", + "zsh", + "bash", + "desktop", + // Pages that run script when opened + "html", + "htm", + "xhtml", + "xht", + "mht", + "mhtml", + "svg", + "svgz", + ) + + /** Whether [fileName]'s last extension is one a user should double-check before saving. */ + fun isRisky(fileName: String): Boolean = fileName.substringAfterLast('.', "").trim().lowercase() in RISKY_EXTENSIONS + + // Path-reserved characters become "_"; C0/C1 controls, DEL, bidi controls and zero-width characters + // are dropped outright, since they can make "invoice[RLO]gpj.apk" render as "invoicekpa.jpg". + private val reservedNameChars = Regex("[:*?\"<>|]") + private val invisibleNameChars = Regex("[\\u0000-\\u001f\\u007f-\\u009f\\u061c\\u200b-\\u200f\\u202a-\\u202e\\u2060-\\u2069\\ufeff]") + + /** + * A plain file name from a page's suggestion: no path parts, no reserved or invisible characters, at + * most [MAX_NAME_LENGTH] long with its extension kept. Null when nothing usable is left. + */ + fun safeFileName(suggested: String?): String? { + val base = + suggested + ?.substringAfterLast('/') + ?.substringAfterLast('\\') + ?.replace(invisibleNameChars, "") + ?.replace(reservedNameChars, "_") + ?.trim() + ?.takeIf { it.isNotEmpty() && it != "." && it != ".." } + ?: return null + if (base.length <= MAX_NAME_LENGTH) return base + val ext = base.substringAfterLast('.', "") + return if (ext.isNotEmpty() && ext.length <= 16) { + base.take(MAX_NAME_LENGTH - ext.length - 1).trimEnd() + "." + ext + } else { + base.take(MAX_NAME_LENGTH) + } + } + + /** A decoded `data:` URL: the declared MIME type (null when absent) and the payload bytes. */ + class DataUrl( + val mimeType: String?, + val bytes: ByteArray, + ) + + private val lenientBase64 = Base64.Mime.withPadding(Base64.PaddingOption.PRESENT_OPTIONAL) + + /** + * Decodes `data:[][;param=v…][;base64],`, base64 or percent-encoded. Null when it is + * not a data URL, is malformed, or decodes to more than [maxBytes] — a refused download, never a + * truncated one. The encoded length is checked first, so an oversized payload is never decoded. + */ + fun decodeDataUrl( + dataUrl: String, + maxBytes: Int = MAX_INLINE_BYTES, + ): DataUrl? { + if (!dataUrl.startsWith("data:", ignoreCase = true)) return null + val comma = dataUrl.indexOf(',') + if (comma < 0) return null + val params = dataUrl.substring(5, comma).split(';') + val mime = + params + .first() + .trim() + .lowercase() + .takeIf { it.isNotEmpty() } + val isBase64 = params.size > 1 && params.last().trim().equals("base64", ignoreCase = true) + val payloadLength = dataUrl.length - comma - 1 + val bytes = + if (isBase64) { + // 4 chars per 3 bytes, plus room for the CRLF a MIME encoder puts every 76 chars. + if (payloadLength.toLong() > maxBytes / 3 * 4L + maxBytes / 57 * 2L + 1024) return null + runCatching { lenientBase64.decode(dataUrl, comma + 1, dataUrl.length) }.getOrNull() ?: return null + } else { + // A percent escape is 3 chars per byte; the exact size is counted before allocating. + if (payloadLength.toLong() > maxBytes * 3L) return null + percentDecode(dataUrl, comma + 1, maxBytes) ?: return null + } + if (bytes.size > maxBytes) return null + return DataUrl(mime, bytes) + } + + /** + * RFC 3986 percent-decoding of [text] from [start] (a `+` stays a `+`, other characters are UTF-8). + * Null on a broken escape or when the result would exceed [maxBytes], checked before allocating it. + */ + private fun percentDecode( + text: String, + start: Int, + maxBytes: Int, + ): ByteArray? { + var size = 0L + var i = start + while (i < text.length) { + val c = text[i] + if (c == '%') { + if (i + 2 >= text.length || hexValue(text[i + 1]) < 0 || hexValue(text[i + 2]) < 0) return null + size += 1 + i += 3 + } else if (c.isHighSurrogate() && i + 1 < text.length && text[i + 1].isLowSurrogate()) { + size += 4 + i += 2 + } else { + // A lone surrogate encodes as U+FFFD, 3 bytes, like any other char from U+0800 up. + size += + when { + c.code < 0x80 -> 1 + c.code < 0x800 -> 2 + else -> 3 + } + i++ + } + if (size > maxBytes) return null + } + val out = ByteArray(size.toInt()) + var written = 0 + i = start + var runStart = start + while (i <= text.length) { + if (i == text.length || text[i] == '%') { + if (i > runStart) { + val run = text.encodeToByteArray(runStart, i) + run.copyInto(out, written) + written += run.size + } + if (i == text.length) break + out[written++] = ((hexValue(text[i + 1]) shl 4) or hexValue(text[i + 2])).toByte() + i += 3 + runStart = i + } else { + i++ + } + } + return if (written == out.size) out else out.copyOf(written) + } + + private fun hexValue(c: Char): Int = + when (c) { + in '0'..'9' -> c - '0' + in 'a'..'f' -> c - 'a' + 10 + in 'A'..'F' -> c - 'A' + 10 + else -> -1 + } +} + +/** + * How often one browser surface (a window, or one embedded tab) lets a site show a download card. After + * the user refuses a site's card, it waits [base] before it may ask again, and each further refusal + * doubles that, up to [max]; a Save resets it. Without this a refused page could re-ask in a loop until + * the user gives in. Main-thread only. + */ +class DownloadCooldown( + private val base: Duration = 1.seconds, + private val max: Duration = 1.minutes, + private val timeSource: TimeSource = TimeSource.Monotonic, +) { + private class Hold( + val since: TimeMark, + val window: Duration, + ) + + private val holds = HashMap() + + /** Whether [origin] may show a card now. */ + fun allows(origin: String): Boolean = holds[origin]?.let { it.since.elapsedNow() >= it.window } ?: true + + /** The user answered [origin]'s card: [allowed] resets its wait, a refusal (or dismissal) doubles it. */ + fun answered( + origin: String, + allowed: Boolean, + ) { + val previous = holds[origin]?.window + val window = if (allowed || previous == null) base else minOf(previous * 2, max) + holds[origin] = Hold(timeSource.markNow(), window) + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/browser/BrowserDownloadRulesTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/browser/BrowserDownloadRulesTest.kt new file mode 100644 index 0000000000..95b929c5b9 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/browser/BrowserDownloadRulesTest.kt @@ -0,0 +1,161 @@ +/* + * 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.browser + +import kotlin.io.encoding.Base64 +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds +import kotlin.time.TestTimeSource + +class BrowserDownloadRulesTest { + @Test + fun flagsInstallableAndScriptExtensions() { + assertTrue(BrowserDownloadRules.isRisky("invoice.apk")) + assertTrue(BrowserDownloadRules.isRisky("invoice.pdf.APK")) + assertTrue(BrowserDownloadRules.isRisky("page.html")) + assertFalse(BrowserDownloadRules.isRisky("photo.jpg")) + assertFalse(BrowserDownloadRules.isRisky("apk")) + assertFalse(BrowserDownloadRules.isRisky("download")) + } + + @Test + fun decodesBase64DataUrl() { + val payload = "hello world".encodeToByteArray() + val url = "data:text/plain;charset=utf-8;base64," + Base64.encode(payload) + val decoded = BrowserDownloadRules.decodeDataUrl(url)!! + assertEquals("text/plain", decoded.mimeType) + assertContentEquals(payload, decoded.bytes) + } + + @Test + fun decodesUnpaddedAndWrappedBase64() { + assertContentEquals("hi".encodeToByteArray(), BrowserDownloadRules.decodeDataUrl("data:;base64,aGk")!!.bytes) + assertContentEquals("hello world".encodeToByteArray(), BrowserDownloadRules.decodeDataUrl("data:;base64,aGVsbG8g\r\nd29ybGQ=")!!.bytes) + } + + @Test + fun decodesPercentEncodedDataUrl() { + val decoded = BrowserDownloadRules.decodeDataUrl("DATA:,a%20b+c%C3%A9")!! + assertNull(decoded.mimeType) + assertEquals("a b+cé", decoded.bytes.decodeToString()) + } + + @Test + fun refusesMalformedDataUrls() { + assertNull(BrowserDownloadRules.decodeDataUrl("https://example.com/a.apk")) + assertNull(BrowserDownloadRules.decodeDataUrl("data:text/plain;base64")) + assertNull(BrowserDownloadRules.decodeDataUrl("data:,broken%2")) + assertNull(BrowserDownloadRules.decodeDataUrl("data:,broken%zz")) + } + + @Test + fun refusesOversizedPayloadsInsteadOfTruncating() { + val bytes = ByteArray(100) { it.toByte() } + val url = "data:application/octet-stream;base64," + Base64.encode(bytes) + assertEquals(100, BrowserDownloadRules.decodeDataUrl(url, maxBytes = 100)!!.bytes.size) + assertNull(BrowserDownloadRules.decodeDataUrl(url, maxBytes = 99)) + assertNull(BrowserDownloadRules.decodeDataUrl("data:," + "a".repeat(11), maxBytes = 10)) + } + + @Test + fun base64MarkerMustBeTheLastParameter() { + assertEquals("aGk", BrowserDownloadRules.decodeDataUrl("data:text/plain;base64;x=y,aGk")!!.bytes.decodeToString()) + } + + @Test + fun percentDecodingCountsBytesBeforeAllocating() { + assertEquals(3, BrowserDownloadRules.decodeDataUrl("data:,%E4%B8%AD", maxBytes = 3)!!.bytes.size) + assertEquals("中", BrowserDownloadRules.decodeDataUrl("data:,中", maxBytes = 3)!!.bytes.decodeToString()) + assertNull(BrowserDownloadRules.decodeDataUrl("data:,中中", maxBytes = 5)) + assertEquals(4, BrowserDownloadRules.decodeDataUrl("data:,\uD83D\uDE00", maxBytes = 4)!!.bytes.size) + // A lone surrogate must not overflow the pre-counted buffer. + assertTrue(BrowserDownloadRules.decodeDataUrl("data:,a\uDE00b%41")!!.bytes.isNotEmpty()) + } + + @Test + fun safeFileNameStripsPathsAndInvisibleCharacters() { + assertEquals("evil.apk", BrowserDownloadRules.safeFileName("../../evil.apk")) + assertEquals("evil.apk", BrowserDownloadRules.safeFileName("C:\\x\\evil.apk")) + assertEquals("a_b_.pdf", BrowserDownloadRules.safeFileName("a:b?.pdf")) + assertEquals("invoicegpj.apk", BrowserDownloadRules.safeFileName("invoice\u202Egpj.apk")) + assertEquals("ab.txt", BrowserDownloadRules.safeFileName("a\u200Bb\u0085.txt")) + assertNull(BrowserDownloadRules.safeFileName("..")) + assertNull(BrowserDownloadRules.safeFileName(" \u200F ")) + assertNull(BrowserDownloadRules.safeFileName(null)) + } + + @Test + fun safeFileNameKeepsTheExtensionWhenShortening() { + val name = BrowserDownloadRules.safeFileName("photo.jpg" + "x".repeat(300) + ".apk")!! + assertEquals(BrowserDownloadRules.MAX_NAME_LENGTH, name.length) + assertTrue(name.endsWith(".apk")) + assertTrue(BrowserDownloadRules.isRisky(name)) + } + + @Test + fun cooldownHoldsOffOnlyTheAnsweredOrigin() { + val clock = TestTimeSource() + val cooldown = DownloadCooldown(1.seconds, 1.minutes, clock) + assertTrue(cooldown.allows("https://a.example")) + + cooldown.answered("https://a.example", allowed = true) + assertFalse(cooldown.allows("https://a.example")) + assertTrue(cooldown.allows("https://b.example")) + + clock += 999.milliseconds + assertFalse(cooldown.allows("https://a.example")) + clock += 1.milliseconds + assertTrue(cooldown.allows("https://a.example")) + } + + @Test + fun cooldownDoublesOnEachRefusalAndResetsOnSave() { + val clock = TestTimeSource() + val cooldown = DownloadCooldown(1.seconds, 4.seconds, clock) + val site = "https://a.example" + + cooldown.answered(site, allowed = false) // 1s + clock += 1.seconds + assertTrue(cooldown.allows(site)) + cooldown.answered(site, allowed = false) // 2s + clock += 1.seconds + assertFalse(cooldown.allows(site)) + clock += 1.seconds + assertTrue(cooldown.allows(site)) + cooldown.answered(site, allowed = false) // 4s + cooldown.answered(site, allowed = false) // capped at 4s + clock += 3.seconds + assertFalse(cooldown.allows(site)) + clock += 1.seconds + assertTrue(cooldown.allows(site)) + + cooldown.answered(site, allowed = true) // back to 1s + clock += 1.seconds + assertTrue(cooldown.allows(site)) + } +} diff --git a/commonsUI/src/commonMain/composeResources/values/strings.xml b/commonsUI/src/commonMain/composeResources/values/strings.xml index 5233867a0e..b7db85531f 100644 --- a/commonsUI/src/commonMain/composeResources/values/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values/strings.xml @@ -6355,4 +6355,10 @@ Ask Allowed Blocked + + + Download this file? + This file type can run code. Check that the name matches what you meant to get. + Save + From %1$s diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/DownloadPromptCard.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/DownloadPromptCard.kt new file mode 100644 index 0000000000..22ea19d944 --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/DownloadPromptCard.kt @@ -0,0 +1,147 @@ +/* + * 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.browser.ui.pill + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +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.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.browser.BrowserChrome +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.browser_pill_cancel +import com.vitorpamplona.amethyst.commons.resources.browser_pill_download_from +import com.vitorpamplona.amethyst.commons.resources.browser_pill_download_risky +import com.vitorpamplona.amethyst.commons.resources.browser_pill_download_save +import com.vitorpamplona.amethyst.commons.resources.browser_pill_download_title +import com.vitorpamplona.amethyst.commons.ui.note.types.formatBytes +import com.vitorpamplona.amethyst.commons.ui.stringRes +import kotlinx.coroutines.delay +import kotlin.time.Duration.Companion.milliseconds + +/** + * A download the page started, waiting for consent before anything is fetched or written to the shared + * Downloads collection. A page can start one without any gesture, so the card names the site (the + * WebView-reported origin, never a page-supplied field), the exact file name that would be saved, its + * size ([sizeBytes] is -1 when the server didn't say) and, for a network file, the host it comes from + * ([sourceHost]) when that isn't [host]. Nothing is saved until the user taps Save. + */ +@Composable +fun DownloadPromptCard( + host: String?, + security: BrowserChrome.Security, + fileName: String, + sizeBytes: Long, + sourceHost: String?, + risky: Boolean, + onAllow: () -> Unit, + onDeny: () -> Unit, +) { + // Save ignores taps for a moment after the card appears, so a tap meant for whatever was under the + // finger (say, a page dialog's button the page just replaced with this card) can't land on it. + var armed by remember(fileName) { mutableStateOf(false) } + LaunchedEffect(fileName) { + delay(ARM_DELAY) + armed = true + } + PageCard { + if (host != null) { + OriginBadge(host, security) + Spacer(Modifier.height(20.dp)) + } + Text( + stringRes(Res.string.browser_pill_download_title), + style = MaterialTheme.typography.titleLarge, + ) + Spacer(Modifier.height(16.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + Modifier.size(44.dp).clip(CircleShape).background(MaterialTheme.colorScheme.primaryContainer), + contentAlignment = Alignment.Center, + ) { + Icon(MaterialSymbols.Download, contentDescription = null, tint = MaterialTheme.colorScheme.onPrimaryContainer, filled = true) + } + Spacer(Modifier.width(14.dp)) + Column { + // One line, cut in the middle: the extension is what tells "invoice.pdf" from "invoice.pdf.apk". + Text(fileName, style = MaterialTheme.typography.titleSmall, maxLines = 1, overflow = TextOverflow.MiddleEllipsis) + val details = + listOfNotNull( + sizeBytes.takeIf { it >= 0 }?.let { formatBytes(it) }, + // A network file can come from another host than the page asking (an ad frame, a CDN). + sourceHost?.takeUnless { it.equals(host, ignoreCase = true) }?.let { stringRes(Res.string.browser_pill_download_from, it) }, + ) + if (details.isNotEmpty()) { + Text(details.joinToString(" · "), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.MiddleEllipsis) + } + } + } + if (risky) { + Spacer(Modifier.height(16.dp)) + Row( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.6f)) + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + PillActionIcon(BrowserChrome.Action.ACCESS_INFO, tint = MaterialTheme.colorScheme.onErrorContainer, size = 18.dp) + Spacer(Modifier.width(10.dp)) + Text(stringRes(Res.string.browser_pill_download_risky), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onErrorContainer, fontWeight = FontWeight.Medium) + } + } + Spacer(Modifier.height(24.dp)) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { + TextButton(onClick = onDeny) { Text(stringRes(Res.string.browser_pill_cancel)) } + Spacer(Modifier.width(8.dp)) + Button(onClick = onAllow, enabled = armed) { Text(stringRes(Res.string.browser_pill_download_save)) } + } + } +} + +private val ARM_DELAY = 500.milliseconds diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/PageSheets.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/PageSheets.kt index 8c28a74bc7..dff2b52fc3 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/PageSheets.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/PageSheets.kt @@ -137,7 +137,7 @@ fun OriginBadge( /** The card frame every page-initiated prompt shares. */ @Composable -private fun PageCard(content: @Composable () -> Unit) { +internal fun PageCard(content: @Composable () -> Unit) { Surface( shape = RoundedCornerShape(28.dp), color = MaterialTheme.colorScheme.surfaceContainerHigh, diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserChromeHost.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserChromeHost.kt index a781eb4722..2950d2c9d4 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserChromeHost.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserChromeHost.kt @@ -56,6 +56,7 @@ import com.vitorpamplona.amethyst.commons.browser.ui.pill.BrowserPillUi import com.vitorpamplona.amethyst.commons.browser.ui.pill.CertificateInfo 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.DownloadPromptCard import com.vitorpamplona.amethyst.commons.browser.ui.pill.FindInPagePill import com.vitorpamplona.amethyst.commons.browser.ui.pill.PageDialogCard import com.vitorpamplona.amethyst.commons.browser.ui.pill.PageDialogType @@ -130,6 +131,21 @@ class BrowserChromeHost( val answer: (allow: Boolean, remember: Boolean) -> Unit, ) + /** + * A download the page started, waiting for consent before anything is fetched or written into the + * shared Downloads collection. [fileName]/[sizeBytes] (-1 when unknown) describe exactly what would + * be saved; nothing is saved until [answer] runs with true. + */ + class PendingDownload( + val host: String?, + val security: BrowserChrome.Security, + val fileName: String, + val sizeBytes: Long, + val sourceHost: String?, + val risky: Boolean, + val answer: (allow: Boolean) -> Unit, + ) + var ui by mutableStateOf(initial) var expanded by mutableStateOf(false) private set @@ -146,6 +162,7 @@ class BrowserChromeHost( var dialog by mutableStateOf(null) var permissionPrompt by mutableStateOf(null) + var downloadPrompt by mutableStateOf(null) private var pageInfoOpen by mutableStateOf(false) private var accessInfo by mutableStateOf(null) private var certificate by mutableStateOf(null) @@ -345,6 +362,29 @@ class BrowserChromeHost( ) } } + downloadPrompt?.let { pending -> + Dialog(onDismissRequest = { + downloadPrompt = null + pending.answer(false) + }) { + DownloadPromptCard( + host = pending.host, + security = pending.security, + fileName = pending.fileName, + sizeBytes = pending.sizeBytes, + sourceHost = pending.sourceHost, + risky = pending.risky, + onAllow = { + downloadPrompt = null + pending.answer(true) + }, + onDeny = { + downloadPrompt = null + pending.answer(false) + }, + ) + } + } accessInfo?.let { info -> Dialog(onDismissRequest = { accessInfo = null }, properties = DialogProperties(usePlatformDefaultWidth = false)) { Box(Modifier.fillMaxWidth().padding(16.dp), contentAlignment = Alignment.Center) { diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserDownloads.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserDownloads.kt index 6e97344b45..1676f074d2 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserDownloads.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserDownloads.kt @@ -27,17 +27,18 @@ import android.os.Environment import android.os.Handler import android.os.Looper import android.provider.MediaStore -import android.util.Base64 import android.webkit.MimeTypeMap import android.webkit.URLUtil import android.widget.Toast +import androidx.core.net.toUri +import com.vitorpamplona.amethyst.commons.browser.BrowserDownloadRules import com.vitorpamplona.quartz.utils.Log import okhttp3.Request import java.io.File import java.io.OutputStream -import java.net.URLDecoder import java.util.concurrent.Executors import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean import com.vitorpamplona.amethyst.commons.R as CommonsR /** @@ -45,6 +46,12 @@ import com.vitorpamplona.amethyst.commons.R as CommonsR * `blob:` URLs (which only the page can read, so the browser-extras script hands their bytes over) — into * the system Downloads collection, the way Chrome does. * + * A page can start a download without any gesture, so everything it starts arrives as a [DownloadOffer] + * that the surface shows on a consent card; nothing is fetched or written until the user taps Save. That + * covers both entry points: the WebView `DownloadListener` ([offerListenerDownload]) and the + * `browser.download` bridge envelope ([offerInline]), which any top-frame script can post directly. The + * long-press "Download image" item ([download]) is the one ungated path: the user's own tap starts it. + * * Network downloads follow the page's own route: through the Tor SOCKS proxy when the site is on Tor (OkHttp * leaves SOCKS hosts unresolved, so even DNS goes through Tor), directly otherwise. They carry the page's * cookies from its own per-account storage profile and its user agent, so a logged-in download works. @@ -53,14 +60,42 @@ object BrowserDownloads { private const val TAG = "BrowserDownloads" /** Cap for bytes a page hands over for a blob:/data: download (they travel as base64 over the bridge). */ - const val MAX_INLINE_BYTES = 25 * 1024 * 1024 + const val MAX_INLINE_BYTES = BrowserDownloadRules.MAX_INLINE_BYTES private val io = Executors.newSingleThreadExecutor { Thread(it, "napplet-downloads").apply { isDaemon = true } } + + // Decoding an inline payload (up to 25 MiB) is kept off the main thread, and off [io] so a long + // transfer already running there doesn't hold the next consent card back. + private val decoder = Executors.newSingleThreadExecutor { Thread(it, "napplet-download-decode").apply { isDaemon = true } } private val main = Handler(Looper.getMainLooper()) /** - * A WebView `DownloadListener` hit. [cookie] must be read on the main thread (from the tab's own - * profile) before calling; the transfer itself runs on a background thread. + * A page-initiated download, resolved to what its consent card shows: [save] stores one file named + * [fileName], typed by that name's extension (so the system can't append a different one), and until + * it runs no byte has been fetched or written. [save] runs at most once, however often it is called. + */ + class DownloadOffer internal constructor( + val fileName: String, + /** The size in bytes: exact for inline data, the server's advertised length otherwise; -1 when unknown. */ + val sizeBytes: Long, + /** The host a network download is fetched from (it can differ from the page's), or null for inline data. */ + val sourceHost: String?, + private val start: (Context) -> Unit, + ) { + private val started = AtomicBoolean(false) + + /** Whether [fileName] is something that can be installed or run, which the card warns about. */ + val risky: Boolean get() = BrowserDownloadRules.isRisky(fileName) + + fun save(context: Context) { + if (started.compareAndSet(false, true)) start(context.applicationContext) + } + } + + /** + * The long-press "Download image" item: the user's tap is the gesture, so it saves without a card. + * [cookie] must be read on the main thread (from the tab's own profile) before calling; the transfer + * itself runs on a background thread. */ fun download( context: Context, @@ -76,8 +111,85 @@ object BrowserDownloads { saveDataUrl(app, url, null) return } - if (!url.startsWith("https://", ignoreCase = true) && !url.startsWith("http://", ignoreCase = true)) return - val name = URLUtil.guessFileName(url, contentDisposition, mimeType) + if (!isHttp(url)) return + startNetworkDownload(app, url, networkName(url, contentDisposition, mimeType), userAgent, cookie, proxyPort) + } + + /** + * A WebView `DownloadListener` hit: a download the page started. Calls [onReady] exactly once, on the + * main thread, with the offer for the consent card (null when the URL can't be saved). [contentLength] is + * the size the listener reported (<= 0 when unknown); [cookie] must be read on the main thread from + * the tab's own profile. Nothing is fetched until the offer's save runs. + */ + fun offerListenerDownload( + url: String, + userAgent: String?, + contentDisposition: String?, + mimeType: String?, + contentLength: Long, + cookie: String?, + proxyPort: Int, + onReady: (DownloadOffer?) -> Unit, + ) { + if (url.startsWith("data:", ignoreCase = true)) { + offerInline(url, null, onReady) + return + } + if (!isHttp(url)) { + onReady(null) + return + } + val name = networkName(url, contentDisposition, mimeType) + onReady( + DownloadOffer(name, contentLength.takeIf { it > 0 } ?: -1L, url.toUri().host) { app -> + startNetworkDownload(app, url, name, userAgent, cookie, proxyPort) + }, + ) + } + + /** + * A page's inline download (a `browser.download` envelope's `data:` URL). Decodes it off the main + * thread, then calls [onReady] exactly once, on the main thread, with the offer (its size is the true + * decoded length), or null when the payload is malformed or oversized and is refused without a card. + */ + fun offerInline( + dataUrl: String, + suggestedName: String?, + onReady: (DownloadOffer?) -> Unit, + ) { + decoder.execute { + // Throwable, not Exception: an OutOfMemoryError here must refuse the download, not kill the + // process (and every tab in it) or leave the caller waiting on a callback that never comes. + val offer = + try { + BrowserDownloadRules.decodeDataUrl(dataUrl)?.let { data -> + val name = safeName(suggestedName, data.mimeType) + DownloadOffer(name, data.bytes.size.toLong(), null) { app -> writeInBackground(app, name, data.bytes) } + } + } catch (e: Throwable) { + Log.w(TAG, "Inline download refused", e) + null + } + main.post { onReady(offer) } + } + } + + private fun isHttp(url: String) = url.startsWith("https://", ignoreCase = true) || url.startsWith("http://", ignoreCase = true) + + private fun networkName( + url: String, + contentDisposition: String?, + mimeType: String?, + ) = safeName(URLUtil.guessFileName(url, contentDisposition, mimeType), mimeType) + + private fun startNetworkDownload( + app: Context, + url: String, + name: String, + userAgent: String?, + cookie: String?, + proxyPort: Int, + ) { toast(app, app.getString(CommonsR.string.browser_download_started, name)) io.execute { val ok = @@ -101,8 +213,7 @@ object BrowserDownloads { .build() client.newCall(request).execute().use { response -> if (!response.isSuccessful) error("HTTP ${response.code}") - val type = mimeType?.takeIf { it.isNotBlank() && it != "application/octet-stream" } ?: response.body.contentType()?.let { "${it.type}/${it.subtype}" } - write(app, name, type) { out -> response.body.byteStream().use { it.copyTo(out) } } + write(app, name) { out -> response.body.byteStream().use { it.copyTo(out) } } } }.onFailure { Log.w(TAG, "Download failed for $url", it) } .getOrDefault(false) @@ -110,39 +221,26 @@ object BrowserDownloads { } } - /** Saves a `data:` URL (`data:[mime][;base64],payload`). */ - fun saveDataUrl( - context: Context, + /** Saves a `data:` URL (`data:[mime][;base64],payload`) the user asked for directly, decoded off the main thread. */ + private fun saveDataUrl( + app: Context, dataUrl: String, suggestedName: String?, ) { - val app = context.applicationContext - val header = dataUrl.substringBefore(',', "") - val payload = dataUrl.substringAfter(',', "") - val mime = header.removePrefix("data:").substringBefore(';').ifBlank { "application/octet-stream" } - val bytes = - runCatching { - if (header.endsWith(";base64", ignoreCase = true)) { - Base64.decode(payload, Base64.DEFAULT) - } else { - URLDecoder.decode(payload, "UTF-8").toByteArray() - } - }.getOrNull() ?: return - saveBytes(app, suggestedName, mime, bytes) + decoder.execute { + val data = runCatching { BrowserDownloadRules.decodeDataUrl(dataUrl) }.getOrNull() ?: return@execute + writeInBackground(app, safeName(suggestedName, data.mimeType), data.bytes) + } } - /** Saves bytes a page handed over (a `blob:` download, via the browser-extras script). */ - fun saveBytes( - context: Context, - suggestedName: String?, - mimeType: String?, + private fun writeInBackground( + app: Context, + name: String, bytes: ByteArray, ) { if (bytes.size > MAX_INLINE_BYTES) return - val app = context.applicationContext - val name = safeName(suggestedName, mimeType) io.execute { - val ok = runCatching { write(app, name, mimeType) { it.write(bytes) } }.getOrDefault(false) + val ok = runCatching { write(app, name) { it.write(bytes) } }.getOrDefault(false) toast(app, app.getString(if (ok) CommonsR.string.browser_download_saved else CommonsR.string.browser_download_failed, name)) } } @@ -155,7 +253,6 @@ object BrowserDownloads { private fun write( context: Context, name: String, - mimeType: String?, body: (OutputStream) -> Unit, ): Boolean { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { @@ -163,7 +260,9 @@ object BrowserDownloads { val values = ContentValues().apply { put(MediaStore.Downloads.DISPLAY_NAME, name) - mimeType?.let { put(MediaStore.Downloads.MIME_TYPE, it) } + // Typed by the approved name alone: a page- or server-supplied type that disagrees with + // the extension would make MediaStore append its own ("invoice.pdf" -> "invoice.pdf.apk"). + put(MediaStore.Downloads.MIME_TYPE, mimeTypeOf(name)) put(MediaStore.Downloads.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS) put(MediaStore.Downloads.IS_PENDING, 1) } @@ -183,23 +282,28 @@ object BrowserDownloads { return true } - /** A plain filename: the page's suggestion without path parts, else "download" + the MIME's extension. */ + /** + * The file name to save under: the page's suggestion made safe ([BrowserDownloadRules.safeFileName]), + * else "download" + the MIME's extension. + */ private fun safeName( suggested: String?, mimeType: String?, ): String { - val base = - suggested - ?.substringAfterLast('/') - ?.substringAfterLast('\\') - ?.replace(Regex("[\\u0000-\\u001f:*?\"<>|]"), "_") - ?.trim() - ?.takeIf { it.isNotEmpty() && it != "." && it != ".." } - if (base != null) return base.take(120) + BrowserDownloadRules.safeFileName(suggested)?.let { return it } val ext = mimeType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } return if (ext != null) "download.$ext" else "download" } + /** The MIME type [name]'s extension implies, or octet-stream, which MediaStore stores under the name as given. */ + private fun mimeTypeOf(name: String): String = + name + .substringAfterLast('.', "") + .lowercase() + .takeIf { it.isNotEmpty() } + ?.let { MimeTypeMap.getSingleton().getMimeTypeFromExtension(it) } + ?: "application/octet-stream" + private fun toast( context: Context, text: String, diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserExtrasScript.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserExtrasScript.kt index acb3b06ec1..cbb8c7246b 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserExtrasScript.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserExtrasScript.kt @@ -29,7 +29,9 @@ package com.vitorpamplona.amethyst.napplethost * - `` — reported (`browser.themeColor`, normalized to `rgb(r, g, b)`) whenever it * or the colour scheme changes, so the window can tint its system bars and Recents entry. * - `blob:` / `data:` downloads — only the page can read a `blob:` URL, so a click on (or a programmatic - * `.click()` of) an `` pointing at one is turned into its bytes (`browser.download`). + * `.click()` of) an `` pointing at one is turned into its bytes (`browser.download`). The + * native side treats it as a request: the user confirms every save on a consent card, so a page that + * posts the envelope itself gains nothing over using the script. * * Messages travel over the same origin-scoped native bridge as NIP-07; the host handles `browser.*` types * itself and never forwards them to the broker. diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt index 4525300d1b..38d2c5503b 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt @@ -84,6 +84,7 @@ import com.vitorpamplona.amethyst.commons.browser.BrowserChrome import com.vitorpamplona.amethyst.commons.browser.BrowserChrome.Action import com.vitorpamplona.amethyst.commons.browser.BrowserSitePermission import com.vitorpamplona.amethyst.commons.browser.BrowserSitePermission.Decision +import com.vitorpamplona.amethyst.commons.browser.DownloadCooldown import com.vitorpamplona.amethyst.commons.browser.OmniboxInput import com.vitorpamplona.amethyst.commons.browser.ui.pill.BrowserPillEvent import com.vitorpamplona.amethyst.commons.browser.ui.pill.BrowserPillUi @@ -225,6 +226,8 @@ class NappletBrowserActivity : ComponentActivity() { private val originTokens = mutableMapOf() private val pendingByOrigin = mutableMapOf>() private val mintInFlight = mutableSetOf() + private val downloadCooldown = DownloadCooldown() + private var preparingDownload = false /** * Back walks out of fullscreen video, then the find bar, then the page's history, then leaves. Enabled @@ -361,8 +364,20 @@ class NappletBrowserActivity : ComponentActivity() { wv.webViewClient = BrowserClient() wv.webChromeClient = BrowserChromeClient() wv.setFindListener { active, total, _ -> chrome?.setFindResult(active, total) } - wv.setDownloadListener { url, userAgent, contentDisposition, mimeType, _ -> - BrowserDownloads.download(this, url, userAgent, contentDisposition, mimeType, BrowserWebTools.cookieManager(wv).getCookie(url), if (useTor) proxyPort else -1) + wv.setDownloadListener { url, userAgent, contentDisposition, mimeType, contentLength -> + // The page's origin; a fresh popup still on about:blank has none, so name the file's own. + val origin = + chrome + ?.ui + ?.chrome + ?.url + ?.let(BrowserChrome::originOf) ?: BrowserChrome.originOf(url) ?: return@setDownloadListener + if (!canOfferDownload(origin)) return@setDownloadListener + // Read on the main thread: the cookie jar is the tab's own per-account WebView profile. + val cookie = BrowserWebTools.cookieManager(wv).getCookie(url) + prepareDownloadOffer(origin) { ready -> + BrowserDownloads.offerListenerDownload(url, userAgent, contentDisposition, mimeType, contentLength, cookie, if (useTor) proxyPort else -1, ready) + } } wv.setBackgroundColor(resolveThemeColor(android.R.attr.colorBackground)) wv.dropSystemBarInsets() @@ -423,6 +438,7 @@ class NappletBrowserActivity : ComponentActivity() { // A dialog still up would leak its window and leave the page's JS blocked on an unanswered result. chrome?.dialog?.answer?.invoke(false, null, false) chrome?.permissionPrompt?.answer?.invoke(false, false) + chrome?.downloadPrompt?.answer?.invoke(false) customViewCallback?.onCustomViewHidden() destroyWebView() super.onDestroy() @@ -892,15 +908,27 @@ class NappletBrowserActivity : ComponentActivity() { val raw = message.data ?: return val envelope = parseJsonObjectOrNull(raw) ?: return - // Browser conveniences (share, theme colour, blob downloads) are handled here, never brokered. - if (envelope.stringOrNull("type").orEmpty().startsWith("browser.")) { - onBrowserMessage(envelope) - return - } + // The origin the WebView reports, which the page can't forge, keys every decision below. + val origin = trustedOrigin(sourceOrigin) ?: return - val scheme = sourceOrigin.scheme ?: return - val host = sourceOrigin.host ?: return - val origin = "$scheme://$host" + if (sourceOrigin.port > 0) ":${sourceOrigin.port}" else "" + // Browser conveniences (share, theme colour, blob downloads) are handled here, never brokered. + // A download still asks the user first ([offerInlineDownload]): any top-frame script can post one. + when (envelope.stringOrNull("type")) { + "browser.share" -> { + if (resumed) BrowserWebTools.share(this, envelope.stringOrNull("title"), envelope.stringOrNull("text"), envelope.stringOrNull("url")) + return + } + "browser.themeColor" -> { + themeColor = BrowserChrome.parseCssRgb(envelope.stringOrNull("color")) + applyThemeColor(themeColor) + updateTaskDescription() + return + } + "browser.download" -> { + offerInlineDownload(origin, envelope) + return + } + } val id = envelope.stringOrNull("id").orEmpty().ifEmpty { "fire-${fireSeq++}" } val msg = @@ -923,27 +951,69 @@ class NappletBrowserActivity : ComponentActivity() { } } - /** A `browser.*` message from [BrowserExtrasScript]. */ - private fun onBrowserMessage(envelope: JsonObject) { - when (envelope.stringOrNull("type")) { - "browser.share" -> - if (resumed) { - BrowserWebTools.share(this, envelope.stringOrNull("title"), envelope.stringOrNull("text"), envelope.stringOrNull("url")) - } - "browser.themeColor" -> { - themeColor = BrowserChrome.parseCssRgb(envelope.stringOrNull("color")) - applyThemeColor(themeColor) - updateTaskDescription() - } - "browser.download" -> { - val data = envelope.stringOrNull("data") ?: return - if (data.startsWith("data:") && data.length <= BrowserDownloads.MAX_INLINE_BYTES / 3 * 4 + 256) { - BrowserDownloads.saveDataUrl(this, data, envelope.stringOrNull("name")) - } - } + /** `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 + val host = sourceOrigin.host ?: return null + return "$scheme://$host" + if (sourceOrigin.port > 0) ":${sourceOrigin.port}" else "" + } + + /** + * A `browser.download` envelope: the bytes a page wants saved (a `blob:` download the extras script + * read, or one a script forged). Keyed on the WebView-reported [origin], never an envelope field. + */ + private fun offerInlineDownload( + origin: String, + envelope: JsonObject, + ) { + if (!canOfferDownload(origin)) return + val data = envelope.stringOrNull("data") ?: return + prepareDownloadOffer(origin) { ready -> BrowserDownloads.offerInline(data, envelope.stringOrNull("name"), ready) } + } + + /** + * Whether [origin] may put a download card up now: never over another page prompt (so a page can't swap + * the name under the user's finger, or pop the card where a dialog's button just was), never while an + * offer is still being prepared (so a page can't queue decodes), and not within its cooldown. + */ + private fun canOfferDownload(origin: String): Boolean { + val host = chrome ?: return false + return host.downloadPrompt == null && host.dialog == null && host.permissionPrompt == null && !preparingDownload && downloadCooldown.allows(origin) + } + + /** Runs [prepare] (which answers exactly once, on the main thread) and shows the offer it produces. */ + private fun prepareDownloadOffer( + origin: String, + prepare: ((BrowserDownloads.DownloadOffer?) -> Unit) -> Unit, + ) { + preparingDownload = true + prepare { offer -> + preparingDownload = false + if (offer != null) showDownloadOffer(origin, offer) } } + /** Shows [offer]'s consent card; the file is fetched or written only if the user taps Save. */ + private fun showDownloadOffer( + origin: String, + offer: BrowserDownloads.DownloadOffer, + ) { + val host = chrome ?: return + if (isDestroyed || !canOfferDownload(origin)) return + host.downloadPrompt = + BrowserChromeHost.PendingDownload( + host = BrowserChrome.displayHost(origin), + security = BrowserChrome.security(host.ui.chrome), + fileName = offer.fileName, + sizeBytes = offer.sizeBytes, + sourceHost = offer.sourceHost, + risky = offer.risky, + ) { allowed -> + downloadCooldown.answered(origin, allowed) + if (allowed) offer.save(this) + } + } + private fun requestBrowserToken(origin: String) { if (!mintInFlight.add(origin)) return val msg = @@ -1652,6 +1722,7 @@ class NappletBrowserActivity : ComponentActivity() { companion object { private const val TAG = "NappletBrowserActivity" + 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). */ diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserContract.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserContract.kt index f814830628..7b42f651d9 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserContract.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserContract.kt @@ -184,6 +184,22 @@ object NappletBrowserContract { /** Leave HTML fullscreen (the user pressed back). */ const val MSG_EXIT_FULLSCREEN = 33 + /** + * Provider → client: a download the page started (an attachment, ``, or an inline + * `browser.download`) needs the user's consent before anything is fetched or written into the shared + * Downloads collection. Carries [KEY_DOWNLOAD_ID], the WebView-reported [KEY_BROWSER_ORIGIN] (never a + * page-supplied field), the sanitized [KEY_DOWNLOAD_NAME], [KEY_DOWNLOAD_SIZE], [KEY_DOWNLOAD_SOURCE], and + * [KEY_DOWNLOAD_RISKY] (an install-or-script-like extension). Answered with + * [MSG_DOWNLOAD_CONSENT_RESULT] on every outcome; the bytes themselves never leave the `:napplet` process. + */ + const val MSG_DOWNLOAD_CONSENT = 34 + + /** Client → provider: the user's answer to [MSG_DOWNLOAD_CONSENT]: [KEY_DOWNLOAD_ID] + [KEY_DOWNLOAD_ALLOWED]. */ + const val MSG_DOWNLOAD_CONSENT_RESULT = 35 + + /** Provider → client: withdraw the [MSG_DOWNLOAD_CONSENT] card [KEY_DOWNLOAD_ID] (its tab closed). */ + const val MSG_DOWNLOAD_CANCEL = 36 + const val KEY_CAN_GO_FORWARD = "canGoForward" const val KEY_FIND_QUERY = "findQuery" const val KEY_FIND_FORWARD = "findForward" @@ -206,6 +222,24 @@ object NappletBrowserContract { const val KEY_PERMISSIONS = "permissions" const val KEY_BROWSER_ORIGIN = "browserOrigin" + /** Correlates a [MSG_DOWNLOAD_CONSENT] prompt with its [MSG_DOWNLOAD_CONSENT_RESULT] answer. */ + const val KEY_DOWNLOAD_ID = "downloadId" + + /** The sanitized file name the consented download will be stored under (already path/control-char stripped). */ + const val KEY_DOWNLOAD_NAME = "downloadName" + + /** The size in bytes the consented download will write, or -1 when the server didn't say. */ + const val KEY_DOWNLOAD_SIZE = "downloadSize" + + /** The host a network download is fetched from (absent for inline data), shown when it isn't the page's. */ + const val KEY_DOWNLOAD_SOURCE = "downloadSource" + + /** Whether [KEY_DOWNLOAD_NAME]'s extension is one the user should double-check (installer/script-like). */ + const val KEY_DOWNLOAD_RISKY = "downloadRisky" + + /** The user's answer in [MSG_DOWNLOAD_CONSENT_RESULT]: true = save, false = discard. */ + const val KEY_DOWNLOAD_ALLOWED = "downloadAllowed" + const val KEY_FILE_CHOOSER_ID = "fileChooserId" const val KEY_FILE_CHOOSER_ACCEPT = "fileChooserAccept" const val KEY_FILE_CHOOSER_MULTIPLE = "fileChooserMultiple" diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserService.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserService.kt index e58e8a861e..dcd898d7df 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserService.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserService.kt @@ -65,6 +65,7 @@ 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.NappletWebContract import com.vitorpamplona.amethyst.commons.util.parseJsonObjectOrNull @@ -146,6 +147,11 @@ class NappletBrowserService : Service() { val pendingByOrigin = mutableMapOf>() val mintInFlight = mutableSetOf() + // Page-initiated downloads: how often each site may ask, and whether an offer is being prepared + // (decoded) — one at a time, so a page can't queue up decodes. + val downloadCooldown = DownloadCooldown() + var preparingDownload = false + val replyMessenger = Messenger(Handler(Looper.getMainLooper()) { onBrokerReply(this, it) }) } @@ -154,6 +160,17 @@ class NappletBrowserService : Service() { // WebView's PermissionRequest → our relay id, so a page's cancellation can withdraw the prompt. private val pendingWebPermissions = mutableMapOf() + // Download consent cards the main process is showing, by relay id, until the client answers or the + // tab closes. The offer holds the decoded bytes (or the URL to fetch) the card describes. + private class PendingDownload( + val sessionId: String, + val origin: String, + val offer: BrowserDownloads.DownloadOffer, + ) + + private val pendingDownloads = mutableMapOf() + private var downloadSeq = 0L + // The shim never changes; read+decode it once instead of per tab on the main thread. private val shimJs: String by lazy { readContractAsset(NappletWebContract.SHIM_JS_PATH).decodeToString() } @@ -295,6 +312,14 @@ class NappletBrowserService : Service() { .toSet(), ) } + NappletBrowserContract.MSG_DOWNLOAD_CONSENT_RESULT -> { + val data = msg.data ?: return true + val pending = pendingDownloads.remove(data.getLong(NappletBrowserContract.KEY_DOWNLOAD_ID)) ?: return true + val tab = tabs[pending.sessionId] ?: return true + val allowed = data.getBoolean(NappletBrowserContract.KEY_DOWNLOAD_ALLOWED, false) + tab.downloadCooldown.answered(pending.origin, allowed) + if (allowed) pending.offer.save(this) + } NappletBrowserContract.MSG_EXIT_FULLSCREEN -> tabFor(msg)?.let { exitFullscreen(it) } NappletBrowserContract.MSG_RELOAD -> tabFor(msg)?.webView?.reload() NappletBrowserContract.MSG_BACK -> tabFor(msg)?.webView?.let { if (it.canGoBack()) it.goBack() } @@ -446,6 +471,12 @@ class NappletBrowserService : Service() { // 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.remove(id) + sendToClient(tab, NappletBrowserContract.MSG_DOWNLOAD_CANCEL) { putLong(NappletBrowserContract.KEY_DOWNLOAD_ID, id) } + } tab.customViewCallback?.onCustomViewHidden() tab.customViewCallback = null tab.customView = null @@ -461,9 +492,16 @@ class NappletBrowserService : Service() { BrowserWebTools.applyBrowserSettings(wv) wv.webViewClient = BrowserClient(tab) wv.webChromeClient = BrowserChromeClient(tab) - wv.setDownloadListener { url, userAgent, contentDisposition, mimeType, _ -> - val route = if (tab != null && tab.useTor) tab.proxyPort else -1 - BrowserDownloads.download(this, url, userAgent, contentDisposition, mimeType, BrowserWebTools.cookieManager(wv).getCookie(url), route) + wv.setDownloadListener { url, userAgent, contentDisposition, mimeType, contentLength -> + if (tab == null) return@setDownloadListener + // The page's origin; a fresh popup still on about:blank has none, so name the file's own. + val origin = wv.url?.let(BrowserChrome::originOf) ?: BrowserChrome.originOf(url) ?: return@setDownloadListener + if (!canOfferDownload(tab, origin)) return@setDownloadListener + // Read on the main thread: the cookie jar is the tab's own per-account WebView profile. + val cookie = BrowserWebTools.cookieManager(wv).getCookie(url) + prepareDownloadOffer(tab, origin) { ready -> + BrowserDownloads.offerListenerDownload(url, userAgent, contentDisposition, mimeType, contentLength, cookie, if (tab.useTor) tab.proxyPort else -1, ready) + } } } @@ -953,21 +991,22 @@ class NappletBrowserService : Service() { val raw = message.data ?: return val envelope = parseJsonObjectOrNull(raw) ?: return - // Browser conveniences (share, blob downloads) are handled here, never brokered. The theme colour - // only matters to a window with system bars, which an embedded tab doesn't own. + // The origin the WebView reports, which the page can't forge, keys every decision below. + val origin = trustedOrigin(sourceOrigin) ?: return + + // Browser conveniences (share, blob downloads) are handled here, never brokered; a download still + // asks the user first ([offerInlineDownload]), since any top-frame script can post one. The theme + // colour only matters to a window with system bars, which an embedded tab doesn't own. when (envelope.stringOrNull("type")) { "browser.share" -> { BrowserWebTools.share(this, envelope.stringOrNull("title"), envelope.stringOrNull("text"), envelope.stringOrNull("url")) return } + "browser.themeColor" -> return "browser.download" -> { - val data = envelope.stringOrNull("data") ?: return - if (data.startsWith("data:") && data.length <= BrowserDownloads.MAX_INLINE_BYTES / 3 * 4 + 256) { - BrowserDownloads.saveDataUrl(this, data, envelope.stringOrNull("name")) - } + offerInlineDownload(tab, origin, envelope) return } - "browser.themeColor" -> return } // IME events aren't brokered — the main app hosts the keyboard. Relay the envelope to the client. @@ -980,10 +1019,6 @@ class NappletBrowserService : Service() { return } - val scheme = sourceOrigin.scheme ?: return - val host = sourceOrigin.host ?: return - val origin = "$scheme://$host" + if (sourceOrigin.port > 0) ":${sourceOrigin.port}" else "" - val id = envelope.stringOrNull("id").orEmpty().ifEmpty { "fire-${tab.fireSeq++}" } val msg = Message.obtain(null, NappletIpc.MSG_REQUEST).apply { @@ -1005,6 +1040,78 @@ class NappletBrowserService : Service() { } } + /** `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 + val host = sourceOrigin.host ?: return null + return "$scheme://$host" + if (sourceOrigin.port > 0) ":${sourceOrigin.port}" else "" + } + + /** + * A `browser.download` envelope: the bytes a page wants saved (a `blob:` download the extras script + * read, or one a script forged). Keyed on the WebView-reported [origin], never an envelope field. + */ + private fun offerInlineDownload( + tab: BrowserTab, + origin: String, + envelope: JsonObject, + ) { + if (!canOfferDownload(tab, origin)) return + val data = envelope.stringOrNull("data") ?: return + prepareDownloadOffer(tab, origin) { ready -> BrowserDownloads.offerInline(data, envelope.stringOrNull("name"), ready) } + } + + /** + * Whether [origin] may put a download card up in [tab] now: never over another of the tab's page prompts + * (so a page can't swap the name under the user's finger, or pop the card where a dialog's button just + * was), never while an offer is still being prepared (so a page can't queue decodes), and not within + * its cooldown. + */ + private fun canOfferDownload( + tab: BrowserTab, + origin: String, + ) = !tab.preparingDownload && + tab.jsDialogs.isEmpty() && + tab.permissionRequests.isEmpty() && + pendingDownloads.values.none { it.sessionId == tab.sessionId } && + tab.downloadCooldown.allows(origin) + + /** Runs [prepare] (which answers exactly once, on the main thread) and relays the offer it produces. */ + private fun prepareDownloadOffer( + tab: BrowserTab, + origin: String, + prepare: ((BrowserDownloads.DownloadOffer?) -> Unit) -> Unit, + ) { + tab.preparingDownload = true + prepare { offer -> + tab.preparingDownload = false + if (offer != null) showDownloadOffer(tab, origin, offer) + } + } + + /** + * Asks the main process to show [offer]'s consent card (this provider has no window of its own); the + * file is fetched or written only if the user taps Save there. + */ + private fun showDownloadOffer( + tab: BrowserTab, + origin: String, + offer: BrowserDownloads.DownloadOffer, + ) { + if (tabs[tab.sessionId] !== tab || !canOfferDownload(tab, origin)) return + val id = ++downloadSeq + val sent = + sendToClient(tab, NappletBrowserContract.MSG_DOWNLOAD_CONSENT) { + putLong(NappletBrowserContract.KEY_DOWNLOAD_ID, id) + putString(NappletBrowserContract.KEY_BROWSER_ORIGIN, origin) + putString(NappletBrowserContract.KEY_DOWNLOAD_NAME, offer.fileName) + putLong(NappletBrowserContract.KEY_DOWNLOAD_SIZE, offer.sizeBytes) + putString(NappletBrowserContract.KEY_DOWNLOAD_SOURCE, offer.sourceHost) + putBoolean(NappletBrowserContract.KEY_DOWNLOAD_RISKY, offer.risky) + } + if (sent) pendingDownloads[id] = PendingDownload(tab.sessionId, origin, offer) + } + private fun requestBrowserToken( tab: BrowserTab, origin: String,