diff --git a/mirai-console/frontend/mirai-console-terminal/src/ConsoleInputImpl.kt b/mirai-console/frontend/mirai-console-terminal/src/ConsoleInputImpl.kt index 858f449d8..96446b1a1 100644 --- a/mirai-console/frontend/mirai-console-terminal/src/ConsoleInputImpl.kt +++ b/mirai-console/frontend/mirai-console-terminal/src/ConsoleInputImpl.kt @@ -9,67 +9,11 @@ package net.mamoe.mirai.console.terminal -import kotlinx.coroutines.CancellableContinuation -import kotlinx.coroutines.suspendCancellableCoroutine import net.mamoe.mirai.console.util.ConsoleInput -import org.fusesource.jansi.Ansi -import org.jline.reader.EndOfFileException -import java.time.Instant -import java.time.LocalDateTime -import java.time.ZoneId -import java.time.format.DateTimeFormatter -import java.util.concurrent.Executors -import kotlin.coroutines.resumeWithException internal object ConsoleInputImpl : ConsoleInput { - private val format = DateTimeFormatter.ofPattern("HH:mm:ss") - internal val thread = Executors.newSingleThreadExecutor { task -> - Thread(task, "Mirai Console Input Thread").also { - it.isDaemon = false - } - } - internal var executingCoroutine: CancellableContinuation? = null - - override suspend fun requestInput(hint: String): String { - return suspendCancellableCoroutine { coroutine -> - if (thread.isShutdown || thread.isTerminated) { - coroutine.resumeWithException(EndOfFileException()) - return@suspendCancellableCoroutine - } - executingCoroutine = coroutine - kotlin.runCatching { - thread.submit { - kotlin.runCatching { - waitDownloadingProgressEmpty() - lineReader.readLine( - if (hint.isNotEmpty()) { - prePrintNewLog() - lineReader.printAbove( - Ansi.ansi() - .fgCyan() - .a( - LocalDateTime.ofInstant(Instant.now(), ZoneId.systemDefault()) - .format(format) - ) - .a(" ") - .fgMagenta().a(hint) - .reset() - .toString() - ) - "$hint > " - } else "> " - ) - }.let { result -> - executingCoroutine = null - coroutine.resumeWith(result) - } - } - }.onFailure { error -> - executingCoroutine = null - kotlin.runCatching { coroutine.resumeWithException(EndOfFileException(error)) } - } - } + return JLineInputDaemon.nextInput(hint) } } diff --git a/mirai-console/frontend/mirai-console-terminal/src/ConsoleThread.kt b/mirai-console/frontend/mirai-console-terminal/src/ConsoleThread.kt index eb4305dfe..27199930b 100644 --- a/mirai-console/frontend/mirai-console-terminal/src/ConsoleThread.kt +++ b/mirai-console/frontend/mirai-console-terminal/src/ConsoleThread.kt @@ -24,7 +24,6 @@ import net.mamoe.mirai.console.command.parse.CommandValueArgument import net.mamoe.mirai.console.terminal.noconsole.NoConsole import net.mamoe.mirai.console.util.ConsoleInternalApi import net.mamoe.mirai.console.util.cast -import net.mamoe.mirai.console.util.requestInput import net.mamoe.mirai.console.util.safeCast import net.mamoe.mirai.utils.MiraiLogger import net.mamoe.mirai.utils.warning @@ -47,16 +46,12 @@ internal fun startupConsoleThread() { runCatching { // 应该仅关闭用户输入 terminal.reader().shutdown() - ConsoleInputImpl.thread.shutdownNow() - runCatching { - ConsoleInputImpl.executingCoroutine?.cancel(EndOfFileException()) - } }.exceptionOrNull()?.printStackTrace() } MiraiConsole.launch(CoroutineName("Console Command")) { while (true) { val next = try { - MiraiConsole.requestInput("").let { + JLineInputDaemon.nextCmd().let { when { it.isBlank() -> it it.startsWith(CommandManager.commandPrefix) -> it diff --git a/mirai-console/frontend/mirai-console-terminal/src/JLineInputDaemon.kt b/mirai-console/frontend/mirai-console-terminal/src/JLineInputDaemon.kt new file mode 100644 index 000000000..cb254cc5b --- /dev/null +++ b/mirai-console/frontend/mirai-console-terminal/src/JLineInputDaemon.kt @@ -0,0 +1,218 @@ +/* + * Copyright 2019-2022 Mamoe Technologies and contributors. + * + * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证. + * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link. + * + * https://github.com/mamoe/mirai/blob/dev/LICENSE + */ + +package net.mamoe.mirai.console.terminal + +import kotlinx.coroutines.CancellableContinuation +import kotlinx.coroutines.Runnable +import kotlinx.coroutines.isActive +import kotlinx.coroutines.suspendCancellableCoroutine +import net.mamoe.mirai.console.terminal.noconsole.NoConsole +import net.mamoe.mirai.utils.ConcurrentLinkedDeque +import net.mamoe.mirai.utils.cast +import org.jline.reader.MaskingCallback +import org.jline.reader.impl.LineReaderImpl +import org.jline.utils.AttributedStringBuilder +import org.jline.utils.AttributedStyle +import java.util.* + +internal object JLineInputDaemon : Runnable { + lateinit var terminal0: MiraiConsoleImplementationTerminal + private val readerImpl: LineReaderImpl get() = lineReader.cast() + + private var pausedByDaemon: Boolean = false + private var canResumeByNewRequest: Boolean = false + + class Request( + val masked: Boolean = false, + val delayable: Boolean = false, + val coroutine: CancellableContinuation, + val prompt: String? = null, + ) + + private val pwdMasker = object : MaskingCallback { + override fun display(line: String): String { + return buildString(line.length) { repeat(line.length) { append('*') } } + } + + override fun history(line: String?): String? { + return null + } + } + + val queue = ConcurrentLinkedDeque() + val queueDelayable = ConcurrentLinkedDeque() + + val queueStateChangeNoticer = Object() + var processing: Request? = null + + override fun run() { + + while (terminal0.isActive) { + val nextTask = queue.poll() ?: queueDelayable.poll() + + if (nextTask == null) { + synchronized(queueStateChangeNoticer) { + if (queue.isEmpty() && queueDelayable.isEmpty()) { + queueStateChangeNoticer.wait() + } + } + continue + } + if (nextTask.coroutine.isCancelled) continue + + + synchronized(queueStateChangeNoticer) { + processing = nextTask + updateFlags(nextTask) + } + + val rsp = kotlin.runCatching { + lineReader.readLine( + nextTask.prompt ?: "> ", + null, + if (nextTask.masked) pwdMasker else null, + null + ) + } + + val crtProcessing: Request + synchronized(queueStateChangeNoticer) { + crtProcessing = processing ?: error("!processing lost") + processing = null + } + crtProcessing.coroutine.resumeWith(rsp) + } + } + + internal fun sendRequest(req: Request) { + if (terminal is NoConsole) { + req.coroutine.resumeWith(kotlin.runCatching { + lineReader.readLine() + }) + return + } + + req.coroutine.invokeOnCancellation { + if (req.delayable) { + queueDelayable + } else { + queue + }.remove(req) + + synchronized(queueStateChangeNoticer) { + if (processing !== req) return@invokeOnCancellation + + val nnextTask: Request + while (true) { + val nnextTask2 = queue.poll() ?: queueDelayable.poll() + if (nnextTask2 == null) { + suspendReader(true) + return@invokeOnCancellation + } + if (nnextTask2.coroutine.isCancelled) continue + + nnextTask = nnextTask2 + break + } + + processing = nnextTask + updateFlags(nnextTask) + } + } + + synchronized(queueStateChangeNoticer) { + val crtProcessing = processing + if (crtProcessing != null) { + if (crtProcessing.delayable) { + processing = req + queueDelayable.addLast(crtProcessing) + + updateFlags(req) + if (lineReader.isReading) { + readerImpl.redisplay() + } + return@synchronized + } + } + + if (req.delayable) { + queueDelayable + } else { + queue + }.addLast(req) + + queueStateChangeNoticer.notify() + } + tryResumeReader(true) + } + + private fun updateFlags(req: Request) { + if (req.masked) { + lineReaderMaskingCallback[lineReader] = pwdMasker + } else { + lineReaderMaskingCallback[lineReader] = null + } + readerImpl.setPrompt(req.prompt ?: "> ") + } + + + @Synchronized + internal fun suspendReader(canResumeByNewRequest: Boolean) { + if (!lineReader.isReading) return + + terminal.pause() + pausedByDaemon = true + this.canResumeByNewRequest = canResumeByNewRequest + lineReaderReadingField.setBoolean(lineReader, false) + terminalDisplay.update(Collections.emptyList(), 0) + } + + @Synchronized + internal fun tryResumeReader(byNewReq: Boolean) { + if (!pausedByDaemon) return + if (byNewReq && !canResumeByNewRequest) return + + pausedByDaemon = false + terminal.resume() + lineReaderReadingField.setBoolean(lineReader, true) + readerImpl.redisplay() + } + + suspend fun nextInput(hint: String): String = suspendCancellableCoroutine { cort -> + sendRequest( + Request( + masked = false, + delayable = false, + coroutine = cort, + prompt = "$hint> " + ) + ) + } + + suspend fun nextCmd(): String = suspendCancellableCoroutine { cort -> + sendRequest(Request(masked = false, delayable = true, coroutine = cort)) + } + + suspend fun nextPwd(): String = suspendCancellableCoroutine { cort -> + sendRequest( + Request( + masked = true, + delayable = false, + coroutine = cort, + prompt = AttributedStringBuilder() + .style(AttributedStyle.DEFAULT.background(AttributedStyle.CYAN).foreground(AttributedStyle.WHITE)) + .append("PASSWORD") + .style(AttributedStyle.DEFAULT) + .append("> ") + .toAnsi() + ) + ) + } +} diff --git a/mirai-console/frontend/mirai-console-terminal/src/LoggingService.kt b/mirai-console/frontend/mirai-console-terminal/src/LoggingService.kt index d3dbe0799..b352b3629 100644 --- a/mirai-console/frontend/mirai-console-terminal/src/LoggingService.kt +++ b/mirai-console/frontend/mirai-console-terminal/src/LoggingService.kt @@ -9,23 +9,9 @@ package net.mamoe.mirai.console.terminal -import kotlinx.coroutines.* -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.channels.onFailure +import kotlinx.coroutines.CoroutineScope import net.mamoe.mirai.utils.TestOnly -import net.mamoe.mirai.utils.systemProp import java.io.File -import java.io.RandomAccessFile -import java.nio.file.Files -import java.time.Instant -import java.time.ZoneId -import java.time.temporal.ChronoUnit -import java.util.concurrent.Executors -import java.util.concurrent.Future -import java.util.concurrent.ThreadFactory -import java.util.concurrent.TimeUnit -import java.util.concurrent.atomic.AtomicInteger -import java.util.concurrent.atomic.AtomicReference // Used by https://github.com/iTXTech/soyuz, change with care. internal sealed class LoggingService { @@ -45,108 +31,14 @@ internal class LoggingServiceNoop : LoggingService() { } } -@OptIn(ConsoleTerminalExperimentalApi::class) internal class LoggingServiceI( - private val scope: CoroutineScope, + scope: CoroutineScope, ) : LoggingService() { - - private val threadPool = Executors.newScheduledThreadPool(3, object : ThreadFactory { - private val group = ThreadGroup("mirai console terminal logging") - private val counter = AtomicInteger(0) - override fun newThread(r: Runnable): Thread { - return Thread( - group, - r, - "Mirai Console Terminal Logging Thread#" + counter.getAndIncrement() - ).also { thread -> - thread.isDaemon = true - } - } - }) - private val threadDispatcher = threadPool.asCoroutineDispatcher() - internal val pipelineSize = systemProp("mirai.console.terminal.log.buffer", 2048).toInt() - private val pipeline = Channel(capacity = pipelineSize) - internal lateinit var autoSplitTask: Future<*> - - @Suppress("BlockingMethodInNonBlockingContext") fun startup(logDir: File) { - logDir.mkdirs() - - val outputLock = Any() - val output = AtomicReference() - - fun switchLogFile() { - val latestLogFile = logDir.resolve("latest.log") - var targetFile: File - if (latestLogFile.isFile) { - var counter = 0 - do { - targetFile = logDir.resolve("log-$counter.log") - counter++ - } while (targetFile.exists()) - - } else { - targetFile = latestLogFile - } - - synchronized(outputLock) { - output.get()?.close() - if (latestLogFile !== targetFile) { - Files.move(latestLogFile.toPath(), targetFile.toPath()) - } - output.set(RandomAccessFile(latestLogFile, "rw").also { it.seek(it.length()) }) - } - } - switchLogFile() - - @OptIn(TestOnly::class) - switchLogFileNow = ::switchLogFile - - scope.launch(threadDispatcher) { - while (isActive) { - val nextLine = pipeline.receive() - synchronized(outputLock) { - output.get().let { out -> - out.write(nextLine.toByteArray()) - out.write('\n'.code) - } - } - } - } - - // Daily split log files - val nextDayTimeSec = Instant.now() - .atZone(ZoneId.systemDefault()) - .plus(1, ChronoUnit.DAYS) - .withHour(0) - .withMinute(0) - .withSecond(0) - .toEpochSecond() - - autoSplitTask = threadPool.scheduleAtFixedRate( - ::switchLogFile, - nextDayTimeSec * 1000 - System.currentTimeMillis(), - TimeUnit.DAYS.toMillis(1), TimeUnit.MILLISECONDS - ) - - scope.coroutineContext.job.invokeOnCompletion { - threadPool.shutdown() - synchronized(outputLock) { - output.get()?.close() - } - } } - private fun pushInPool(line: String) { - scope.launch(threadDispatcher, start = CoroutineStart.UNDISPATCHED) { - pipeline.send(line) - } - } override fun pushLine(line: String) { - pipeline.trySend(line).onFailure { - pushInPool(line) - } } } \ No newline at end of file diff --git a/mirai-console/frontend/mirai-console-terminal/src/MiraiConsoleImplementationTerminal.kt b/mirai-console/frontend/mirai-console-terminal/src/MiraiConsoleImplementationTerminal.kt index 2295b17fb..cb7a3b78c 100644 --- a/mirai-console/frontend/mirai-console-terminal/src/MiraiConsoleImplementationTerminal.kt +++ b/mirai-console/frontend/mirai-console-terminal/src/MiraiConsoleImplementationTerminal.kt @@ -22,41 +22,42 @@ package net.mamoe.mirai.console.terminal -import kotlinx.coroutines.* +import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch import net.mamoe.mirai.console.ConsoleFrontEndImplementation -import net.mamoe.mirai.console.MiraiConsole import net.mamoe.mirai.console.MiraiConsoleFrontEndDescription import net.mamoe.mirai.console.MiraiConsoleImplementation -import net.mamoe.mirai.console.command.CommandManager import net.mamoe.mirai.console.data.MultiFilePluginDataStorage import net.mamoe.mirai.console.data.PluginDataStorage import net.mamoe.mirai.console.fontend.ProcessProgress +import net.mamoe.mirai.console.frontendbase.AbstractMiraiConsoleFrontendImplementation +import net.mamoe.mirai.console.frontendbase.FrontendBase +import net.mamoe.mirai.console.frontendbase.logging.AllDroppedLogRecorder +import net.mamoe.mirai.console.frontendbase.logging.LogRecorder import net.mamoe.mirai.console.plugin.jvm.JvmPluginLoader import net.mamoe.mirai.console.plugin.loader.PluginLoader import net.mamoe.mirai.console.terminal.ConsoleInputImpl.requestInput import net.mamoe.mirai.console.terminal.noconsole.AllEmptyLineReader import net.mamoe.mirai.console.terminal.noconsole.NoConsole +import net.mamoe.mirai.console.terminal.noconsole.SystemOutputPrintStream import net.mamoe.mirai.console.util.ConsoleExperimentalApi import net.mamoe.mirai.console.util.ConsoleInput import net.mamoe.mirai.console.util.ConsoleInternalApi import net.mamoe.mirai.console.util.SemVersion -import net.mamoe.mirai.utils.* +import net.mamoe.mirai.utils.BotConfiguration +import net.mamoe.mirai.utils.LoginSolver +import net.mamoe.mirai.utils.StandardCharImageLoginSolver import org.fusesource.jansi.Ansi import org.jline.reader.LineReader import org.jline.reader.LineReaderBuilder -import org.jline.reader.impl.LineReaderImpl import org.jline.reader.impl.completer.NullCompleter import org.jline.terminal.Terminal import org.jline.terminal.TerminalBuilder import org.jline.terminal.impl.AbstractWindowsTerminal -import org.jline.utils.AttributedString -import org.jline.utils.Display import java.nio.file.Path import java.nio.file.Paths -import kotlin.concurrent.withLock -import kotlin.coroutines.Continuation -import kotlin.reflect.KClass -import kotlin.reflect.KProperty /** * mirai-console-terminal 后端实现 @@ -74,28 +75,40 @@ open class MiraiConsoleImplementationTerminal override val dataStorageForBuiltIns: PluginDataStorage = MultiFilePluginDataStorage(rootPath.resolve("data")), override val configStorageForJvmPluginLoader: PluginDataStorage = MultiFilePluginDataStorage(rootPath.resolve("config")), override val configStorageForBuiltIns: PluginDataStorage = MultiFilePluginDataStorage(rootPath.resolve("config")), -) : MiraiConsoleImplementation, CoroutineScope by CoroutineScope( - SupervisorJob() + CoroutineName("MiraiConsoleImplementationTerminal") + - CoroutineExceptionHandler { coroutineContext, throwable -> - if (throwable is CancellationException) { - return@CoroutineExceptionHandler - } - val coroutineName = coroutineContext[CoroutineName]?.name ?: "" - MiraiConsole.mainLogger.error("Exception in coroutine $coroutineName", throwable) - }) { - override val jvmPluginLoader: JvmPluginLoader by lazy { backendAccess.createDefaultJvmPluginLoader(coroutineContext) } - override val commandManager: CommandManager by lazy { backendAccess.createDefaultCommandManager(coroutineContext) } - override val consoleInput: ConsoleInput get() = ConsoleInputImpl - override val isAnsiSupported: Boolean get() = true - override val consoleDataScope: MiraiConsoleImplementation.ConsoleDataScope by lazy { - MiraiConsoleImplementation.ConsoleDataScope.createDefault( - coroutineContext, - dataStorageForBuiltIns, - configStorageForBuiltIns - ) +) : AbstractMiraiConsoleFrontendImplementation("MiraiConsoleImplementationTerminal") { + + @Suppress("MemberVisibilityCanBePrivate") + override val frontendBase = object : FrontendBase() { + override val scope: CoroutineScope + get() = this@MiraiConsoleImplementationTerminal + + override val workingDirectory: Path + get() = this@MiraiConsoleImplementationTerminal.rootPath + + override fun initLogRecorder(): LogRecorder { + if (ConsoleTerminalSettings.noLogging) { + return AllDroppedLogRecorder + } + return super.initLogRecorder() + } + + override fun initScreen_forwardStdToScreen() { + lineReader + super.initScreen_forwardStdToScreen() + } + + override fun printToScreenDirectly(msg: String) { + printToScreen(msg) + + @Suppress("DEPRECATION") + logService.pushLine(msg) + } } - // used in test + override val consoleInput: ConsoleInput get() = ConsoleInputImpl + override val isAnsiSupported: Boolean get() = true + + @Deprecated("Used by iTXTech; for binary compatibility") internal val logService: LoggingService override fun createLoginSolver(requesterBot: Long, configuration: BotConfiguration): LoginSolver { @@ -103,58 +116,14 @@ open class MiraiConsoleImplementationTerminal return StandardCharImageLoginSolver(input = { requestInput("LOGIN> ") }) } - @Suppress("DeprecatedCallableAddReplaceWith") - @Deprecated( - "Deprecated for removal. Implement the other overload, or use MiraiConsole.createLogger instead.", - level = DeprecationLevel.ERROR - ) - override fun createLogger(identity: String?): MiraiLogger { - return MiraiLogger.Factory.create(MiraiConsoleImplementationTerminal::class, identity) -// return PlatformLogger(identity = identity, output = { line -> -// val text = line + ANSI_RESET -// lineReader.printAbove(text) -// logService.pushLine(text) -// }) - } - - override fun createLoggerFactory(context: MiraiConsoleImplementation.FrontendLoggingInitContext): MiraiLogger.Factory { - // platformImplementation is not used by Terminal - - return object : MiraiLogger.Factory { - override fun create(requester: Class<*>, identity: String?): MiraiLogger { - return PlatformLogger(identity = identity ?: requester.simpleName, output = { line -> - val text = line + ANSI_RESET - prePrintNewLog() - lineReader.printAbove(text) - postPrintNewLog() - logService.pushLine(text) - }) - } - - override fun create(requester: KClass<*>, identity: String?): MiraiLogger { - return PlatformLogger(identity = identity ?: requester.simpleName, output = { line -> - val text = line + ANSI_RESET - prePrintNewLog() - lineReader.printAbove(text) - postPrintNewLog() - logService.pushLine(text) - }) - } - - } - } init { with(rootPath.toFile()) { mkdir() require(isDirectory) { "rootDir $absolutePath is not a directory" } - logService = if (ConsoleTerminalSettings.noLogging) { - LoggingServiceNoop() - } else { - LoggingServiceI(childScope("Log Service")).also { service -> - service.startup(resolve("logs")) - } - } + + @Suppress("DEPRECATION") + logService = LoggingServiceNoop() } } @@ -163,7 +132,12 @@ open class MiraiConsoleImplementationTerminal override fun preStart() { registerSignalHandler() - overrideSTD(this) + + JLineInputDaemon.terminal0 = this + if (terminal !is NoConsole) { + frontendBase.newDaemon("JLine Input Daemon", JLineInputDaemon).start() + } + launch(CoroutineName("Mirai Console Terminal Downloading Progress Bar Updater")) { while (isActive) { downloadingProgressDaemonStub() @@ -178,7 +152,10 @@ open class MiraiConsoleImplementationTerminal kotlin.runCatching { downloadingProgressCoroutine?.resumeWith(Result.success(Unit)) } - return TerminalProcessProgress(lineReader).also { terminalDownloadingProgresses.add(it) } + return TerminalProcessProgress(lineReader).also { + terminalDownloadingProgresses.add(it) + terminal.writer().print("\u001B[?25l") // hide cursor + } } } @@ -192,149 +169,12 @@ val lineReader: LineReader by lazy { .build() } -internal val terminalDisplay: Display by object : kotlin.properties.ReadOnlyProperty { - val delegate: () -> Display by lazy { - val terminal = terminal - if (terminal is NoConsole) { - val display = Display(terminal, false) - return@lazy { display } - } - - val lr = lineReader - val field = LineReaderImpl::class.java.declaredFields.first { it.type == Display::class.java } - field.isAccessible = true - return@lazy { field[lr] as Display } - } - - override fun getValue(thisRef: Any?, property: KProperty<*>): Display { - return delegate() - } -} - -internal val terminalExecuteLock: java.util.concurrent.locks.Lock by lazy { - val terminal = terminal - if (terminal is NoConsole) return@lazy java.util.concurrent.locks.ReentrantLock() - val lr = lineReader - val field = LineReaderImpl::class.java.declaredFields.first { - java.util.concurrent.locks.Lock::class.java.isAssignableFrom(it.type) - } - field.isAccessible = true - field[lr].cast() -} -private val terminalDownloadingProgressesNoticer = Object() -private var containDownloadingProgress: Boolean = false - get() = field || terminalDownloadingProgresses.isNotEmpty() - -internal val terminalDownloadingProgresses = mutableListOf() -private var downloadingProgressCoroutine: Continuation? = null -private suspend fun downloadingProgressDaemonStub() { - delay(500L) - if (containDownloadingProgress) { - updateTerminalDownloadingProgresses() - } else { - suspendCancellableCoroutine { cp -> - downloadingProgressCoroutine = cp - } - downloadingProgressCoroutine = null - } -} - -internal fun updateTerminalDownloadingProgresses() { - if (!containDownloadingProgress) return - - runCatching { downloadingProgressCoroutine?.resumeWith(Result.success(Unit)) } - - terminalExecuteLock.withLock { - if (terminalDownloadingProgresses.isNotEmpty()) { - val wid = terminal.width - if (wid == 0) { // Run in idea - if (terminalDownloadingProgresses.removeIf { it.pendingErase }) { - updateTerminalDownloadingProgresses() - return - } - terminalDisplay.update(listOf(AttributedString.EMPTY), 0, false) - // Error in idea when more than one bar displaying - terminalDisplay.update(listOf(terminalDownloadingProgresses[0].let { - it.updateTxt(0); it.ansiMsg - }), 0) - } else { - if (terminalDownloadingProgresses.size > 4) { - // to mush. delete some completed status - var allowToDelete = terminalDownloadingProgresses.size - 4 - terminalDownloadingProgresses.removeIf { pg -> - if (allowToDelete == 0) { - return@removeIf false - } - if (pg.pendingErase) { - allowToDelete-- - return@removeIf true - } - return@removeIf false - } - } - terminalDisplay.update(terminalDownloadingProgresses.map { - it.updateTxt(wid); it.ansiMsg - }, 0) - cleanupErase() - } - } else { - terminalDisplay.update(emptyList(), 0) - (lineReader as LineReaderImpl).let { lr -> - if (lr.isReading) { - lr.redisplay() - } - } - noticeDownloadingProgressEmpty() - } - } -} - -internal fun prePrintNewLog() { - if (!containDownloadingProgress) return - if (terminalDownloadingProgresses.isNotEmpty()) { - terminalExecuteLock.withLock { - terminalDisplay.update(emptyList(), 0) - } - } -} - -internal fun cleanupErase() { - val now = currentTimeMillis() - terminalDownloadingProgresses.removeIf { pg -> - if (!pg.pendingErase) return@removeIf false - if (now > pg.eraseTimestamp) { - pg.ansiMsg = AttributedString.EMPTY - return@removeIf true - } - return@removeIf false - } -} - -internal fun postPrintNewLog() { - if (!containDownloadingProgress) return - updateTerminalDownloadingProgresses() - cleanupErase() -} - -private fun noticeDownloadingProgressEmpty() { - synchronized(terminalDownloadingProgressesNoticer) { - containDownloadingProgress = false - if (terminalDownloadingProgresses.isEmpty()) { - terminalDownloadingProgressesNoticer.notifyAll() - } - } -} - -internal fun waitDownloadingProgressEmpty() { - synchronized(terminalDownloadingProgressesNoticer) { - if (containDownloadingProgress) { - terminalDownloadingProgressesNoticer.wait() - } - } -} val terminal: Terminal = run { - if (ConsoleTerminalSettings.noConsole) return@run NoConsole + if (ConsoleTerminalSettings.noConsole) { + SystemOutputPrintStream // init value + return@run NoConsole + } TerminalBuilder.builder() .name("Mirai Console") diff --git a/mirai-console/frontend/mirai-console-terminal/src/MiraiConsoleTerminalLoader.kt b/mirai-console/frontend/mirai-console-terminal/src/MiraiConsoleTerminalLoader.kt index ab231ae3d..3efb2fafc 100644 --- a/mirai-console/frontend/mirai-console-terminal/src/MiraiConsoleTerminalLoader.kt +++ b/mirai-console/frontend/mirai-console-terminal/src/MiraiConsoleTerminalLoader.kt @@ -286,38 +286,11 @@ internal fun exitProcessAndForceHalt(code: Int): Nothing { } } -internal fun overrideSTD(terminal: MiraiConsoleImplementation) { - if (ConsoleTerminalSettings.noConsole) { - SystemOutputPrintStream // Avoid StackOverflowError when launch with no console mode - } - lineReader // Initialize real frontend first. #1936 - System.setOut( - PrintStream( - BufferedOutputStream( - logger = MiraiLogger.Factory.create(terminal::class, "stdout")::info - ), - false, - "UTF-8" - ) - ) - System.setErr( - PrintStream( - BufferedOutputStream( - logger = MiraiLogger.Factory.create(terminal::class, "stderr")::warning - ), - false, - "UTF-8" - ) - ) -} - internal object ConsoleCommandSenderImplTerminal : MiraiConsoleImplementation.ConsoleCommandSenderImpl { override suspend fun sendMessage(message: String) { kotlin.runCatching { - prePrintNewLog() - lineReader.printAbove(message + ANSI_RESET) - postPrintNewLog() + printToScreen(message + ANSI_RESET) }.onFailure { exception -> // If failed. It means JLine Terminal not working... PrintStream(FileOutputStream(FileDescriptor.err)).use { diff --git a/mirai-console/frontend/mirai-console-terminal/src/TerminalProcessProgress.kt b/mirai-console/frontend/mirai-console-terminal/src/TerminalProcessProgress.kt index 89ecb864f..2ed72266c 100644 --- a/mirai-console/frontend/mirai-console-terminal/src/TerminalProcessProgress.kt +++ b/mirai-console/frontend/mirai-console-terminal/src/TerminalProcessProgress.kt @@ -9,10 +9,22 @@ package net.mamoe.mirai.console.terminal +import kotlinx.coroutines.delay +import kotlinx.coroutines.suspendCancellableCoroutine import net.mamoe.mirai.console.fontend.ProcessProgress +import net.mamoe.mirai.console.terminal.noconsole.NoConsole +import net.mamoe.mirai.utils.cast +import net.mamoe.mirai.utils.currentTimeMillis +import org.jline.reader.MaskingCallback +import org.jline.reader.impl.LineReaderImpl import org.jline.utils.AttributedString import org.jline.utils.AttributedStringBuilder import org.jline.utils.AttributedStyle +import org.jline.utils.Display +import java.lang.reflect.Field +import kotlin.concurrent.withLock +import kotlin.coroutines.Continuation +import kotlin.reflect.KProperty internal class TerminalProcessProgress( private val reader: org.jline.reader.LineReader, @@ -145,10 +157,8 @@ internal class TerminalProcessProgress( updateTxt(reader.terminal.width) if (failed) { terminalDownloadingProgresses.remove(this) - prePrintNewLog() - reader.printAbove(ansiMsg) + printToScreen(ansiMsg) ansiMsg = AttributedString.EMPTY - postPrintNewLog() return } // terminalDownloadingProgresses.remove(this) @@ -161,4 +171,192 @@ internal class TerminalProcessProgress( // reader.printAbove(ansiMsg) // ansiMsg = AttributedString.EMPTY } -} \ No newline at end of file +} + + +internal val terminalDisplay: Display by object : kotlin.properties.ReadOnlyProperty { + val delegate: () -> Display by lazy { + val terminal = terminal + if (terminal is NoConsole) { + val display = Display(terminal, false) + return@lazy { display } + } + + val lr = lineReader + val field = LineReaderImpl::class.java.declaredFields.first { it.type == Display::class.java } + field.isAccessible = true + return@lazy { field[lr] as Display } + } + + override fun getValue(thisRef: Any?, property: KProperty<*>): Display { + return delegate() + } +} +internal val lineReaderMaskingCallback: Field by lazy { + val field = LineReaderImpl::class.java.declaredFields.first { + MaskingCallback::class.java.isAssignableFrom(it.type) + } + field.isAccessible = true + field +} + +internal val lineReaderReadingField: Field by lazy { + val field = LineReaderImpl::class.java.getDeclaredField("reading") + field.isAccessible = true + field +} + +internal val terminalExecuteLock: java.util.concurrent.locks.Lock by lazy { + val terminal = terminal + if (terminal is NoConsole) return@lazy java.util.concurrent.locks.ReentrantLock() + val lr = lineReader + val field = LineReaderImpl::class.java.declaredFields.first { + java.util.concurrent.locks.Lock::class.java.isAssignableFrom(it.type) + } + field.isAccessible = true + field[lr].cast() +} + +private val terminalDownloadingProgressesNoticer = Object() +internal var containDownloadingProgress: Boolean = false + get() = field || terminalDownloadingProgresses.isNotEmpty() + +internal val terminalDownloadingProgresses = mutableListOf() + + +internal var downloadingProgressCoroutine: Continuation? = null +internal suspend fun downloadingProgressDaemonStub() { + delay(500L) + if (containDownloadingProgress) { + updateTerminalDownloadingProgresses() + } else { + suspendCancellableCoroutine { cp -> + downloadingProgressCoroutine = cp + } + downloadingProgressCoroutine = null + } +} + +internal fun updateTerminalDownloadingProgresses() { + if (!containDownloadingProgress) return + + runCatching { downloadingProgressCoroutine?.resumeWith(Result.success(Unit)) } + + JLineInputDaemon.suspendReader(false) + + terminalExecuteLock.withLock { + if (terminalDownloadingProgresses.isNotEmpty()) { + val wid = terminal.width + if (wid == 0) { // Run in idea + if (terminalDownloadingProgresses.removeIf { it.pendingErase }) { + updateTerminalDownloadingProgresses() + return + } + terminalDisplay.update(listOf(AttributedString.EMPTY), 0, false) + // Error in idea when more than one bar displaying + terminalDisplay.update(listOf(terminalDownloadingProgresses[0].let { + it.updateTxt(0); it.ansiMsg + }), 0) + } else { + if (terminalDownloadingProgresses.size > 4) { + // to mush. delete some completed status + var allowToDelete = terminalDownloadingProgresses.size - 4 + terminalDownloadingProgresses.removeIf { pg -> + if (allowToDelete == 0) { + return@removeIf false + } + if (pg.pendingErase) { + allowToDelete-- + return@removeIf true + } + return@removeIf false + } + } + terminalDisplay.update(terminalDownloadingProgresses.map { + it.updateTxt(wid); it.ansiMsg + }, 0) + cleanupErase() + } + } else { + terminalDisplay.update(emptyList(), 0) + (lineReader as LineReaderImpl).let { lr -> + if (lr.isReading) { + lr.redisplay() + } + } + noticeDownloadingProgressEmpty() + terminal.writer().print("\u001B[?25h") // show cursor + } + } +} + +internal fun printToScreen(msg: String) { + if (!containDownloadingProgress) { + if (msg.endsWith(ANSI_RESET)) { + lineReader.printAbove(msg) + } else { + lineReader.printAbove(msg + ANSI_RESET) + } + return + } + terminalExecuteLock.withLock { + terminalDisplay.update(emptyList(), 0) + + if (msg.endsWith(ANSI_RESET)) { + lineReader.printAbove(msg) + } else { + lineReader.printAbove(msg + ANSI_RESET) + } + + updateTerminalDownloadingProgresses() + cleanupErase() + } +} + +internal fun printToScreen(msg: AttributedString) { + if (!containDownloadingProgress) { + return lineReader.printAbove(msg) + } + terminalExecuteLock.withLock { + terminalDisplay.update(emptyList(), 0) + + lineReader.printAbove(msg) + + updateTerminalDownloadingProgresses() + cleanupErase() + } +} + + +internal fun cleanupErase() { + val now = currentTimeMillis() + terminalDownloadingProgresses.removeIf { pg -> + if (!pg.pendingErase) return@removeIf false + if (now > pg.eraseTimestamp) { + pg.ansiMsg = AttributedString.EMPTY + return@removeIf true + } + return@removeIf false + } +} + + +private fun noticeDownloadingProgressEmpty() { + synchronized(terminalDownloadingProgressesNoticer) { + containDownloadingProgress = false + if (terminalDownloadingProgresses.isEmpty()) { + terminalDownloadingProgressesNoticer.notifyAll() + } + + JLineInputDaemon.tryResumeReader(false) + } +} + +internal fun waitDownloadingProgressEmpty() { + synchronized(terminalDownloadingProgressesNoticer) { + if (containDownloadingProgress) { + terminalDownloadingProgressesNoticer.wait() + } + } +} +