Created
March 27, 2024 20:58
-
-
Save megri/4ed0dcc1a05338a4d274b2510bd5fa02 to your computer and use it in GitHub Desktop.
Mill-driven hot-reload for Indigo
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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", | |
) | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!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> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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