diff options
| author | Elizabeth Alexander Hunt <me@liz.coffee> | 2026-11-12 21:11:08 -0800 |
|---|---|---|
| committer | Elizabeth Alexander Hunt <me@liz.coffee> | 2026-11-12 21:11:08 -0800 |
| commit | 3afb04de43726d71580b626e856c68b6ed79984e (patch) | |
| tree | 33f7bcb227ff8cfcb046a10036d2faa69c2edd1f /core/src/jvmMain/kotlin/coffee/liz | |
| download | rookery-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/kotlin/coffee/liz')
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 + } +} |
