use crate::protocol::{
BatchEncoder, HostEvent, Mutation, PropertyValue, ProtocolError, encode_event,
};
use crate::schema::{
ANDROID_BRIDGE_CLASS, BOUNDARY_SCHEMA, BoundaryOp, BoundaryParam, Color, ColorRole,
ColorScheme, DESIGN_SYSTEM_SCHEMA, DesignSystem, EVENT_SCHEMA, EventPayloadType, FieldSchema,
FieldSlot, FieldType, KEY_SCHEMA, Key, MODIFIER_SCHEMA, NOTIFICATION_PERMISSION_SCHEMA,
NotificationImportance, NotificationPermission, NotificationPresentation, PROPERTY_SCHEMA,
PROTOCOL_VERSION, Paint, PropertyKind, ROLE_ENUM_SCHEMA, SCHEMA_HASH, Selection, ShapeRole,
SpaceRole, Theme, WEB_BATCH_BYTES, WEB_BATCH_FIELDS, WEB_EVENT_BUFFER_BYTES,
WEB_EVENT_BUFFER_OFFSET, WEB_HOST_GLOBAL, WEB_MODULE_GLOBAL, WEB_RENDERER_IMPORT_MODULE,
WEB_RUST_REGION_BASE, WEB_START_SYMBOL, WIDGET_SCHEMA, WINDOW_HEIGHT_CLASS_SCHEMA,
WINDOW_SIZE_CLASS_SCHEMA, WidgetKind,
};
use crate::tokens::DESIGN_TOKENS;
use crate::{EventPayload, Modifier};
use std::fmt::Write as _;
pub const GENERATED_RELATIVE_PATH: &str = "../renderer/desktop/src/protocol/Protocol.gen.kt";
pub const SCHEMA_HASH_RELATIVE_PATH: &str = "schema-hash.txt";
pub const MUTATION_VECTOR_RELATIVE_PATH: &str = "tests/vectors/mutations.bin";
pub const EVENT_VECTOR_RELATIVE_PATH: &str = "tests/vectors/events.bin";
pub const VECTOR_DESCRIPTION_RELATIVE_PATH: &str = "tests/vectors/vectors.json";
const KOTLIN_PACKAGE: &str = "dev.darkpyonix.composerust.protocol";
pub fn generate_schema_hash() -> String {
format!("{SCHEMA_HASH:#018x}\n")
}
pub fn generate_kotlin() -> String {
let mut output = String::new();
output.push_str("// Generated by `cargo run -p compose-rust --bin codegen`. DO NOT EDIT.\n\n");
writeln!(output, "package {KOTLIN_PACKAGE}\n").unwrap();
output.push_str("import java.nio.ByteBuffer\n");
output.push_str("import java.nio.ByteOrder\n");
output.push_str("import java.nio.charset.StandardCharsets\n\n");
write_enum(&mut output, "WidgetKind", WIDGET_SCHEMA);
write_enum(&mut output, "PropertyKind", PROPERTY_SCHEMA);
write_enum(&mut output, "Key", KEY_SCHEMA);
write_enum(&mut output, "WindowSizeClass", WINDOW_SIZE_CLASS_SCHEMA);
write_enum(&mut output, "WindowHeightClass", WINDOW_HEIGHT_CLASS_SCHEMA);
for role in ROLE_ENUM_SCHEMA {
write_enum(&mut output, role.name, role.variants);
}
output.push_str(
r#"enum class LoopMode(val wire: Byte) {
/** The Renderer runs the loop and the Host blocks inside it. Desktop. */
Renderer(0),
/** The platform owns the process and the loop. Android, iOS and the web. */
Platform(1),
}
"#,
);
output.push_str(
r#"sealed interface Paint {
data class Role(val role: ColorRole) : Paint
/** A literal 0xAARRGGBB colour. */
data class Literal(val argb: Int) : Paint
/**
* A registered brush: a gradient, or a picture laid out as a fill.
*
* An id, because a list of stops does not fit the two words a paint has and because a
* brush has to outlive the frame that draws it.
*/
data class Asset(val assetId: Int) : Paint
}
data class Theme(
val designSystem: DesignSystem,
val fallback: DesignSystem,
val colorScheme: ColorScheme,
val adaptive: Boolean,
/**
* The font asset each type role resolves to, indexed by the role's ordinal, with zero
* where the role keeps the system font.
*
* Per theme rather than per node: an application changes what a role is made of and
* every piece of text in that role changes with it. A node that could name a font
* would be a node deciding typography.
*/
val fonts: List<Int> = List(TypeRole.entries.size) { 0 },
/**
* The application's own colours, laid over whichever design system this resolves to.
*
* Both schemes arrive together, so a switch between light and dark is answered from
* here and never asks the Host. Empty where the application gave none.
*/
val palette: List<PaletteEntry> = emptyList(),
/**
* What was wrong with entries the palette arrived with, one sentence each.
*
* Those entries are already left out of [palette] and the rest of it stands. Whoever
* applies the theme reports each of these as a protocol error: a theme is never thrown
* away whole for one bad entry, because that would open the application with no colours
* at all.
*/
val paletteProblems: List<String> = emptyList(),
) {
/** The font asset for [role], or null where the role keeps the system font. */
fun font(role: TypeRole): Int? = fonts.getOrNull(role.ordinal)?.takeIf { it != 0 }
/** The colour the application gave [role] in this scheme, as 0xAARRGGBB, or null. */
fun paletteColor(role: ColorRole, dark: Boolean): Int? =
palette.firstOrNull { it.role == role && it.dark == dark }?.argb
}
/** One role's colour in one scheme, as the application gave it. */
data class PaletteEntry(val role: ColorRole, val dark: Boolean, val argb: Int)
/**
* What the application asked of its own window.
*
* A zero measurement means the application did not ask, so the choice is the Renderer's.
* This arrives in the first batch, which the Renderer reads before it stands the window
* up; a platform where the window is not ours ignores it.
*/
data class Window(
val chrome: Chrome,
/**
* Which of the two title bars this window has.
*
* `Chrome` says who draws the caption; this says, inside that, what kind of window the
* caption belongs to. What it is worth in pixels is this side's answer: on macOS how
* far in the buttons sit and how round the window is, on Windows and Linux how tall
* the caption is.
*/
val titleBar: TitleBar,
/**
* What the window calls itself.
*
* Empty means the application said nothing and the renderer uses its own name. A
* desktop lists windows by this, so a window with no title of its own is listed
* under whatever the renderer happened to be called.
*/
val title: String,
/**
* The picture the window wears, as an asset id, or zero for none.
*
* An id rather than a path or a name: a path is a fact about the machine the
* application was built on, and a name asks the toolkit to find something it may not
* have. Zero leaves the toolkit's own icon, which is what every window here wore
* until this existed.
*/
val icon: Int,
val width: Int,
val height: Int,
val minWidth: Int,
val minHeight: Int,
val resizable: Boolean,
)
"#,
);
output.push_str("sealed interface PropertyValue {\n");
output.push_str(" data object None : PropertyValue\n");
output.push_str(" data class Text(val value: String) : PropertyValue\n");
output.push_str(" data class Bool(val value: Boolean) : PropertyValue\n");
output.push_str(" data class Integer(val value: Long) : PropertyValue\n");
output.push_str(" data class Float(val value: kotlin.Float) : PropertyValue\n");
output.push_str(" class Bytes(val value: ByteArray) : PropertyValue\n");
output.push_str("}\n\n");
write_draw_commands(&mut output);
write_text_spans(&mut output);
output.push_str("sealed interface Modifier {\n");
for variant in MODIFIER_SCHEMA {
if variant.fields.is_empty() {
writeln!(output, " data object {} : Modifier", variant.name).unwrap();
} else {
write!(output, " data class {}(", variant.name).unwrap();
for (index, field) in variant.fields.iter().enumerate() {
if index != 0 {
output.push_str(", ");
}
write!(output, "val {}: {}", field.name, kotlin_type(field.ty)).unwrap();
}
output.push_str(") : Modifier\n");
}
}
output.push_str("}\n\n");
output.push_str(
r#"sealed interface Mutation {
data class Create(val nodeId: Int, val widget: WidgetKind) : Mutation
data class SetProp(val nodeId: Int, val property: PropertyKind, val value: PropertyValue) : Mutation
data class SetModifier(val nodeId: Int, val index: Int, val modifier: Modifier) : Mutation
data class Insert(val parentId: Int, val nodeId: Int, val index: Int) : Mutation
data class Move(val parentId: Int, val nodeId: Int, val index: Int) : Mutation
data class Remove(val nodeId: Int) : Mutation
data class SetText(val nodeId: Int, val text: String, val selectionStart: Int, val selectionEnd: Int) : Mutation
data class AppendText(val nodeId: Int, val text: String) : Mutation
data class SetTheme(val theme: Theme) : Mutation
data class SetWindow(val window: Window) : Mutation
/**
* The bytes of one asset. `kind` is the raw wire tag rather than an [AssetKind],
* because a kind this Renderer cannot read has to be reported as a protocol error by
* the asset cache instead of stopping the rest of the batch from being applied.
*
* `bytes` is already a copy: the batch buffer is only valid for the call that carried
* it, and an asset has to outlive the frame that draws it.
*/
data class RegisterAsset(val assetId: Int, val kind: Int, val bytes: ByteArray) : Mutation {
override fun equals(other: Any?): Boolean =
this === other ||
(other is RegisterAsset && assetId == other.assetId && kind == other.kind &&
bytes.contentEquals(other.bytes))
override fun hashCode(): Int =
(assetId * 31 + kind) * 31 + bytes.contentHashCode()
}
data class ReleaseAsset(val assetId: Int) : Mutation
/**
* One sentence to say to the user, with an optional thing to do about it.
*
* It names no node because it is not in the tree. The Host says it once; how long it
* stays, where it sits and what happens when a second one arrives while the first is
* still up are the Renderer's to decide.
*
* `handlerId` is 0 when the message has no action, and `action` is then empty.
*/
data class ShowMessage(
val handlerId: Long,
val text: String,
val action: String,
val duration: MessageDuration,
) : Mutation
/**
* One notification to show outside the window.
*
* Not a node: it outlives the component that posted it, and when it goes away is up to
* the user in the notification centre. Posting again under the same `key` replaces it;
* an empty `key` is named by the Renderer and can be neither replaced nor withdrawn. An
* empty action label is no button.
*/
data class PostNotification(
val key: String,
val title: String,
val body: String,
val channel: String,
val action1: String,
val action2: String,
val importance: NotificationImportance,
val presentation: NotificationPresentation,
) : Mutation
/** Takes back the notification posted under `key`, if it is still showing. */
data class WithdrawNotification(val key: String) : Mutation
/** Asks the platform for permission. The answer comes back as an event. */
data object RequestNotificationPermission : Mutation
}
"#,
);
output.push_str("sealed interface HostEvent {\n");
output.push_str(" val nodeId: Int\n");
output.push_str(" val handlerId: Long\n\n");
for event in EVENT_SCHEMA {
write!(
output,
" data class {}(override val nodeId: Int, override val handlerId: Long",
event.name
)
.unwrap();
match event.payload {
EventPayloadType::None => {}
EventPayloadType::Text => output.push_str(", val text: String"),
EventPayloadType::ProtocolError => {
output.push_str(", val code: Int, val message: String");
}
EventPayloadType::KeyDown => {
output.push_str(", val key: Key, val shiftKey: Boolean, val ctrlKey: Boolean, val altKey: Boolean, val metaKey: Boolean");
}
EventPayloadType::Range => output.push_str(", val start: Int, val count: Int"),
EventPayloadType::Double => output.push_str(", val value: Double"),
EventPayloadType::WindowSize => {
output.push_str(
", val widthDp: kotlin.Float, val heightDp: kotlin.Float, val sizeClass: WindowSizeClass, val heightClass: WindowHeightClass",
);
}
EventPayloadType::DesignSystem => output.push_str(", val system: DesignSystem"),
EventPayloadType::NotificationActivation => {
output.push_str(", val action: Int, val key: String");
}
EventPayloadType::NotificationPermission => {
output.push_str(", val state: NotificationPermission");
}
}
output.push_str(") : HostEvent\n");
}
output.push_str("}\n\n");
output.push_str(
r#"class ProtocolException(message: String, val offset: Int) :
IllegalArgumentException("$message at byte offset $offset")
object Protocol {
"#,
);
writeln!(
output,
" const val SCHEMA_HASH: Long = {}L",
SCHEMA_HASH as i64
)
.unwrap();
writeln!(
output,
" const val PROTOCOL_VERSION: Int = {PROTOCOL_VERSION}"
)
.unwrap();
output.push_str(
r#"
private const val TAG_ENVELOPE = 0
private const val TAG_CREATE = 1
private const val TAG_SET_PROP = 2
private const val TAG_SET_MODIFIER = 3
private const val TAG_INSERT = 4
private const val TAG_MOVE = 5
private const val TAG_REMOVE = 6
private const val TAG_SET_TEXT = 7
private const val TAG_APPEND_TEXT = 8
private const val TAG_SET_THEME = 9
private const val TAG_REGISTER_ASSET = 10
private const val TAG_RELEASE_ASSET = 11
private const val TAG_SHOW_MESSAGE = 12
private const val TAG_SET_WINDOW = 13
private const val TAG_POST_NOTIFICATION = 14
private const val TAG_WITHDRAW_NOTIFICATION = 15
private const val TAG_REQUEST_NOTIFICATION_PERMISSION = 16
private const val ENVELOPE_LENGTH = 12
/** Four role tags, one font asset id per type role, then the palette's reference. */
private val THEME_RECORD_LENGTH = 20 + 4 * TypeRole.entries.size
/** Bytes of one palette entry: a role tag, a scheme tag and a colour. */
const val PALETTE_ENTRY_LENGTH = 8
/**
* The high bit of each of eight bytes, which is where UTF-8 stops being ASCII. Written
* as a negative literal because 0x8080808080808080 does not fit a signed `Long`; the
* bits are what matter and they are the same either way.
*/
private const val ASCII_HIGH_BITS = -0x7f7f7f7f7f7f7f80L
private const val VALUE_NONE = 0
private const val VALUE_TEXT = 1
private const val VALUE_BOOL = 2
private const val VALUE_INTEGER = 3
private const val VALUE_FLOAT = 4
private const val VALUE_BYTES = 5
/** Decodes a batch in place. Must not copy the buffer; only strings become Kotlin Strings. */
fun decode(batch: ByteBuffer, onMutation: (Mutation) -> Unit) {
val base = batch.position()
val available = batch.remaining()
val previousOrder = batch.order()
batch.order(ByteOrder.LITTLE_ENDIAN)
try {
requireRange(available, 0, ENVELOPE_LENGTH, 0)
if (readU16(batch, base, available, 0) != TAG_ENVELOPE ||
readU16(batch, base, available, 2) != ENVELOPE_LENGTH
) {
throw ProtocolException("invalid batch envelope", 0)
}
val recordsLengthLong = readU32(batch, base, available, 4)
val countLong = readU32(batch, base, available, 8)
if (recordsLengthLong < ENVELOPE_LENGTH ||
recordsLengthLong > available.toLong() ||
recordsLengthLong % 4L != 0L
) {
throw ProtocolException("invalid records length", 4)
}
if (countLong > Int.MAX_VALUE.toLong()) {
throw ProtocolException("record count is too large", 8)
}
val recordsLength = recordsLengthLong.toInt()
val count = countLong.toInt()
var offset = ENVELOPE_LENGTH
var decoded = 0
while (offset < recordsLength) {
requireRange(recordsLength, offset, 4, offset)
val tag = readU16(batch, base, available, offset)
val length = readU16(batch, base, available, offset + 2)
if (length < 4 || length % 4 != 0 || offset.toLong() + length > recordsLength) {
throw ProtocolException("invalid record length", offset + 2)
}
val mutation = when (tag) {
TAG_CREATE -> {
requireRecordLength(length, 12, offset)
Mutation.Create(
readU32(batch, base, available, offset + 4).toInt(),
widgetKind(readU16(batch, base, available, offset + 8), offset + 8),
)
}
TAG_SET_PROP -> {
requireRecordLength(length, 24, offset)
val value = when (val valueKind = readU16(batch, base, available, offset + 10)) {
VALUE_NONE -> PropertyValue.None
VALUE_TEXT -> PropertyValue.Text(readString(batch, base, available, offset + 12))
VALUE_BOOL -> PropertyValue.Bool(readU64(batch, base, available, offset + 12) != 0L)
VALUE_INTEGER -> PropertyValue.Integer(readU64(batch, base, available, offset + 12))
VALUE_FLOAT -> PropertyValue.Float(
kotlin.Float.fromBits(readU64(batch, base, available, offset + 12).toInt()),
)
VALUE_BYTES -> PropertyValue.Bytes(readBytes(batch, base, available, offset + 12))
else -> throw ProtocolException("unknown property value tag $valueKind", offset + 10)
}
Mutation.SetProp(
readU32(batch, base, available, offset + 4).toInt(),
propertyKind(readU16(batch, base, available, offset + 8), offset + 8),
value,
)
}
TAG_SET_MODIFIER -> {
requireRecordLength(length, 28, offset)
Mutation.SetModifier(
readU32(batch, base, available, offset + 4).toInt(),
readU16(batch, base, available, offset + 8),
modifier(
readU16(batch, base, available, offset + 10),
readU64(batch, base, available, offset + 12),
readU64(batch, base, available, offset + 20),
offset + 10,
),
)
}
TAG_INSERT -> {
requireRecordLength(length, 16, offset)
Mutation.Insert(
readU32(batch, base, available, offset + 4).toInt(),
readU32(batch, base, available, offset + 8).toInt(),
readU32(batch, base, available, offset + 12).toInt(),
)
}
TAG_MOVE -> {
requireRecordLength(length, 16, offset)
Mutation.Move(
readU32(batch, base, available, offset + 4).toInt(),
readU32(batch, base, available, offset + 8).toInt(),
readU32(batch, base, available, offset + 12).toInt(),
)
}
TAG_REMOVE -> {
requireRecordLength(length, 8, offset)
Mutation.Remove(readU32(batch, base, available, offset + 4).toInt())
}
TAG_SET_TEXT -> {
requireRecordLength(length, 24, offset)
Mutation.SetText(
readU32(batch, base, available, offset + 4).toInt(),
readString(batch, base, available, offset + 8),
readU32(batch, base, available, offset + 16).toInt(),
readU32(batch, base, available, offset + 20).toInt(),
)
}
TAG_APPEND_TEXT -> {
requireRecordLength(length, 16, offset)
Mutation.AppendText(
readU32(batch, base, available, offset + 4).toInt(),
readString(batch, base, available, offset + 8),
)
}
TAG_SET_THEME -> {
requireRecordLength(length, THEME_RECORD_LENGTH, offset)
val adaptive = readU16(batch, base, available, offset + 10)
if (adaptive > 1) {
throw ProtocolException("invalid adaptive flag $adaptive", offset + 10)
}
// One slot per type role, in the order the wire fixes them in.
val fonts = List(TypeRole.entries.size) { role ->
readU32(batch, base, available, offset + 12 + 4 * role).toInt()
}
val (entries, problems) = palette(
readBytes(batch, base, available, offset + 12 + 4 * TypeRole.entries.size),
)
Mutation.SetTheme(
Theme(
designSystem(readU16(batch, base, available, offset + 4), offset + 4),
designSystem(readU16(batch, base, available, offset + 6), offset + 6),
colorScheme(readU16(batch, base, available, offset + 8), offset + 8),
adaptive == 1,
fonts,
entries,
problems,
),
)
}
TAG_SET_WINDOW -> {
requireRecordLength(length, 32, offset)
val resizable = readU16(batch, base, available, offset + 16)
if (resizable > 1) {
throw ProtocolException("invalid resizable flag $resizable", offset + 16)
}
Mutation.SetWindow(
Window(
chrome(readU16(batch, base, available, offset + 4), offset + 4),
titleBar(readU16(batch, base, available, offset + 6), offset + 6),
readString(batch, base, available, offset + 20),
readU32(batch, base, available, offset + 28).toInt(),
readU16(batch, base, available, offset + 8),
readU16(batch, base, available, offset + 10),
readU16(batch, base, available, offset + 12),
readU16(batch, base, available, offset + 14),
resizable == 1,
),
)
}
TAG_REGISTER_ASSET -> {
requireRecordLength(length, 20, offset)
Mutation.RegisterAsset(
readU32(batch, base, available, offset + 4).toInt(),
readU16(batch, base, available, offset + 8),
readBytes(batch, base, available, offset + 12),
)
}
TAG_RELEASE_ASSET -> {
requireRecordLength(length, 8, offset)
Mutation.ReleaseAsset(readU32(batch, base, available, offset + 4).toInt())
}
TAG_SHOW_MESSAGE -> {
requireRecordLength(length, 32, offset)
Mutation.ShowMessage(
readU64(batch, base, available, offset + 4),
readString(batch, base, available, offset + 12),
readString(batch, base, available, offset + 20),
messageDuration(readU16(batch, base, available, offset + 28), offset + 28),
)
}
TAG_POST_NOTIFICATION -> {
requireRecordLength(length, 56, offset)
Mutation.PostNotification(
readString(batch, base, available, offset + 4),
readString(batch, base, available, offset + 12),
readString(batch, base, available, offset + 20),
readString(batch, base, available, offset + 28),
readString(batch, base, available, offset + 36),
readString(batch, base, available, offset + 44),
notificationImportance(readU16(batch, base, available, offset + 52), offset + 52),
notificationPresentation(readU16(batch, base, available, offset + 54), offset + 54),
)
}
TAG_WITHDRAW_NOTIFICATION -> {
requireRecordLength(length, 12, offset)
Mutation.WithdrawNotification(readString(batch, base, available, offset + 4))
}
TAG_REQUEST_NOTIFICATION_PERMISSION -> {
requireRecordLength(length, 4, offset)
Mutation.RequestNotificationPermission
}
else -> throw ProtocolException("unknown mutation tag $tag", offset)
}
onMutation(mutation)
decoded += 1
offset += length
}
if (offset != recordsLength || decoded != count) {
throw ProtocolException("record count does not match envelope", offset)
}
} finally {
batch.order(previousOrder)
}
}
/**
* Reads a palette, keeping every sound entry and saying what was wrong with the rest.
*
* Lenient on purpose, unlike the rest of the batch: an unknown role, a scheme that is
* neither light nor dark, a second value for the same role and scheme, or a length
* that is not a whole number of entries is one entry's fault, and the others are
* still what the application asked for.
*/
fun palette(bytes: ByteArray): Pair<List<PaletteEntry>, List<String>> {
if (bytes.isEmpty()) return emptyList<PaletteEntry>() to emptyList()
val buffer = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN)
val entries = ArrayList<PaletteEntry>(bytes.size / PALETTE_ENTRY_LENGTH)
val problems = ArrayList<String>()
if (bytes.size % PALETTE_ENTRY_LENGTH != 0) {
problems += "a palette of ${bytes.size} bytes is not a whole number of " +
"$PALETTE_ENTRY_LENGTH byte entries; the bytes past the last whole one are ignored"
}
for (index in 0 until bytes.size / PALETTE_ENTRY_LENGTH) {
val at = index * PALETTE_ENTRY_LENGTH
val roleTag = buffer.getShort(at).toInt() and 0xffff
val schemeTag = buffer.getShort(at + 2).toInt() and 0xffff
val argb = buffer.getInt(at + 4)
val role = paletteRole(roleTag)
if (role == null) {
problems += "palette entry $index names colour role $roleTag, which does not exist"
continue
}
val dark = when (schemeTag) {
1 -> false
2 -> true
else -> {
problems += "palette entry $index gives $role for scheme $schemeTag; " +
"an entry is for light (1) or dark (2), and following the system is not a scheme"
continue
}
}
if (entries.any { it.role == role && it.dark == dark }) {
problems += "palette entry $index gives $role in ${if (dark) "dark" else "light"} " +
"a second time; the first value stands"
continue
}
entries += PaletteEntry(role, dark, argb)
}
return entries to problems
}
/** Encodes one event into `out` (little-endian), returns bytes written. */
fun encodeEvent(event: HostEvent, out: ByteBuffer): Int {
val start = out.position()
val previousOrder = out.order()
out.order(ByteOrder.LITTLE_ENDIAN)
try {
val text = when (event) {
"#,
);
for event in EVENT_SCHEMA {
match event.payload {
EventPayloadType::Text => {
writeln!(
output,
" is HostEvent.{} -> event.text.toByteArray(StandardCharsets.UTF_8)",
event.name
)
.unwrap();
}
EventPayloadType::ProtocolError => {
writeln!(
output,
" is HostEvent.{} -> event.message.toByteArray(StandardCharsets.UTF_8)",
event.name
)
.unwrap();
}
EventPayloadType::NotificationActivation => {
writeln!(
output,
" is HostEvent.{} -> event.key.toByteArray(StandardCharsets.UTF_8)",
event.name
)
.unwrap();
}
EventPayloadType::None => {
writeln!(
output,
" is HostEvent.{} -> null",
event.name
)
.unwrap();
}
EventPayloadType::KeyDown
| EventPayloadType::Range
| EventPayloadType::Double
| EventPayloadType::WindowSize
| EventPayloadType::DesignSystem
| EventPayloadType::NotificationPermission => {
writeln!(
output,
" is HostEvent.{} -> null",
event.name
)
.unwrap();
}
}
}
output.push_str(
r#" }
val recordLength = when (event) {
"#,
);
for event in EVENT_SCHEMA {
let length = match event.payload {
EventPayloadType::None => 16,
EventPayloadType::Text => 24,
EventPayloadType::ProtocolError => 28,
EventPayloadType::KeyDown => 20,
EventPayloadType::Range => 24,
EventPayloadType::Double => 24,
EventPayloadType::WindowSize => 32,
EventPayloadType::DesignSystem => 20,
EventPayloadType::NotificationActivation => 28,
EventPayloadType::NotificationPermission => 20,
};
writeln!(
output,
" is HostEvent.{} -> {length}",
event.name
)
.unwrap();
}
output.push_str(
r#" }
val totalLength = recordLength.toLong() + (text?.size ?: 0)
if (totalLength > Int.MAX_VALUE || totalLength > out.remaining().toLong()) {
throw ProtocolException("event output buffer is too small", 0)
}
val tag = when (event) {
"#,
);
for event in EVENT_SCHEMA {
writeln!(
output,
" is HostEvent.{} -> {}",
event.name, event.tag
)
.unwrap();
}
output.push_str(
r#" }
out.putShort(tag.toShort())
out.putShort(recordLength.toShort())
out.putInt(event.nodeId)
out.putLong(event.handlerId)
when (event) {
"#,
);
for event in EVENT_SCHEMA {
match event.payload {
EventPayloadType::None => {
writeln!(
output,
" is HostEvent.{} -> Unit",
event.name
)
.unwrap();
}
EventPayloadType::Text => {
writeln!(
output,
" is HostEvent.{} -> writeStringReference(out, recordLength, text!!)",
event.name
)
.unwrap();
}
EventPayloadType::ProtocolError => {
writeln!(output, " is HostEvent.{} -> {{", event.name).unwrap();
output.push_str(" out.putInt(event.code)\n");
output.push_str(
" writeStringReference(out, recordLength, text!!)\n",
);
output.push_str(" }\n");
}
EventPayloadType::KeyDown => {
writeln!(output, " is HostEvent.{} -> {{", event.name).unwrap();
output.push_str(" out.putShort(keyTag(event.key).toShort())\n");
output.push_str(" var modifiers = 0\n");
output.push_str(
" if (event.shiftKey) modifiers = modifiers or 0x01\n",
);
output.push_str(
" if (event.ctrlKey) modifiers = modifiers or 0x02\n",
);
output.push_str(
" if (event.altKey) modifiers = modifiers or 0x04\n",
);
output.push_str(
" if (event.metaKey) modifiers = modifiers or 0x08\n",
);
output.push_str(" out.put(modifiers.toByte())\n");
output.push_str(" out.put(0.toByte())\n");
output.push_str(" }\n");
}
EventPayloadType::Range => {
writeln!(output, " is HostEvent.{} -> {{", event.name).unwrap();
output.push_str(" out.putInt(event.start)\n");
output.push_str(" out.putInt(event.count)\n");
output.push_str(" }\n");
}
EventPayloadType::DesignSystem => {
writeln!(output, " is HostEvent.{} -> {{", event.name).unwrap();
output.push_str(" out.putInt(designSystemTag(event.system))\n");
output.push_str(" }\n");
}
EventPayloadType::NotificationActivation => {
writeln!(output, " is HostEvent.{} -> {{", event.name).unwrap();
output.push_str(" out.putInt(event.action)\n");
output.push_str(
" writeStringReference(out, recordLength, text!!)\n",
);
output.push_str(" }\n");
}
EventPayloadType::NotificationPermission => {
writeln!(output, " is HostEvent.{} -> {{", event.name).unwrap();
output.push_str(
" out.putInt(notificationPermissionTag(event.state))\n",
);
output.push_str(" }\n");
}
EventPayloadType::Double => {
writeln!(
output,
" is HostEvent.{} -> out.putDouble(event.value)",
event.name
)
.unwrap();
}
EventPayloadType::WindowSize => {
writeln!(output, " is HostEvent.{} -> {{", event.name).unwrap();
output.push_str(" out.putFloat(event.widthDp)\n");
output.push_str(" out.putFloat(event.heightDp)\n");
output.push_str(
" out.putInt(windowSizeClassTag(event.sizeClass))\n",
);
output.push_str(
" out.putInt(windowHeightClassTag(event.heightClass))\n",
);
output.push_str(" }\n");
}
}
}
output.push_str(
r#" }
if (text != null) out.put(text)
return out.position() - start
} finally {
out.order(previousOrder)
}
}
/**
* Handshake payload the Renderer sends to compose_rust_host_init.
*
* `loopMode` says who owns the frame loop: the Renderer on desktop, the platform on
* Android, iOS and the web.
*/
fun handshake(out: ByteBuffer, loopMode: LoopMode = LoopMode.Renderer): Int {
val start = out.position()
if (out.remaining() < 12) {
throw ProtocolException("handshake output buffer is too small", 0)
}
val previousOrder = out.order()
out.order(ByteOrder.LITTLE_ENDIAN)
try {
out.putLong(SCHEMA_HASH)
out.putShort(PROTOCOL_VERSION.toShort())
out.put(loopMode.wire)
out.put(0.toByte()) // Reserved for alignment.
return out.position() - start
} finally {
out.order(previousOrder)
}
}
private fun writeStringReference(out: ByteBuffer, offset: Int, bytes: ByteArray) {
out.putInt(offset)
out.putInt(bytes.size)
}
private fun widgetKind(tag: Int, offset: Int): WidgetKind = when (tag) {
"#,
);
for variant in WIDGET_SCHEMA {
writeln!(
output,
" {} -> WidgetKind.{}",
variant.tag, variant.name
)
.unwrap();
}
output.push_str(
r#" else -> throw ProtocolException("unknown widget tag $tag", offset)
}
private fun propertyKind(tag: Int, offset: Int): PropertyKind = when (tag) {
"#,
);
for variant in PROPERTY_SCHEMA {
writeln!(
output,
" {} -> PropertyKind.{}",
variant.tag, variant.name
)
.unwrap();
}
output.push_str(
r#" else -> throw ProtocolException("unknown property tag $tag", offset)
}
private fun keyTag(key: Key): Int = when (key) {
"#,
);
for variant in KEY_SCHEMA {
writeln!(output, " Key.{} -> {}", variant.name, variant.tag).unwrap();
}
output.push_str(" }\n\n");
output.push_str(
" private fun windowSizeClassTag(sizeClass: WindowSizeClass): Int = when (sizeClass) {\n",
);
for variant in WINDOW_SIZE_CLASS_SCHEMA {
writeln!(
output,
" WindowSizeClass.{} -> {}",
variant.name, variant.tag
)
.unwrap();
}
output.push_str(" }\n\n");
output.push_str(
" private fun windowHeightClassTag(heightClass: WindowHeightClass): Int = when (heightClass) {\n",
);
for variant in WINDOW_HEIGHT_CLASS_SCHEMA {
writeln!(
output,
" WindowHeightClass.{} -> {}",
variant.name, variant.tag
)
.unwrap();
}
output.push_str(" }\n\n");
output
.push_str(" private fun designSystemTag(system: DesignSystem): Int = when (system) {\n");
for variant in DESIGN_SYSTEM_SCHEMA {
writeln!(
output,
" DesignSystem.{} -> {}",
variant.name, variant.tag
)
.unwrap();
}
output.push_str(" }\n\n");
output.push_str(
" private fun notificationPermissionTag(state: NotificationPermission): Int = when (state) {\n",
);
for variant in NOTIFICATION_PERMISSION_SCHEMA {
writeln!(
output,
" NotificationPermission.{} -> {}",
variant.name, variant.tag
)
.unwrap();
}
output.push_str(" }\n\n");
output.push_str(" private fun paletteRole(tag: Int): ColorRole? = when (tag) {\n");
for variant in crate::schema::COLOR_ROLE_SCHEMA {
writeln!(
output,
" {} -> ColorRole.{}",
variant.tag, variant.name
)
.unwrap();
}
output.push_str(" else -> null\n }\n\n");
for role in ROLE_ENUM_SCHEMA {
writeln!(
output,
" private fun {}(tag: Int, offset: Int): {} = when (tag) {{",
lower_camel(role.name),
role.name
)
.unwrap();
for variant in role.variants {
writeln!(
output,
" {} -> {}.{}",
variant.tag, role.name, variant.name
)
.unwrap();
}
writeln!(
output,
" else -> throw ProtocolException(\"unknown {} tag $tag\", offset)",
role.name
)
.unwrap();
output.push_str(" }\n\n");
}
output.push_str(
r#" private fun paint(bits: Long, offset: Int): Paint {
val value = bits.toInt()
return when (val kind = (bits ushr 32).toInt()) {
1 -> Paint.Role(colorRole(value, offset))
2 -> Paint.Literal(value)
3 -> Paint.Asset(value)
else -> throw ProtocolException("unknown paint kind $kind", offset)
}
}
private fun modifier(tag: Int, first: Long, second: Long, offset: Int): Modifier = when (tag) {
"#,
);
for variant in MODIFIER_SCHEMA {
write!(
output,
" {} -> Modifier.{}",
variant.tag, variant.name
)
.unwrap();
if variant.fields.is_empty() {
output.push('\n');
} else {
output.push('(');
for (index, field) in variant.fields.iter().enumerate() {
if index != 0 {
output.push_str(", ");
}
output.push_str(&modifier_decode_expression(field));
}
output.push_str(")\n");
}
}
output.push_str(
r#" else -> throw ProtocolException("unknown modifier tag $tag", offset)
}
/**
* An arena range with no UTF-8 requirement, used by both a drawing command list and an
* asset's bytes. For an asset this is the one copy this side makes, and it happens at
* registration rather than per frame.
*/
private fun readBytes(batch: ByteBuffer, base: Int, available: Int, referenceOffset: Int): ByteArray {
val offsetLong = readU32(batch, base, available, referenceOffset)
val lengthLong = readU32(batch, base, available, referenceOffset + 4)
if (offsetLong > Int.MAX_VALUE || lengthLong > Int.MAX_VALUE) {
throw ProtocolException("byte range is too large", referenceOffset)
}
val offset = offsetLong.toInt()
val length = lengthLong.toInt()
requireRange(available, offset, length, referenceOffset)
val copy = ByteArray(length)
copyOut(batch, base + offset, copy, length)
return copy
}
/**
* Copies a range of the arena into an array.
*
* The absolute bulk read that says the same thing in one call, `get(index, array,
* offset, length)`, arrived in Java 13 and is not in every Android runtime this has to
* run on. So the read goes through the buffer's own position, which every version has,
* and puts it back afterwards: the arena is read from one thread and a position left
* where the last read ended would make the next one read the wrong bytes.
*
* Nothing is allocated. A `duplicate()` would avoid touching the position and costs an
* object per call on a path that runs per string in every batch.
*/
private fun copyOut(batch: ByteBuffer, at: Int, into: ByteArray, length: Int) {
val mark = batch.position()
batch.position(at)
batch.get(into, 0, length)
batch.position(mark)
}
/**
* The string Compose is handed, and the only copy of the text this side makes.
*
* Validating and building are two passes rather than one because the two things that
* do both are each wrong here. `String(bytes, UTF_8)` replaces a malformed byte with
* U+FFFD, and a malformed string means the two sides disagree about the arena, which
* has to be reported rather than drawn. A `CharsetDecoder` reports it, but assembles
* the text as `char` first, two bytes a character, and then copies that into the
* `String`, and it needs a decoder and two buffer views of its own to do it.
*
* So the bytes are checked where they lie, which allocates nothing, and then copied
* once into a buffer this object keeps and handed to `String`, which by then has
* nothing left to replace. What remains is the one allocation the string itself is.
*/
private fun readString(batch: ByteBuffer, base: Int, available: Int, referenceOffset: Int): String {
val offsetLong = readU32(batch, base, available, referenceOffset)
val lengthLong = readU32(batch, base, available, referenceOffset + 4)
if (offsetLong > Int.MAX_VALUE || lengthLong > Int.MAX_VALUE) {
throw ProtocolException("string range is too large", referenceOffset)
}
val offset = offsetLong.toInt()
val length = lengthLong.toInt()
requireRange(available, offset, length, referenceOffset)
if (length == 0) {
return ""
}
requireUtf8(batch, base + offset, length, offset)
val scratch = stringBytes(length)
copyOut(batch, base + offset, scratch, length)
return String(scratch, 0, length, StandardCharsets.UTF_8)
}
/**
* Reads a range of the arena as UTF-8 without building anything out of it.
*
* The bounds are Unicode's, and the ones that look arbitrary are the point. A two byte
* sequence starts at 0xc2 because 0xc0 and 0xc1 could only encode what one byte
* already encodes; the second byte after 0xe0 starts at 0xa0 and after 0xf0 at 0x90
* for the same reason; 0xed stops at 0x9f because the surrogate halves follow it; and
* four byte sequences stop at 0xf4 0x8f because U+10FFFF is the last code point. The
* platform's decoder refuses every one of those, and this has to refuse them too, or
* a range it called text would come out of `String` as replacement characters.
*/
private fun requireUtf8(batch: ByteBuffer, start: Int, length: Int, errorOffset: Int) {
var index = 0
while (index < length) {
val lead = batch.get(start + index).toInt() and 0xff
if (lead < 0x80) {
index += 1
// Eight bytes at a time for as long as the text stays ASCII, which most
// text is and all of a streaming append usually is: a long with no high
// bit anywhere in it is eight characters that need nothing else checked.
// The mask is the same at both ends, so which order the long is read in
// does not matter. Entered only after an ASCII byte, so text with no
// ASCII in it never pays for the attempt.
while (index + 8 <= length &&
batch.getLong(start + index) and ASCII_HIGH_BITS == 0L
) {
index += 8
}
continue
}
val width = when {
lead >= 0xc2 && lead <= 0xdf -> 2
lead >= 0xe0 && lead <= 0xef -> 3
lead >= 0xf0 && lead <= 0xf4 -> 4
else -> malformedUtf8(errorOffset)
}
if (index + width > length) {
malformedUtf8(errorOffset)
}
val lowest = when (lead) {
0xe0 -> 0xa0
0xf0 -> 0x90
else -> 0x80
}
val highest = when (lead) {
0xed -> 0x9f
0xf4 -> 0x8f
else -> 0xbf
}
val second = batch.get(start + index + 1).toInt() and 0xff
if (second < lowest || second > highest) {
malformedUtf8(errorOffset)
}
var step = 2
while (step < width) {
val continuation = batch.get(start + index + step).toInt() and 0xff
if (continuation < 0x80 || continuation > 0xbf) {
malformedUtf8(errorOffset)
}
step += 1
}
index += width
}
}
private fun malformedUtf8(errorOffset: Int): Nothing =
throw ProtocolException("string is not valid UTF-8", errorOffset)
/**
* The buffer every string is copied through, grown to fit and then kept.
*
* A frame's strings go through it one after another, so a stream of keystrokes costs
* nothing here once it is big enough. It doubles rather than fitting exactly, because
* text that grows a character at a time would otherwise reallocate on every keystroke.
*
* One buffer is enough, and it needs no lock, because a batch is decoded on the
* Renderer's UI thread and on no other: the Host's worker threads update signals and
* ask for a frame, they never call across the boundary themselves. Two threads
* decoding at once would hand each other half a string.
*/
private var stringScratch = ByteArray(256)
private fun stringBytes(length: Int): ByteArray {
val scratch = stringScratch
if (scratch.size >= length) {
return scratch
}
var size = scratch.size
while (size < length) {
size = if (size > Int.MAX_VALUE / 2) length else size + size
}
val grown = ByteArray(size)
stringScratch = grown
return grown
}
private fun readU16(batch: ByteBuffer, base: Int, available: Int, offset: Int): Int {
requireRange(available, offset, 2, offset)
return batch.getShort(base + offset).toInt() and 0xffff
}
private fun readU32(batch: ByteBuffer, base: Int, available: Int, offset: Int): Long {
requireRange(available, offset, 4, offset)
return batch.getInt(base + offset).toLong() and 0xffff_ffffL
}
private fun readU64(batch: ByteBuffer, base: Int, available: Int, offset: Int): Long {
requireRange(available, offset, 8, offset)
return batch.getLong(base + offset)
}
private fun requireRecordLength(actual: Int, expected: Int, offset: Int) {
if (actual != expected) {
throw ProtocolException("invalid record length $actual for tag", offset + 2)
}
}
private fun requireRange(available: Int, offset: Int, length: Int, errorOffset: Int) {
if (offset < 0 || length < 0 || offset.toLong() + length > available.toLong()) {
throw ProtocolException("truncated or out-of-range data", errorOffset)
}
}
}
"#,
);
write_design_tokens(&mut output);
output
}
fn write_design_tokens(output: &mut String) {
output.push_str(
r#"
/** One rung of the type ladder. Sizes are sp, spacing may be negative. */
data class TypeToken(
val size: Float,
val weight: Int,
val lineHeight: Float,
val letterSpacing: Float,
val monospace: Boolean,
)
/**
* The generated half of one design system's tables: colours, type, shapes and spacing.
*
* Arrays are indexed by the role's ordinal, which matches its wire tag minus one.
* The rules that consume them (elevation rendering, ButtonVariant styling, motion) are
* written by hand in the Renderer.
*/
class DesignTokenTable(
val system: DesignSystem,
/** The published guideline these values come from. */
val reference: String,
val defaultFamily: String,
val monospaceFamily: String,
private val lightColors: IntArray,
private val darkColors: IntArray,
private val typeScale: Array<TypeToken>,
private val shapeRadii: FloatArray,
private val spaces: FloatArray,
) {
/** The 0xAARRGGBB value for a role. `dark` is the scheme the Renderer resolved. */
fun color(role: ColorRole, dark: Boolean): Int =
if (dark) darkColors[role.ordinal] else lightColors[role.ordinal]
fun type(role: TypeRole): TypeToken = typeScale[role.ordinal]
/** Corner radius in dp. */
fun radius(role: ShapeRole): Float = shapeRadii[role.ordinal]
/** Spacing in dp. */
fun space(role: SpaceRole): Float = spaces[role.ordinal]
}
object DesignTokens {
"#,
);
for table in DESIGN_TOKENS {
let name = upper_snake(&format!("{:?}", table.system));
writeln!(
output,
" val {name}: DesignTokenTable = DesignTokenTable("
)
.unwrap();
writeln!(output, " DesignSystem.{:?},", table.system).unwrap();
writeln!(output, " \"{}\",", table.reference).unwrap();
writeln!(output, " \"{}\",", table.default_family).unwrap();
writeln!(output, " \"{}\",", table.monospace_family).unwrap();
for dark in [false, true] {
output.push_str(" intArrayOf(\n");
for token in table.colors.iter() {
let color = if dark { token.dark } else { token.light };
writeln!(
output,
" 0x{:08x}.toInt(), // {:?}",
color.to_argb(),
token.role
)
.unwrap();
}
output.push_str(" ),\n");
}
output.push_str(" arrayOf(\n");
for token in table.type_scale.iter() {
writeln!(
output,
" TypeToken({:?}f, {}, {:?}f, {:?}f, {}), // {:?}",
token.size,
token.weight,
token.line_height,
token.letter_spacing,
token.monospace,
token.role
)
.unwrap();
}
output.push_str(" ),\n floatArrayOf(");
for (index, token) in table.shapes.iter().enumerate() {
if index != 0 {
output.push_str(", ");
}
write!(output, "{:?}f", token.radius).unwrap();
}
output.push_str("),\n floatArrayOf(");
for (index, token) in table.spaces.iter().enumerate() {
if index != 0 {
output.push_str(", ");
}
write!(output, "{:?}f", token.value).unwrap();
}
output.push_str("),\n )\n\n");
}
output.push_str(" fun of(system: DesignSystem): DesignTokenTable = when (system) {\n");
for table in DESIGN_TOKENS {
writeln!(
output,
" DesignSystem.{:?} -> {}",
table.system,
upper_snake(&format!("{:?}", table.system))
)
.unwrap();
}
output.push_str(" }\n}\n");
}
fn upper_snake(name: &str) -> String {
let mut output = String::new();
for (index, character) in name.chars().enumerate() {
if character.is_uppercase() && index != 0 {
output.push('_');
}
output.extend(character.to_uppercase());
}
output
}
pub fn canvas_vector() -> crate::drawing::DrawList {
crate::drawing::DrawList::builder()
.line(Paint::Role(ColorRole::Primary), 1.0, 2.0, 3.0, 4.0, 1.5)
.rect(
Paint::Literal(Color::argb(0xff11_2233)),
0.0,
0.0,
8.0,
9.0,
0.0,
)
.round_rect(
Paint::Role(ColorRole::Surface),
1.0,
2.0,
3.0,
4.0,
5.0,
6.0,
)
.circle(Paint::Role(ColorRole::Error), 4.0, 5.0, 6.0, 0.5)
.arc(
Paint::Role(ColorRole::Outline),
1.0,
2.0,
3.0,
0.0,
90.0,
2.0,
)
.polyline_ref(Paint::Role(ColorRole::Secondary), 42, 3.0)
.text_at(
Paint::Role(ColorRole::OnSurface),
"한글",
7.0,
8.0,
crate::schema::TypeRole::Label,
)
.build()
}
pub const VECTOR_PALETTE: crate::palette::Palette = crate::palette::Palette::new()
.with(
ColorRole::Primary,
Color::rgb(0xe8590c),
Color::rgb(0xff8a4c),
)
.with_light(ColorRole::SyntaxKeyword, Color::argb(0x8011_2233));
pub fn spans_vector() -> crate::spans::TextSpans {
use crate::spans::{TextSpan, TextSpans};
TextSpans::new([
TextSpan::new(0, 4)
.bold()
.with_color(Paint::Role(ColorRole::SyntaxKeyword))
.with_background(Paint::Role(ColorRole::DiffAddedEmphasis)),
TextSpan::new(5, 2).with_background(Paint::Literal(Color::argb(0xff44_5566))),
])
}
pub fn generate_mutation_vector() -> Result<Vec<u8>, ProtocolError> {
let canvas_vector_bytes = canvas_vector();
let spans_vector_bytes = spans_vector();
let mutations = [
Mutation::SetTheme(
Theme::adaptive(DesignSystem::Cupertino)
.with_color_scheme(ColorScheme::Dark)
.with_palette(&VECTOR_PALETTE),
),
Mutation::Create {
node_id: 1,
widget: WidgetKind::Box,
},
Mutation::SetProp {
node_id: 2,
property: PropertyKind::Text,
value: PropertyValue::String("안녕"),
},
Mutation::SetProp {
node_id: 2,
property: PropertyKind::Enabled,
value: PropertyValue::Bool(true),
},
Mutation::SetProp {
node_id: 2,
property: PropertyKind::OnClick,
value: PropertyValue::Integer(99),
},
Mutation::SetProp {
node_id: 2,
property: PropertyKind::Multiline,
value: PropertyValue::Float(1.5),
},
Mutation::SetProp {
node_id: 2,
property: PropertyKind::Placeholder,
value: PropertyValue::None,
},
Mutation::SetModifier {
node_id: 1,
index: 0,
modifier: Modifier::Empty,
},
Mutation::SetModifier {
node_id: 1,
index: 1,
modifier: Modifier::Padding(16.0),
},
Mutation::SetModifier {
node_id: 1,
index: 2,
modifier: Modifier::FillMaxWidth,
},
Mutation::SetModifier {
node_id: 1,
index: 3,
modifier: Modifier::FillMaxHeight,
},
Mutation::SetModifier {
node_id: 1,
index: 4,
modifier: Modifier::Width(120.0),
},
Mutation::SetModifier {
node_id: 1,
index: 5,
modifier: Modifier::Height(48.0),
},
Mutation::SetModifier {
node_id: 1,
index: 6,
modifier: Modifier::Size {
width: 20.0,
height: 30.0,
},
},
Mutation::SetModifier {
node_id: 1,
index: 7,
modifier: Modifier::Background(Paint::Literal(Color::argb(0xff11_2233))),
},
Mutation::SetModifier {
node_id: 1,
index: 8,
modifier: Modifier::Clickable { handler_id: 42 },
},
Mutation::SetModifier {
node_id: 1,
index: 9,
modifier: Modifier::Background(Paint::Role(ColorRole::Surface)),
},
Mutation::SetModifier {
node_id: 1,
index: 10,
modifier: Modifier::PaddingRole(SpaceRole::Md),
},
Mutation::SetModifier {
node_id: 1,
index: 11,
modifier: Modifier::PaddingEach {
start: 1.0,
top: 2.0,
end: 3.0,
bottom: 4.0,
},
},
Mutation::SetModifier {
node_id: 1,
index: 12,
modifier: Modifier::Weight(0.5),
},
Mutation::SetModifier {
node_id: 1,
index: 13,
modifier: Modifier::Shape {
top_start: 4.0,
top_end: 8.0,
bottom_end: 12.0,
bottom_start: 16.0,
},
},
Mutation::SetModifier {
node_id: 1,
index: 14,
modifier: Modifier::ShapeRole(ShapeRole::Large),
},
Mutation::SetModifier {
node_id: 1,
index: 15,
modifier: Modifier::Border {
width: 2.0,
paint: Paint::Role(ColorRole::Outline),
},
},
Mutation::SetModifier {
node_id: 1,
index: 16,
modifier: Modifier::Elevation(6.0),
},
Mutation::Insert {
parent_id: 1,
node_id: 2,
index: 0,
},
Mutation::Move {
parent_id: 1,
node_id: 2,
index: 1,
},
Mutation::Remove { node_id: 3 },
Mutation::SetText {
node_id: 4,
text: "compose",
selection: Some(Selection { start: 1, end: 4 }),
},
Mutation::AppendText {
node_id: 4,
text: " token",
},
Mutation::Create {
node_id: 5,
widget: WidgetKind::Canvas,
},
Mutation::SetProp {
node_id: 5,
property: PropertyKind::Commands,
value: PropertyValue::Bytes(canvas_vector_bytes.as_bytes()),
},
Mutation::Create {
node_id: 6,
widget: WidgetKind::Chip,
},
Mutation::SetProp {
node_id: 6,
property: PropertyKind::Text,
value: PropertyValue::String("필터"),
},
Mutation::SetProp {
node_id: 6,
property: PropertyKind::Checked,
value: PropertyValue::Bool(true),
},
Mutation::SetProp {
node_id: 6,
property: PropertyKind::OnClick,
value: PropertyValue::Integer(101),
},
Mutation::Create {
node_id: 7,
widget: WidgetKind::FloatingAction,
},
Mutation::SetProp {
node_id: 7,
property: PropertyKind::Text,
value: PropertyValue::String("New"),
},
Mutation::SetProp {
node_id: 7,
property: PropertyKind::Icon,
value: PropertyValue::Integer(crate::schema::IconRole::Add as i64),
},
Mutation::SetProp {
node_id: 7,
property: PropertyKind::OnClick,
value: PropertyValue::Integer(102),
},
Mutation::RegisterAsset {
asset_id: 5,
kind: crate::schema::AssetKind::Png,
bytes: &[0x89, b'P', b'N', b'G'],
},
Mutation::ReleaseAsset { asset_id: 5 },
Mutation::ShowMessage {
handler_id: 77,
text: "삭제했습니다",
action: "Undo",
duration: crate::schema::MessageDuration::Long,
},
Mutation::PostNotification {
key: "session/7",
title: "세션이 끝났습니다",
body: "테스트 214개 통과",
channel: "세션",
action_1: "열기",
action_2: "",
importance: NotificationImportance::Urgent,
presentation: NotificationPresentation::WhenInactive,
},
Mutation::WithdrawNotification { key: "session/7" },
Mutation::RequestNotificationPermission,
Mutation::SetProp {
node_id: 2,
property: PropertyKind::Spans,
value: PropertyValue::Bytes(spans_vector_bytes.as_bytes()),
},
Mutation::SetProp {
node_id: 2,
property: PropertyKind::Collapsible,
value: PropertyValue::Bool(true),
},
];
let mut encoder = BatchEncoder::default();
for mutation in &mutations {
encoder.encode(mutation)?;
}
Ok(encoder.finish()?.to_vec())
}
pub fn generate_event_vector() -> Result<Vec<u8>, ProtocolError> {
let events = [
HostEvent {
node_id: 7,
handler_id: 11,
payload: EventPayload::Clicked,
},
HostEvent {
node_id: 8,
handler_id: 12,
payload: EventPayload::TextChanged("한글"),
},
HostEvent {
node_id: 8,
handler_id: 13,
payload: EventPayload::TextSubmitted("done"),
},
HostEvent {
node_id: 8,
handler_id: 14,
payload: EventPayload::FocusLost,
},
HostEvent {
node_id: 0,
handler_id: 0,
payload: EventPayload::ProtocolError {
code: 9,
message: "bad tag",
},
},
HostEvent {
node_id: 9,
handler_id: 15,
payload: EventPayload::KeyDown {
key: Key::Enter,
shift_key: true,
ctrl_key: true,
alt_key: true,
meta_key: true,
},
},
HostEvent {
node_id: 10,
handler_id: 16,
payload: EventPayload::RangeRequested {
start: 100,
count: 20,
},
},
HostEvent {
node_id: 11,
handler_id: 17,
payload: EventPayload::ValueChanged(-19_723.5),
},
HostEvent {
node_id: 0,
handler_id: 0,
payload: EventPayload::WindowSizeChanged {
width_dp: 840.0,
height_dp: 600.0,
class: crate::schema::WindowSizeClass::Expanded,
height_class: crate::schema::WindowHeightClass::Medium,
},
},
HostEvent {
node_id: 0,
handler_id: 0,
payload: EventPayload::NotificationActivated {
action: 1,
key: "session/7",
},
},
HostEvent {
node_id: 0,
handler_id: 0,
payload: EventPayload::NotificationPermissionChanged(NotificationPermission::Denied),
},
];
let mut output = Vec::new();
let mut encoded = Vec::new();
for event in &events {
encode_event(event, &mut encoded)?;
output.extend_from_slice(&encoded);
}
Ok(output)
}
pub fn generate_vector_description() -> String {
format!(
r#"{{
"format": "compose-rust protocol vectors v1",
"schemaHashUnsigned": "{SCHEMA_HASH}",
"byteOrder": "little-endian",
"mutations": {{
"file": "mutations.bin",
"description": "One batch covering every record, property value, modifier layout, drawing command, asset, message, notification command, palette entry, text run and split pane property",
"recordCount": 46,
"palette": [
{{ "role": "Primary", "scheme": "Light", "argb": "ffe8590c" }},
{{ "role": "Primary", "scheme": "Dark", "argb": "ffff8a4c" }},
{{ "role": "SyntaxKeyword", "scheme": "Light", "argb": "80112233" }}
],
"spanRecordLength": 36,
"strings": ["안녕", "compose", " token", "필터", "New", "삭제했습니다", "Undo", "session/7", "세션이 끝났습니다", "테스트 214개 통과", "세션", "열기", "", "session/7"],
"assets": [{{ "assetId": 5, "kind": "Png", "bytes": "89504e47" }}]
}},
"events": {{
"file": "events.bin",
"description": "Eleven independently decodable event records concatenated in schema order",
"records": [
{{ "type": "Clicked", "offset": 0, "length": 16, "nodeId": 7, "handlerId": 11 }},
{{ "type": "TextChanged", "offset": 16, "length": 30, "nodeId": 8, "handlerId": 12, "text": "한글" }},
{{ "type": "TextSubmitted", "offset": 46, "length": 28, "nodeId": 8, "handlerId": 13, "text": "done" }},
{{ "type": "FocusLost", "offset": 74, "length": 16, "nodeId": 8, "handlerId": 14 }},
{{ "type": "ProtocolError", "offset": 90, "length": 35, "nodeId": 0, "handlerId": 0, "code": 9, "message": "bad tag" }},
{{ "type": "KeyDown", "offset": 125, "length": 20, "nodeId": 9, "handlerId": 15, "key": "Enter", "shiftKey": true, "ctrlKey": true, "altKey": true, "metaKey": true }},
{{ "type": "RangeRequested", "offset": 145, "length": 24, "nodeId": 10, "handlerId": 16, "start": 100, "count": 20 }},
{{ "type": "ValueChanged", "offset": 169, "length": 24, "nodeId": 11, "handlerId": 17, "value": -19723.5 }},
{{ "type": "WindowSizeChanged", "offset": 193, "length": 32, "nodeId": 0, "handlerId": 0, "widthDp": 840.0, "heightDp": 600.0, "sizeClass": "Expanded", "heightClass": "Medium" }},
{{ "type": "NotificationActivated", "offset": 225, "length": 37, "nodeId": 0, "handlerId": 0, "action": 1, "key": "session/7" }},
{{ "type": "NotificationPermissionChanged", "offset": 262, "length": 20, "nodeId": 0, "handlerId": 0, "state": "Denied" }}
]
}}
}}
"#
)
}
fn write_enum(output: &mut String, name: &str, variants: &[crate::schema::EnumVariantSchema]) {
write!(output, "enum class {name} {{ ").unwrap();
for (index, variant) in variants.iter().enumerate() {
if index != 0 {
output.push_str(", ");
}
output.push_str(variant.name);
}
output.push_str(" }\n\n");
}
fn kotlin_type(ty: FieldType) -> String {
match ty {
FieldType::Float => "kotlin.Float".to_owned(),
FieldType::U32 => "Int".to_owned(),
FieldType::U64 => "Long".to_owned(),
FieldType::Paint => "Paint".to_owned(),
FieldType::Role(name) => format!("{KOTLIN_PACKAGE}.{name}"),
}
}
fn slot_word(slot: FieldSlot) -> (&'static str, bool) {
match slot {
FieldSlot::FirstLow | FieldSlot::First => ("first", false),
FieldSlot::FirstHigh => ("first", true),
FieldSlot::SecondLow | FieldSlot::Second => ("second", false),
FieldSlot::SecondHigh => ("second", true),
}
}
fn modifier_decode_expression(field: &FieldSchema) -> String {
let (word, high) = slot_word(field.slot);
let narrow = if high {
format!("({word} ushr 32).toInt()")
} else {
format!("{word}.toInt()")
};
match field.ty {
FieldType::Float => format!("kotlin.Float.fromBits({narrow})"),
FieldType::U32 => narrow,
FieldType::U64 => word.to_owned(),
FieldType::Paint => format!("paint({word}, offset)"),
FieldType::Role(name) => format!("{}({narrow}, offset)", lower_camel(name)),
}
}
fn lower_camel(name: &str) -> String {
let mut characters = name.chars();
match characters.next() {
Some(first) => first.to_lowercase().chain(characters).collect(),
None => String::new(),
}
}
fn write_draw_commands(output: &mut String) {
use crate::drawing::{COMMAND_LEN, DRAW_COMMAND_SCHEMA, DrawFieldType};
output.push_str(
"/**\n * One drawing command. Coordinates are dp from the Canvas's top-left corner.\n *\n \
* Colour is a [Paint], so a command may name a ColorRole and the design system\n \
* decides what it looks like.\n */\n",
);
output.push_str("sealed interface DrawCommand {\n");
output.push_str(" val paint: Paint\n\n");
for command in DRAW_COMMAND_SCHEMA {
write!(
output,
" data class {}(override val paint: Paint",
command.name
)
.unwrap();
for field in command.fields {
let ty = match field.ty {
DrawFieldType::Float => "kotlin.Float",
DrawFieldType::U32 => "Int",
DrawFieldType::Role(name) => name,
};
write!(output, ", val {}: {ty}", field.name).unwrap();
}
output.push_str(") : DrawCommand\n");
}
output.push_str("}\n\n");
output.push_str(
r#"/** Decodes a Canvas command list: fixed-size records, then the TextAt strings. */
object DrawCommands {
"#,
);
writeln!(output, " const val COMMAND_LENGTH = {COMMAND_LEN}\n").unwrap();
output.push_str(
r#" fun decode(bytes: ByteArray): List<DrawCommand> {
val buffer = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN)
val commands = ArrayList<DrawCommand>(bytes.size / COMMAND_LENGTH)
var offset = 0
while (offset + COMMAND_LENGTH <= bytes.size) {
val tag = buffer.getShort(offset).toInt() and 0xffff
val length = buffer.getShort(offset + 2).toInt() and 0xffff
// The text region starts where the records stop decoding, so an unreadable
// record ends the list rather than failing the whole batch.
if (length != COMMAND_LENGTH) break
val paintBits = buffer.getLong(offset + 4)
val kind = (paintBits ushr 32).toInt()
if (kind != 1 && kind != 2) break
val paint = if (kind == 1) {
Paint.Role(colorRoleOrNull(paintBits.toInt()) ?: break)
} else {
Paint.Literal(paintBits.toInt())
}
fun word(index: Int): Int = buffer.getInt(offset + 12 + index * 4)
fun real(index: Int): kotlin.Float = kotlin.Float.fromBits(word(index))
val command = when (tag) {
"#,
);
for command in DRAW_COMMAND_SCHEMA {
write!(
output,
" {} -> DrawCommand.{}(paint",
command.tag, command.name
)
.unwrap();
for field in command.fields {
match field.ty {
DrawFieldType::Float => write!(output, ", real({})", field.word).unwrap(),
DrawFieldType::U32 => write!(output, ", word({})", field.word).unwrap(),
DrawFieldType::Role(name) => write!(
output,
", {}OrNull(word({})) ?: break",
lower_first(name),
field.word
)
.unwrap(),
}
}
output.push_str(")\n");
}
output.push_str(
r#" else -> break
}
commands.add(command)
offset += COMMAND_LENGTH
}
return commands
}
/** The string a TextAt points at, measured from the start of the list. */
fun textOf(bytes: ByteArray, command: DrawCommand.TextAt): String {
val end = command.textOffset.toLong() + command.textLength.toLong()
if (command.textOffset < 0 || command.textLength < 0 || end > bytes.size.toLong()) {
return ""
}
return String(bytes, command.textOffset, command.textLength, StandardCharsets.UTF_8)
}
"#,
);
let mut roles: Vec<&str> = DRAW_COMMAND_SCHEMA
.iter()
.flat_map(|command| command.fields.iter())
.filter_map(|field| match field.ty {
DrawFieldType::Role(name) => Some(name),
_ => None,
})
.collect();
roles.push("ColorRole");
roles.dedup();
for role in roles {
let variants = ROLE_ENUM_SCHEMA
.iter()
.find(|entry| entry.name == role)
.expect("a drawing command names a role the schema declares")
.variants;
writeln!(
output,
" private fun {}OrNull(tag: Int): {role}? = when (tag) {{",
lower_first(role)
)
.unwrap();
for variant in variants {
writeln!(output, " {} -> {role}.{}", variant.tag, variant.name).unwrap();
}
output.push_str(" else -> null\n }\n\n");
}
output.push_str("}\n\n");
}
fn write_text_spans(output: &mut String) {
use crate::spans::{
FLAG_BOLD, FLAG_ITALIC, FLAG_STRIKETHROUGH, FLAG_UNDERLINE, SPAN_BACKGROUND_AT,
SPAN_COLOR_AT, SPAN_FLAGS_AT, SPAN_HANDLER_AT, SPAN_LEN, SPAN_LENGTH_AT, SPAN_ROLE_AT,
SPAN_START_AT,
};
let role_kind = Paint::Role(ColorRole::Primary).to_bits() >> 32;
let literal_kind = Paint::Literal(Color::argb(0)).to_bits() >> 32;
let asset_kind = Paint::Asset(0).to_bits() >> 32;
output.push_str(
r#"/**
* One run of different treatment inside a Text's string, as the Host wrote it.
*
* Offsets are bytes of the string's UTF-8, because that is what the string is measured in
* on the side that wrote them. A null paint is a run that says nothing about that colour.
*/
data class TextSpanRecord(
val start: Int,
val length: Int,
val typeRole: TypeRole?,
val color: Paint?,
/** What is painted behind this run's letters only. */
val background: Paint?,
val bold: Boolean,
val italic: Boolean,
val underline: Boolean,
val strikethrough: Boolean,
/** The handler a press on this run reports to, or zero where it is not a link. */
val handlerId: Long,
)
/** Decodes the run list a Text carries in its `Spans` property. */
object SpanRecords {
"#,
);
writeln!(output, " const val SPAN_LENGTH = {SPAN_LEN}").unwrap();
for (name, value) in [
("START_AT", SPAN_START_AT),
("LENGTH_AT", SPAN_LENGTH_AT),
("ROLE_AT", SPAN_ROLE_AT),
("FLAGS_AT", SPAN_FLAGS_AT),
("COLOR_AT", SPAN_COLOR_AT),
("BACKGROUND_AT", SPAN_BACKGROUND_AT),
("HANDLER_AT", SPAN_HANDLER_AT),
] {
writeln!(output, " private const val {name} = {value}").unwrap();
}
for (name, value) in [
("FLAG_BOLD", FLAG_BOLD),
("FLAG_ITALIC", FLAG_ITALIC),
("FLAG_UNDERLINE", FLAG_UNDERLINE),
("FLAG_STRIKETHROUGH", FLAG_STRIKETHROUGH),
] {
writeln!(output, " private const val {name} = {value}").unwrap();
}
output.push_str(
r#"
/** The runs in [bytes], or null where it is not a whole number of records. */
fun decode(bytes: ByteArray): List<TextSpanRecord>? {
if (bytes.size % SPAN_LENGTH != 0) return null
val buffer = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN)
return List(bytes.size / SPAN_LENGTH) { index ->
val at = index * SPAN_LENGTH
val flags = buffer.getShort(at + FLAGS_AT).toInt() and 0xffff
TextSpanRecord(
start = buffer.getInt(at + START_AT),
length = buffer.getInt(at + LENGTH_AT),
typeRole = typeRoleOrNull(buffer.getShort(at + ROLE_AT).toInt() and 0xffff),
color = paintOrNull(buffer.getLong(at + COLOR_AT)),
background = paintOrNull(buffer.getLong(at + BACKGROUND_AT)),
bold = flags and FLAG_BOLD != 0,
italic = flags and FLAG_ITALIC != 0,
underline = flags and FLAG_UNDERLINE != 0,
strikethrough = flags and FLAG_STRIKETHROUGH != 0,
handlerId = buffer.getLong(at + HANDLER_AT),
)
}
}
/** A paint, or null for zero and for anything that does not decode. */
private fun paintOrNull(bits: Long): Paint? {
val value = bits.toInt()
return when ((bits ushr 32).toInt()) {
"#,
);
writeln!(
output,
" {role_kind} -> colorRoleOrNull(value)?.let {{ Paint.Role(it) }}"
)
.unwrap();
writeln!(output, " {literal_kind} -> Paint.Literal(value)").unwrap();
writeln!(output, " {asset_kind} -> Paint.Asset(value)").unwrap();
output.push_str(" else -> null\n }\n }\n\n");
for role in ["ColorRole", "TypeRole"] {
let variants = ROLE_ENUM_SCHEMA
.iter()
.find(|entry| entry.name == role)
.expect("the span record names a role the schema declares")
.variants;
writeln!(
output,
" private fun {}OrNull(tag: Int): {role}? = when (tag) {{",
lower_first(role)
)
.unwrap();
for variant in variants {
writeln!(output, " {} -> {role}.{}", variant.tag, variant.name).unwrap();
}
output.push_str(" else -> null\n }\n\n");
}
output.push_str("}\n\n");
}
fn lower_first(name: &str) -> String {
let mut characters = name.chars();
match characters.next() {
Some(first) => first.to_lowercase().chain(characters).collect(),
None => String::new(),
}
}
pub const JNI_RUST_RELATIVE_PATH: &str = "src/boundary_jni.gen.rs";
pub const ANDROID_BRIDGE_RELATIVE_PATH: &str = "../renderer/android/src/bridge/HostBridge.gen.kt";
pub const ANDROID_FAST_NATIVE_RELATIVE_PATH: &str =
"../renderer/android/src/bridge/FastNative.gen.kt";
const ANDROID_KOTLIN_PACKAGE: &str = "dev.darkpyonix.composerust.ui.platform";
const OUT_SLOTS: &[(&str, &str)] = &[
("BatchOffset", "the batch's start inside the arena"),
("BatchLength", "the batch's length in bytes"),
("Result", "the handler's synchronous result"),
(
"ArenaAddress",
"the arena's base address, which moves when it grows",
),
("ArenaCapacity", "the arena's capacity in bytes"),
];
fn jni_symbol(op_name: &str) -> String {
format!(
"Java_{}_native{}",
ANDROID_BRIDGE_CLASS.replace('/', "_"),
op_name
)
}
fn kotlin_native_name(op_name: &str) -> String {
format!("native{op_name}")
}
pub fn generate_jni_rust() -> String {
let mut output = String::new();
output.push_str("// Generated by `cargo run -p compose-rust --bin codegen`. DO NOT EDIT.\n");
output.push_str(
r#"//! JNI shims for the Android boundary.
//!
//! Kotlin owns the process and the frame loop, so every call here starts on the Renderer
//! UI thread and returns on the same call stack. Nothing is copied: a call reports where
//! its batch sits inside the Host's arena, and the Renderer reads the arena through a
//! direct byte buffer it made once.
#![allow(non_snake_case)]
use crate::boundary::{
MutationBatch, RendererApi, STATUS_OK, STATUS_PROTOCOL_ERROR, current_arena,
install_renderer_api,
};
use jni::JNIEnv;
use jni::JavaVM;
use jni::objects::{GlobalRef, JByteBuffer, JClass, JLongArray, JStaticMethodID};
use jni::signature::{Primitive, ReturnType};
use jni::sys::jobject;
use jni::sys::{JNI_ERR, JNI_VERSION_1_6, jint, jlong};
use std::ffi::c_int;
use std::os::raw::c_void;
use std::sync::OnceLock;
unsafe extern "C" {
/// Defined by the application's cdylib through `compose_rust::android_main!`. It
/// registers the root component before the Renderer's first init call.
fn compose_rust_android_main();
}
"#,
);
writeln!(
output,
"/// The number of `out` slots a batch-returning call writes."
)
.unwrap();
writeln!(
output,
"pub const OUT_SLOTS: usize = {};\n",
OUT_SLOTS.len()
)
.unwrap();
output.push_str(
r#"struct FrameRequestUpcall {
class: GlobalRef,
method: JStaticMethodID,
}
static VM: OnceLock<JavaVM> = OnceLock::new();
static UPCALL: OnceLock<FrameRequestUpcall> = OnceLock::new();
/// The Activity owns the loop, so the Host never runs one.
extern "C" fn platform_run() -> c_int {
STATUS_OK as c_int
}
/// Called from a Host worker thread, never from the UI thread.
///
/// The worker attaches to the JavaVM once and stays attached for the life of the process:
/// attaching and detaching around every call would cost more than the frame it asks for.
extern "C" fn request_frame() {
let (Some(vm), Some(upcall)) = (VM.get(), UPCALL.get()) else {
return;
};
let Ok(mut env) = vm.attach_current_thread_permanently() else {
return;
};
let class = <&JClass>::from(upcall.class.as_obj());
// SAFETY: the method id was resolved on this class for the signature `()V`, and the
// argument list matches it.
let result = unsafe {
env.call_static_method_unchecked(
class,
upcall.method,
ReturnType::Primitive(Primitive::Void),
&[],
)
};
if result.is_err() {
// A failed upcall is not worth aborting the process for. Clearing the pending
// exception leaves the VM usable.
let _ = env.exception_clear();
}
}
/// Resolves a direct byte buffer to the address the Host may read `length` bytes from.
fn direct_address(env: &JNIEnv<'_>, buffer: &JByteBuffer<'_>, length: jint) -> Option<*const u8> {
if length < 0 {
return None;
}
let address = env.get_direct_buffer_address(buffer).ok()?;
let capacity = env.get_direct_buffer_capacity(buffer).ok()?;
if capacity < length as usize {
return None;
}
Some(address.cast_const())
}
/// Reports where the batch sits, without copying a byte of it.
fn report(env: &mut JNIEnv<'_>, out: &JLongArray<'_>, batch: &MutationBatch) {
let (base, capacity) = current_arena();
let offset = if batch.ptr.is_null() || base.is_null() {
0
} else {
(batch.ptr as usize).wrapping_sub(base as usize)
};
let values: [jlong; OUT_SLOTS] = [
offset as jlong,
jlong::from(batch.len),
batch.result,
base as usize as jlong,
capacity as jlong,
];
let _ = env.set_long_array_region(out, 0, &values);
}
"#,
);
for op in BOUNDARY_SCHEMA {
write!(output, "{}", jni_shim(op)).unwrap();
}
output.push_str(
r#"/// Hands the Renderer a view of the Host's arena, not a copy of it.
///
/// Not a boundary operation: it exposes no Host behaviour, it only tells the JVM where
/// memory it may already read lives. The Renderer asks for it once, and again whenever a
/// call reports a different arena address, which is what happens when the arena grows and
/// moves.
#[unsafe(no_mangle)]
pub extern "system" fn ARENA_SYMBOL(mut env: JNIEnv<'_>, _class: JClass<'_>) -> jobject {
let (base, capacity) = current_arena();
if base.is_null() || capacity == 0 {
return std::ptr::null_mut();
}
// SAFETY: the arena belongs to this thread's Host and stays where it is until it
// grows, and every call reports the address so the Renderer can notice that.
match unsafe { env.new_direct_byte_buffer(base.cast_mut(), capacity) } {
Ok(buffer) => buffer.into_raw(),
Err(_) => std::ptr::null_mut(),
}
}
/// Runs on `System.loadLibrary`, before any boundary call.
///
/// # Safety
/// The JavaVM calls this with its own `JavaVM` pointer.
#[unsafe(no_mangle)]
pub unsafe extern "system" fn JNI_OnLoad(vm: *mut jni::sys::JavaVM, _reserved: *mut c_void) -> jint {
// SAFETY: the pointer comes from the JavaVM itself.
let Ok(vm) = (unsafe { JavaVM::from_raw(vm) }) else {
return JNI_ERR;
};
let Ok(mut env) = vm.attach_current_thread_permanently() else {
return JNI_ERR;
};
// The class is resolved here, on a Java-created thread, and kept as a global
// reference. A Host worker thread's class loader cannot see application classes, so
// looking it up later would fail.
let Ok(class) = env.find_class(BRIDGE_CLASS) else {
return JNI_ERR;
};
let Ok(method) = env.get_static_method_id(&class, "onFrameRequested", "()V") else {
return JNI_ERR;
};
let Ok(global) = env.new_global_ref(&class) else {
return JNI_ERR;
};
let _ = UPCALL.set(FrameRequestUpcall {
class: global,
method,
});
let _ = VM.set(vm);
let _ = install_renderer_api(RendererApi {
run: platform_run,
request_frame,
});
// SAFETY: the application's cdylib defines this symbol.
unsafe { compose_rust_android_main() };
JNI_VERSION_1_6
}
"#,
);
writeln!(
output,
"\nconst BRIDGE_CLASS: &str = \"{ANDROID_BRIDGE_CLASS}\";"
)
.unwrap();
rustfmt(output.replace("ARENA_SYMBOL", &jni_symbol("ArenaBuffer")))
}
fn rustfmt(source: String) -> String {
use std::io::Write as _;
use std::process::{Command, Stdio};
let Ok(mut child) = Command::new("rustfmt")
.args(["--edition", "2024", "--emit", "stdout", "--quiet"])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.spawn()
else {
return source;
};
let Some(mut stdin) = child.stdin.take() else {
return source;
};
let written = stdin.write_all(source.as_bytes());
#[allow(clippy::drop_non_drop)]
drop(stdin);
let Ok(output) = child.wait_with_output() else {
return source;
};
if written.is_err() || !output.status.success() {
return source;
}
String::from_utf8(output.stdout).unwrap_or(source)
}
fn jni_shim(op: &BoundaryOp) -> String {
let mut output = String::new();
writeln!(output, "/// `{}`, which calls `{}`.", op.name, op.symbol).unwrap();
output.push_str("#[unsafe(no_mangle)]\npub extern \"system\" fn ");
write!(output, "{}", jni_symbol(op.name)).unwrap();
output.push_str("(\n");
if op.returns_batch {
output.push_str(" mut env: JNIEnv<'_>,\n");
} else {
output.push_str(" _env: JNIEnv<'_>,\n");
}
output.push_str(" _class: JClass<'_>,\n");
for param in op.params {
match param {
BoundaryParam::Bytes { name } => {
writeln!(output, " {name}: JByteBuffer<'_>,").unwrap();
writeln!(output, " {name}_length: jint,").unwrap();
}
BoundaryParam::Nanos { name } => {
writeln!(output, " {}: jlong,", snake_case(name)).unwrap();
}
}
}
if op.returns_batch {
output.push_str(" out: JLongArray<'_>,\n");
}
output.push_str(") -> jint {\n");
let mut arguments = Vec::new();
for param in op.params {
match param {
BoundaryParam::Bytes { name } => {
writeln!(
output,
" let Some(address) = direct_address(&env, &{name}, {name}_length) else {{"
)
.unwrap();
output.push_str(" return STATUS_PROTOCOL_ERROR;\n };\n");
arguments.push("address".to_owned());
arguments.push(format!("{name}_length as u32"));
}
BoundaryParam::Nanos { name } => {
arguments.push(format!("{} as u64", snake_case(name)));
}
}
}
if op.returns_batch || op.name == "ReleaseBatch" {
output.push_str(" let mut batch = MutationBatch::default();\n");
arguments.push("&raw mut batch".to_owned());
}
let call = format!("crate::boundary::{}({})", op.symbol, arguments.join(", "));
if op.symbol == "compose_rust_host_shutdown" {
writeln!(output, " {call};").unwrap();
output.push_str(" STATUS_OK\n}\n\n");
return output;
}
output.push_str(
" // SAFETY: every pointer above addresses the number of readable bytes the\n\
\x20 // boundary contract promises, and the batch is local to this call.\n",
);
let statement = if op.returns_batch {
format!(" let status = unsafe {{ {call} }};")
} else {
format!(" unsafe {{ {call} }};")
};
if statement.len() > 100 {
if op.returns_batch {
output.push_str(" let status = unsafe {\n");
} else {
output.push_str(" unsafe {\n");
}
writeln!(output, " {call}").unwrap();
output.push_str(" };\n");
} else {
output.push_str(&statement);
output.push('\n');
}
if op.returns_batch {
output.push_str(" report(&mut env, &out, &batch);\n status\n}\n\n");
} else {
output.push_str(" STATUS_OK\n}\n\n");
}
output
}
fn snake_case(name: &str) -> String {
let mut output = String::new();
for (index, character) in name.char_indices() {
if character.is_ascii_uppercase() {
if index != 0 {
output.push('_');
}
output.push(character.to_ascii_lowercase());
} else {
output.push(character);
}
}
output
}
pub fn generate_android_bridge_kotlin() -> String {
let mut output = String::new();
output.push_str("// Generated by `cargo run -p compose-rust --bin codegen`. DO NOT EDIT.\n");
output.push_str("@file:JvmName(\"HostBridge\")\n\n");
writeln!(output, "package {ANDROID_KOTLIN_PACKAGE}\n").unwrap();
output.push_str("import android.util.Log\n");
output.push_str("import dalvik.annotation.optimization.FastNative\n");
output.push_str("import java.nio.ByteBuffer\n\n");
output.push_str(
r#"// The Android boundary.
//
// Every declaration below is generated from the Rust boundary schema, so it cannot drift
// from the shims that implement it. A batch-returning call writes the slots below into the
// caller's reusable LongArray and copies nothing: the batch itself stays in the Host's
// arena, which the caller reads through one direct ByteBuffer.
"#,
);
for (index, (name, description)) in OUT_SLOTS.iter().enumerate() {
writeln!(output, "/** Slot {index}: {description}. */").unwrap();
writeln!(
output,
"const val OUT_{}: Int = {index}",
screaming_snake_case(name)
)
.unwrap();
}
writeln!(
output,
"\n/** The size of the `out` array a batch-returning call expects. */"
)
.unwrap();
writeln!(output, "const val OUT_SLOTS: Int = {}\n", OUT_SLOTS.len()).unwrap();
for op in BOUNDARY_SCHEMA {
writeln!(output, "/** `{}`, which calls `{}`. */", op.name, op.symbol).unwrap();
if op.fast {
output.push_str("@FastNative\n");
}
write!(output, "external fun {}(", kotlin_native_name(op.name)).unwrap();
let mut parameters = Vec::new();
for param in op.params {
match param {
BoundaryParam::Bytes { name } => {
parameters.push(format!("{name}: ByteBuffer"));
parameters.push(format!("{name}Length: Int"));
}
BoundaryParam::Nanos { name } => parameters.push(format!("{name}: Long")),
}
}
if op.returns_batch {
parameters.push("out: LongArray".to_owned());
}
writeln!(output, "{}): Int\n", parameters.join(", ")).unwrap();
}
output.push_str(
r#"/**
* A view of the Host's batch arena.
*
* Null before the Host exists. Ask again whenever `OUT_ARENA_ADDRESS` changes: a grown
* arena is a new allocation, and the old view points at freed memory.
*/
external fun nativeArenaBuffer(): ByteBuffer?
/**
* Called from a Host worker thread through JNI.
*
* It must stay cheap and thread-safe: it only bumps a counter the UI thread observes
* inside its frame clock, so any number of requests between two frames become one frame.
*/
fun onFrameRequested() {
// Evidence that domain work stays off the UI thread, off by default. Turn it on with
// `adb shell setprop log.tag.dxc-frame VERBOSE`: the thread named in the line is the
// Host worker that asked, and it is never the UI thread.
if (Log.isLoggable(FRAME_TAG, Log.VERBOSE)) {
Log.v(FRAME_TAG, "frame requested by ${Thread.currentThread().name}")
}
FrameRequests.request()
}
private const val FRAME_TAG = "dxc-frame"
"#,
);
output
}
pub fn generate_fast_native_kotlin() -> String {
String::from(
r#"// Generated by `cargo run -p compose-rust --bin codegen`. DO NOT EDIT.
package dalvik.annotation.optimization
/**
* The runtime's marker for a native method that needs no thread state transition.
*
* The runtime carries it, but `android.jar` does not declare it, so the only way to apply
* it from application code is to declare it. It is matched by type descriptor, and a
* runtime that does not know it ignores it, which is why an unannotated call still works.
*
* A method carrying it must not run long: the thread stays in the runnable state, so the
* garbage collector cannot suspend it while the call is in flight. Only calls that cannot
* run the VirtualDom carry it.
*/
@Retention(AnnotationRetention.BINARY)
@Target(AnnotationTarget.FUNCTION)
annotation class FastNative
"#,
)
}
fn screaming_snake_case(name: &str) -> String {
let mut output = String::new();
for (index, character) in name.char_indices() {
if character.is_ascii_uppercase() && index != 0 {
output.push('_');
}
output.push(character.to_ascii_uppercase());
}
output
}
pub const WASM_RUST_RELATIVE_PATH: &str = "src/boundary_wasm.gen.rs";
pub const WEB_BRIDGE_RELATIVE_PATH: &str = "../renderer/web/src/bridge/HostBridge.gen.kt";
pub const WEB_LOADER_RELATIVE_PATH: &str = "../renderer/web/resources/compose-rust-host.gen.mjs";
const WEB_KOTLIN_PACKAGE: &str = "dev.darkpyonix.composerust.ui.platform";
pub const WEB_HOST_WASM_NAME: &str = "compose_rust_host.wasm";
pub const WEB_MEMORY_MIN_PAGES: u32 = 128;
fn web_symbol(op_name: &str) -> String {
format!("compose_rust_host_web_{}", snake_case(op_name))
}
fn web_kotlin_name(op_name: &str) -> String {
format!("host{op_name}")
}
fn web_parameters(op: &BoundaryOp) -> Vec<String> {
let mut parameters = Vec::new();
for param in op.params {
match param {
BoundaryParam::Bytes { name } => {
parameters.push((*name).to_owned());
parameters.push(format!("{name}Length"));
}
BoundaryParam::Nanos { name } => {
parameters.push(format!("{name}Low"));
parameters.push(format!("{name}High"));
}
}
}
if op.returns_batch || op.name == "ReleaseBatch" {
parameters.push("out".to_owned());
}
parameters
}
const INSTALL_HOST_KOTLIN: &str = r##"/**
* Instantiates the Host on this module's memory and answers with the address of the block
* it lends back: the record a call reports into, and the buffer an event is encoded in.
* Zero means there is no Host on this page.
*
* Called once, from `main`. That is the first moment at which both halves exist: this
* module's own instantiation created the memory the Host imports, and the page compiled the
* Host's module before that started.
*/
@JsFun(
"""() => {
const compiled = globalThis.MODULE_GLOBAL;
if (!compiled) return 0;
const memory = wasmExports.memory;
const pages = memory.buffer.byteLength / 65536;
// This module's memory starts at zero pages and the Host's import declares a minimum,
// so the two module types do not match until this has run.
if (pages < MEMORY_MIN_PAGES) memory.grow(MEMORY_MIN_PAGES - pages);
// The wasm-bindgen placeholders dioxus-core brings in through subsecond. A browser will
// not instantiate a module with an import nobody supplied, used or not, and their names
// carry a per-version hash, so this answers whatever is asked rather than a list that
// would go stale. Nothing on the boundary goes near one.
const unbound = (namespace) => new Proxy({}, {
get: (_, name) => () => {
throw new Error('compose-rust: the Host called ' + namespace + '.' + String(name) +
', which is a wasm-bindgen import this page does not provide. Nothing on the ' +
'boundary uses one, so a call here means the Host reached JavaScript through a ' +
'dependency rather than through the boundary.');
},
});
const host = new WebAssembly.Instance(compiled, {
env: { memory },
RENDERER_IMPORT_MODULE: {
// The exported function object itself, not a closure around it: bound this way the
// engine builds no JavaScript frame for the call.
compose_rust_renderer_request_frame:
wasmExports.compose_rust_renderer_request_frame,
},
__wbindgen_placeholder__: unbound('__wbindgen_placeholder__'),
__wbindgen_externref_xform__: unbound('__wbindgen_externref_xform__'),
}).exports;
globalThis.HOST_GLOBAL = host;
const block = host.START_SYMBOL();
if (block < REGION_BASE) {
// Either the Host could not start, or its data landed in the half of the memory this
// module's allocator uses. The second draws a wrong screen instead of failing, so
// neither is allowed to become the first boundary call.
globalThis.HOST_GLOBAL = undefined;
throw new Error('compose-rust: the Host reported its boundary block at ' + block +
', which is not inside the region above REGION_BASE that it was linked into. ' +
'Check that it was linked with --import-memory and --global-base.');
}
return block;
}""",
)
external fun installHost(): Int
"##;
pub fn generate_web_bridge_kotlin() -> String {
let mut output = String::new();
output.push_str("// Generated by `cargo run -p compose-rust --bin codegen`. DO NOT EDIT.\n");
output.push_str(
"@file:OptIn(\n \
kotlin.js.ExperimentalWasmJsInterop::class,\n \
kotlin.wasm.ExperimentalWasmInterop::class,\n)\n\n",
);
writeln!(output, "package {WEB_KOTLIN_PACKAGE}\n").unwrap();
output.push_str("import kotlin.wasm.WasmExport\n\n");
output.push_str(
r#"// The web boundary.
//
// One WebAssembly.Memory holds both modules. The Kotlin module defines and exports it,
// because a Kotlin/Wasm module cannot import one, and the Host's module is linked against
// it, so a batch is read where the Host wrote it and no byte of it is copied.
//
// Every call below goes through a JavaScript arrow function of a fixed shape, measured at
// about 12ns. It is there because the two halves of the wiring cannot both be had: a wasm
// import bound straight to a wasm export costs 1.5ns but has to be supplied before the
// Kotlin module is instantiated, and the memory it would need to share does not exist
// until that instantiation. The forwarder passes its arguments on and does nothing else:
// no encoding, no copy, no queue.
//
// The other direction has no JavaScript on it. The page hands the Host's instantiation the
// function object Kotlin exports below, and the engine binds that edge as a wasm call.
"#,
);
writeln!(
output,
"/**\n \
* The first address that belongs to the Host's module.\n \
*\n \
* Below it is this module's `kotlin.wasm.unsafe` allocator; at and above it are the\n \
* Host's data, stack and heap. Two allocators hand out addresses in one memory, and an\n \
* overlap does not crash, it draws the wrong screen, so every address that crosses is\n \
* checked against this line.\n \
*/\n\
const val RUST_REGION_BASE: Int = {WEB_RUST_REGION_BASE}\n"
)
.unwrap();
output.push_str(
"/**\n \
* The batch record the Host writes, field by field.\n \
*\n \
* A wasm32 `#[repr(C)]` layout: two four byte fields and then an eight byte one, which\n \
* the padding before it puts at 8. The Host's generated shims assert this against the\n \
* layout its compiler chose, so a disagreement stops that build.\n \
*/\n",
);
for field in WEB_BATCH_FIELDS {
writeln!(output, "/** {}. */", field.description).unwrap();
writeln!(
output,
"const val BATCH_{}_OFFSET: Int = {}",
screaming_snake_case(field.name),
field.offset
)
.unwrap();
}
writeln!(
output,
"\n/** How long that record is, padding included. */\nconst val BATCH_BYTES: Int = {WEB_BATCH_BYTES}\n"
)
.unwrap();
writeln!(
output,
"/**\n \
* Where the event buffer starts inside the block the Host lends this side.\n \
*\n \
* The out record comes first and the event buffer follows it. Both belong to the Host,\n \
* because `kotlin.wasm.unsafe` only hands out addresses inside a scope and this side\n \
* needs two buffers that outlive every scope it could open.\n \
*/\n\
const val EVENT_BUFFER_OFFSET: Int = {WEB_EVENT_BUFFER_OFFSET}\n"
)
.unwrap();
writeln!(
output,
"/** How much room there is to encode one event into. */\nconst val EVENT_BUFFER_BYTES: Int = {WEB_EVENT_BUFFER_BYTES}\n"
)
.unwrap();
for op in BOUNDARY_SCHEMA {
let parameters = web_parameters(op);
let arguments = parameters.join(", ");
writeln!(output, "/** `{}`, which calls `{}`. */", op.name, op.symbol).unwrap();
writeln!(
output,
"@JsFun(\"({arguments}) => globalThis.{WEB_HOST_GLOBAL}.{}({arguments})\")",
web_symbol(op.name)
)
.unwrap();
let declared: Vec<String> = parameters
.iter()
.map(|name| format!("{name}: Int"))
.collect();
writeln!(
output,
"external fun {}({}): Int\n",
web_kotlin_name(op.name),
declared.join(", ")
)
.unwrap();
}
output.push_str(
INSTALL_HOST_KOTLIN
.replace("MODULE_GLOBAL", WEB_MODULE_GLOBAL)
.replace("HOST_GLOBAL", WEB_HOST_GLOBAL)
.replace("RENDERER_IMPORT_MODULE", WEB_RENDERER_IMPORT_MODULE)
.replace("START_SYMBOL", WEB_START_SYMBOL)
.replace("MEMORY_MIN_PAGES", &WEB_MEMORY_MIN_PAGES.to_string())
.replace("REGION_BASE", &WEB_RUST_REGION_BASE.to_string())
.as_str(),
);
writeln!(
output,
"/**\n \
* Called by the Host when work done off the frame loop needs one.\n \
*\n \
* A browser tab is one thread, so this arrives on the same thread that will answer it.\n \
* It only bumps a counter the frame clock observes, so any number of requests between\n \
* two frames become one frame. The page also calls it once after the Host is installed,\n \
* which is what gets the first tree drawn.\n \
*/\n\
@WasmExport(\"compose_rust_renderer_request_frame\")\n\
fun onFrameRequested() {{\n \
FrameRequests.request()\n\
}}"
)
.unwrap();
output
}
pub fn generate_web_loader_js() -> String {
let mut output = String::new();
output.push_str("// Generated by `cargo run -p compose-rust --bin codegen`. DO NOT EDIT.\n");
output.push_str(
r#"//
// The page's half of the web boundary: compile the Host, and leave it where the Renderer
// will look for it.
//
// `WebAssembly.instantiate` wants every import before it will give you an instance, and
// the two modules each hold what the other needs. The Renderer's module defines the one
// linear memory, which the Host imports; the Host exports the functions the Renderer
// calls. Nothing inside wasm breaks that cycle, so the page breaks it by order:
//
// 1. This module is evaluated before the Renderer's, because it comes first on the page,
// and compiles the Host. Compiling needs no imports.
// 2. The Renderer's module is instantiated, which is what creates the memory.
// 3. The Renderer's `main` instantiates the Host on that memory, and does it
// synchronously, because the module was already compiled in step 1.
//
// Only step 1 is here. Step 3 needs the memory and the Renderer's own exports, and only
// the Renderer's module can name either, so it sits in the generated Kotlin beside the
// forwarders.
"#,
);
writeln!(output, "const HOST_WASM = './{WEB_HOST_WASM_NAME}';\n").unwrap();
writeln!(output, "globalThis.{WEB_MODULE_GLOBAL} = null;").unwrap();
output.push_str("try {\n globalThis.");
output.push_str(WEB_MODULE_GLOBAL);
output.push_str(
r#" = await WebAssembly.compileStreaming(
fetch(new URL(HOST_WASM, import.meta.url)),
);
} catch (error) {
// No Host on this page, which is a page serving the renderer on its own. The renderer
// falls back to its scripted development host, and this says why on the console rather
// than leaving an empty screen to be puzzled over.
console.info(
`compose-rust: no Host module beside this page (${HOST_WASM}: ${error}). ` +
'The renderer will draw its development host instead.',
);
}
"#,
);
output
}
pub fn generate_wasm_rust() -> String {
let mut output = String::new();
output.push_str("// Generated by `cargo run -p compose-rust --bin codegen`. DO NOT EDIT.\n");
output.push_str(
r#"//! The wasm shims for the web boundary.
//!
//! The Renderer owns the one linear memory and this module imports it, so a batch is read
//! where it was written. That leaves the addresses: every argument below is an address or a
//! length in that shared memory, and both buffers a call names belong to this side, because
//! the Renderer has no allocator that can hold memory from one frame to the next.
//!
//! Every argument is 32 bits wide. A 64 bit one would reach the forwarder as a `BigInt`,
//! which is a heap allocation on the call that happens every frame, so a frame timestamp
//! arrives as its two halves and is put back together here.
use crate::boundary::{
MutationBatch, RendererApi, STATUS_OK, STATUS_PROTOCOL_ERROR, install_renderer_api,
};
use crate::schema::{
WEB_BATCH_BYTES, WEB_EVENT_BUFFER_BYTES, WEB_EVENT_BUFFER_OFFSET, WEB_RUST_REGION_BASE,
};
use crate::{LaunchBuilder, LoopMode, Runtime};
use std::ffi::c_int;
use std::mem::{offset_of, size_of};
"#,
);
writeln!(
output,
"#[link(wasm_import_module = \"{WEB_RENDERER_IMPORT_MODULE}\")]"
)
.unwrap();
output.push_str(
r#"unsafe extern "C" {
/// Bound to the function the Renderer exports, with no JavaScript in between: the page
/// hands the exported function object straight to this module's instantiation, which it
/// can because this module is instantiated second.
fn compose_rust_renderer_request_frame();
}
/// The record layout the generated Kotlin reads a reply out of.
///
/// Written down on the Kotlin side as constants and checked here against what this
/// compiler actually chose. A layout that disagreed would not fail anywhere else: the
/// Renderer would read four bytes at the wrong offset and draw whatever they happened to
/// mean.
const _: () = {
assert!(size_of::<MutationBatch>() == WEB_BATCH_BYTES as usize);
"#,
);
for field in WEB_BATCH_FIELDS {
let member = match field.name {
"Address" => "ptr",
"Length" => "len",
"Result" => "result",
other => panic!("no MutationBatch member is named {other}"),
};
writeln!(
output,
" assert!(offset_of!(MutationBatch, {member}) == {});",
field.offset
)
.unwrap();
}
output.push_str("};\n\n");
output.push_str(
r#"/// What the Host lends the Renderer, once, for the life of the page.
///
/// The Renderer cannot own either buffer. `kotlin.wasm.unsafe` hands out addresses that
/// are only valid inside the scope that asked for them, and opening a scope per call is
/// the steady-state allocation the frame budget does not have. So both sit here, in this
/// module's region, and the Renderer keeps the address.
#[repr(C)]
struct BoundaryBlock {
/// Where a call writes what it did. The Renderer reads it and releases it on the same
/// call stack.
out: MutationBatch,
/// Where the Renderer encodes one event before the call that carries it.
event: [u8; WEB_EVENT_BUFFER_BYTES as usize],
}
/// A browser tab is one thread, and the Renderer needs this address to stay where it is
/// between calls, so it is a static rather than a thread local.
static mut BLOCK: BoundaryBlock = BoundaryBlock {
out: MutationBatch {
ptr: std::ptr::null(),
len: 0,
result: 0,
},
event: [0; WEB_EVENT_BUFFER_BYTES as usize],
};
/// Where the Renderer is told the event buffer starts, against where it really starts.
const _: () = assert!(offset_of!(BoundaryBlock, event) == WEB_EVENT_BUFFER_OFFSET as usize);
/// The page owns the loop, so the Host never runs one.
extern "C" fn platform_run() -> c_int {
STATUS_OK as c_int
}
extern "C" fn request_frame() {
// SAFETY: the page supplied this import from the Renderer's exports before this
// module was instantiated, so there is a function here to call.
unsafe { compose_rust_renderer_request_frame() };
}
/// Whether an address the Renderer passed is one this side lent it.
///
/// Every buffer a call names is in this module's region. An address below the region is
/// either the Renderer allocator's, which would mean the two allocators have grown into
/// each other, or a stray number, and neither is worth reading or writing through.
fn lent(address: u32) -> bool {
address >= WEB_RUST_REGION_BASE
}
"#,
);
for op in BOUNDARY_SCHEMA {
write!(output, "{}", wasm_shim(op)).unwrap();
}
writeln!(
output,
r#"/// Starts the Host and reports where the block it lends the Renderer sits.
///
/// A page has no library loader, so this is where the work `JNI_OnLoad` does on Android
/// goes: install the renderer API, then register the application's runtime. Zero means the
/// block is not somewhere the Renderer may read, and the Renderer makes no boundary call at
/// all in that case.
///
/// The application exports this as `{WEB_START_SYMBOL}` through its authoring layer's
/// entry macro (`web_main!` in the Dioxus adapter), and the export lives there rather than
/// here because a wasm module cannot be linked with an undefined symbol the way an ELF
/// shared library can: an import nobody satisfies stops the module from being
/// instantiated, so this crate's own module must not name a function only an application
/// can define.
///
/// The builder comes from the application rather than being made here, because
/// everything an application settles before it launches, its theme above all, is settled
/// on a builder. Making one here would mean a page ignored the theme its own desktop
/// binary uses and drew the same screens in a different design system.
pub fn web_start(
builder: LaunchBuilder,
runtime: impl Fn() -> Box<dyn Runtime> + Send + Sync + 'static,
) -> u32 {{
let _ = install_renderer_api(RendererApi {{
run: platform_run,
request_frame,
}});
let status = builder
.with_mode(LoopMode::Platform)
.try_launch_runtime(runtime);
if status != STATUS_OK {{
return 0;
}}
let address = (&raw const BLOCK) as usize as u32;
if lent(address) {{ address }} else {{ 0 }}
}}"#
)
.unwrap();
rustfmt(output)
}
fn wasm_shim(op: &BoundaryOp) -> String {
let mut output = String::new();
writeln!(output, "/// `{}`, which calls `{}`.", op.name, op.symbol).unwrap();
output.push_str("#[unsafe(no_mangle)]\npub extern \"C\" fn ");
write!(output, "{}(", web_symbol(op.name)).unwrap();
let parameters = web_parameters(op);
let declared: Vec<String> = parameters
.iter()
.map(|name| format!("{}: u32", snake_case(name)))
.collect();
writeln!(output, "{}) -> i32 {{", declared.join(", ")).unwrap();
let mut guards: Vec<String> = Vec::new();
let mut arguments: Vec<String> = Vec::new();
let mut bindings: Vec<String> = Vec::new();
for param in op.params {
match param {
BoundaryParam::Bytes { name } => {
let address = snake_case(name);
let length = snake_case(&format!("{name}Length"));
guards.push(format!("({length} == 0 || lent({address}))"));
arguments.push(format!("{address} as *const u8"));
arguments.push(length);
}
BoundaryParam::Nanos { name } => {
let low = snake_case(&format!("{name}Low"));
let high = snake_case(&format!("{name}High"));
bindings.push(format!(
" let {} = (u64::from({high}) << 32) | u64::from({low});",
snake_case(name)
));
arguments.push(snake_case(name));
}
}
}
let takes_out = op.returns_batch || op.name == "ReleaseBatch";
if takes_out {
guards.push("lent(out)".to_owned());
arguments.push("out as *mut MutationBatch".to_owned());
}
if !guards.is_empty() {
let condition = if guards.len() == 1 {
guards[0].clone()
} else {
guards.join(" && ")
};
writeln!(output, " if !({condition}) {{").unwrap();
output.push_str(" return STATUS_PROTOCOL_ERROR;\n }\n");
}
for binding in bindings {
writeln!(output, "{binding}").unwrap();
}
let call = format!("crate::boundary::{}({})", op.symbol, arguments.join(", "));
if op.symbol == "compose_rust_host_shutdown" {
writeln!(output, " {call};").unwrap();
output.push_str(" STATUS_OK\n}\n\n");
return output;
}
output.push_str(
" // SAFETY: every address above was checked to be inside this module's region,\n\
\x20 // which is where the only buffers the Renderer can name live.\n",
);
if op.returns_batch {
writeln!(output, " unsafe {{ {call} }}").unwrap();
} else {
writeln!(output, " unsafe {{ {call} }};").unwrap();
output.push_str(" STATUS_OK\n");
}
output.push_str("}\n\n");
output
}