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/Main.kt68
-rw-r--r--desktop/src/main/kotlin/coffee/liz/rookery/desktop/Shots.kt86
2 files changed, 151 insertions, 3 deletions
diff --git a/desktop/src/main/kotlin/coffee/liz/rookery/desktop/Main.kt b/desktop/src/main/kotlin/coffee/liz/rookery/desktop/Main.kt
index 5a5de5e..c2f65ca 100644
--- a/desktop/src/main/kotlin/coffee/liz/rookery/desktop/Main.kt
+++ b/desktop/src/main/kotlin/coffee/liz/rookery/desktop/Main.kt
@@ -9,6 +9,8 @@ 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.images.BraveImages
+import coffee.liz.rookery.images.ImageHit
import coffee.liz.rookery.model.Event
import coffee.liz.rookery.scramble.TnoodleScrambler
import coffee.liz.rookery.store.FolderBlobStore
@@ -41,7 +43,15 @@ class LocalConfig {
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") } }
+ private fun save() {
+ dir.createDirectories()
+ file.outputStream().use { props.store(it, "rookery local config") }
+ // Holds API keys: owner-only where the filesystem supports it.
+ runCatching { java.nio.file.Files.setPosixFilePermissions(file, java.nio.file.attribute.PosixFilePermissions.fromString("rw-------")) }
+ }
+
+ /** Brave Search API key: env ROOKERY_BRAVE_KEY, else `braveApiKey` in this (never synced) file. */
+ val braveApiKey: String? get() = System.getenv("ROOKERY_BRAVE_KEY")?.takeIf { it.isNotBlank() } ?: props.getProperty("braveApiKey")?.takeIf { it.isNotBlank() }
val deviceId: String = props.getProperty("deviceId") ?: run {
val host = runCatching { java.net.InetAddress.getLocalHost().hostName }.getOrNull()?.filter { it.isLetterOrDigit() || it == '-' }?.take(24) ?: "desktop"
@@ -90,12 +100,62 @@ class DesktopHost(private val config: LocalConfig) : Host {
override val syncFolder: String get() = config.syncFolder
override fun setSyncFolder(path: String) { config.syncFolder = path }
override val deviceId: String get() = config.deviceId
+ override val braveApiKey: String? get() = config.braveApiKey
+ override val touchInput: Boolean get() = false
+
+ private val http: java.net.http.HttpClient by lazy {
+ java.net.http.HttpClient.newBuilder().connectTimeout(java.time.Duration.ofSeconds(10))
+ .followRedirects(java.net.http.HttpClient.Redirect.NORMAL).build()
+ }
+
+ private fun get(url: String, headers: Map<String, String> = emptyMap()): ByteArray {
+ require(url.startsWith("https://")) { "refusing a non-https address" }
+ val req = java.net.http.HttpRequest.newBuilder(java.net.URI(url)).timeout(java.time.Duration.ofSeconds(20))
+ .header("User-Agent", "rookery").apply { headers.forEach { (k, v) -> header(k, v) } }.GET().build()
+ val res = http.send(req, java.net.http.HttpResponse.BodyHandlers.ofInputStream())
+ if (res.statusCode() !in 200..299) { res.body().close(); error("the server said ${res.statusCode()}") }
+ // images only need to be so big; stop reading rather than fill memory
+ return res.body().use { it.readNBytes(MAX_DOWNLOAD + 1) }.also { check(it.size <= MAX_DOWNLOAD) { "that file is too large" } }
+ }
+
+ override suspend fun searchImages(query: String): List<ImageHit> = withContext(Dispatchers.IO) {
+ val key = config.braveApiKey ?: error("No Brave Search key on this machine.")
+ val body = try {
+ get(BraveImages.url(query), mapOf("Accept" to "application/json", BraveImages.KEY_HEADER to key)).decodeToString()
+ } catch (e: java.io.IOException) { error("couldn't reach Brave (${e.message ?: "offline?"}).") }
+ BraveImages.parse(body)
+ }
+
+ override suspend fun fetchBytes(url: String): ByteArray = withContext(Dispatchers.IO) { get(url) }
+
+ /** Fit inside 640px and re-encode as WebP, which is what gets synced. */
+ override fun shrinkImage(bytes: ByteArray): Pair<ByteArray, String>? = runCatching {
+ val img = org.jetbrains.skia.Image.makeFromEncoded(bytes)
+ val k = minOf(1f, 640f / maxOf(img.width, img.height))
+ val w = (img.width * k).toInt().coerceAtLeast(1); val h = (img.height * k).toInt().coerceAtLeast(1)
+ val surface = org.jetbrains.skia.Surface.makeRasterN32Premul(w, h)
+ surface.canvas.drawImageRect(
+ img, org.jetbrains.skia.Rect.makeWH(img.width.toFloat(), img.height.toFloat()), org.jetbrains.skia.Rect.makeWH(w.toFloat(), h.toFloat()),
+ org.jetbrains.skia.FilterMipmap(org.jetbrains.skia.FilterMode.LINEAR, org.jetbrains.skia.MipmapMode.LINEAR), null, true,
+ )
+ val data = surface.makeImageSnapshot().encodeToData(org.jetbrains.skia.EncodedImageFormat.WEBP, 82) ?: return null
+ data.bytes to "webp"
+ }.getOrNull()
+
+ var onFullscreen: (Boolean) -> Unit = {}
+ override fun setFullscreen(on: Boolean) = onFullscreen(on)
+
+ private companion object { const val MAX_DOWNLOAD = 12 * 1024 * 1024 }
}
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")
+ if (args.firstOrNull() == "--shots") return shots(args.drop(1))
+ // OpenGL is the reliable choice on Linux; elsewhere (Metal on macOS, Direct3D on Windows) keep Skiko's default.
+ if (System.getProperty("os.name").lowercase().contains("linux") && System.getProperty("skiko.renderApi") == null) {
+ System.setProperty("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)
@@ -106,7 +166,9 @@ fun main(args: Array<String>) {
val state = rememberWindowState(size = DpSize(1280.dp, 820.dp))
val scope = rememberCoroutineScope()
val model = remember {
- AppModel(repo, scrambler, DesktopHost(config), scope).apply {
+ val host = DesktopHost(config)
+ host.onFullscreen = { on -> state.placement = if (on) androidx.compose.ui.window.WindowPlacement.Fullscreen else androidx.compose.ui.window.WindowPlacement.Floating }
+ AppModel(repo, scrambler, host, 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_BLD")?.let { b -> coffee.liz.rookery.ui.BldMode.entries.firstOrNull { it.name == b }?.let { bldMode = it } }
diff --git a/desktop/src/main/kotlin/coffee/liz/rookery/desktop/Shots.kt b/desktop/src/main/kotlin/coffee/liz/rookery/desktop/Shots.kt
new file mode 100644
index 0000000..d7621eb
--- /dev/null
+++ b/desktop/src/main/kotlin/coffee/liz/rookery/desktop/Shots.kt
@@ -0,0 +1,86 @@
+package coffee.liz.rookery.desktop
+
+import androidx.compose.ui.ImageComposeScene
+import androidx.compose.ui.unit.Density
+import coffee.liz.rookery.audio.Chime
+import coffee.liz.rookery.images.ImageHit
+import coffee.liz.rookery.model.Event
+import coffee.liz.rookery.model.Penalty
+import coffee.liz.rookery.model.Solve
+import coffee.liz.rookery.scramble.TnoodleScrambler
+import coffee.liz.rookery.store.MemoryBlobStore
+import coffee.liz.rookery.store.MemoryLogStorage
+import coffee.liz.rookery.store.Repo
+import coffee.liz.rookery.store.Store
+import coffee.liz.rookery.ui.AppModel
+import coffee.liz.rookery.ui.BldMode
+import coffee.liz.rookery.ui.Host
+import coffee.liz.rookery.ui.RookeryApp
+import coffee.liz.rookery.ui.Tab
+import coffee.liz.rookery.ui.ThemeMode
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import org.jetbrains.skia.EncodedImageFormat
+import java.io.File
+import kotlin.random.Random
+
+/**
+ * `rookery --shots <dir>`: draws each screen offscreen with made-up data and writes PNGs. A development
+ * aid for looking at the UI without a window (or anyone's real data) involved.
+ */
+fun shots(args: List<String>) {
+ val out = File(args.firstOrNull() ?: "shots").apply { mkdirs() }
+ val repo = Repo(Store("shots", MemoryLogStorage(), MemoryBlobStore(), System::currentTimeMillis))
+ val rng = Random(5)
+ repo.createSession("s1", "Main 3x3", Event.E333, 1)
+ repo.createSession("s2", "Main Pyra", Event.PYRAM, 2)
+ repeat(40) { i ->
+ repo.addSolve(Solve("x$i", "s1", Event.E333, 11_000 + rng.nextLong(6_000), if (i == 17) Penalty.DNF else if (i == 30) Penalty.PLUS2 else Penalty.NONE, "R U R' U' F2 D L2 B", "", 1_000L + i))
+ }
+ val words = listOf("AB" to "abacus", "AD" to "adelie", "CH" to "cheese", "GN" to "gentoo", "LT" to "latte", "MA" to "macaroni", "CI" to "cider", "PG" to "penguin", "WX" to "wax", "KR" to "krill", "FS" to "fish", "IG" to "igloo")
+ for ((p, w) in words) repo.setPrimaryWord(p, w, 5)
+ repo.setPairNote("AD", "the penguin")
+
+ val host = object : Host {
+ override fun playChime(notes: List<Chime.Note>) {}
+ override suspend fun pickFile(title: String, extensions: List<String>): String? = null
+ override suspend fun pickFolder(title: String): String? = null
+ override suspend fun readImport(path: String, mainEntry: String?) = "" to { _: String -> null as ByteArray? }
+ override suspend fun readFile(path: String) = ByteArray(0)
+ override val syncFolder = "~/Sync/rookery"
+ override fun setSyncFolder(path: String) {}
+ override val deviceId = "sample-device"
+ override val braveApiKey: String? = "x"
+ override val touchInput = false
+ override suspend fun searchImages(query: String) = emptyList<ImageHit>()
+ override suspend fun fetchBytes(url: String) = ByteArray(0)
+ override fun shrinkImage(bytes: ByteArray): Pair<ByteArray, String>? = null
+ override fun setFullscreen(on: Boolean) {}
+ }
+ val model = AppModel(repo, TnoodleScrambler(), host, CoroutineScope(SupervisorJob() + Dispatchers.Default))
+ model.selectSession("s1")
+
+ fun shoot(name: String, w: Int = 1280, h: Int = 800, settle: Long = 2500, setup: () -> Unit) {
+ setup()
+ val scene = ImageComposeScene(w * 2, h * 2, Density(2f)) { RookeryApp(model) }
+ val start = System.nanoTime()
+ var img = scene.render(0)
+ while (System.nanoTime() - start < settle * 1_000_000) { Thread.sleep(30); img = scene.render(System.nanoTime() - start) }
+ File(out, "$name.png").writeBytes(img.encodeToData(EncodedImageFormat.PNG)!!.bytes)
+ scene.close()
+ println("wrote $name.png")
+ }
+ model.setTheme(ThemeMode.DARK)
+ shoot("timer") { model.tab = Tab.TIMER }
+ shoot("display") { model.changeDisplayMode(true) }
+ model.changeDisplayMode(false)
+ shoot("party", settle = 1800) { model.testParty() }
+ model.party = null
+ for (mode in BldMode.entries) shoot("bld-${mode.name.lowercase()}", settle = 4000) { model.tab = Tab.BLD; model.bldMode = mode }
+ shoot("settings") { model.tab = Tab.SETTINGS }
+ shoot("import") { model.tab = Tab.IMPORT }
+ model.setTheme(ThemeMode.LIGHT)
+ shoot("timer-paper") { model.tab = Tab.TIMER }
+ kotlin.system.exitProcess(0)
+}