summaryrefslogtreecommitdiff
path: root/core/src/jvmMain
diff options
context:
space:
mode:
authorElizabeth Alexander Hunt <me@liz.coffee>2026-11-12 21:11:08 -0800
committerElizabeth Alexander Hunt <me@liz.coffee>2026-11-12 21:11:08 -0800
commit3afb04de43726d71580b626e856c68b6ed79984e (patch)
tree33f7bcb227ff8cfcb046a10036d2faa69c2edd1f /core/src/jvmMain
downloadrookery-3afb04de43726d71580b626e856c68b6ed79984e.tar.gz
rookery-3afb04de43726d71580b626e856c68b6ed79984e.zip
Rookery: penguin-themed cubing toolkit, phases 0-1 and GAN BLE
Kotlin Multiplatform + Compose desktop app. - Sync: per-device append-only op logs (HLC-stamped LWW) in a plain folder, safe under Syncthing/Drive; content-addressed image blobs. - Timer: WCA inspection, +2/DNF, solve table, WCA-correct aoN/moN, per-event lifetime PBs with a pixel-penguin party and synthesized 8-bit chime. Mascot sprite from liz.coffee. - Scrambles for all WCA events + FTO via tnoodle-lib, with previews. - Importers: csTimer exports (incl. smart-cube reconstructions and cube MACs) and the Letter Pair Gym prototype (words, notes, images, history, practice stats). Idempotent; headless `--import`. - Letter-pair grid with miss heatmap, glance lookup and editor. - 3D puzzle engine (polyhedron + cut planes) and Canvas renderer: NxN 2-7, Pyraminx, Skewb, verified sticker-for-sticker against tnoodle; 3D replay of smart-cube solves. - GAN Gen2/3/4 smart-cube protocol (ported from gan-web-bluetooth, MIT) with a BlueZ D-Bus transport and `--cube scan|monitor` CLI. Not yet tested against hardware. - lizCI pipeline (.ci) building a test+package Docker target. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'core/src/jvmMain')
-rw-r--r--core/src/jvmMain/kotlin/coffee/liz/rookery/Platform.jvm.kt6
-rw-r--r--core/src/jvmMain/kotlin/coffee/liz/rookery/audio/ChimePlayer.kt21
-rw-r--r--core/src/jvmMain/kotlin/coffee/liz/rookery/bt/BlueZTransport.kt287
-rw-r--r--core/src/jvmMain/kotlin/coffee/liz/rookery/bt/JvmAes128.kt17
-rw-r--r--core/src/jvmMain/kotlin/coffee/liz/rookery/scramble/TnoodleScrambler.kt50
-rw-r--r--core/src/jvmMain/kotlin/coffee/liz/rookery/store/FolderStorage.kt96
6 files changed, 477 insertions, 0 deletions
diff --git a/core/src/jvmMain/kotlin/coffee/liz/rookery/Platform.jvm.kt b/core/src/jvmMain/kotlin/coffee/liz/rookery/Platform.jvm.kt
new file mode 100644
index 0000000..bbccb57
--- /dev/null
+++ b/core/src/jvmMain/kotlin/coffee/liz/rookery/Platform.jvm.kt
@@ -0,0 +1,6 @@
+package coffee.liz.rookery
+
+actual fun epochMillis(): Long = System.currentTimeMillis()
+actual fun monoMillis(): Long = System.nanoTime() / 1_000_000
+actual fun randomId(): String = java.util.UUID.randomUUID().toString().replace("-", "").take(16)
+actual fun <T> locked(lock: Any, block: () -> T): T = synchronized(lock, block)
diff --git a/core/src/jvmMain/kotlin/coffee/liz/rookery/audio/ChimePlayer.kt b/core/src/jvmMain/kotlin/coffee/liz/rookery/audio/ChimePlayer.kt
new file mode 100644
index 0000000..3b70b67
--- /dev/null
+++ b/core/src/jvmMain/kotlin/coffee/liz/rookery/audio/ChimePlayer.kt
@@ -0,0 +1,21 @@
+package coffee.liz.rookery.audio
+
+import javax.sound.sampled.AudioFormat
+import javax.sound.sampled.AudioSystem
+
+/** Plays [Chime] PCM on a daemon thread. Silently does nothing if there's no audio device. */
+object ChimePlayer {
+ fun play(notes: List<Chime.Note>) {
+ val pcm = Chime.render(notes)
+ Thread {
+ runCatching {
+ val format = AudioFormat(Chime.SAMPLE_RATE.toFloat(), 16, 1, true, false)
+ val bytes = ByteArray(pcm.size * 2)
+ for (i in pcm.indices) { bytes[2 * i] = pcm[i].toByte(); bytes[2 * i + 1] = (pcm[i].toInt() shr 8).toByte() }
+ AudioSystem.getSourceDataLine(format).use { line ->
+ line.open(format); line.start(); line.write(bytes, 0, bytes.size); line.drain()
+ }
+ }
+ }.apply { isDaemon = true; name = "chime" }.start()
+ }
+}
diff --git a/core/src/jvmMain/kotlin/coffee/liz/rookery/bt/BlueZTransport.kt b/core/src/jvmMain/kotlin/coffee/liz/rookery/bt/BlueZTransport.kt
new file mode 100644
index 0000000..db2ae70
--- /dev/null
+++ b/core/src/jvmMain/kotlin/coffee/liz/rookery/bt/BlueZTransport.kt
@@ -0,0 +1,287 @@
+@file:Suppress("FunctionName")
+
+package coffee.liz.rookery.bt
+
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.NonCancellable
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.callbackFlow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.flow.flowOn
+import kotlinx.coroutines.flow.receiveAsFlow
+import kotlinx.coroutines.withContext
+import kotlinx.coroutines.withTimeoutOrNull
+import kotlinx.coroutines.channels.awaitClose
+import org.freedesktop.dbus.DBusPath
+import org.freedesktop.dbus.annotations.DBusInterfaceName
+import org.freedesktop.dbus.connections.impl.DBusConnection
+import org.freedesktop.dbus.connections.impl.DBusConnectionBuilder
+import org.freedesktop.dbus.interfaces.DBusInterface
+import org.freedesktop.dbus.interfaces.ObjectManager
+import org.freedesktop.dbus.interfaces.Properties
+import org.freedesktop.dbus.types.Variant
+import java.util.concurrent.CopyOnWriteArrayList
+
+@DBusInterfaceName("org.bluez.Adapter1")
+@JvmSuppressWildcards
+internal interface BluezAdapter1 : DBusInterface {
+ fun StartDiscovery()
+ fun StopDiscovery()
+ fun SetDiscoveryFilter(filter: Map<String, Variant<*>>)
+}
+
+@DBusInterfaceName("org.bluez.Device1")
+internal interface BluezDevice1 : DBusInterface {
+ fun Connect()
+ fun Disconnect()
+}
+
+@DBusInterfaceName("org.bluez.GattCharacteristic1")
+@JvmSuppressWildcards
+internal interface BluezGattCharacteristic1 : DBusInterface {
+ fun ReadValue(options: Map<String, Variant<*>>): ByteArray
+ fun WriteValue(value: ByteArray, options: Map<String, Variant<*>>)
+ fun StartNotify()
+ fun StopNotify()
+}
+
+/**
+ * [BleTransport] over BlueZ's D-Bus API (Linux). Only discovers, connects and disconnects:
+ * never pairs, trusts or changes adapter settings. Close it to release the D-Bus connection.
+ */
+class BlueZTransport(private val adapterName: String? = null) : BleTransport, AutoCloseable {
+ private val bus: DBusConnection by lazy {
+ DBusConnectionBuilder.forSystemBus().withShared(false).build().also { c ->
+ c.addSigHandler(Properties.PropertiesChanged::class.java) { s ->
+ // dbus-java can't match on a well-known sender name, so filter by BlueZ's object paths instead.
+ if (s.path.startsWith(BLUEZ_ROOT)) listeners.forEach { it(s.path, s.interfaceName, s.propertiesChanged) }
+ }
+ c.addSigHandler(ObjectManager.InterfacesAdded::class.java) { s ->
+ val path = s.signalSource.path
+ if (path.startsWith(BLUEZ_ROOT)) s.interfaces.forEach { (iface, props) -> listeners.forEach { it(path, iface, props) } }
+ }
+ }
+ }
+
+ /** (objectPath, interface, changedProperties) */
+ private val listeners = CopyOnWriteArrayList<(String, String, Map<String, Variant<*>>) -> Unit>()
+
+ private fun managedObjects(): Map<String, Map<String, Map<String, Variant<*>>>> =
+ bus.getRemoteObject(BLUEZ, "/", ObjectManager::class.java).GetManagedObjects().mapKeys { it.key.path }
+
+ private fun adapterPath(objects: Map<String, Map<String, Map<String, Variant<*>>>> = managedObjects()): String {
+ val adapters = objects.filterValues { ADAPTER in it }.keys.sorted()
+ if (adapterName != null) return adapters.firstOrNull { it.endsWith("/$adapterName") } ?: error("No Bluetooth adapter $adapterName")
+ return adapters.firstOrNull { objects[it]!![ADAPTER]!!["Powered"]?.value == true } ?: adapters.firstOrNull()
+ ?: error("No Bluetooth adapter found (is bluetoothd running?)")
+ }
+
+ private fun adapter(path: String) = bus.getRemoteObject(BLUEZ, path, BluezAdapter1::class.java)
+
+ private fun devicePath(adapter: String, address: String) = "$adapter/dev_" + address.uppercase().replace(':', '_')
+
+ override fun scan(): Flow<BleAdvertisement> = callbackFlow {
+ val objects = managedObjects()
+ val adapterPath = adapterPath(objects)
+ val devices = HashMap<String, MutableMap<String, Any?>>()
+ fun update(path: String, props: Map<String, Variant<*>>, emitIfNoRssi: Boolean) {
+ if (!path.startsWith("$adapterPath/dev_") || path.count { it == '/' } != adapterPath.count { it == '/' } + 1) return
+ val merged = synchronized(devices) {
+ devices.getOrPut(path) { HashMap() }.also { m -> props.forEach { (k, v) -> m[k] = v.value } }.toMap()
+ }
+ if (!emitIfNoRssi && merged["RSSI"] == null) return
+ toAdvertisement(merged)?.let { trySend(it) }
+ }
+ val listener: (String, String, Map<String, Variant<*>>) -> Unit = { path, iface, props -> if (iface == DEVICE) update(path, props, true) }
+ listeners += listener
+ // Devices BlueZ already knows about are only reported if they carry a recent RSSI.
+ objects.forEach { (path, ifaces) -> ifaces[DEVICE]?.let { update(path, it, false) } }
+ val adapter = adapter(adapterPath)
+ runCatching { adapter.SetDiscoveryFilter(mapOf("Transport" to Variant("le"), "DuplicateData" to Variant(true))) }
+ try {
+ adapter.StartDiscovery()
+ } catch (e: Exception) {
+ // Another client may already be discovering on this adapter; that still yields results.
+ if (e.message?.contains("InProgress") != true) {
+ listeners -= listener
+ throw e
+ }
+ }
+ awaitClose {
+ listeners -= listener
+ runCatching { adapter.StopDiscovery() }
+ }
+ }.flowOn(Dispatchers.IO)
+
+ override suspend fun connect(address: String): BleConnection = withContext(Dispatchers.IO) {
+ val objects = managedObjects()
+ val adapterPath = adapterPath(objects)
+ val path = devicePath(adapterPath, address)
+ if (path !in objects) {
+ // BlueZ only knows devices it has seen: discover until this one shows up.
+ withTimeoutOrNull(20_000) { scan().first { it.address.equals(address, ignoreCase = true) } }
+ ?: error("Device $address not found; is it awake and in range?")
+ }
+ val conn = BlueZConnection(path, address)
+ try {
+ conn.open()
+ } catch (e: Throwable) {
+ conn.close()
+ throw e
+ }
+ conn
+ }
+
+ override fun close() {
+ runCatching { bus.close() }
+ }
+
+ private fun props(path: String, iface: String): Map<String, Any?> =
+ bus.getRemoteObject(BLUEZ, path, Properties::class.java).GetAll(iface).mapValues { it.value.value }
+
+ private inner class BlueZConnection(private val path: String, override val address: String) : BleConnection {
+ private val device = bus.getRemoteObject(BLUEZ, path, BluezDevice1::class.java)
+ private val gone = CompletableDeferred<Unit>()
+ private val notifying = CopyOnWriteArrayList<String>()
+ private val channels = CopyOnWriteArrayList<Channel<ByteArray>>()
+ private val ownListeners = CopyOnWriteArrayList<(String, String, Map<String, Variant<*>>) -> Unit>()
+ private var chars: Map<Pair<String, String>, String> = emptyMap()
+ override var name: String? = null
+ private set
+
+ private fun listen(l: (String, String, Map<String, Variant<*>>) -> Unit) {
+ ownListeners += l
+ listeners += l
+ }
+
+ suspend fun open() {
+ listen { p, iface, changed ->
+ if (p == path && iface == DEVICE && changed["Connected"]?.value == false) markGone()
+ }
+ var last: Exception? = null
+ for (attempt in 1..3) {
+ try {
+ device.Connect()
+ last = null
+ break
+ } catch (e: Exception) {
+ // "le-connection-abort-by-local" and friends are common and usually transient.
+ last = e
+ delay(500)
+ }
+ }
+ last?.let { throw IllegalStateException("Connect to $address failed: ${it.message}", it) }
+ val resolved = withTimeoutOrNull(15_000) {
+ while (props(path, DEVICE)["ServicesResolved"] != true) delay(100)
+ true
+ }
+ check(resolved == true) { "GATT services of $address were not resolved in time" }
+ val p = props(path, DEVICE)
+ name = p["Name"] as? String
+ }
+
+ override suspend fun discoverServices(): List<BleService> = withContext(Dispatchers.IO) {
+ val objects = managedObjects().filterKeys { it.startsWith("$path/") }
+ val services = objects.mapNotNull { (p, ifaces) -> ifaces[GATT_SERVICE]?.let { p to (it["UUID"]!!.value as String).lowercase() } }.toMap()
+ val found = HashMap<Pair<String, String>, String>()
+ val byService = HashMap<String, MutableList<BleCharacteristic>>()
+ objects.forEach { (p, ifaces) ->
+ val c = ifaces[GATT_CHAR] ?: return@forEach
+ val servicePath = (c["Service"]?.value as? DBusPath)?.path ?: p.substringBeforeLast('/')
+ val serviceUuid = services[servicePath] ?: return@forEach
+ val uuid = (c["UUID"]!!.value as String).lowercase()
+ found[serviceUuid to uuid] = p
+ byService.getOrPut(servicePath) { mutableListOf() } += BleCharacteristic(uuid, stringList(c["Flags"]?.value).toSet())
+ }
+ chars = found
+ services.map { (p, uuid) -> BleService(uuid, byService[p].orEmpty()) }
+ }
+
+ private fun charPath(service: String, characteristic: String): String =
+ chars[service.lowercase() to characteristic.lowercase()] ?: error("Characteristic $characteristic of service $service not found")
+
+ private fun char(service: String, characteristic: String) =
+ bus.getRemoteObject(BLUEZ, charPath(service, characteristic), BluezGattCharacteristic1::class.java)
+
+ override suspend fun read(service: String, characteristic: String): ByteArray = withContext(Dispatchers.IO) {
+ char(service, characteristic).ReadValue(emptyMap())
+ }
+
+ override suspend fun write(service: String, characteristic: String, value: ByteArray) = withContext(Dispatchers.IO) {
+ char(service, characteristic).WriteValue(value, emptyMap())
+ }
+
+ override suspend fun subscribe(service: String, characteristic: String): Flow<ByteArray> = withContext(Dispatchers.IO) {
+ val cp = charPath(service, characteristic)
+ val ch = Channel<ByteArray>(Channel.UNLIMITED)
+ channels += ch
+ listen { p, iface, changed ->
+ if (p == cp && iface == GATT_CHAR) changed["Value"]?.let { v -> toBytes(v.value)?.let { ch.trySend(it) } }
+ }
+ bus.getRemoteObject(BLUEZ, cp, BluezGattCharacteristic1::class.java).StartNotify()
+ notifying += cp
+ ch.receiveAsFlow()
+ }
+
+ override val disconnected: Flow<Unit> = flow { gone.await(); emit(Unit) }
+
+ private fun markGone() {
+ channels.forEach { it.close() }
+ gone.complete(Unit)
+ }
+
+ fun close() {
+ ownListeners.forEach { listeners -= it }
+ markGone()
+ }
+
+ override suspend fun disconnect() = withContext(NonCancellable + Dispatchers.IO) {
+ notifying.forEach { cp -> runCatching { bus.getRemoteObject(BLUEZ, cp, BluezGattCharacteristic1::class.java).StopNotify() } }
+ notifying.clear()
+ runCatching { device.Disconnect() }
+ close()
+ }
+ }
+
+ private companion object {
+ const val BLUEZ = "org.bluez"
+ const val BLUEZ_ROOT = "/org/bluez/"
+ const val ADAPTER = "org.bluez.Adapter1"
+ const val DEVICE = "org.bluez.Device1"
+ const val GATT_SERVICE = "org.bluez.GattService1"
+ const val GATT_CHAR = "org.bluez.GattCharacteristic1"
+
+ fun toBytes(v: Any?): ByteArray? = when (v) {
+ is ByteArray -> v
+ is List<*> -> ByteArray(v.size) { (v[it] as Number).toByte() }
+ is Array<*> -> ByteArray(v.size) { (v[it] as Number).toByte() }
+ else -> null
+ }
+
+ fun stringList(v: Any?): List<String> = when (v) {
+ is List<*> -> v.map { it.toString() }
+ is Array<*> -> v.map { it.toString() }
+ else -> emptyList()
+ }
+
+ fun toAdvertisement(p: Map<String, Any?>): BleAdvertisement? {
+ val address = p["Address"] as? String ?: return null
+ val mfr = (p["ManufacturerData"] as? Map<*, *>)?.mapNotNull { (k, v) ->
+ val id = (k as? Number)?.toInt() ?: return@mapNotNull null
+ val bytes = toBytes((v as? Variant<*>)?.value ?: v) ?: return@mapNotNull null
+ id to bytes
+ }?.toMap().orEmpty()
+ return BleAdvertisement(
+ address = address,
+ name = p["Name"] as? String,
+ rssi = (p["RSSI"] as? Number)?.toInt(),
+ manufacturerData = mfr,
+ serviceUuids = stringList(p["UUIDs"]).map { it.lowercase() },
+ )
+ }
+ }
+}
diff --git a/core/src/jvmMain/kotlin/coffee/liz/rookery/bt/JvmAes128.kt b/core/src/jvmMain/kotlin/coffee/liz/rookery/bt/JvmAes128.kt
new file mode 100644
index 0000000..b96748c
--- /dev/null
+++ b/core/src/jvmMain/kotlin/coffee/liz/rookery/bt/JvmAes128.kt
@@ -0,0 +1,17 @@
+package coffee.liz.rookery.bt
+
+import javax.crypto.Cipher
+import javax.crypto.spec.SecretKeySpec
+
+/** [Aes128] over javax.crypto (AES/ECB/NoPadding); works on desktop JVMs and Android. */
+object JvmAes128 : Aes128 {
+ private fun run(mode: Int, key: ByteArray, block: ByteArray): ByteArray {
+ require(key.size == 16 && block.size == 16) { "AES-128 needs a 16-byte key and block" }
+ val c = Cipher.getInstance("AES/ECB/NoPadding")
+ c.init(mode, SecretKeySpec(key, "AES"))
+ return c.doFinal(block)
+ }
+
+ override fun encryptBlock(key: ByteArray, block: ByteArray): ByteArray = run(Cipher.ENCRYPT_MODE, key, block)
+ override fun decryptBlock(key: ByteArray, block: ByteArray): ByteArray = run(Cipher.DECRYPT_MODE, key, block)
+}
diff --git a/core/src/jvmMain/kotlin/coffee/liz/rookery/scramble/TnoodleScrambler.kt b/core/src/jvmMain/kotlin/coffee/liz/rookery/scramble/TnoodleScrambler.kt
new file mode 100644
index 0000000..e0e7b31
--- /dev/null
+++ b/core/src/jvmMain/kotlin/coffee/liz/rookery/scramble/TnoodleScrambler.kt
@@ -0,0 +1,50 @@
+package coffee.liz.rookery.scramble
+
+import coffee.liz.rookery.model.Event
+import org.worldcubeassociation.tnoodle.scrambles.PuzzleRegistry
+import java.util.concurrent.ConcurrentHashMap
+import java.util.concurrent.Executors
+import java.util.concurrent.Future
+
+/**
+ * WCA-official random-state scrambles from tnoodle-lib, with one scramble generated ahead per event
+ * so pressing "next" is instant even for 4x4 (whose tables take a few seconds to build).
+ */
+class TnoodleScrambler : Scrambler {
+ private val pool = Executors.newSingleThreadExecutor { r -> Thread(r, "scrambler").apply { isDaemon = true; priority = Thread.MIN_PRIORITY } }
+ private val ahead = ConcurrentHashMap<Event, Future<String>>()
+
+ private fun registry(e: Event): PuzzleRegistry = when (e) {
+ Event.E333, Event.E333OH -> PuzzleRegistry.THREE
+ Event.E222 -> PuzzleRegistry.TWO
+ Event.E444 -> PuzzleRegistry.FOUR
+ Event.E555 -> PuzzleRegistry.FIVE
+ Event.E666 -> PuzzleRegistry.SIX
+ Event.E777 -> PuzzleRegistry.SEVEN
+ Event.E333BF, Event.E333MBF -> PuzzleRegistry.THREE_NI
+ Event.E444BF -> PuzzleRegistry.FOUR_NI
+ Event.E555BF -> PuzzleRegistry.FIVE_NI
+ Event.E333FM -> PuzzleRegistry.THREE_FM
+ Event.CLOCK -> PuzzleRegistry.CLOCK
+ Event.MINX -> PuzzleRegistry.MEGA
+ Event.PYRAM -> PuzzleRegistry.PYRA
+ Event.SKEWB -> PuzzleRegistry.SKEWB
+ Event.SQ1 -> PuzzleRegistry.SQ1
+ Event.FTO -> PuzzleRegistry.FTO
+ }
+
+ private fun submit(e: Event): Future<String> = pool.submit<String> { registry(e).scrambler.generateScramble() }
+
+ override fun warm(event: Event) { ahead.computeIfAbsent(event, ::submit) }
+
+ override fun next(event: Event): String {
+ val f = ahead.remove(event) ?: submit(event)
+ val s = f.get()
+ ahead[event] = submit(event)
+ return s
+ }
+
+ override fun previewSvg(event: Event, scramble: String): String? = runCatching {
+ registry(event).scrambler.drawScramble(scramble, null).toString()
+ }.getOrNull()
+}
diff --git a/core/src/jvmMain/kotlin/coffee/liz/rookery/store/FolderStorage.kt b/core/src/jvmMain/kotlin/coffee/liz/rookery/store/FolderStorage.kt
new file mode 100644
index 0000000..64064b5
--- /dev/null
+++ b/core/src/jvmMain/kotlin/coffee/liz/rookery/store/FolderStorage.kt
@@ -0,0 +1,96 @@
+package coffee.liz.rookery.store
+
+import java.io.RandomAccessFile
+import java.nio.file.Files
+import java.nio.file.Path
+import java.nio.file.StandardOpenOption
+import java.security.MessageDigest
+import kotlin.io.path.createDirectories
+import kotlin.io.path.exists
+import kotlin.io.path.isDirectory
+import kotlin.io.path.listDirectoryEntries
+import kotlin.io.path.name
+import kotlin.io.path.readBytes
+import kotlin.io.path.writeBytes
+
+/**
+ * Op log in a plain folder, safe under any file-syncing tool:
+ *
+ * ```
+ * <root>/devices/<nodeId>/ops-000001.jsonl only <nodeId> ever writes here
+ * <root>/blobs/<sha256>.<ext> write-once, content addressed
+ * ```
+ *
+ * Readers remember a byte offset per file and only consume complete lines, so a half-synced
+ * file is simply read again later.
+ */
+class FolderLogStorage(private val root: Path, private val nodeId: String, private val segmentBytes: Long = 8L shl 20) : LogStorage {
+ private val devices = root.resolve("devices")
+ private val ownDir = devices.resolve(nodeId).also { it.createDirectories() }
+ private val offsets = HashMap<Path, Long>()
+
+ private fun currentSegment(): Path {
+ val segs = ownDir.listDirectoryEntries("ops-*.jsonl").sorted()
+ val last = segs.lastOrNull()
+ if (last != null && Files.size(last) < segmentBytes) return last
+ val next = (last?.name?.removePrefix("ops-")?.removeSuffix(".jsonl")?.toIntOrNull() ?: 0) + 1
+ return ownDir.resolve("ops-" + next.toString().padStart(6, '0') + ".jsonl")
+ }
+
+ override fun append(lines: List<String>) {
+ if (lines.isEmpty()) return
+ val bytes = lines.joinToString("\n", postfix = "\n").toByteArray()
+ val seg = currentSegment()
+ Files.newByteChannel(seg, StandardOpenOption.CREATE, StandardOpenOption.APPEND, StandardOpenOption.WRITE).use { ch ->
+ val buf = java.nio.ByteBuffer.wrap(bytes)
+ while (buf.hasRemaining()) ch.write(buf)
+ (ch as? java.nio.channels.FileChannel)?.force(false)
+ }
+ // Our own writes are already applied by the Store; skip them on the next read.
+ offsets[seg] = (offsets[seg] ?: 0) + bytes.size
+ }
+
+ override fun readNew(): List<String> {
+ if (!devices.exists()) return emptyList()
+ val out = mutableListOf<String>()
+ for (dev in devices.listDirectoryEntries().filter { it.isDirectory() }.sorted()) {
+ for (seg in dev.listDirectoryEntries("ops-*.jsonl").sorted()) {
+ val from = offsets[seg] ?: 0
+ val size = Files.size(seg)
+ if (size <= from) continue
+ RandomAccessFile(seg.toFile(), "r").use { f ->
+ f.seek(from)
+ val buf = ByteArray((size - from).toInt())
+ f.readFully(buf)
+ val end = buf.lastIndexOf('\n'.code.toByte())
+ if (end < 0) return@use // torn line still syncing
+ String(buf, 0, end, Charsets.UTF_8).split('\n').filterTo(out) { it.isNotBlank() }
+ offsets[seg] = from + end + 1
+ }
+ }
+ }
+ return out
+ }
+}
+
+class FolderBlobStore(root: Path) : BlobStore {
+ private val dir = root.resolve("blobs").also { it.createDirectories() }
+
+ override fun put(bytes: ByteArray, ext: String): String {
+ val hash = MessageDigest.getInstance("SHA-256").digest(bytes).joinToString("") { "%02x".format(it) }
+ val name = "$hash.${ext.lowercase().filter(Char::isLetterOrDigit).ifEmpty { "bin" }}"
+ val path = dir.resolve(name)
+ if (!path.exists()) {
+ val tmp = dir.resolve(".$name.tmp")
+ tmp.writeBytes(bytes)
+ Files.move(tmp, path, java.nio.file.StandardCopyOption.ATOMIC_MOVE)
+ }
+ return name
+ }
+
+ override fun get(name: String): ByteArray? {
+ if (name.contains('/') || name.contains("..")) return null
+ val p = dir.resolve(name)
+ return if (p.exists()) p.readBytes() else null
+ }
+}