package ocelot.desktop import buildinfo.BuildInfo import ocelot.desktop.geometry.Size2D import ocelot.desktop.inventory.Items import ocelot.desktop.ui.UiHandler import ocelot.desktop.ui.swing.SplashScreen import ocelot.desktop.ui.widget._ import ocelot.desktop.ui.widget.modal.notification.{NotificationDialog, NotificationType} import ocelot.desktop.util.CommandLine.Argument import ocelot.desktop.util._ import org.apache.commons.io.FileUtils import org.apache.commons.lang3.SystemUtils import org.lwjgl.opengl.Display import totoro.ocelot.brain.Ocelot import totoro.ocelot.brain.nbt.ExtendedNBT.{extendNBTTagCompound, extendNBTTagList} import totoro.ocelot.brain.nbt.{CompressedStreamTools, NBT, NBTTagCompound, NBTTagString} import totoro.ocelot.brain.user.User import totoro.ocelot.brain.workspace.Workspace import java.awt.Component import java.io._ import java.nio.file._ import java.util.concurrent.locks.{Lock, ReentrantLock} import javax.swing.{JDialog, JFileChooser, UIManager} import scala.collection.mutable import scala.collection.mutable.ArrayBuffer import scala.concurrent.duration.Duration import scala.io.Source import scala.jdk.CollectionConverters._ import scala.util.{Failure, Success, Try, Using} object OcelotDesktop // This configures Log4j appenders & loggers, it should come before Logging in inheritance hierarchy extends LoggingConfiguration with Logging { System.setProperty("awt.useSystemAAFontSettings", "on") System.setProperty("swing.aatext", "true") System.setProperty("LWJGL_WM_CLASS", "Ocelot Desktop") // Required to make all subsequent swing components look "native" try UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName) catch { case ex: Exception => logger.error("Unable to perform setLookAndFeel()", ex) } private val splashScreen = new SplashScreen() var root: RootWidget = _ val players: ArrayBuffer[User] = ArrayBuffer(User("myself")) val tpsCounter = new FPSCalculator val ticker = new Ticker private val TickerIntervalHistorySize = 5 val tickerIntervalHistory = new mutable.Queue[Duration](TickerIntervalHistorySize) def pushToTickerIntervalHistory(interval: Duration): Unit = { if (tickerIntervalHistory.size >= TickerIntervalHistorySize) tickerIntervalHistory.dequeue() tickerIntervalHistory.enqueue(interval) } private val tickLock: Lock = new ReentrantLock() def withTickLockAcquired(f: => Unit): Unit = withLockAcquired(tickLock)(f) private def mainInner(args: mutable.HashMap[Argument, Option[String]]): Unit = { logger.info("Starting up Ocelot Desktop") logger.info(s"Version: ${BuildInfo.version} (${BuildInfo.commit.take(7)})") splashScreen.setStatus("Loading configuration...", 0.10f) val customConfigPath = args.get(CommandLine.ConfigPath).flatten val desktopConfigPath: Path = if (customConfigPath.isDefined) { Paths.get(customConfigPath.get) } else { // TODO: migration for old locations of ocelot.conf, can be safely removed later // TODO: uncomment this line and delete everything below it when you're ready! // OcelotPaths.desktopConfig val newConfigPath = OcelotPaths.desktopConfig try { if (!Files.exists(newConfigPath)) { val oldConfigPath = if (SystemUtils.IS_OS_WINDOWS) Paths.get(OcelotPaths.windowsAppDataDirectoryName, "Ocelot", "ocelot.conf") else Paths.get(OcelotPaths.linuxHomeDirectoryName, ".config", "ocelot", "ocelot.conf") if (Files.exists(oldConfigPath)) Files.move(oldConfigPath, newConfigPath) } } catch { case _: Throwable => } // TODO: end of upper todo <3 newConfigPath } Settings.load(desktopConfigPath) Messages.load(Source.fromURL(getClass.getResource("/ocelot/desktop/messages.txt"))) ColorScheme.load(Source.fromURL(getClass.getResource("/ocelot/desktop/colorscheme.txt"))) splashScreen.setStatus("Initializing brain...", 0.20f) Ocelot.configPath = Settings.get.brainCustomConfigPath.map(Paths.get(_)) Ocelot.librariesPath = Some(OcelotPaths.libraries) Ocelot.isPlayerOnlinePredicate = Some(player => players.exists(_.nickname == player)) Ocelot.initialize(logger) Items.init() splashScreen.setStatus("Initializing GUI...", 0.30f) createWorkspace() UiHandler.loadLibraries() splashScreen.setStatus("Loading resources...", 0.60f) UiHandler.init() val loadRecentWorkspace = Settings.get.recentWorkspace.isDefined && Settings.get.openLastWorkspace root = new RootWidget(!loadRecentWorkspace) root.size = Size2D(Display.getWidth / Settings.get.scaleFactor, Display.getHeight / Settings.get.scaleFactor) UiHandler.setRoot(root) splashScreen.setStatus("Loading workspace...", 0.90f) val cmdLineWorkspaceArgument = args.get(CommandLine.WorkspacePath).flatten if (loadRecentWorkspace || cmdLineWorkspaceArgument.isDefined) { val result = cmdLineWorkspaceArgument .orElse(Settings.get.recentWorkspace) .map(new File(_)) .toRight(new IllegalArgumentException("Received None as a directory path")) .toTry .map(load) result match { case Failure(exception) => val errorMessage = if (cmdLineWorkspaceArgument.isDefined) "Could not open the specified workspace..." else "Could not open the recent workspace..." logger.error(errorMessage, exception) new NotificationDialog(s"$errorMessage\n($exception)\nI will create a default one", NotificationType.Info) .addCloseButton() .show() root.workspaceView.createDefaultWorkspace() Settings.get.recentWorkspace = None case Success(_) => } } val updateThread = new Thread(() => try { val currentThread = Thread.currentThread() while (!currentThread.isInterrupted) { Profiler.measure("tick") { withTickLockAcquired { workspace.update() updateThreadTasks.run() tpsCounter.tick() } } ticker.waitNext() } } catch { case _: InterruptedException => // ignore }, "update-thread") updateThread.start() splashScreen.dispose() logger.info("Ocelot Desktop is up and ready!") UiHandler.start() logger.info("Cleaning up") updateThread.interrupt() try updateThread.join() catch { case _: InterruptedException => } WebcamCapture.cleanup() Settings.save(desktopConfigPath) UiHandler.terminate() ResourceManager.checkEmpty() Ocelot.shutdown() logger.info("Thanks for using Ocelot Desktop") System.exit(0) } def main(rawArgs: Array[String]): Unit = { val args = CommandLine.parse(rawArgs) if (args.contains(CommandLine.Help)) { println(CommandLine.doc) } else { try mainInner(args) catch { case e: Exception => val sw = new StringWriter val pw = new PrintWriter(sw) e.printStackTrace(pw) logger.error(s"${sw.toString}") System.exit(1) } } } private def saveWorld(nbt: NBTTagCompound): Unit = withTickLockAcquired { val backendNBT = new NBTTagCompound val frontendNBT = new NBTTagCompound workspace.save(backendNBT) root.workspaceView.save(frontendNBT) frontendNBT.setNewTagList("players", players.map(player => new NBTTagString(player.nickname))) nbt.setTag("back", backendNBT) nbt.setTag("front", frontendNBT) } private def loadWorld(nbt: NBTTagCompound): Unit = withTickLockAcquired { val backendNBT = nbt.getCompoundTag("back") val frontendNBT = nbt.getCompoundTag("front") workspace.load(backendNBT) root.workspaceView.load(frontendNBT) if (frontendNBT.hasKey("players")) { players.clear() players.addAll(frontendNBT.getTagList("players", NBT.TAG_STRING).map((player: NBTTagString) => User(player.getString))) } } private var lastAutosave: Long = -1 def resetAutosave(): Unit = { if (Settings.get.autosave) { lastAutosave = System.currentTimeMillis() logger.debug("Autosave interval was reset.") } } def updateAutosave(): Unit = { if (Settings.get.autosave) { if (lastAutosave == -1 || System.currentTimeMillis() - lastAutosave > Settings.get.autosavePeriod * 1000) { val path = savePath.getOrElse(tmpPath) logger.debug(s"Autosave to $path...") saveTo(path) lastAutosave = System.currentTimeMillis() } } } private var _savePath: Option[Path] = None private def savePath: Option[Path] = _savePath private def savePath_=(path: Option[Path]): Unit = { _savePath = path UiHandler.windowTitleSuffix = path.map(_.toString) } private val tmpPath = Files.createTempDirectory("ocelot-save") Runtime.getRuntime.addShutdownHook(new Thread(() => { // if autosave is turned on and the project is not saved properly, we would like to keep the temporary project if (!Settings.get.autosave || savePath.isEmpty) { FileUtils.deleteDirectory(tmpPath.toFile) } })) def newWorkspace(): Unit = showCloseConfirmationDialog("Save workspace before opening a new one?") { root.workspaceView.newWorkspace() savePath = None Settings.get.recentWorkspace = None resetAutosave() } private def saveTo(outputPath: Path): Unit = { val oldPath = workspace.path if (oldPath != outputPath) { val (oldFiles, newFiles) = Using.resources(Files.list(oldPath), Files.list(outputPath)) { (oldDirStream, newDirStream) => val oldFiles = oldDirStream.iterator.asScala.toArray val newFiles = newDirStream.iterator.asScala.toArray (oldFiles, newFiles) } val toRemove = newFiles.intersect(oldFiles) for (path <- toRemove) { if (Files.isDirectory(path)) { FileUtils.deleteDirectory(path.toFile) } else { Files.delete(path) } } for (path <- oldFiles) { val oldFile = oldPath.resolve(path.getFileName).toFile val newFile = outputPath.resolve(path.getFileName).toFile if (Files.isDirectory(path)) { FileUtils.copyDirectory(oldFile, newFile) } else { FileUtils.copyFile(oldFile, newFile) } } workspace.path = outputPath } val path = outputPath.resolve("workspace.nbt") try { Files.move(path, path.resolveSibling("workspace.nbt.bak"), StandardCopyOption.REPLACE_EXISTING) } catch { case _: FileNotFoundException | _: NoSuchFileException => // no workspace file to back up -- that's okay } Using.resource(new DataOutputStream(new FileOutputStream(path.toFile))) { writer => val nbt = new NBTTagCompound saveWorld(nbt) CompressedStreamTools.writeCompressed(nbt, writer) } logger.info(s"Saved workspace to: $outputPath") } def save(continuation: => Unit): Unit = savePath match { case Some(savePath) => Try { saveTo(savePath) continuation } match { case f @ Failure(_) => this.savePath = None showFailureMessage(f) case Success(_) => } case None => showSaveDialog(continuation) } def saveAs(): Unit = showSaveDialog() def showOpenDialog(): Unit = showFileChooserDialog(JFileChooser.OPEN_DIALOG, JFileChooser.DIRECTORIES_ONLY) { case Some(dir) => load(dir) case None => Success(()) } def load(dir: File): Try[Unit] = { val path = Paths.get(dir.getCanonicalPath, "workspace.nbt") if (Files.exists(path)) { Using(new DataInputStream(Files.newInputStream(path))) { reader => val nbt = CompressedStreamTools.readCompressed(reader) Transaction.run { tx => val oldSavePath = savePath savePath = Some(dir.toPath) tx.onFailure { savePath = oldSavePath } Settings.get.recentWorkspace = Some(dir.getCanonicalPath) workspace.path = dir.toPath loadWorld(nbt) resetAutosave() } } } else Failure(new FileNotFoundException("Specified directory does not contain 'workspace.nbt'")) } def showFileChooserDialog(dialogType: Int, selectionMode: Int)(f: Option[File] => Try[Unit]): Unit = { new Thread(() => { val lastFile = savePath.map(_.toFile).orNull val chooser = new JFileChooser(lastFile) { override def createDialog(parent: Component): JDialog = { val dialog = super.createDialog(parent) dialog.setModal(true) dialog.setAlwaysOnTop(true) dialog } } chooser.setFileSelectionMode(selectionMode) chooser.setDialogType(dialogType) val selectedFile = Option.when(chooser.showDialog(null, null) == JFileChooser.APPROVE_OPTION)(chooser.getSelectedFile) // users of [showFileChooserDialog] expect that the continuation is run on the main thread. // let's meet this expectation. UiHandler.UiThreadTasks.add(() => { val result = f(selectedFile) result match { case f@Failure(_) => showFailureMessage(f) case Success(_) => } }) }).start() } private def showFailureMessage(failure: Failure[Unit]): Unit = { val Failure(exception) = failure logger.error("File operation failed", exception) new NotificationDialog( s"Something went wrong!\n($exception)\nCheck the log file for a full stacktrace.", NotificationType.Error ).addCloseButton().show() } def showAddPlayerDialog(): Unit = new InputDialog( "Add new player", text => OcelotDesktop.selectPlayer(text) ).show() def player: User = if (players.nonEmpty) players.head else User("myself") def selectPlayer(name: String): Unit = { players.indexWhere(_.nickname == name) match { case -1 => players.prepend(User(name)) case i => val player = players(i) players.remove(i) players.prepend(player) } } def removePlayer(name: String): Unit = { players.indexWhere(_.nickname == name) match { case -1 => case i => players.remove(i) } } def exit(): Unit = showCloseConfirmationDialog() { UiHandler.exit() } var workspace: Workspace = _ val updateThreadTasks = new TaskQueue() private def createWorkspace(): Unit = { workspace = new Workspace(tmpPath) } private def prepareSavePath(path: Path)(continuation: => Unit): Unit = { val nonEmpty = try Using.resource(Files.list(path))(_.iterator.asScala.nonEmpty) catch { case _: FileNotFoundException | _: NoSuchFileException => logger.info(s"Save path $path does not exist: creating a new directory") Files.createDirectory(path) false } if (nonEmpty) { new NotificationDialog( """Chosen save path is not empty. |Files in the save directory will be included in the workspace. |They may be overwritten, causing loss of data. |Proceed with saving anyway?""".stripMargin, NotificationType.Warning ) { addButton("Cancel") { close() } addButton("Yes") { close() continuation } }.show() } else continuation } private def showSaveDialog(continuation: => Unit): Unit = showFileChooserDialog(JFileChooser.SAVE_DIALOG, JFileChooser.DIRECTORIES_ONLY) { dir => Try { if (dir.nonEmpty) { prepareSavePath(dir.get.toPath) { Transaction.run { tx => val oldSavePath = savePath savePath = dir.map(_.toPath) tx.onFailure { savePath = oldSavePath } save(continuation) Settings.get.recentWorkspace = dir.map(_.getCanonicalPath) resetAutosave() } } } } } private def showCloseConfirmationDialog(prompt: Option[String])(continuation: => Unit): Unit = { if (UiHandler.root.modalDialogPool.children.exists(_.isInstanceOf[CloseConfirmationDialog])) { return } for (savePath <- savePath if Settings.get.saveOnExit) { saveTo(savePath) continuation return } val prompt_ = prompt new CloseConfirmationDialog { override def prompt: String = prompt_.getOrElse(super.prompt) override def onSaveSelected(): Unit = save { close() continuation } override def onNoSaveSelected(): Unit = { close() continuation } }.show() } private def showCloseConfirmationDialog(prompt: String)(continuation: => Unit): Unit = showCloseConfirmationDialog(Some(prompt))(continuation) private def showCloseConfirmationDialog()(continuation: => Unit): Unit = showCloseConfirmationDialog(None)(continuation) }