Skip to content

Instantly share code, notes, and snippets.

@megri
Created March 27, 2024 20:58
Show Gist options
  • Save megri/4ed0dcc1a05338a4d274b2510bd5fa02 to your computer and use it in GitHub Desktop.
Save megri/4ed0dcc1a05338a4d274b2510bd5fa02 to your computer and use it in GitHub Desktop.
Mill-driven hot-reload for Indigo
import mill._, scalalib._, scalajslib._
import scala.collection.mutable
object ElectronModule {
class Worker(val dstBase: os.Path) extends AutoCloseable {
private val updateTimes = mutable.Map[os.Path, Long]()
private var process: os.SubProcess = null
def updateApp(electronEntrypoint: os.Path, watchedSourceBases: Seq[os.Path])(implicit ctx: mill.api.Ctx): Unit = {
val updates = (
for {
srcBase <- geny.Generator(watchedSourceBases: _*)
(srcPath, srcAttrs) <- os.walk.stream.attrs(srcBase)
wasUpdated = copyIfUpdated(srcBase, srcPath, srcAttrs)
if wasUpdated
} yield srcPath
).toSeq
if (!processIsDead() && updates.exists(_ == electronEntrypoint)) {
sendPM("DESTROY")
process.destroy()
// process.wrapped.onExit().get()
// we could await process termination by uncommenting the line above, or we can go ahead and just set process to
// null which should in theory be faster.
process = null
}
if (processIsDead()) {
T.log.info("Restarting app.")
process = os
.proc("npx", "--no-install", "electron", electronEntrypoint.last)
.spawn(
cwd = dstBase,
stdout = os.ProcessOutput.Readlines(line => T.log.info(s"[from process] $line")),
)
} else {
sendPM("RELOAD")
}
}
def close(): Unit = {
if (process != null && process.isAlive()) {
process.stdin.writeLine("DESTROY")
process.stdin.flush()
process.destroy()
}
process = null
}
private def copyIfUpdated(srcBase: os.Path, src: os.Path, srcAttrs: os.StatInfo)(implicit
ctx: mill.api.Ctx
): Boolean = {
val dst = dstBase / src.subRelativeTo(srcBase)
if (srcAttrs.isDir) {
os.makeDir.all(dst)
false
} else {
val srcUpdatedAt = srcAttrs.mtime.toMillis
val dstUpdatedAt = updateTimes.getOrElse(dst, 0L)
val shouldUpdate = dstUpdatedAt < srcUpdatedAt
if (shouldUpdate) {
def relDst = dst.subRelativeTo(dstBase)
T.log.debug(s"Replacing '$relDst'.")
if (srcAttrs.isSymLink) {
os.remove(dst)
os.symlink(dst, dstBase / os.readLink.absolute(src).subRelativeTo(srcBase))
} else {
os.copy(src, dst, replaceExisting = true, copyAttributes = true)
}
updateTimes(dst) = srcUpdatedAt
}
shouldUpdate
}
}
private def processIsDead() = process == null || !process.isAlive()
private def sendPM(msg: String)(implicit ctx: mill.api.Ctx): Unit = {
if (!processIsDead()) {
process.stdin.writeLine(msg)
process.stdin.flush()
} else {
T.log.info(s"Wanting to send '$msg' to the process but it is dead.")
}
}
}
}
trait ElectronModule extends ScalaJSModule {
def electronVersion: T[Option[String]] = None
def appRoot = T.source { PathRef(millSourcePath / "app-root") }
def devWorker = T.worker {
new ElectronModule.Worker(T.dest)
}
def installElectron = T {
val electronArtifact = electronVersion() match {
case Some(v) => s"electron@$v"
case None => "electron"
}
T.log.debug(s"Installing $electronArtifact…")
os.proc("npm", "install", "-D", electronArtifact).call(cwd = T.dest)
T.log.debug(s"Electron installed to '${T.dest}'.")
PathRef(T.dest)
}
def electronEntrypoint: T[os.Path] = appRoot().path / "index.js"
def dev = T {
val electronRootPath = installElectron().path
val appRootPath = appRoot().path
val appCodePath = fastLinkJS().dest.path
devWorker().updateApp(electronEntrypoint(), Seq(electronRootPath, appRootPath, appCodePath))
}
}
object core extends ElectronModule {
def scalaVersion = "3.3.3"
def ammoniteVersion = "3.0.0-M1"
def scalacOptions = Seq("-Wunused:imports")
def scalaJSVersion = "1.16.0"
val indigoVersion = "0.16.0"
def ivyDeps = Agg(
ivy"io.indigoengine::indigo::$indigoVersion",
ivy"io.indigoengine::indigo-json-circe::$indigoVersion",
ivy"io.indigoengine::indigo-extras::$indigoVersion",
ivy"com.outr::scribe::3.13.0",
)
object test extends ScalaJSTests with TestModule.Munit {
def ivyDeps = Agg(
ivy"org.scalameta::munit::1.0.0-M10",
)
}
}
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Security-Policy" content="script-src 'self' 'unsafe-inline'">
<meta charset="UTF-8">
<title>Hello, Indigo!</title>
<style>
body {
padding: 0;
margin: 0;
overflow-x: hidden;
overflow-y: hidden;
background-color: black;
}
#indigo-container {
display: flex;
align-items: center;
justify-content: center;
padding: 0;
margin: 0;
width: 100vw;
height: 100vh;
}
</style>
</head>
<body>
<!-- This div's id is hardcoded, and several parts of this reference implementation look for it. -->
<div id="indigo-container"></div>
<script type="text/javascript" src="main.js"></script>
<script type="text/javascript">
IndigoGame.launch("indigo-container", { "width": window.innerWidth.toString(), "height": window.innerHeight.toString() })
</script>
</body>
</html>
const { app, BrowserWindow } = require('electron')
const readline = require('readline')
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
terminal: false,
})
const createWindow = () => {
const win = new BrowserWindow({
width: 600,
height: 400
})
win.loadFile('index.html')
return win
}
const attachStdinHandler = (win) => {
rl.on('line', line => {
switch (line) {
case 'DESTROY':
console.log('destroying')
app.quit()
break
case 'RELOAD':
console.log('reloading')
win.reload()
break
default:
console.log(`received unrecognised command ${line}.`)
}
})
}
app.whenReady().then(createWindow).then(attachStdinHandler)
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit()
})
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment