summaryrefslogtreecommitdiff
path: root/desktop/src
diff options
context:
space:
mode:
Diffstat (limited to 'desktop/src')
-rw-r--r--desktop/src/main/kotlin/coffee/liz/rookery/desktop/CubeCli.kt80
-rw-r--r--desktop/src/main/kotlin/coffee/liz/rookery/desktop/Main.kt147
2 files changed, 227 insertions, 0 deletions
diff --git a/desktop/src/main/kotlin/coffee/liz/rookery/desktop/CubeCli.kt b/desktop/src/main/kotlin/coffee/liz/rookery/desktop/CubeCli.kt
new file mode 100644
index 0000000..b183420
--- /dev/null
+++ b/desktop/src/main/kotlin/coffee/liz/rookery/desktop/CubeCli.kt
@@ -0,0 +1,80 @@
+package coffee.liz.rookery.desktop
+
+import coffee.liz.rookery.bt.BleAdvertisement
+import coffee.liz.rookery.bt.BlueZTransport
+import coffee.liz.rookery.bt.GanCubeConnector
+import coffee.liz.rookery.bt.GanSmartCube
+import coffee.liz.rookery.bt.JvmAes128
+import coffee.liz.rookery.bt.SmartCubeEvent
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.CoroutineStart
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.withTimeoutOrNull
+import kotlin.system.exitProcess
+
+/**
+ * `rookery --cube scan [seconds] [--all]` lists nearby GAN smart cubes (or every BLE device) for ~10 s.
+ * `rookery --cube monitor <MAC>` connects and streams moves until Ctrl-C.
+ */
+fun cubeCli(args: List<String>) {
+ val usage = "usage: --cube scan [seconds] [--all] | --cube monitor <MAC>"
+ when (args.firstOrNull()) {
+ "scan" -> scan(args.getOrNull(1)?.toLongOrNull() ?: 10, all = "--all" in args)
+ "monitor" -> monitor(args.getOrNull(1) ?: run { System.err.println(usage); exitProcess(2) })
+ else -> { System.err.println(usage); exitProcess(2) }
+ }
+ exitProcess(0)
+}
+
+private fun scan(seconds: Long, all: Boolean) = BlueZTransport().use { transport ->
+ println("Scanning for ${if (all) "BLE devices" else "GAN cubes"} for $seconds s...")
+ val seen = LinkedHashMap<String, BleAdvertisement>()
+ runBlocking {
+ withTimeoutOrNull(seconds * 1000) {
+ transport.scan().collect { adv ->
+ if (!all && !GanCubeConnector.isGanCube(adv)) return@collect
+ if (adv.address !in seen) println(" found ${adv.name ?: "?"} ${adv.address} rssi ${adv.rssi ?: "?"}")
+ seen[adv.address] = adv
+ }
+ }
+ }
+ if (seen.isEmpty()) println(if (all) "No BLE devices found." else "No GAN cubes found (turn the cube a little to wake it).")
+ else {
+ println("${seen.size} ${if (all) "device" else "cube"}(s):")
+ seen.values.forEach { println(" ${it.name ?: "?"}\t${it.address}\trssi ${it.rssi ?: "?"}") }
+ }
+}
+
+private fun monitor(mac: String) = BlueZTransport().use { transport ->
+ runBlocking {
+ println("Connecting to $mac...")
+ val cube = GanCubeConnector(transport, JvmAes128).connect(this, mac)
+ println("Connected: ${cube.name ?: "?"} (${(cube as? GanSmartCube)?.generation ?: "?"})")
+ val done = CompletableDeferred<Unit>()
+ Runtime.getRuntime().addShutdownHook(Thread { runBlocking { withTimeoutOrNull(2000) { cube.disconnect() } } })
+ var firstMoveHost: Long? = null
+ val collector = launch(start = CoroutineStart.UNDISPATCHED) {
+ cube.events.collect { e ->
+ when (e) {
+ is SmartCubeEvent.Move -> {
+ val t0 = firstMoveHost ?: e.hostTimestampMs.also { firstMoveHost = it }
+ println("move %-3s host +%6d ms cube %s%s".format(e.move, e.hostTimestampMs - t0, e.cubeTimestampMs?.let { "$it ms" } ?: "-", if (e.recovered) " (recovered)" else ""))
+ }
+ is SmartCubeEvent.Facelets -> println("facelets ${e.facelets}")
+ is SmartCubeEvent.Battery -> println("battery ${e.percent}%")
+ is SmartCubeEvent.Hardware -> println("hardware ${e.name} hw ${e.hardwareVersion} sw ${e.softwareVersion}" + (e.productDate?.let { " made $it" } ?: "") + if (e.gyroSupported) " gyro" else "")
+ is SmartCubeEvent.Gyro -> Unit // too chatty to print
+ SmartCubeEvent.Disconnected -> { println("disconnected"); done.complete(Unit) }
+ }
+ }
+ }
+ cube.requestHardware(); delay(300)
+ cube.requestBattery(); delay(300)
+ cube.requestFacelets()
+ println("Streaming moves; Ctrl-C to quit.")
+ done.await()
+ collector.cancel()
+ }
+}
diff --git a/desktop/src/main/kotlin/coffee/liz/rookery/desktop/Main.kt b/desktop/src/main/kotlin/coffee/liz/rookery/desktop/Main.kt
new file mode 100644
index 0000000..60d7443
--- /dev/null
+++ b/desktop/src/main/kotlin/coffee/liz/rookery/desktop/Main.kt
@@ -0,0 +1,147 @@
+package coffee.liz.rookery.desktop
+
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.unit.DpSize
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.Window
+import androidx.compose.ui.window.application
+import androidx.compose.ui.window.rememberWindowState
+import coffee.liz.rookery.audio.Chime
+import coffee.liz.rookery.audio.ChimePlayer
+import coffee.liz.rookery.model.Event
+import coffee.liz.rookery.scramble.TnoodleScrambler
+import coffee.liz.rookery.store.FolderBlobStore
+import coffee.liz.rookery.store.FolderLogStorage
+import coffee.liz.rookery.store.Repo
+import coffee.liz.rookery.store.Store
+import coffee.liz.rookery.ui.AppModel
+import coffee.liz.rookery.ui.Host
+import coffee.liz.rookery.ui.RookeryApp
+import coffee.liz.rookery.ui.handleTimerKey
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import java.awt.FileDialog
+import java.awt.Frame
+import java.nio.file.Path
+import java.nio.file.Paths
+import java.util.Properties
+import java.util.zip.ZipFile
+import javax.swing.JFileChooser
+import kotlin.io.path.createDirectories
+import kotlin.io.path.exists
+import kotlin.io.path.inputStream
+import kotlin.io.path.outputStream
+import kotlin.io.path.readBytes
+
+/** Per-machine settings (never synced): which folder to sync, and this device's id. */
+class LocalConfig {
+ private val dir: Path = (System.getenv("XDG_CONFIG_HOME")?.let { Paths.get(it) } ?: Paths.get(System.getProperty("user.home"), ".config")).resolve("rookery")
+ private val file = dir.resolve("config.properties")
+ private val props = Properties().apply { if (file.exists()) file.inputStream().use { load(it) } }
+
+ private fun save() { dir.createDirectories(); file.outputStream().use { props.store(it, "rookery local config") } }
+
+ val deviceId: String = props.getProperty("deviceId") ?: run {
+ val host = runCatching { java.net.InetAddress.getLocalHost().hostName }.getOrNull()?.filter { it.isLetterOrDigit() || it == '-' }?.take(24) ?: "desktop"
+ "$host-${coffee.liz.rookery.randomId().take(6)}".also { props.setProperty("deviceId", it); save() }
+ }
+
+ var syncFolder: String
+ get() = props.getProperty("syncFolder") ?: defaultData().resolve("sync").toString()
+ set(v) { props.setProperty("syncFolder", v); save() }
+
+ private fun defaultData(): Path =
+ (System.getenv("XDG_DATA_HOME")?.let { Paths.get(it) } ?: Paths.get(System.getProperty("user.home"), ".local", "share")).resolve("rookery")
+}
+
+class DesktopHost(private val config: LocalConfig) : Host {
+ override fun playChime(notes: List<Chime.Note>) = ChimePlayer.play(notes)
+
+ override suspend fun pickFile(title: String, extensions: List<String>): String? = withContext(Dispatchers.Main) {
+ val d = FileDialog(null as Frame?, title, FileDialog.LOAD)
+ d.setFilenameFilter { _, name -> extensions.any { name.lowercase().endsWith(".$it") } }
+ d.isVisible = true
+ d.file?.let { Paths.get(d.directory, it).toString() }
+ }
+
+ override suspend fun pickFolder(title: String): String? = withContext(Dispatchers.Main) {
+ val c = JFileChooser(config.syncFolder).apply { dialogTitle = title; fileSelectionMode = JFileChooser.DIRECTORIES_ONLY }
+ if (c.showOpenDialog(null) == JFileChooser.APPROVE_OPTION) c.selectedFile.absolutePath else null
+ }
+
+ override suspend fun readFile(path: String): ByteArray = withContext(Dispatchers.IO) { Paths.get(path).readBytes() }
+
+ override suspend fun readImport(path: String, mainEntry: String?): Pair<String, (String) -> ByteArray?> = withContext(Dispatchers.IO) {
+ if (!path.lowercase().endsWith(".zip")) {
+ val dir = Paths.get(path).parent
+ Paths.get(path).readBytes().decodeToString() to { rel: String -> dir.resolve(rel).normalize().takeIf { it.startsWith(dir) && it.exists() }?.readBytes() }
+ } else {
+ // Read everything up front so the loader works after the zip is closed.
+ val entries = ZipFile(path).use { z -> z.entries().toList().filter { !it.isDirectory }.associate { it.name to z.getInputStream(it).readBytes() } }
+ val main = entries.entries.firstOrNull { it.key == mainEntry || it.key.endsWith("/$mainEntry") }
+ ?: error("no $mainEntry in ${Paths.get(path).fileName}")
+ val prefix = main.key.removeSuffix(mainEntry ?: "")
+ main.value.decodeToString() to { rel: String -> entries[prefix + rel] }
+ }
+ }
+
+ override val syncFolder: String get() = config.syncFolder
+ override fun setSyncFolder(path: String) { config.syncFolder = path }
+ override val deviceId: String get() = config.deviceId
+}
+
+fun main(args: Array<String>) {
+ if (args.firstOrNull() == "--import") return headlessImport(args.drop(1))
+ if (args.firstOrNull() == "--cube") return cubeCli(args.drop(1))
+ System.setProperty("skiko.renderApi", System.getProperty("skiko.renderApi") ?: "OPENGL")
+ val config = LocalConfig()
+ val root = Paths.get(config.syncFolder).also { it.createDirectories() }
+ val store = Store(config.deviceId, FolderLogStorage(root, config.deviceId), FolderBlobStore(root), System::currentTimeMillis)
+ val repo = Repo(store)
+ val scrambler = TnoodleScrambler().apply { warm(Event.E333) }
+
+ application {
+ val state = rememberWindowState(size = DpSize(1280.dp, 820.dp))
+ val scope = rememberCoroutineScope()
+ val model = remember {
+ AppModel(repo, scrambler, DesktopHost(config), scope).apply {
+ // dev hooks for screenshots: ROOKERY_TAB=PAIRS, ROOKERY_PARTY=1, ROOKERY_REPLAY=1, ROOKERY_EVENT=pyram
+ System.getenv("ROOKERY_TAB")?.let { t -> coffee.liz.rookery.ui.Tab.entries.firstOrNull { it.name == t }?.let { tab = it } }
+ System.getenv("ROOKERY_EVENT")?.let { id -> Event.byId(id)?.let { e -> scope.launch { kotlinx.coroutines.delay(1500); createSession(e) } } }
+ if (System.getenv("ROOKERY_REPLAY") != null) scope.launch {
+ kotlinx.coroutines.delay(4000)
+ derived.sessionSolves.lastOrNull { it.moves != null }?.let { selectedSolveId = it.id; startReplay(it) }
+ }
+ if (System.getenv("ROOKERY_PARTY") != null) scope.launch { kotlinx.coroutines.delay(5000); testParty() }
+ }
+ }
+ Window(
+ onCloseRequest = ::exitApplication, state = state, title = "rookery",
+ onPreviewKeyEvent = { handleTimerKey(model, it) },
+ ) {
+ RookeryApp(model)
+ }
+ }
+}
+
+/** `rookery --import <file>...`: import csTimer .txt or Letter Pair Gym .zip files without opening a window. */
+private fun headlessImport(paths: List<String>) {
+ val config = LocalConfig()
+ val root = Paths.get(config.syncFolder).also { it.createDirectories() }
+ val repo = Repo(Store(config.deviceId, FolderLogStorage(root, config.deviceId), FolderBlobStore(root), System::currentTimeMillis))
+ val host = DesktopHost(config)
+ val importer = coffee.liz.rookery.importer.Importer(repo)
+ for (p in paths) {
+ println("» $p")
+ val summary = kotlinx.coroutines.runBlocking {
+ if (p.lowercase().endsWith(".zip") || p.endsWith("backup.json")) {
+ val (text, files) = host.readImport(p, "backup.json")
+ importer.letterPairGym(text, files)
+ } else importer.csTimer(host.readFile(p).decodeToString())
+ }
+ summary.lines.forEach { println(" ✓ $it") }
+ summary.warnings.forEach { println(" ! $it") }
+ }
+}