diff --git a/.gitignore b/.gitignore index 3e8bbcc..ded275c 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ out/ .bsp/ .metals/ .vscode/ +.idea/ diff --git a/README.md b/README.md index ffc64f1..bf3a892 100644 --- a/README.md +++ b/README.md @@ -1,67 +1,51 @@ +# native-terminal -# windows-ansi +[![Build status](https://github.com/alexarchambault/native-terminal/workflows/CI/badge.svg)](https://github.com/alexarchambault/native-terminal/actions?query=workflow%3ACI) +[![Maven Central](https://img.shields.io/maven-central/v/io.github.alexarchambault.native-terminal/native-terminal.svg)](https://maven-badges.herokuapp.com/maven-central/io.github.alexarchambault.native-terminal/native-terminal) -[![Build status](https://github.com/alexarchambault/windows-ansi/workflows/CI/badge.svg)](https://github.com/alexarchambault/windows-ansi/actions?query=workflow%3ACI) -[![Maven Central](https://img.shields.io/maven-central/v/io.github.alexarchambault.windows-ansi/windows-ansi.svg)](https://maven-badges.herokuapp.com/maven-central/io.github.alexarchambault.windows-ansi/windows-ansi) - -*windows-ansi* is a small Java library to setup / interact with a Windows terminal. It allows to +*native-terminal* is a small Java library to setup / interact with a terminals in a native fashion. It allows to - query the terminal size, and -- change the console mode so that it accepts ANSI escape codes. +- on Windows, change the console mode so that it accepts ANSI escape codes. It relies on internals of the [jansi](https://github.com/fusesource/jansi) library to do so, and also works from GraalVM native images. -Compared to using [jline](https://github.com/jline/jline3), *windows-ansi* only and solely calls the right -`kernel32.dll` system calls (like [`SetConsoleMode`](https://docs.microsoft.com/en-us/windows/console/setconsolemode) -or [`GetConsoleScreenBufferInfo`](https://docs.microsoft.com/en-us/windows/console/getconsolescreenbufferinfo)), lowering the odds of something going wrong when generating or using a GraalVM native image for example. +Compared to using [jline](https://github.com/jline/jline3), *native-terminal* only and solely calls the right +`ioctl` system calls +(or `kernel32.dll` system calls, like [`SetConsoleMode`](https://docs.microsoft.com/en-us/windows/console/setconsolemode) +or [`GetConsoleScreenBufferInfo`](https://docs.microsoft.com/en-us/windows/console/getconsolescreenbufferinfo), on Windows), +lowering the odds of something going wrong when generating or using a GraalVM native image for example. ## Usage Add to your `build.sbt` ```scala -libraryDependencies += "io.github.alexarchambault.windows-ansi" % "windows-ansi" % "0.0.1" -``` - -The latest version is [![Maven Central](https://img.shields.io/maven-central/v/io.github.alexarchambault.windows-ansi/windows-ansi.svg)](https://maven-badges.herokuapp.com/maven-central/io.github.alexarchambault.windows-ansi/windows-ansi). - -The `WindowsAnsi` methods should only be called from Windows. You can check that -the current application is running on Windows with: -```java -boolean isWindows = System.getProperty("os.name") - .toLowerCase(java.util.Locale.ROOT) - .contains("windows"); +libraryDependencies += "io.github.alexarchambault.native-terminal" % "native-terminal" % "0.0.7" ``` -Alternatively, when using Graal native image, the following should work too, and has the benefit of simply -discarding one of the `if` branches at image generation time: -```java -// requires the org.graalvm.nativeimage:svm dependency, -// which can usually be marked as "provided" -if (com.oracle.svm.core.os.IsDefined.WIN32()) { - // call io.github.alexarchambault.windowsansi.WindowsAnsi methods -} else { - // not on Windows, handle things like you would on Unixes -} -``` +The latest version is [![Maven Central](https://img.shields.io/maven-central/v/io.github.alexarchambault.native-terminal/native-terminal.svg)](https://maven-badges.herokuapp.com/maven-central/io.github.alexarchambault.native-terminal/native-terminal). ### Change terminal mode -Change the terminal mode so that it accepts ANSI escape codes with +Change the terminal mode on Windows, so that it accepts ANSI escape codes with ```java -import io.github.alexarchambault.windowsansi.WindowsAnsi; +import io.github.alexarchambault.nativeterm.NativeTerminal; -boolean success = WindowsAnsi.setup(); +boolean success = NativeTerminal.setupAnsi(); ``` A returned value of `false` means ANSI escape codes aren't supported by the Windows version you're running on. These are supposed to be supported by Windows 10 build 10586 (Nov. 2015) onwards. +Calling this method is safe on other platforms. It simply returns true in that case. + ### Get terminal size ```java -import io.github.alexarchambault.windowsansi.WindowsAnsi; +import io.github.alexarchambault.nativeterm.NativeTerminal; +import io.github.alexarchambault.nativeterm.TerminalSize; -WindowsAnsi.Size size = WindowsAnsi.terminalSize(); +TerminalSize size = NativeTerminal.size(); int width = size.getWidth(); int height = size.getHeight(); ``` diff --git a/build.sc b/build.sc index 3b5e0e4..ca3bdc0 100644 --- a/build.sc +++ b/build.sc @@ -10,13 +10,13 @@ import mill.scalalib.publish._ trait WindowsAnsiPublishModule extends PublishModule with Mima { def pomSettings = PomSettings( description = artifactName(), - organization = "io.github.alexarchambault.windows-ansi", - url = "https://github.com/alexarchambault/windows-ansi", + organization = "io.github.alexarchambault.native-terminal", + url = "https://github.com/alexarchambault/native-terminal", licenses = Seq( License.`Apache-2.0`, License.`GPL-2.0-with-classpath-exception` ), - versionControl = VersionControl.github("alexarchambault", "windows-ansi"), + versionControl = VersionControl.github("alexarchambault", "native-terminal"), developers = Seq( Developer("alexarchambault", "Alex Archambault", "https://github.com/alexarchambault") ) @@ -60,16 +60,17 @@ trait WindowsAnsiPublishModule extends PublishModule with Mima { } } -object jni extends JavaModule with WindowsAnsiPublishModule { - def artifactName = "windows-ansi" +object native extends JavaModule with WindowsAnsiPublishModule { + def artifactName = "native-terminal" def ivyDeps = Agg( + ivy"io.github.alexarchambault:is-terminal:0.1.1", ivy"org.fusesource.jansi:jansi:2.4.1" ) } -object `jni-graalvm` extends JavaModule with WindowsAnsiPublishModule { - def moduleDeps = Seq(jni) - def artifactName = "windows-ansi-graalvm" +object `native-graalvm` extends JavaModule with WindowsAnsiPublishModule { + def moduleDeps = Seq(native) + def artifactName = "native-terminal-graalvm" def pomSettings = super.pomSettings().copy( licenses = Seq(License.`GPL-2.0-with-classpath-exception`) ) @@ -84,8 +85,8 @@ object `jni-graalvm` extends JavaModule with WindowsAnsiPublishModule { } } -object ps extends JavaModule with WindowsAnsiPublishModule { - def artifactName = "windows-ansi-ps" +object fallbacks extends JavaModule with WindowsAnsiPublishModule { + def artifactName = "native-terminal-fallbacks" def mimaPreviousVersions = { val publishedSince = coursier.core.Version("0.0.2") super.mimaPreviousVersions().dropWhile { v => diff --git a/jni-graalvm/src/io/github/alexarchambault/windowsansi/WindowsAnsiSubstitutions.java b/jni-graalvm/src/io/github/alexarchambault/windowsansi/WindowsAnsiSubstitutions.java deleted file mode 100644 index f6bbeac..0000000 --- a/jni-graalvm/src/io/github/alexarchambault/windowsansi/WindowsAnsiSubstitutions.java +++ /dev/null @@ -1,24 +0,0 @@ -package io.github.alexarchambault.windowsansi; - -import com.oracle.svm.core.annotate.Substitute; -import com.oracle.svm.core.annotate.TargetClass; -import org.graalvm.nativeimage.Platform; -import org.graalvm.nativeimage.Platforms; - -import java.io.IOException; - -@TargetClass(className = "io.github.alexarchambault.windowsansi.WindowsAnsi") -@Platforms({Platform.DARWIN.class, Platform.LINUX.class}) -public final class WindowsAnsiSubstitutions { - - @Substitute - public static WindowsAnsi.Size terminalSize() { - throw new RuntimeException("Not available on this platform"); - } - - @Substitute - public static boolean setup() throws IOException { - return false; - } - -} diff --git a/jni-graalvm/src/io/github/alexarchambault/windowsansi/NativeImageFeature.java b/native-graalvm/src/io/github/alexarchambault/windowsansi/NativeImageFeature.java similarity index 62% rename from jni-graalvm/src/io/github/alexarchambault/windowsansi/NativeImageFeature.java rename to native-graalvm/src/io/github/alexarchambault/windowsansi/NativeImageFeature.java index 1e285ad..fe0ffc4 100644 --- a/jni-graalvm/src/io/github/alexarchambault/windowsansi/NativeImageFeature.java +++ b/native-graalvm/src/io/github/alexarchambault/windowsansi/NativeImageFeature.java @@ -54,7 +54,8 @@ final class NativeImageFeature implements Feature { private static final List JNI_CLASS_NAMES = Arrays.asList( "org.fusesource.jansi.internal.CLibrary", "org.fusesource.jansi.internal.CLibrary$WinSize", - "org.fusesource.jansi.internal.CLibrary$Termios", + "org.fusesource.jansi.internal.CLibrary$Termios"); + private static final List WINDOWS_JNI_CLASS_NAMES = Arrays.asList( "org.fusesource.jansi.internal.Kernel32", "org.fusesource.jansi.internal.Kernel32$SMALL_RECT", "org.fusesource.jansi.internal.Kernel32$COORD", @@ -70,14 +71,19 @@ final class NativeImageFeature implements Feature { @Override public void beforeAnalysis(BeforeAnalysisAccess access) { - if (Platform.includedIn(Platform.WINDOWS.class)) { + /* + * Each listed class has a native method named "init" that initializes all declared + * fields of the class using JNI. So when the "init" method gets reachable (which means + * the class initializer got reachable), we need to register all fields for JNI access. + */ + JNI_CLASS_NAMES.stream() + .map(access::findClassByName) + .filter(Objects::nonNull) + .map(jniClass -> ReflectionUtil.lookupMethod(jniClass, "init")) + .forEach(initMethod -> access.registerReachabilityHandler(a -> registerJNIFields(initMethod), initMethod)); - /* - * Each listed class has a native method named "init" that initializes all declared - * fields of the class using JNI. So when the "init" method gets reachable (which means - * the class initializer got reachable), we need to register all fields for JNI access. - */ - JNI_CLASS_NAMES.stream() + if (Platform.includedIn(Platform.WINDOWS.class)) { + WINDOWS_JNI_CLASS_NAMES.stream() .map(access::findClassByName) .filter(Objects::nonNull) .map(jniClass -> ReflectionUtil.lookupMethod(jniClass, "init")) @@ -85,7 +91,7 @@ public void beforeAnalysis(BeforeAnalysisAccess access) { } } - private AtomicBoolean resourceRegistered = new AtomicBoolean(); + private final AtomicBoolean resourceRegistered = new AtomicBoolean(); private void registerJNIFields(Method initMethod) { Class jniClass = initMethod.getDeclaringClass(); @@ -93,11 +99,28 @@ private void registerJNIFields(Method initMethod) { if (!resourceRegistered.getAndSet(true)) { /* The native library that is included as a resource in the .jar file. */ - String resource = "META-INF/native/windows64/jansi.dll"; - InputStream is = jniClass.getClassLoader().getResourceAsStream(resource); - if (is == null) - throw new RuntimeException("Could not find resource " + resource); - Resources.registerResource(resource, is); + String resource = null; + if (Platform.includedIn(Platform.WINDOWS_AMD64.class)) + resource = "Windows/x86_64/jansi.dll"; + else if (Platform.includedIn(Platform.WINDOWS_AARCH64.class)) + // FIXME File name should be changed to jansi.dll in the upcoming jansi version + resource = "Windows/arm64/libjansi.so"; + else if (Platform.includedIn(Platform.LINUX_AMD64.class)) + resource = "Linux/x86_64/libjansi.so"; + else if (Platform.includedIn(Platform.LINUX_AARCH64.class)) + resource = "Linux/arm64/libjansi.so"; + else if (Platform.includedIn(Platform.DARWIN_AMD64.class)) + resource = "Mac/x86_64/libjansi.jnilib"; + else if (Platform.includedIn(Platform.DARWIN_AARCH64.class)) + resource = "Mac/arm64/libjansi.jnilib"; + + if (resource != null) { + resource = "org/fusesource/jansi/internal/native/" + resource; + InputStream is = jniClass.getClassLoader().getResourceAsStream(resource); + if (is == null) + throw new RuntimeException("Could not find resource " + resource); + Resources.registerResource(resource, is); + } } } } diff --git a/native-graalvm/src/io/github/alexarchambault/windowsansi/Placeholder.java b/native-graalvm/src/io/github/alexarchambault/windowsansi/Placeholder.java new file mode 100644 index 0000000..cea2203 --- /dev/null +++ b/native-graalvm/src/io/github/alexarchambault/windowsansi/Placeholder.java @@ -0,0 +1,8 @@ +package io.github.alexarchambault.windowsansi; + +/** + * Empty public class to make javadoc happy + */ +public final class Placeholder { + private Placeholder() {} +} diff --git a/native/src/io/github/alexarchambault/nativeterm/NativeTerminal.java b/native/src/io/github/alexarchambault/nativeterm/NativeTerminal.java new file mode 100644 index 0000000..3177907 --- /dev/null +++ b/native/src/io/github/alexarchambault/nativeterm/NativeTerminal.java @@ -0,0 +1,54 @@ +package io.github.alexarchambault.nativeterm; + +import io.github.alexarchambault.isterminal.IsTerminal; +import io.github.alexarchambault.nativeterm.internal.UnixTerm; +import io.github.alexarchambault.nativeterm.internal.WindowsTerm; + +import java.io.IOException; + +/** + * Use these methods to query the terminal size and enable ANSI output in it + */ +public final class NativeTerminal { + private NativeTerminal() {} + + final static boolean isWindows; + + static { + isWindows = System.getProperty("os.name") + .toLowerCase(java.util.Locale.ROOT) + .contains("windows"); + } + + /** + * Gets the terminal size + * + * This uses an {@code ioctl} call on Linux / Mac, and a {@code kernel32.dll} method + * on Windows. Both are done via JNI, using libraries that ship with jansi. + * + * @return the terminal size + */ + public static TerminalSize getSize() { + if (isWindows) + return WindowsTerm.getSize(); + return UnixTerm.getSize(true); + } + + /** + * Enables ANSI terminal output (only needed on Windows) + * + * It is safe to call this method on non-Windows systems. It simply + * returns true in that case. + * + * Under-the-hood, this calls a {@code kernel32.dll} method, via JNI, + * using libraries that ship with jansi. + * + * @throws IOException if anything goes wrong + * @return Whether ANSI output is enabled + */ + public static boolean setupAnsi() throws IOException { + if (isWindows && IsTerminal.isTerminal()) + return WindowsTerm.setupAnsi(); + return true; + } +} diff --git a/native/src/io/github/alexarchambault/nativeterm/NativeTerminalFallbacks.java b/native/src/io/github/alexarchambault/nativeterm/NativeTerminalFallbacks.java new file mode 100644 index 0000000..1e67625 --- /dev/null +++ b/native/src/io/github/alexarchambault/nativeterm/NativeTerminalFallbacks.java @@ -0,0 +1,71 @@ +package io.github.alexarchambault.nativeterm; + +import io.github.alexarchambault.isterminal.IsTerminal; +import io.github.alexarchambault.nativeterm.internal.ScriptRunner; +import io.github.alexarchambault.nativeterm.internal.WindowsTermScripts; + +import java.io.IOException; + +import static io.github.alexarchambault.nativeterm.NativeTerminal.isWindows; + +/** + * Methods to query the terminal size and enable ANSI output in it like in {@link NativeTerminal} + */ +public final class NativeTerminalFallbacks { + + private NativeTerminalFallbacks() {} + + /** + * Gets the terminal size + * + * This runs {@code tput} via an external process on Linux / Mac, and runs + * a PowerShell script that calls {@code kernel32.dll} methods on Windows + * + * @throws InterruptedException if the PowerShell sub-process gets interrupted + * @throws IOException if anything goes wrong + * @return the terminal size + */ + public static TerminalSize getSize() throws InterruptedException, IOException { + if (!IsTerminal.isTerminal()) + throw new IllegalArgumentException("Cannot get terminal size without a terminal"); + if (isWindows) { + String output = ScriptRunner.runPowerShellScript(WindowsTermScripts.getConsoleDimScript).trim(); + String[] lines = output.split("\\r?\\n"); + String lastLine = lines[lines.length - 1]; + if (lastLine.startsWith("Error:")) + throw new IOException(lastLine); + if (lastLine.startsWith("Size: ")) { + lastLine = lastLine.substring("Size: ".length()); + String[] split = lastLine.split("\\s+"); + return new TerminalSize(Integer.parseInt(split[0]), Integer.parseInt(split[1])); + } + throw new IOException("Invalid output from PowerShell script that gets terminal size: '" + output + "'"); + } + else { + return new TerminalSize(ScriptRunner.runTput("cols"), ScriptRunner.runTput("lines")); + } + } + + /** + * Enables ANSI terminal output (only needed on Windows) + * + * It is safe to call this method on non-Windows systems. It simply + * returns true in that case. + * + * Under-the-hood, this calls a {@code kernel32.dll} method, via a PowerShell script, + * run in an external process. + * + * @throws IOException if anything goes wrong + * @throws InterruptedException if the PowerShell sub-process gets interrupted + * @return Whether ANSI output is enabled + */ + public static boolean setupAnsi() throws InterruptedException, IOException { + if (isWindows && IsTerminal.isTerminal()) { + String output = ScriptRunner.runPowerShellScript(WindowsTermScripts.enableAnsiScript); + String[] lines = output.split("\\r?\\n"); + String lastLine = lines[lines.length - 1]; + return Boolean.parseBoolean(lastLine); + } + return true; + } +} diff --git a/native/src/io/github/alexarchambault/nativeterm/TerminalSize.java b/native/src/io/github/alexarchambault/nativeterm/TerminalSize.java new file mode 100644 index 0000000..2eb7580 --- /dev/null +++ b/native/src/io/github/alexarchambault/nativeterm/TerminalSize.java @@ -0,0 +1,67 @@ +package io.github.alexarchambault.nativeterm; + +import java.util.Objects; + +/** + * Terminal size + */ +public final class TerminalSize { + private final int width; + private final int height; + + TerminalSize(int width, int height) { + this.width = width; + this.height = height; + } + + /** + * Create a {@code TerminalSize} instance + * @param width terminal width + * @param height terminal height + * @return the freshly built instance + */ + public static TerminalSize of(int width, int height) { + return new TerminalSize(width, height); + } + + + // methods below are generated by IntelliJ + + /** + * Gets the terminal height + * @return the terminal height + */ + public int getHeight() { + return height; + } + + /** + * Gets the terminal width + * @return the terminal width + */ + public int getWidth() { + return width; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + TerminalSize size = (TerminalSize) o; + return width == size.width && + height == size.height; + } + + @Override + public int hashCode() { + return Objects.hash(width, height); + } + + @Override + public String toString() { + return "TerminalSize{" + + "width=" + width + + ", height=" + height + + '}'; + } +} diff --git a/native/src/io/github/alexarchambault/nativeterm/internal/ScriptRunner.java b/native/src/io/github/alexarchambault/nativeterm/internal/ScriptRunner.java new file mode 100644 index 0000000..aaef2f0 --- /dev/null +++ b/native/src/io/github/alexarchambault/nativeterm/internal/ScriptRunner.java @@ -0,0 +1,103 @@ +package io.github.alexarchambault.nativeterm.internal; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +/** + * Utilities to run scripts that interact with the terminal + */ +public final class ScriptRunner { + + private ScriptRunner() {} + + /** + * Runs a PowerShell script + * @param script the script to run + * @return the output of the script + * @throws InterruptedException if the PowerShell sub-process gets interrupted + * @throws IOException if anything goes wrong + */ + public static String runPowerShellScript(String script) throws InterruptedException, IOException { + + String fullScript = "& {\n" + + script + + "\n}"; + + Base64.Encoder base64 = Base64.getEncoder(); + String encodedScript = base64.encodeToString(fullScript.getBytes(StandardCharsets.UTF_16LE)); + + ProcessBuilder builder = new ProcessBuilder( + "powershell.exe", + "-NoProfile", + "-NonInteractive", + "-EncodedCommand", encodedScript); + builder.inheritIO(); + + Process proc = builder.start(); + + StringBuilder results = new StringBuilder(); + BufferedReader reader = new BufferedReader(new InputStreamReader(proc.getInputStream(), StandardCharsets.UTF_8)); + try { + String line = reader.readLine(); + while (line != null) { + results.append(line); + results.append("\r\n"); + line = reader.readLine(); + } + } finally { + proc.destroy(); + try { + reader.close(); + } catch (IOException e) { + e.printStackTrace(); + } + } + + int retCode = proc.waitFor(); + if (retCode != 0) + throw new IOException("Error running powershell script (return code: " + retCode + ")"); + return results.toString(); + } + + /** + * Runs {@code tput} in the current terminal + * @param s the {@code tput} parameter you're interested in + * @return the value of that parameter returned by {@code tput}, as an integer + * @throws InterruptedException if the tput sub-process gets interrupted + * @throws IOException if anything goes wrong + */ + public static int runTput(String s) throws InterruptedException, IOException { + Process proc = new ProcessBuilder() + .command("tput", s) + .redirectOutput(ProcessBuilder.Redirect.PIPE) + .redirectInput(ProcessBuilder.Redirect.INHERIT) + .redirectError(ProcessBuilder.Redirect.INHERIT) + .start(); + + StringBuilder results = new StringBuilder(); + BufferedReader reader = new BufferedReader(new InputStreamReader(proc.getInputStream(), StandardCharsets.UTF_8)); + try { + String line = reader.readLine(); + while (line != null) { + results.append(line); + results.append("\r\n"); + line = reader.readLine(); + } + } finally { + proc.destroy(); + try { + reader.close(); + } catch (IOException e) { + e.printStackTrace(); + } + } + + int exitCode = proc.waitFor(); + if (exitCode != 0) throw new IOException("tput failed"); + return Integer.parseInt(results.toString().trim()); + } + +} diff --git a/native/src/io/github/alexarchambault/nativeterm/internal/UnixTerm.java b/native/src/io/github/alexarchambault/nativeterm/internal/UnixTerm.java new file mode 100644 index 0000000..d5901aa --- /dev/null +++ b/native/src/io/github/alexarchambault/nativeterm/internal/UnixTerm.java @@ -0,0 +1,27 @@ +package io.github.alexarchambault.nativeterm.internal; + +import io.github.alexarchambault.nativeterm.TerminalSize; +import org.fusesource.jansi.internal.CLibrary; + +import static org.fusesource.jansi.internal.CLibrary.*; + +/** + * Utilities to get the terminal size on Linux / Mac + */ +public final class UnixTerm { + + private UnixTerm() {} + + /** + * Gets the terminal size on Linux / Mac + * @param useStdout whether to use stdout or stderr + * @return the terminal size + */ + public static TerminalSize getSize(boolean useStdout) { + WinSize sz = new WinSize(); + int fd = useStdout ? STDOUT_FILENO : STDERR_FILENO; + ioctl(fd, CLibrary.TIOCGWINSZ, sz); + return TerminalSize.of(sz.ws_col, sz.ws_row); + } + +} diff --git a/jni/src/io/github/alexarchambault/windowsansi/WindowsAnsi.java b/native/src/io/github/alexarchambault/nativeterm/internal/WindowsTerm.java similarity index 61% rename from jni/src/io/github/alexarchambault/windowsansi/WindowsAnsi.java rename to native/src/io/github/alexarchambault/nativeterm/internal/WindowsTerm.java index 53482db..3745872 100644 --- a/jni/src/io/github/alexarchambault/windowsansi/WindowsAnsi.java +++ b/native/src/io/github/alexarchambault/nativeterm/internal/WindowsTerm.java @@ -1,13 +1,18 @@ -package io.github.alexarchambault.windowsansi; +package io.github.alexarchambault.nativeterm.internal; +import io.github.alexarchambault.nativeterm.TerminalSize; import org.fusesource.jansi.internal.Kernel32; import java.io.IOException; import java.net.URL; import java.util.Locale; -import java.util.Objects; -public final class WindowsAnsi { +/** + * Utilities to interact with the terminal on Windows in a native fashion + */ +public final class WindowsTerm { + + private WindowsTerm() {} static { // Workaround while we can't benefit from https://github.com/fusesource/jansi/pull/292 @@ -27,14 +32,23 @@ public final class WindowsAnsi { } // adapted from https://github.com/jline/jline3/blob/8bb13a89fad80e51726a29e4b1f8a0724fed78b2/terminal-jna/src/main/java/org/jline/terminal/impl/jna/win/JnaWinSysTerminal.java#L92-L96 - public static Size terminalSize() { + /** + * Gets the terminal size + * @return the terminal size + */ + public static TerminalSize getSize() { long outputHandle = Kernel32.GetStdHandle(Kernel32.STD_OUTPUT_HANDLE); Kernel32.CONSOLE_SCREEN_BUFFER_INFO info = new Kernel32.CONSOLE_SCREEN_BUFFER_INFO(); Kernel32.GetConsoleScreenBufferInfo(outputHandle, info); - return Size.of(info.windowWidth(), info.windowHeight()); + return TerminalSize.of(info.windowWidth(), info.windowHeight()); } - public static boolean setup() throws IOException { + /** + * Enables ANSI output + * @return whether ANSI output is enabled + * @throws IOException if anything goes wrong + */ + public static boolean setupAnsi() throws IOException { // from https://github.com/jline/jline3/blob/0660ae29f3af2ca3b56cdeca1530072306988e4d/terminal/src/main/java/org/jline/terminal/impl/AbstractWindowsTerminal.java#L53 final int ENABLE_VIRTUAL_TERMINAL_PROCESSING = 0x0004; @@ -44,55 +58,8 @@ public static boolean setup() throws IOException { int[] mode = new int[1]; if (Kernel32.GetConsoleMode(console, mode) == 0) throw new IOException("Failed to get console mode: " + Kernel32.getLastErrorMessage()); + // FIXME Check if ENABLE_VIRTUAL_TERMINAL_PROCESSING is already set in mode[0] ?? return Kernel32.SetConsoleMode(console, mode[0] | ENABLE_VIRTUAL_TERMINAL_PROCESSING) != 0; } - - public static final class Size { - private final int width; - private final int height; - - Size(int width, int height) { - this.width = width; - this.height = height; - } - - public static Size of(int width, int height) { - return new Size(width, height); - } - - - // methods below are generated by IntelliJ - - public int getHeight() { - return height; - } - - public int getWidth() { - return width; - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - Size size = (Size) o; - return width == size.width && - height == size.height; - } - - @Override - public int hashCode() { - return Objects.hash(width, height); - } - - @Override - public String toString() { - return "Size{" + - "width=" + width + - ", height=" + height + - '}'; - } - } - } diff --git a/native/src/io/github/alexarchambault/nativeterm/internal/WindowsTermScripts.java b/native/src/io/github/alexarchambault/nativeterm/internal/WindowsTermScripts.java new file mode 100644 index 0000000..295237b --- /dev/null +++ b/native/src/io/github/alexarchambault/nativeterm/internal/WindowsTermScripts.java @@ -0,0 +1,113 @@ +package io.github.alexarchambault.nativeterm.internal; + +/** + * PowerShell scripts to interact with the terminal on Windows + */ +public final class WindowsTermScripts { + + private WindowsTermScripts() {} + + static boolean isWindows; + + static { + isWindows = System.getProperty("os.name") + .toLowerCase(java.util.Locale.ROOT) + .contains("windows"); + } + + // adapted from https://github.com/rprichard/winpty/blob/7e59fe2d09adf0fa2aa606492e7ca98efbc5184e/misc/ConinMode.ps1 + /** + * Script to enable ANSI output + */ + public static String enableAnsiScript = + "[Console]::OutputEncoding = [System.Text.Encoding]::UTF8\n" + + "$signature = @'\n" + + "[DllImport(\"kernel32.dll\", SetLastError = true)]\n" + + "public static extern IntPtr GetStdHandle(int nStdHandle);\n" + + "[DllImport(\"kernel32.dll\", SetLastError = true)]\n" + + "public static extern uint GetConsoleMode(\n" + + " IntPtr hConsoleHandle,\n" + + " out uint lpMode);\n" + + "[DllImport(\"kernel32.dll\", SetLastError = true)]\n" + + "public static extern uint SetConsoleMode(\n" + + " IntPtr hConsoleHandle,\n" + + " uint dwMode);\n" + + "public const int STD_OUTPUT_HANDLE = -11;\n" + + "public const int ENABLE_VIRTUAL_TERMINAL_PROCESSING = 0x0004;\n" + + "'@\n" + + "\n" + + "$WinAPI = Add-Type -MemberDefinition $signature `\n" + + " -Name WinAPI -Namespace ConinModeScript `\n" + + " -PassThru\n" + + "\n" + + "$handle = $WinAPI::GetStdHandle($WinAPI::STD_OUTPUT_HANDLE)\n" + + "$mode = 0\n" + + "$ret = $WinAPI::GetConsoleMode($handle, [ref]$mode)\n" + + "if ($ret -eq 0) {\n" + + " throw \"GetConsoleMode failed (is stdin a console?)\"\n" + + "}\n" + + "$ret = $WinAPI::SetConsoleMode($handle, $mode -bor $WinAPI::ENABLE_VIRTUAL_TERMINAL_PROCESSING)\n" + + "if ($ret -eq 0) {\n" + + " throw \"SetConsoleMode failed (is stdin a console?)\"\n" + + "}\n"; + + /** + * Script to get the terminal size + */ + public static String getConsoleDimScript = + "[Console]::OutputEncoding = [System.Text.Encoding]::UTF8\n" + + "$signature = @\"\n" + + "using System;\n" + + "using System.Runtime.InteropServices;\n" + + "\n" + + "public class Kernel32\n" + + "{\n" + + " [DllImport(\"kernel32.dll\", SetLastError = true)]\n" + + " public static extern bool GetConsoleScreenBufferInfo(IntPtr hConsoleOutput, out CONSOLE_SCREEN_BUFFER_INFO lpConsoleScreenBufferInfo);\n" + + "\n" + + " [DllImport(\"kernel32.dll\", SetLastError = true)]\n" + + " public static extern IntPtr GetStdHandle(int nStdHandle);\n" + + "\n" + + " public const int STD_OUTPUT_HANDLE = -11;\n" + + "\n" + + " [StructLayout(LayoutKind.Sequential)]\n" + + " public struct COORD\n" + + " {\n" + + " public short X;\n" + + " public short Y;\n" + + " }\n" + + "\n" + + " [StructLayout(LayoutKind.Sequential)]\n" + + " public struct SMALL_RECT\n" + + " {\n" + + " public short Left;\n" + + " public short Top;\n" + + " public short Right;\n" + + " public short Bottom;\n" + + " }\n" + + "\n" + + " [StructLayout(LayoutKind.Sequential)]\n" + + " public struct CONSOLE_SCREEN_BUFFER_INFO\n" + + " {\n" + + " public COORD dwSize;\n" + + " public COORD dwCursorPosition;\n" + + " public short wAttributes;\n" + + " public SMALL_RECT srWindow;\n" + + " public COORD dwMaximumWindowSize;\n" + + " }\n" + + "}\n" + + "\"@\n" + + "\n" + + "Add-Type -TypeDefinition $signature -Language CSharp\n" + + "\n" + + "$outputHandle = [Kernel32]::GetStdHandle([Kernel32]::STD_OUTPUT_HANDLE)\n" + + "\n" + + "$info = New-Object Kernel32+CONSOLE_SCREEN_BUFFER_INFO\n" + + "\n" + + "if ([Kernel32]::GetConsoleScreenBufferInfo($outputHandle, [ref]$info)) {\n" + + " Write-Host \"Size: $($info.srWindow.Right - $info.srWindow.Left + 1) $($info.srWindow.Bottom - $info.srWindow.Top + 1)\"\n" + + "} else {\n" + + " Write-Host \"Error: \" + [System.Runtime.InteropServices.Marshal]::GetLastWin32Error()\n" + + "}\n"; + +} diff --git a/ps/src/io/github/alexarchambault/windowsansi/PowershellRunner.java b/ps/src/io/github/alexarchambault/windowsansi/PowershellRunner.java deleted file mode 100644 index 92619cc..0000000 --- a/ps/src/io/github/alexarchambault/windowsansi/PowershellRunner.java +++ /dev/null @@ -1,30 +0,0 @@ -package io.github.alexarchambault.windowsansi; - -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.util.Base64; - -public final class PowershellRunner { - - public static void runScript(String script) throws InterruptedException, IOException { - - String fullScript = "& {\n" + - script + - "\n}"; - - Base64.Encoder base64 = Base64.getEncoder(); - String encodedScript = base64.encodeToString(fullScript.getBytes(StandardCharsets.UTF_16LE)); - - ProcessBuilder builder = new ProcessBuilder( - "powershell.exe", - "-NoProfile", - "-NonInteractive", - "-EncodedCommand", encodedScript); - builder.inheritIO(); - - int retCode = builder.start().waitFor(); - if (retCode != 0) - throw new IOException("Error running powershell script (return code: " + retCode + ")"); - } - -} diff --git a/ps/src/io/github/alexarchambault/windowsansi/WindowsAnsiPs.java b/ps/src/io/github/alexarchambault/windowsansi/WindowsAnsiPs.java deleted file mode 100644 index 6d695e8..0000000 --- a/ps/src/io/github/alexarchambault/windowsansi/WindowsAnsiPs.java +++ /dev/null @@ -1,53 +0,0 @@ -package io.github.alexarchambault.windowsansi; - -import java.io.IOException; - -public final class WindowsAnsiPs { - - static boolean isWindows; - - static { - isWindows = System.getProperty("os.name") - .toLowerCase(java.util.Locale.ROOT) - .contains("windows"); - } - - // adapted from https://github.com/rprichard/winpty/blob/7e59fe2d09adf0fa2aa606492e7ca98efbc5184e/misc/ConinMode.ps1 - static String script = "$signature = @'\n" + - "[DllImport(\"kernel32.dll\", SetLastError = true)]\n" + - "public static extern IntPtr GetStdHandle(int nStdHandle);\n" + - "[DllImport(\"kernel32.dll\", SetLastError = true)]\n" + - "public static extern uint GetConsoleMode(\n" + - " IntPtr hConsoleHandle,\n" + - " out uint lpMode);\n" + - "[DllImport(\"kernel32.dll\", SetLastError = true)]\n" + - "public static extern uint SetConsoleMode(\n" + - " IntPtr hConsoleHandle,\n" + - " uint dwMode);\n" + - "public const int STD_OUTPUT_HANDLE = -11;\n" + - "public const int ENABLE_VIRTUAL_TERMINAL_PROCESSING = 0x0004;\n" + - "'@\n" + - "\n" + - "$WinAPI = Add-Type -MemberDefinition $signature `\n" + - " -Name WinAPI -Namespace ConinModeScript `\n" + - " -PassThru\n" + - "\n" + - "$handle = $WinAPI::GetStdHandle($WinAPI::STD_OUTPUT_HANDLE)\n" + - "$mode = 0\n" + - "$ret = $WinAPI::GetConsoleMode($handle, [ref]$mode)\n" + - "if ($ret -eq 0) {\n" + - " throw \"GetConsoleMode failed (is stdin a console?)\"\n" + - "}\n" + - "$ret = $WinAPI::SetConsoleMode($handle, $mode -bor $WinAPI::ENABLE_VIRTUAL_TERMINAL_PROCESSING)\n" + - "if ($ret -eq 0) {\n" + - " throw \"SetConsoleMode failed (is stdin a console?)\"\n" + - "}\n"; - - // not sure what happens on Windows versions that don't support ANSI mode - public static void setup() throws InterruptedException, IOException { - if (isWindows && System.console() != null) { - PowershellRunner.runScript(script); - } - } - -}