Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
3 changes: 3 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,6 @@ docs/validation/offline-workflows/** -text -whitespace

# Preserve context workflow receipts and native XML bytes.
docs/validation/context-workflows/** -text -whitespace

# Preserve command handoff evidence and native capture bytes.
docs/validation/command-handoff/** -text -whitespace

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion app/src/main/java/dev/tinycast/android/PaletteUi.kt
Original file line number Diff line number Diff line change
Expand Up @@ -72,9 +72,9 @@ fun TinycastApp(store: PaletteStore, globalEnabled: Boolean, run: (Command) -> U
// Queued Back/Cancel callbacks from an older surface cannot dismiss
// a newer incoming review or a replacement command.
if (store.dialog != route) return
if (route in setOf("extension-run", "extension-setup") && store.extensions.launchEpoch != extensionEpoch) return
if (reviewOwner != null) {
if (store.textReview !== reviewOwner) return
if (route in setOf("extension-run", "extension-setup") && store.extensions.launchEpoch != extensionEpoch) return
if (store.returnTextReview(reviewOwner)) dismiss()
return
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,9 +97,17 @@ class ExtensionCatalog(private val context: Context) {
@Volatile private var invocationOwner: Any = Any()
private var runtimeStop: Pair<Any, () -> Unit>? = null
private var commandText: CommandTextInput? = null
private var invocationLaunchProperties = ordinaryLaunchProperties(emptyMap())
private var commandHandoffDepth = 0
private var preparedCommandLaunch: PreparedCommandLaunch? = null
private class PreparedText(val preview: TextInputPreview, val target: TextInputTarget, val epoch: Int)
private var preparedText: PreparedText? = null
internal class RuntimeText(val owner: Any, val target: TextInputTarget, val input: CommandTextInput?)
internal class RuntimeText(val owner: Any, val target: TextInputTarget, val input: CommandTextInput?, val launchProperties: String)
internal class PreparedCommandLaunch internal constructor(
val owner: Any, val extension: InstalledExtension, val source: ExtensionCommand, val target: ExtensionCommand,
val epoch: Int, val preferenceEpoch: Int, val sourceDefinition: String, val targetDefinition: String,
val launchProperties: String, val depth: Int,
)

suspend fun search(query: String, page: Int = 1): List<StoreExtension> = withContext(Dispatchers.IO) {
val q = URLEncoder.encode(query, "UTF-8")
Expand Down Expand Up @@ -166,11 +174,15 @@ class ExtensionCatalog(private val context: Context) {
reviewedText.invalidate(); preparedText = null
begin(extension,command,args,Any(),null)
}
private fun begin(extension: InstalledExtension, command: ExtensionCommand, args: Map<String,String>, owner: Any, input: CommandTextInput?) {
private fun ordinaryLaunchProperties(args: Map<String, String>) = JSONObject().put("arguments", JSONObject(args)).put("launchType", "userInitiated").toString()
private fun begin(extension: InstalledExtension, command: ExtensionCommand, args: Map<String,String>, owner: Any, input: CommandTextInput?,
launchProperties: String = ordinaryLaunchProperties(args), handoffDepth: Int = 0) {
stopRuntime(owner)
preparedCommandLaunch = null
commandText?.let(reviewedText::release)
commandText = input
running = extension to command; arguments = args; runtimeTree = null; runtimeError = null; runtimeStatus = "Starting extension…"; runtimeReady = false; navigationDepth = 1; launchEpoch++
invocationLaunchProperties = launchProperties; commandHandoffDepth = handoffDepth
running = extension to command; arguments = args.toMap(); runtimeTree = null; runtimeError = null; runtimeStatus = "Starting extension…"; runtimeReady = false; navigationDepth = 1; launchEpoch++
}
internal fun launchReviewed(extension: InstalledExtension, command: ExtensionCommand, preview: TextInputPreview, needsSetup: Boolean) {
val target = TextInputTarget(extension.listing.id,command.name)
Expand Down Expand Up @@ -210,8 +222,55 @@ class ExtensionCatalog(private val context: Context) {
return true
}
internal fun captureText(extension: InstalledExtension, command: ExtensionCommand): RuntimeText {
val input = if (running?.first === extension && running?.second === command) commandText else null
return RuntimeText(invocationOwner,TextInputTarget(extension.listing.id,command.name),input)
val current = running?.first === extension && running?.second === command
return RuntimeText(invocationOwner,TextInputTarget(extension.listing.id,command.name),if (current) commandText else null,
if (current) invocationLaunchProperties else ordinaryLaunchProperties(emptyMap()))
}
private fun requireLaunchFile(extension: InstalledExtension, command: ExtensionCommand) {
val directory = extension.directory.canonicalFile
require(directory.parentFile == root.canonicalFile && directory.name == extension.listing.id) { "The target extension is no longer installed" }
val script = File(directory, "${command.name}.js")
require(script.canonicalFile == script.absoluteFile && script.isFile && script.canRead() && script.length() <= 16L * 1024 * 1024) {
"The target command is unavailable; reinstall the extension before launching it"
}
}
internal fun prepareCommandLaunch(owner: Any, extension: InstalledExtension, source: ExtensionCommand,
request: ExtensionCommandLaunch): PreparedCommandLaunch {
check(ownsRuntime(owner, extension, source) && runtimeError == null) { "This command session has ended" }
preparedCommandLaunch = null
check(busy == null && installed.any { it === extension }) { "Wait for extension installation to finish before launching a command" }
check(extension.commands.any { it.name == source.name && it.definition === source.definition }) { "The source command has changed" }
val target = extension.commands.filter { it.name == request.name }.singleOrNull()
?: error("The requested command is unavailable in this extension")
require(target.mode in listOf("view", "no-view")) { "The target command requires an unsupported desktop mode" }
require(!target.definition.optBoolean("disabled") && target.definition.opt("enabled") != false) { "The target command is disabled" }
require(commandHandoffDepth < 16) { "The command launch chain reached its Android limit" }
requireLaunchFile(extension, target)
ExtensionCommandLaunch.requireConfigured(extension.fields(target), preferences(extension, target))
val prepared = PreparedCommandLaunch(owner, extension, source, target, launchEpoch, preferencesEpoch,
source.definition.toString(), target.definition.toString(), request.launchProperties(target.definition), commandHandoffDepth + 1)
preparedCommandLaunch = prepared
return prepared
}
internal fun commitCommandLaunch(prepared: PreparedCommandLaunch): Boolean {
if (preparedCommandLaunch !== prepared) return false
preparedCommandLaunch = null // Every commit attempt consumes its reservation.
if (!ownsRuntime(prepared.owner, prepared.extension, prepared.source) || runtimeError != null || busy != null ||
launchEpoch != prepared.epoch || preferencesEpoch != prepared.preferenceEpoch || commandHandoffDepth + 1 != prepared.depth ||
installed.none { it === prepared.extension }) return false
if (prepared.extension.commands.none { it.name == prepared.source.name && it.definition === prepared.source.definition &&
it.definition.toString() == prepared.sourceDefinition }) return false
val target = prepared.extension.commands.filter { it.name == prepared.target.name }.singleOrNull() ?: return false
if (target.definition !== prepared.target.definition || target.definition.toString() != prepared.targetDefinition) return false
requireLaunchFile(prepared.extension, target)
ExtensionCommandLaunch.requireConfigured(prepared.extension.fields(target), preferences(prepared.extension, target))
// Context is explicit output of the approved parent. Its raw input lease
// and pending approval are never inherited by the new invocation.
val arguments = JSONObject(prepared.launchProperties).getJSONObject("arguments")
val values = arguments.keys().asSequence().associateWith { arguments.getString(it) }
reviewedText.invalidate(); preparedText = null
begin(prepared.extension, prepared.target, values, Any(), null, prepared.launchProperties, prepared.depth)
return true
}
internal fun ownsRuntime(owner: Any, extension: InstalledExtension, command: ExtensionCommand): Boolean =
invocationOwner === owner && running?.first === extension && running?.second === command
Expand All @@ -235,6 +294,7 @@ class ExtensionCatalog(private val context: Context) {
private fun revokeReviewedText() {
stopRuntime(Any())
reviewedText.invalidate(); commandText = null; preparedText = null
preparedCommandLaunch = null; invocationLaunchProperties = ordinaryLaunchProperties(emptyMap()); commandHandoffDepth = 0
}
fun close() { revokeReviewedText(); running = null; runtimeTree = null; runtimeError = null; runtimeStatus = ""; runtimeReady = false }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
package dev.tinycast.android.extensions

import org.json.JSONArray
import org.json.JSONException
import org.json.JSONObject

/** A copied, bounded foreground launch request. It contains no input grant. */
internal class ExtensionCommandLaunch private constructor(
val name: String,
private val arguments: Map<String, String>,
private val contextJson: String?,
private val fallbackText: String?,
) {
fun argumentsFor(definition: JSONObject): Map<String, String> {
val fields = definition.optJSONArray("arguments").objects()
require(fields.size <= MAX_ARGUMENTS) { "This command has too many arguments for an Android launch" }
val names = fields.map { it.getString("name") }
require(names.distinct().size == names.size && names.all { it.isNotBlank() && it.length <= MAX_KEY_UNITS }) {
"This command has unsupported argument definitions"
}
require(arguments.keys.all { it in names }) { "The launch contains an undeclared command argument" }
require(fields.none { it.optBoolean("required") && arguments[it.getString("name")].isNullOrBlank() }) {
"Complete the target command's required arguments before launching it"
}
return LinkedHashMap(arguments)
}

fun launchProperties(definition: JSONObject): String {
val value = JSONObject().put("arguments", JSONObject(argumentsFor(definition))).put("launchType", "userInitiated")
contextJson?.let { value.put("launchContext", JSONObject(it)) }
fallbackText?.let { value.put("fallbackText", it) }
return value.toString().also {
require(it.length <= MAX_REQUEST_UNITS && it.toByteArray(Charsets.UTF_8).size <= MAX_REQUEST_BYTES) {
"The command launch data exceeds the Android size limit"
}
}
}

companion object {
internal const val MAX_REQUEST_UNITS = 64 * 1024
internal const val MAX_REQUEST_BYTES = 128 * 1024
private const val MAX_ARGUMENTS = 64
private const val MAX_KEY_UNITS = 256
private const val MAX_CONTEXT_VALUES = 2048
private const val MAX_CONTEXT_DEPTH = 16
private const val MAX_FALLBACK_UNITS = 16 * 1024
private val allowedOptions = setOf("name", "type", "arguments", "context", "fallbackText")

fun parse(raw: String): ExtensionCommandLaunch {
// Check before JSON parsing, including direct calls to the JS bridge.
require(raw.length <= MAX_REQUEST_UNITS && raw.toByteArray(Charsets.UTF_8).size <= MAX_REQUEST_BYTES) {
"The command launch data exceeds the Android size limit"
}
requireBoundedJsonNesting(raw)
val parameters = try { JSONArray(raw) } catch (_: JSONException) {
throw IllegalArgumentException("Command launch requires valid JSON parameters")
}
require(parameters.length() == 1 && parameters.opt(0) is JSONObject) { "Command launch requires one options object" }
val options = parameters.getJSONObject(0)
require(!options.has("extensionName") && !options.has("ownerOrAuthorName")) {
"Launching another extension is unavailable on Android"
}
require(options.keys().asSequence().all { it in allowedOptions }) { "This command requests unsupported launch options" }
require(options.opt("type") == "userInitiated") { "Only foreground user-initiated command launches are supported on Android" }
val name = options.opt("name") as? String
require(name != null && Regex("[A-Za-z0-9_-]{1,128}").matches(name)) { "Command launch requires a valid command name" }
val values = if (options.has("arguments")) {
require(options.opt("arguments") is JSONObject) { "Command arguments must be a string-valued object" }
options.getJSONObject("arguments")
} else JSONObject()
require(values.length() <= MAX_ARGUMENTS) { "The command launch has too many arguments" }
val args = linkedMapOf<String, String>()
values.keys().forEach { key ->
require(key.isNotBlank() && key.length <= MAX_KEY_UNITS && values.opt(key) is String) {
"Command arguments must have bounded names and string values"
}
args[key] = values.getString(key)
}
val context = if (options.has("context")) {
require(options.opt("context") is JSONObject) { "Launch context must be a JSON object" }
options.getJSONObject("context").also(::validateContext).toString()
} else null
val fallback = if (options.has("fallbackText")) {
val text = options.opt("fallbackText") as? String
require(text != null && text.length <= MAX_FALLBACK_UNITS) { "Fallback text must be a bounded string" }
text
} else null
return ExtensionCommandLaunch(name, args, context, fallback)
}

private fun requireBoundedJsonNesting(raw: String) {
var depth = 0
var quoted = false
var escaped = false
var started = false
var ended = false
for (character in raw) {
if (quoted) {
require(character >= ' ') { "Command launch requires valid JSON parameters" }
if (escaped) escaped = false
else if (character == '\\') escaped = true
else if (character == '"') quoted = false
continue
}
if (character in " \t\r\n") continue
require(!ended) { "Command launch requires one JSON parameter array" }
if (!started) {
require(character == '[') { "Command launch requires a JSON parameter array" }
started = true
}
when (character) {
'"' -> quoted = true
'[', '{' -> require(++depth <= MAX_CONTEXT_DEPTH + 4) { "Command launch JSON is too deeply nested" }
']', '}' -> {
require(--depth >= 0) { "Command launch requires valid JSON parameters" }
if (depth == 0) ended = true
}
'\'', '/', '#' -> throw IllegalArgumentException("Command launch requires JSON without comments or single-quoted strings")
}
}
require(started && ended && !quoted && depth == 0) { "Command launch requires complete JSON parameters" }
}

private fun validateContext(root: JSONObject) {
var values = 0
fun visit(value: Any?, depth: Int) {
require(depth <= MAX_CONTEXT_DEPTH && ++values <= MAX_CONTEXT_VALUES) { "Launch context is too deeply nested or has too many values" }
when (value) {
null, JSONObject.NULL -> Unit
is JSONObject -> value.keys().forEach { key ->
require(key.length <= MAX_KEY_UNITS) { "Launch context has an oversized property name" }
visit(value.get(key), depth + 1)
}
is JSONArray -> repeat(value.length()) { visit(value.get(it), depth + 1) }
is Number -> require(value.toDouble().isFinite()) { "Launch context numbers must be finite" }
is String, is Boolean -> Unit
else -> error("Launch context contains an unsupported value")
}
}
visit(root, 0)
}

fun requireConfigured(fields: List<JSONObject>, preferences: JSONObject) {
fields.forEach { field ->
val name = field.getString("name")
val value = preferences.opt(name)
require(!field.optBoolean("required") || field.optString("type") == "checkbox" ||
value != null && value != JSONObject.NULL && value.toString().isNotBlank()) {
"Configure the target command from the launcher before launching it here"
}
if (field.optString("type") == "dropdown" && value != null && value != JSONObject.NULL && value.toString().isNotBlank()) {
require(field.optJSONArray("data").objects().any { it.optString("value") == value.toString() }) {
"Choose an available target-command preference before launching it here"
}
}
}
}
}
}
Loading
Loading