Repository: ajalt/mordant
Pull request #169 (2024-05-11 19:25:00)

Add some multiplatform utils


Patch:
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index d213687c..6ee07d91 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -44,15 +44,12 @@ jobs:
       - name: Run R8 Jar
         if: ${{ matrix.os == 'ubuntu-latest' }}
         run: java -jar test/proguard/build/libs/main-r8.jar
-      - name: Bundle the build report
-        if: failure()
-        run: find . -type d -name 'reports' | zip -@ -r build-reports.zip
       - name: Upload the build report
         if: failure()
         uses: actions/upload-artifact@master
         with:
-          name: error-report
-          path: build-reports.zip
+          name: build-report-${{ matrix.os }}
+          path: '**/build/reports'
   publish-snapshot:
     needs: test
     runs-on: macos-latest
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f5f32a3d..8fd80faa 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,10 +3,11 @@
 ## Unreleased
 ### Added
 - Publish `iosArm64` and `iosX64` targets.
+- Added `MultiplatformSystem` that provides multiplatform implementations of some non-terminal functionality that commonly used for command line apps: `readEnvironmentVariable`, `exitProcess`, and `readFileAsUtf8`.
 
 ## 2.5.0
 ### Added
-- Publish `linuxArm64` and `wasmJs` targets. 
+- Publish `linuxArm64` and `wasmJs` targets.
 
 ## 2.4.0
 This release includes a complete rewrite of the progress bar system. The new system is more
diff --git a/buildSrc/src/main/kotlin/mordant-native-conventions.gradle.kts b/buildSrc/src/main/kotlin/mordant-native-conventions.gradle.kts
index 3f084b41..fe64ec00 100644
--- a/buildSrc/src/main/kotlin/mordant-native-conventions.gradle.kts
+++ b/buildSrc/src/main/kotlin/mordant-native-conventions.gradle.kts
@@ -28,5 +28,9 @@ kotlin {
         val posixMain by creating { dependsOn(nativeMain.get()) }
         linuxMain.get().dependsOn(posixMain)
         appleMain.get().dependsOn(posixMain)
+        val appleNonDesktopMain by creating { dependsOn(appleMain.get()) }
+        for (target in listOf(iosMain, tvosMain, watchosMain)) {
+            target.get().dependsOn(appleNonDesktopMain)
+        }
     }
 }
diff --git a/mordant/api/mordant.api b/mordant/api/mordant.api
index 8e22afd4..c2b2907c 100644
--- a/mordant/api/mordant.api
+++ b/mordant/api/mordant.api
@@ -198,6 +198,13 @@ public final class com/github/ajalt/mordant/markdown/Markdown : com/github/ajalt
 	public fun render (Lcom/github/ajalt/mordant/terminal/Terminal;I)Lcom/github/ajalt/mordant/rendering/Lines;
 }
 
+public final class com/github/ajalt/mordant/platform/MultiplatformSystem {
+	public static final field INSTANCE Lcom/github/ajalt/mordant/platform/MultiplatformSystem;
+	public final fun exitProcess (I)V
+	public final fun readEnvironmentVariable (Ljava/lang/String;)Ljava/lang/String;
+	public final fun readFileAsUtf8 (Ljava/lang/String;)Ljava/lang/String;
+}
+
 public final class com/github/ajalt/mordant/rendering/AnsiLevel : java/lang/Enum {
 	public static final field ANSI16 Lcom/github/ajalt/mordant/rendering/AnsiLevel;
 	public static final field ANSI256 Lcom/github/ajalt/mordant/rendering/AnsiLevel;
diff --git a/mordant/src/appleMain/kotlin/com/github/ajalt/mordant/internal/MppImpl.kt b/mordant/src/appleMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.apple.kt
similarity index 92%
rename from mordant/src/appleMain/kotlin/com/github/ajalt/mordant/internal/MppImpl.kt
rename to mordant/src/appleMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.apple.kt
index 9dc05b62..e20c134f 100644
--- a/mordant/src/appleMain/kotlin/com/github/ajalt/mordant/internal/MppImpl.kt
+++ b/mordant/src/appleMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.apple.kt
@@ -1,3 +1,5 @@
+@file:OptIn(ExperimentalForeignApi::class)
+
 package com.github.ajalt.mordant.internal
 
 import kotlinx.cinterop.ExperimentalForeignApi
@@ -8,7 +10,6 @@ import platform.posix.TIOCGWINSZ
 import platform.posix.ioctl
 import platform.posix.winsize
 
-@OptIn(ExperimentalForeignApi::class)
 internal actual fun getTerminalSize(): Size? {
     return memScoped {
         val size = alloc<winsize>()
diff --git a/mordant/src/appleNonDesktopMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.appleNonDesktop.kt b/mordant/src/appleNonDesktopMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.appleNonDesktop.kt
new file mode 100644
index 00000000..c741c066
--- /dev/null
+++ b/mordant/src/appleNonDesktopMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.appleNonDesktop.kt
@@ -0,0 +1,3 @@
+package com.github.ajalt.mordant.internal
+
+internal actual fun hasFileSystem(): Boolean = false
diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/MppH.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.kt
similarity index 91%
rename from mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/MppH.kt
rename to mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.kt
index 61f10012..5d35d2aa 100644
--- a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/MppH.kt
+++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.kt
@@ -56,3 +56,9 @@ internal expect fun sendInterceptedPrintRequest(
 internal expect val FAST_ISATTY: Boolean
 
 internal expect val CR_IMPLIES_LF: Boolean
+
+internal expect fun exitProcessMpp(status: Int)
+
+internal expect fun readFileIfExists(filename: String): String?
+
+internal expect fun hasFileSystem(): Boolean
diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/platform/MultiplatformSystem.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/platform/MultiplatformSystem.kt
new file mode 100644
index 00000000..ddda8d1d
--- /dev/null
+++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/platform/MultiplatformSystem.kt
@@ -0,0 +1,41 @@
+package com.github.ajalt.mordant.platform
+
+import com.github.ajalt.mordant.internal.exitProcessMpp
+import com.github.ajalt.mordant.internal.getEnv
+import com.github.ajalt.mordant.internal.readFileIfExists
+
+/**
+ * Utility functions useful for multiplatform command line apps interacting with the system.
+ */
+object MultiplatformSystem {
+    /**
+     * Get the value of an environment variable.
+     *
+     * If the variable is not set, this function returns `null`.
+     */
+    fun readEnvironmentVariable(key: String): String? {
+        return getEnv(key)
+    }
+
+    /**
+     * Immediately exit the process with the given [status] code.
+     *
+     * On browsers, where it's not possible to exit the process, this function is a no-op.
+     */
+    fun exitProcess(status: Int) {
+        exitProcessMpp(status)
+    }
+
+    /**
+     * Read the contents of a file as a UTF-8 string.
+     *
+     * @return The file contents decoded as a UTF-8 string, or `null` if the file could not be read.
+     */
+    fun readFileAsUtf8(path: String): String? {
+        return try {
+            readFileIfExists(path)
+        } catch (e: Throwable) {
+            null
+        }
+    }
+}
diff --git a/mordant/src/jsCommonMain/kotlin/com/github/ajalt/mordant/internal/MppImpl.kt b/mordant/src/jsCommonMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.jsCommon.kt
similarity index 88%
rename from mordant/src/jsCommonMain/kotlin/com/github/ajalt/mordant/internal/MppImpl.kt
rename to mordant/src/jsCommonMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.jsCommon.kt
index 2f26e331..a146e0a5 100644
--- a/mordant/src/jsCommonMain/kotlin/com/github/ajalt/mordant/internal/MppImpl.kt
+++ b/mordant/src/jsCommonMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.jsCommon.kt
@@ -50,6 +50,9 @@ internal interface JsMppImpls {
     fun printStderr(message: String, newline: Boolean)
     fun readLineOrNull(): String?
     fun makeTerminalCursor(terminal: Terminal): TerminalCursor
+    fun exitProcess(status: Int)
+    fun cwd(): String
+    fun readFileIfExists(filename: String): String?
 }
 
 private object BrowserMppImpls : JsMppImpls {
@@ -59,12 +62,18 @@ private object BrowserMppImpls : JsMppImpls {
     override fun stderrInteractive(): Boolean = false
     override fun getTerminalSize(): Size? = null
     override fun printStderr(message: String, newline: Boolean) = browserPrintln(message)
+    override fun exitProcess(status: Int) {}
+    override fun cwd(): String {
+        return "??"
+    }
 
     // readlnOrNull will just throw an exception on browsers
     override fun readLineOrNull(): String? = readlnOrNull()
     override fun makeTerminalCursor(terminal: Terminal): TerminalCursor {
         return BrowserTerminalCursor(terminal)
     }
+
+    override fun readFileIfExists(filename: String): String? = null
 }
 
 internal abstract class BaseNodeMppImpls<BufferT> : JsMppImpls {
@@ -88,7 +97,6 @@ internal abstract class BaseNodeMppImpls<BufferT> : JsMppImpls {
 
     abstract fun allocBuffer(size: Int): BufferT
     abstract fun readSync(fd: Int, buffer: BufferT, offset: Int, len: Int): Int
-
 }
 
 private val impls: JsMppImpls = makeNodeMppImpls() ?: BrowserMppImpls
@@ -103,6 +111,8 @@ internal actual fun printStderr(message: String, newline: Boolean) {
     impls.printStderr(message, newline)
 }
 
+internal actual fun exitProcessMpp(status: Int): Unit = impls.exitProcess(status)
+
 // hideInput is not currently implemented
 internal actual fun readLineOrNullMpp(hideInput: Boolean): String? = impls.readLineOrNull()
 
@@ -111,6 +121,8 @@ internal actual fun makePrintingTerminalCursor(terminal: Terminal): TerminalCurs
     return impls.makeTerminalCursor(terminal)
 }
 
+internal actual fun readFileIfExists(filename: String): String? = impls.readFileIfExists(filename)
+
 // There are no shutdown hooks on browsers, so we don't need to do anything here
 private class BrowserTerminalCursor(terminal: Terminal) : PrintTerminalCursor(terminal)
 
@@ -126,3 +138,4 @@ internal actual fun sendInterceptedPrintRequest(
 }
 
 internal actual val FAST_ISATTY: Boolean = true
+internal actual fun hasFileSystem(): Boolean = impls !is BrowserMppImpls
diff --git a/mordant/src/jsMain/kotlin/com/github/ajalt/mordant/internal/MppImpl.kt b/mordant/src/jsMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.js.kt
similarity index 90%
rename from mordant/src/jsMain/kotlin/com/github/ajalt/mordant/internal/MppImpl.kt
rename to mordant/src/jsMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.js.kt
index f55c992f..12aabacc 100644
--- a/mordant/src/jsMain/kotlin/com/github/ajalt/mordant/internal/MppImpl.kt
+++ b/mordant/src/jsMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.js.kt
@@ -19,6 +19,12 @@ private class NodeMppImpls(private val fs: dynamic) : BaseNodeMppImpls<dynamic>(
     override fun stdoutInteractive(): Boolean = js("Boolean(process.stdout.isTTY)") as Boolean
     override fun stdinInteractive(): Boolean = js("Boolean(process.stdin.isTTY)") as Boolean
     override fun stderrInteractive(): Boolean = js("Boolean(process.stderr.isTTY)") as Boolean
+    override fun exitProcess(status: Int) {
+        process.exit(status)
+    }
+    override fun cwd(): String {
+        return process.cwd() as String
+    }
     override fun getTerminalSize(): Size? {
         // For some undocumented reason, getWindowSize is undefined sometimes, presumably when isTTY
         // is false
@@ -42,6 +48,10 @@ private class NodeMppImpls(private val fs: dynamic) : BaseNodeMppImpls<dynamic>(
     override fun makeTerminalCursor(terminal: Terminal): TerminalCursor {
         return NodeTerminalCursor(terminal)
     }
+
+    override fun readFileIfExists(filename: String): String? {
+        return fs.readFileSync(filename, "utf-8") as? String
+    }
 }
 
 internal actual fun makeNodeMppImpls(): JsMppImpls? {
diff --git a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/MppImpl.kt b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.jvm.kt
similarity index 92%
rename from mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/MppImpl.kt
rename to mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.jvm.kt
index ff314d5a..7f7d2bce 100644
--- a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/MppImpl.kt
+++ b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.jvm.kt
@@ -6,9 +6,11 @@ import com.github.ajalt.mordant.internal.jna.JnaWin32MppImpls
 import com.github.ajalt.mordant.internal.nativeimage.NativeImagePosixMppImpls
 import com.github.ajalt.mordant.internal.nativeimage.NativeImageWin32MppImpls
 import com.github.ajalt.mordant.terminal.*
+import java.io.File
 import java.lang.management.ManagementFactory
 import java.util.concurrent.atomic.AtomicInteger
 import java.util.concurrent.atomic.AtomicReference
+import kotlin.system.exitProcess
 
 private class JvmAtomicRef<T>(value: T) : MppAtomicRef<T> {
     private val ref = AtomicReference(value)
@@ -141,3 +143,14 @@ internal actual fun stdinInteractive(): Boolean = impls.stdinInteractive()
 internal actual fun getTerminalSize(): Size? = impls.getTerminalSize()
 internal actual val FAST_ISATTY: Boolean = true
 internal actual val CR_IMPLIES_LF: Boolean = false
+internal actual fun hasFileSystem(): Boolean = true
+
+internal actual fun exitProcessMpp(status: Int) {
+    exitProcess(status)
+}
+
+internal actual fun readFileIfExists(filename: String): String? {
+    val file = File(filename)
+    if (!file.isFile) return null
+    return file.bufferedReader().use { it.readText() }
+}
diff --git a/mordant/src/jvmMain/resources/META-INF/native-image/com.github.ajalt.mordant/mordant/native-image.properties b/mordant/src/jvmMain/resources/META-INF/native-image/com.github.ajalt.mordant/mordant/native-image.properties
index c2986045..d5e51785 100644
--- a/mordant/src/jvmMain/resources/META-INF/native-image/com.github.ajalt.mordant/mordant/native-image.properties
+++ b/mordant/src/jvmMain/resources/META-INF/native-image/com.github.ajalt.mordant/mordant/native-image.properties
@@ -1,1 +1,1 @@
-Args = --initialize-at-build-time=com.github.ajalt.mordant.internal.MppImplKt
+Args = --initialize-at-build-time=com.github.ajalt.mordant.internal.MppInternal_jvmKt
diff --git a/mordant/src/linuxMain/kotlin/com/github/ajalt/mordant/internal/MppImpl.kt b/mordant/src/linuxMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.linux.kt
similarity index 74%
rename from mordant/src/linuxMain/kotlin/com/github/ajalt/mordant/internal/MppImpl.kt
rename to mordant/src/linuxMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.linux.kt
index 41b9dee9..c062747b 100644
--- a/mordant/src/linuxMain/kotlin/com/github/ajalt/mordant/internal/MppImpl.kt
+++ b/mordant/src/linuxMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.linux.kt
@@ -1,3 +1,5 @@
+@file:OptIn(ExperimentalForeignApi::class)
+
 package com.github.ajalt.mordant.internal
 
 import kotlinx.cinterop.ExperimentalForeignApi
@@ -8,14 +10,15 @@ import platform.posix.TIOCGWINSZ
 import platform.posix.ioctl
 import platform.posix.winsize
 
-@OptIn(ExperimentalForeignApi::class)
 internal actual fun getTerminalSize(): Size? {
     return memScoped {
         val size = alloc<winsize>()
         if (ioctl(STDIN_FILENO, TIOCGWINSZ.toULong(), size) < 0) {
             null
         } else {
-            Size(width=size.ws_col.toInt(), height=size.ws_row.toInt())
+            Size(width = size.ws_col.toInt(), height = size.ws_row.toInt())
         }
     }
 }
+
+internal actual fun hasFileSystem(): Boolean = true
diff --git a/mordant/src/macosMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.macos.kt b/mordant/src/macosMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.macos.kt
new file mode 100644
index 00000000..50ea5706
--- /dev/null
+++ b/mordant/src/macosMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.macos.kt
@@ -0,0 +1,3 @@
+package com.github.ajalt.mordant.internal
+
+internal actual fun hasFileSystem(): Boolean = true
diff --git a/mordant/src/mingwMain/kotlin/com/github/ajalt/mordant/internal/MppImpl.kt b/mordant/src/mingwMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.mingw.kt
similarity index 96%
rename from mordant/src/mingwMain/kotlin/com/github/ajalt/mordant/internal/MppImpl.kt
rename to mordant/src/mingwMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.mingw.kt
index 35406c5a..e0237bfc 100644
--- a/mordant/src/mingwMain/kotlin/com/github/ajalt/mordant/internal/MppImpl.kt
+++ b/mordant/src/mingwMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.mingw.kt
@@ -39,3 +39,5 @@ internal actual fun ttySetEcho(echo: Boolean) = memScoped {
     }
     SetConsoleMode(stdinHandle, newMode)
 }
+
+internal actual fun hasFileSystem(): Boolean = true
diff --git a/mordant/src/nativeMain/kotlin/com/github/ajalt/mordant/internal/MppImpl.kt b/mordant/src/nativeMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.native.kt
similarity index 87%
rename from mordant/src/nativeMain/kotlin/com/github/ajalt/mordant/internal/MppImpl.kt
rename to mordant/src/nativeMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.native.kt
index cb4b9750..ac016a43 100644
--- a/mordant/src/nativeMain/kotlin/com/github/ajalt/mordant/internal/MppImpl.kt
+++ b/mordant/src/nativeMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.native.kt
@@ -8,6 +8,7 @@ import platform.posix.*
 import kotlin.concurrent.AtomicInt
 import kotlin.concurrent.AtomicReference
 import kotlin.experimental.ExperimentalNativeApi
+import kotlin.system.exitProcess
 
 private class NativeAtomicRef<T>(value: T) : MppAtomicRef<T> {
     private val ref = AtomicReference(value)
@@ -141,7 +142,7 @@ internal actual fun sendInterceptedPrintRequest(
     terminalInterface: TerminalInterface,
     interceptors: List<TerminalInterceptor>,
 ) {
-    while(printRequestLock.compareAndSet(0, 1)) {
+    while (printRequestLock.compareAndSet(0, 1)) {
         // spin until we get the lock
     }
     try {
@@ -153,5 +154,26 @@ internal actual fun sendInterceptedPrintRequest(
     }
 }
 
+internal actual fun readFileIfExists(filename: String): String? {
+    val file = fopen(filename, "r") ?: return null
+    val chunks = StringBuilder()
+    try {
+        memScoped {
+            val bufferLength = 64 * 1024
+            val buffer = allocArray<ByteVar>(bufferLength)
+
+            while (true) {
+                val chunk = fgets(buffer, bufferLength, file)?.toKString()
+                if (chunk.isNullOrEmpty()) break
+                chunks.append(chunk)
+            }
+        }
+    } finally {
+        fclose(file)
+    }
+    return chunks.toString()
+}
+
+internal actual fun exitProcessMpp(status: Int): Unit = exitProcess(status)
 internal actual val FAST_ISATTY: Boolean = true
 internal actual val CR_IMPLIES_LF: Boolean = false
diff --git a/mordant/src/wasmJsMain/kotlin/com/github/ajalt/mordant/internal/MppImpl.kt b/mordant/src/wasmJsMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.wasmJs.kt
similarity index 84%
rename from mordant/src/wasmJsMain/kotlin/com/github/ajalt/mordant/internal/MppImpl.kt
rename to mordant/src/wasmJsMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.wasmJs.kt
index 21e9a5d1..6fb2dc43 100644
--- a/mordant/src/wasmJsMain/kotlin/com/github/ajalt/mordant/internal/MppImpl.kt
+++ b/mordant/src/wasmJsMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.wasmJs.kt
@@ -18,6 +18,8 @@ private external object process {
 
     fun on(event: String, listener: () -> Unit)
     fun removeListener(event: String, listener: () -> Unit)
+    fun exit(status: Int)
+    fun cwd() : String
 }
 
 private external interface FsModule {
@@ -35,16 +37,34 @@ private fun nodeReadEnvvar(@Suppress("UNUSED_PARAMETER") key: String): String? =
 private fun nodeWidowSizeIsDefined(): Boolean =
     js("process.stdout.getWindowSize != undefined")
 
+// Have to use js() instead of extern since kotlin can't catch exceptions from external wasm
+// functions
+@Suppress("RedundantNullableReturnType", "UNUSED_PARAMETER")
+private fun nodeReadFileSync(filename: String): String? =
+    js(
+        """{
+            try {
+                return require('fs').readFileSync(filename).toString()
+            } catch (e) {
+                return null
+            }
+        }"""
+    )
+
 private class NodeMppImpls(private val fs: FsModule) : BaseNodeMppImpls<JsAny>() {
     override fun readEnvvar(key: String): String? = nodeReadEnvvar(key)
     override fun stdoutInteractive(): Boolean = process.stdout.isTTY
     override fun stdinInteractive(): Boolean = process.stdin.isTTY
     override fun stderrInteractive(): Boolean = process.stderr.isTTY
+    override fun exitProcess(status: Int): Unit = process.exit(status)
     override fun getTerminalSize(): Size? {
         if (!nodeWidowSizeIsDefined()) return null
         val jsSize = process.stdout.getWindowSize()
         return Size(width = jsSize[0]!!.toInt(), height = jsSize[1]!!.toInt())
     }
+    override fun cwd(): String {
+        return process.cwd()
+    }
 
     override fun printStderr(message: String, newline: Boolean) {
         process.stderr.write(if (newline) message + "\n" else message)
@@ -59,6 +79,10 @@ private class NodeMppImpls(private val fs: FsModule) : BaseNodeMppImpls<JsAny>()
     override fun makeTerminalCursor(terminal: Terminal): TerminalCursor {
         return NodeTerminalCursor(terminal)
     }
+
+    override fun readFileIfExists(filename: String): String? {
+        return nodeReadFileSync(filename)
+    }
 }
 
 internal actual fun browserPrintln(message: String): Unit = js("console.error(message)")
diff --git a/samples/tour/src/commonMain/kotlin/com/github/ajalt/mordant/samples/main.kt b/samples/tour/src/commonMain/kotlin/com/github/ajalt/mordant/samples/main.kt
index b8c15486..59cef45b 100644
--- a/samples/tour/src/commonMain/kotlin/com/github/ajalt/mordant/samples/main.kt
+++ b/samples/tour/src/commonMain/kotlin/com/github/ajalt/mordant/samples/main.kt
@@ -1,7 +1,6 @@
 package com.github.ajalt.mordant.samples
 
 import com.github.ajalt.colormath.model.Oklab
-import com.github.ajalt.colormath.model.RGB
 import com.github.ajalt.colormath.model.SRGB
 import com.github.ajalt.colormath.transform.interpolator
 import com.github.ajalt.colormath.transform.sequence
@@ -16,7 +15,12 @@ import com.github.ajalt.mordant.table.ColumnWidth
 import com.github.ajalt.mordant.table.horizontalLayout
 import com.github.ajalt.mordant.table.table
 import com.github.ajalt.mordant.terminal.Terminal
-import com.github.ajalt.mordant.widgets.*
+import com.github.ajalt.mordant.widgets.Panel
+import com.github.ajalt.mordant.widgets.Text
+import com.github.ajalt.mordant.widgets.UnorderedList
+import com.github.ajalt.mordant.widgets.progress.*
+import com.github.ajalt.mordant.widgets.withPadding
+import kotlin.time.TimeSource
 
 private val shadowColor = rgb("#24218c")
 
@@ -114,18 +118,17 @@ private fun cjkExample(): String {
     """.trimMargin()
 }
 
-private fun progressExample() = progressLayout {
-    padding = 1
+private fun progressExample() = progressBarLayout(spacing = 1) {
     text("file.iso")
     progressBar()
     speed("B/s")
     timeRemaining()
-}.build(25000000000, 30000000000, 351.0)
+}.build(30000000000, 25000000000, TimeSource.Monotonic.markNow(), speed = 351.0)
 
 
 private const val rightArrow = """
-      │╲ 
-  ╭───┘ ╲ 
+      │╲
+  ╭───┘ ╲
   ╰───┐ ╱
       │╱
 """


Test patch:
diff --git a/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/platform/MultiplatformSystemTest.kt b/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/platform/MultiplatformSystemTest.kt
new file mode 100644
index 00000000..5b3ca3f5
--- /dev/null
+++ b/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/platform/MultiplatformSystemTest.kt
@@ -0,0 +1,32 @@
+package com.github.ajalt.mordant.platform
+
+import com.github.ajalt.mordant.internal.hasFileSystem
+import io.kotest.matchers.nulls.shouldNotBeNull
+import io.kotest.matchers.shouldBe
+import io.kotest.matchers.string.shouldNotBeEmpty
+import kotlin.test.Test
+
+
+class MultiplatformSystemTest {
+    @Test
+    fun readEnvironmentVariable() {
+        // The kotlin.test plugin doesn't provide a way to set environment variables that works on
+        // all targets, so just pick a common one that should exist everywhere.
+        val actual = MultiplatformSystem.readEnvironmentVariable("PATH")
+        if (!hasFileSystem()) return
+        actual.shouldNotBeNull().shouldNotBeEmpty()
+    }
+
+    @Test
+    fun readFileAsUtf8() {
+        val actual = MultiplatformSystem.readFileAsUtf8(
+            // Most targets have a cwd of $moduleDir
+            "src/commonTest/resources/multiplatform_system_test.txt"
+        ) ?: MultiplatformSystem.readFileAsUtf8(
+            // js targets have a cwd of $projectDir/build/js/packages/mordant-mordant-test
+            "../../../../mordant/src/commonTest/resources/multiplatform_system_test.txt"
+        )
+        if (!hasFileSystem()) return
+        actual?.trim() shouldBe "pass"
+    }
+}
diff --git a/mordant/src/commonTest/resources/multiplatform_system_test.txt b/mordant/src/commonTest/resources/multiplatform_system_test.txt
new file mode 100644
index 00000000..2ae28399
--- /dev/null
+++ b/mordant/src/commonTest/resources/multiplatform_system_test.txt
@@ -0,0 +1,1 @@
+pass
diff --git a/test/graalvm/build.gradle.kts b/test/graalvm/build.gradle.kts
index 84257ed3..39510230 100644
--- a/test/graalvm/build.gradle.kts
+++ b/test/graalvm/build.gradle.kts
@@ -3,12 +3,6 @@ plugins {
     alias(libs.plugins.graalvm.nativeimage)
 }
 
-java {
-    toolchain {
-        languageVersion.set(JavaLanguageVersion.of(17))
-    }
-}
-
 tasks.test {
     useJUnitPlatform()
 }
diff --git a/test/graalvm/src/test/kotlin/GraalSmokeTest.kt b/test/graalvm/src/test/kotlin/GraalSmokeTest.kt
index 36233968..7cf3626e 100644
--- a/test/graalvm/src/test/kotlin/GraalSmokeTest.kt
+++ b/test/graalvm/src/test/kotlin/GraalSmokeTest.kt
@@ -22,7 +22,7 @@ class GraalSmokeTest {
         val future = animation.execute()
         Thread.sleep(100)
         animation.update { completed = 1 }
-        future.get(100, TimeUnit.MILLISECONDS)
+        future.get(1000, TimeUnit.MILLISECONDS)
     }
 
     @Test
diff --git a/test/proguard/build.gradle.kts b/test/proguard/build.gradle.kts
index 6960ee7d..b1004292 100644
--- a/test/proguard/build.gradle.kts
+++ b/test/proguard/build.gradle.kts
@@ -7,12 +7,6 @@ repositories {
     mavenCentral()
 }
 
-java {
-    toolchain {
-        languageVersion.set(JavaLanguageVersion.of(17))
-    }
-}
-
 val r8: Configuration by configurations.creating
 
 dependencies {