commit be0c0bb321247c0e04ba07d61b4474ef79059787 Author: Gregor Lohaus Date: Wed May 27 14:04:26 2026 +0200 wip diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..81cda03 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.gradle/ +build/ +result +result-* +*.class +*.jar diff --git a/README.md b/README.md new file mode 100644 index 0000000..eea3e20 --- /dev/null +++ b/README.md @@ -0,0 +1,75 @@ +# jlibghostty + +Java FFM bindings for Ghostty's `libghostty-vt`. + +This targets Java 22+ and uses `java.lang.foreign`, not JNI. The public API is intentionally small while Ghostty's C API is still marked unstable upstream. + +## Build + +```sh +nix build +``` + +The default Nix package builds: + +- `share/java/jlibghostty-0.1.0-SNAPSHOT.jar` +- `maven/dev/jlibghostty/jlibghostty/0.1.0-SNAPSHOT/...` + +The jar contains the host platform `libghostty-vt` under `dev/jlibghostty/native//`. + +## Gradle Consumer + +After `nix build`, another Gradle project can consume the generated Maven repository: + +```kotlin +repositories { + maven { + url = uri("/home/anon/Dev/jlibghostty/result/maven") + } +} + +dependencies { + implementation("dev.jlibghostty:jlibghostty:0.1.0-SNAPSHOT") +} + +tasks.withType().configureEach { + jvmArgs("--enable-native-access=ALL-UNNAMED") +} +``` + +If the app runs on the module path, use: + +```sh +--enable-native-access=dev.jlibghostty +``` + +## External Native Library + +The library normally loads the bundled native `libghostty-vt`. To override it: + +```sh +java -Djlibghostty.library.path=/path/to/libghostty-vt.so ... +``` + +or set: + +```sh +export JLIBGHOSTTY_LIBRARY=/path/to/libghostty-vt.so +``` + +## Example + +```java +try (Terminal terminal = Ghostty.open(TerminalOptions.of(80, 24))) { + terminal.write("hello\r\n"); + System.out.println(terminal.snapshot()); +} +``` + +## Development Shell + +```sh +nix develop +``` + +The shell provides Java, Gradle, and `JLIBGHOSTTY_LIBRARY` pointing at the Nix-built `libghostty-vt`. diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..03b1be1 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,68 @@ +plugins { + `java-library` + `maven-publish` +} + +group = "dev.jlibghostty" +version = "0.1.0-SNAPSHOT" + +java { + toolchain { + languageVersion.set(JavaLanguageVersion.of(25)) + } + + withSourcesJar() +} + +tasks.withType().configureEach { + options.release.set(22) +} + +tasks.test { + jvmArgs("--enable-native-access=ALL-UNNAMED") +} + +fun currentPlatform(): String { + val os = System.getProperty("os.name").lowercase() + val arch = System.getProperty("os.arch").lowercase() + + val normalizedOs = when { + os.contains("linux") -> "linux" + os.contains("mac") || os.contains("darwin") -> "macos" + else -> error("Unsupported operating system for bundled libghostty-vt: $os") + } + + val normalizedArch = when (arch) { + "amd64", "x86_64" -> "x86_64" + "aarch64", "arm64" -> "aarch64" + else -> error("Unsupported architecture for bundled libghostty-vt: $arch") + } + + return "$normalizedOs-$normalizedArch" +} + +val ghosttyNativeLib = providers + .gradleProperty("ghosttyNativeLib") + .orElse(providers.environmentVariable("JLIBGHOSTTY_LIBRARY")) + +tasks.processResources { + ghosttyNativeLib.orNull?.let { nativeLib -> + from(nativeLib) { + into("dev/jlibghostty/native/${currentPlatform()}") + rename { System.mapLibraryName("ghostty-vt") } + } + } +} + +publishing { + publications { + create("mavenJava") { + from(components["java"]) + + pom { + name.set("jlibghostty") + description.set("Java FFM bindings for libghostty-vt") + } + } + } +} diff --git a/examples/gradle-consumer/build.gradle.kts b/examples/gradle-consumer/build.gradle.kts new file mode 100644 index 0000000..6b2ba81 --- /dev/null +++ b/examples/gradle-consumer/build.gradle.kts @@ -0,0 +1,15 @@ +plugins { + application +} + +dependencies { + implementation("dev.jlibghostty:jlibghostty:0.1.0-SNAPSHOT") +} + +application { + mainClass.set("example.Main") +} + +tasks.withType().configureEach { + jvmArgs("--enable-native-access=ALL-UNNAMED") +} diff --git a/examples/gradle-consumer/settings.gradle.kts b/examples/gradle-consumer/settings.gradle.kts new file mode 100644 index 0000000..6485bdd --- /dev/null +++ b/examples/gradle-consumer/settings.gradle.kts @@ -0,0 +1,18 @@ +pluginManagement { + repositories { + gradlePluginPortal() + mavenCentral() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + mavenCentral() + maven { + url = uri(System.getenv("JLIBGHOSTTY_MAVEN_REPO") ?: "../../result/maven") + } + } +} + +rootProject.name = "jlibghostty-consumer" diff --git a/examples/gradle-consumer/src/main/java/example/Main.java b/examples/gradle-consumer/src/main/java/example/Main.java new file mode 100644 index 0000000..72262d4 --- /dev/null +++ b/examples/gradle-consumer/src/main/java/example/Main.java @@ -0,0 +1,19 @@ +package example; + +import dev.jlibghostty.Ghostty; +import dev.jlibghostty.Terminal; +import dev.jlibghostty.TerminalOptions; + +public final class Main { + private Main() { + } + + public static void main(String[] args) { + System.out.println("paste safe: " + Ghostty.pasteIsSafe("hello")); + + try (Terminal terminal = Ghostty.open(TerminalOptions.of(80, 24))) { + terminal.write("hello from libghostty-vt\r\n"); + System.out.println(terminal.snapshot()); + } + } +} diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..77f2369 --- /dev/null +++ b/flake.lock @@ -0,0 +1,180 @@ +{ + "nodes": { + "flake-compat": { + "flake": false, + "locked": { + "lastModified": 1761588595, + "narHash": "sha256-XKUZz9zewJNUj46b4AJdiRZJAvSZ0Dqj2BNfXvFlJC4=", + "owner": "edolstra", + "repo": "flake-compat", + "rev": "f387cd2afec9419c8ee37694406ca490c3f34ee5", + "type": "github" + }, + "original": { + "owner": "edolstra", + "repo": "flake-compat", + "type": "github" + } + }, + "ghostty": { + "inputs": { + "flake-compat": "flake-compat", + "home-manager": "home-manager", + "nixpkgs": [ + "nixpkgs" + ], + "systems": "systems", + "zig": "zig", + "zon2nix": "zon2nix" + }, + "locked": { + "lastModified": 1779812402, + "narHash": "sha256-gozJEyJHbaAyrbzODKeWJhxpUrGK6m4DIPDogfjz2BU=", + "owner": "ghostty-org", + "repo": "ghostty", + "rev": "2e5ad917eb4e325a3dbb161c3f41208a8cd35e44", + "type": "github" + }, + "original": { + "owner": "ghostty-org", + "repo": "ghostty", + "type": "github" + } + }, + "home-manager": { + "inputs": { + "nixpkgs": [ + "ghostty", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1770586272, + "narHash": "sha256-Ucci8mu8QfxwzyfER2DQDbvW9t1BnTUJhBmY7ybralo=", + "owner": "nix-community", + "repo": "home-manager", + "rev": "b1f916ba052341edc1f80d4b2399f1092a4873ca", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "home-manager", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1779560665, + "narHash": "sha256-tpyBcxPpcQb8ukyNF7DoCwfSY3VPsxHoYwj00Cayv5o=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "64c08a7ca051951c8eae34e3e3cb1e202fe36786", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "ghostty": "ghostty", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "flake": false, + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + }, + "zig": { + "inputs": { + "flake-compat": [ + "ghostty", + "flake-compat" + ], + "nixpkgs": [ + "ghostty", + "nixpkgs" + ], + "systems": [ + "ghostty", + "systems" + ] + }, + "locked": { + "lastModified": 1776789209, + "narHash": "sha256-G6B7Q4TXn7MZ1mB+f9rymjsYF5PLWoSvmbxijb/99bw=", + "owner": "mitchellh", + "repo": "zig-overlay", + "rev": "14fe971844e841297ddd2ce9783d6892b467af39", + "type": "github" + }, + "original": { + "owner": "mitchellh", + "repo": "zig-overlay", + "type": "github" + } + }, + "zig_2": { + "inputs": { + "nixpkgs": [ + "ghostty", + "zon2nix", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1777234348, + "narHash": "sha256-fKw44a4qbUuI5eTG8k0gPbqMV5TOrjYF35PBzsYgd2U=", + "ref": "refs/heads/main", + "rev": "2c781c0609ecda600ab98f98cca417bbd981bd53", + "revCount": 1677, + "type": "git", + "url": "https://codeberg.org/jcollie/zig-overlay.git" + }, + "original": { + "type": "git", + "url": "https://codeberg.org/jcollie/zig-overlay.git" + } + }, + "zon2nix": { + "inputs": { + "nixpkgs": [ + "ghostty", + "nixpkgs" + ], + "zig": "zig_2" + }, + "locked": { + "lastModified": 1777314365, + "narHash": "sha256-eLxQaD0wc96Neqkln8wHS0rNq/chPODifFkhwrwilEU=", + "owner": "jcollie", + "repo": "zon2nix", + "rev": "a5a1d412ad1ab6305511997bbc92b3a9dd6cb784", + "type": "github" + }, + "original": { + "owner": "jcollie", + "ref": "main", + "repo": "zon2nix", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..513fa80 --- /dev/null +++ b/flake.nix @@ -0,0 +1,164 @@ +{ + description = "Java FFM bindings for libghostty-vt"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + ghostty = { + url = "github:ghostty-org/ghostty"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + }; + + outputs = { self, nixpkgs, ghostty }: + let + systems = [ + "x86_64-linux" + "aarch64-linux" + "x86_64-darwin" + "aarch64-darwin" + ]; + + forAllSystems = nixpkgs.lib.genAttrs systems; + in + { + packages = forAllSystems (system: + let + pkgs = import nixpkgs { inherit system; }; + jdk = + if pkgs ? jdk25_headless then pkgs.jdk25_headless + else if pkgs ? jdk25 then pkgs.jdk25 + else if pkgs ? jdk24_headless then pkgs.jdk24_headless + else if pkgs ? jdk24 then pkgs.jdk24 + else pkgs.jdk; + + version = "0.1.0-SNAPSHOT"; + groupPath = "dev/jlibghostty"; + artifactId = "jlibghostty"; + ghosttyVt = ghostty.packages.${system}.libghostty-vt; + + platformName = + if system == "x86_64-linux" then "linux-x86_64" + else if system == "aarch64-linux" then "linux-aarch64" + else if system == "x86_64-darwin" then "macos-x86_64" + else if system == "aarch64-darwin" then "macos-aarch64" + else throw "unsupported system: ${system}"; + + bundledLibraryName = + if pkgs.stdenv.hostPlatform.isDarwin then "libghostty-vt.dylib" + else "libghostty-vt.so"; + + sharedLibraryPattern = + if pkgs.stdenv.hostPlatform.isDarwin then "libghostty-vt*.dylib" + else "libghostty-vt.so*"; + + package = pkgs.stdenvNoCC.mkDerivation { + pname = artifactId; + inherit version; + src = ./.; + + nativeBuildInputs = [ jdk ]; + + dontConfigure = true; + + buildPhase = '' + runHook preBuild + + mkdir -p build/classes build/test-classes build/resources/${groupPath}/native/${platformName} + + ghostty_lib="$(find ${ghosttyVt} -type f -name '${sharedLibraryPattern}' -print -quit)" + if [ -z "$ghostty_lib" ]; then + echo "Could not find ${sharedLibraryPattern} in ${ghosttyVt}" >&2 + find ${ghosttyVt} -maxdepth 4 -type f >&2 + exit 1 + fi + + bundled_lib="build/resources/${groupPath}/native/${platformName}/${bundledLibraryName}" + cp "$ghostty_lib" "$bundled_lib" + + find src/main/java -name '*.java' | sort > build/sources.txt + javac --release 22 -d build/classes @build/sources.txt + + jar --create \ + --file build/${artifactId}-${version}.jar \ + -C build/classes . \ + -C build/resources . + + jar --create \ + --file build/${artifactId}-${version}-sources.jar \ + -C src/main/java . + + find src/test/java -name '*.java' | sort > build/test-sources.txt + if [ -s build/test-sources.txt ]; then + javac --release 22 -cp build/classes -d build/test-classes @build/test-sources.txt + java \ + --enable-native-access=ALL-UNNAMED \ + -Djlibghostty.library.path="$bundled_lib" \ + -cp build/classes:build/test-classes \ + dev.jlibghostty.GhosttySmokeTest + fi + + runHook postBuild + ''; + + installPhase = '' + runHook preInstall + + mkdir -p "$out/share/java" "$out/maven/${groupPath}/${artifactId}/${version}" + + cp build/${artifactId}-${version}.jar "$out/share/java/" + cp build/${artifactId}-${version}.jar "$out/maven/${groupPath}/${artifactId}/${version}/" + cp build/${artifactId}-${version}-sources.jar "$out/maven/${groupPath}/${artifactId}/${version}/" + + cat > "$out/maven/${groupPath}/${artifactId}/${version}/${artifactId}-${version}.pom" <<'POM' + + 4.0.0 + dev.jlibghostty + jlibghostty + 0.1.0-SNAPSHOT + jlibghostty + Java FFM bindings for libghostty-vt + +POM + + runHook postInstall + ''; + + meta = { + description = "Java FFM bindings for libghostty-vt"; + platforms = systems; + }; + }; + in + { + default = package; + jlibghostty = package; + mavenRepository = package; + }); + + devShells = forAllSystems (system: + let + pkgs = import nixpkgs { inherit system; }; + jdk = + if pkgs ? jdk25_headless then pkgs.jdk25_headless + else if pkgs ? jdk25 then pkgs.jdk25 + else if pkgs ? jdk24_headless then pkgs.jdk24_headless + else if pkgs ? jdk24 then pkgs.jdk24 + else pkgs.jdk; + ghosttyVt = ghostty.packages.${system}.libghostty-vt; + in + { + default = pkgs.mkShell { + packages = + [ jdk pkgs.gradle ] + ++ pkgs.lib.optional (pkgs ? jextract) pkgs.jextract; + + JLIBGHOSTTY_LIBRARY = + if pkgs.stdenv.hostPlatform.isDarwin + then "${ghosttyVt}/lib/libghostty-vt.dylib" + else "${ghosttyVt}/lib/libghostty-vt.so"; + }; + }); + }; +} diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..cc53c08 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,15 @@ +pluginManagement { + repositories { + gradlePluginPortal() + mavenCentral() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + mavenCentral() + } +} + +rootProject.name = "jlibghostty" diff --git a/src/main/java/dev/jlibghostty/Ghostty.java b/src/main/java/dev/jlibghostty/Ghostty.java new file mode 100644 index 0000000..eee20b6 --- /dev/null +++ b/src/main/java/dev/jlibghostty/Ghostty.java @@ -0,0 +1,31 @@ +package dev.jlibghostty; + +import dev.jlibghostty.internal.GhosttyLibrary; + +import java.nio.charset.StandardCharsets; + +public final class Ghostty { + private Ghostty() { + } + + public static Terminal open(TerminalOptions options) { + return Terminal.open(options); + } + + public static boolean pasteIsSafe(String text) { + return pasteIsSafe(text.getBytes(StandardCharsets.UTF_8)); + } + + public static boolean pasteIsSafe(byte[] data) { + return GhosttyLibrary.loadDefault().pasteIsSafe(data); + } + + public static String encodePaste(String text, boolean bracketed) { + byte[] encoded = encodePaste(text.getBytes(StandardCharsets.UTF_8), bracketed); + return new String(encoded, StandardCharsets.UTF_8); + } + + public static byte[] encodePaste(byte[] data, boolean bracketed) { + return GhosttyLibrary.loadDefault().pasteEncode(data, bracketed); + } +} diff --git a/src/main/java/dev/jlibghostty/GhosttyException.java b/src/main/java/dev/jlibghostty/GhosttyException.java new file mode 100644 index 0000000..b5b0003 --- /dev/null +++ b/src/main/java/dev/jlibghostty/GhosttyException.java @@ -0,0 +1,14 @@ +package dev.jlibghostty; + +public final class GhosttyException extends RuntimeException { + private final int resultCode; + + public GhosttyException(String message, int resultCode) { + super(message + " failed with libghostty-vt result " + resultCode); + this.resultCode = resultCode; + } + + public int resultCode() { + return resultCode; + } +} diff --git a/src/main/java/dev/jlibghostty/Terminal.java b/src/main/java/dev/jlibghostty/Terminal.java new file mode 100644 index 0000000..5c8387b --- /dev/null +++ b/src/main/java/dev/jlibghostty/Terminal.java @@ -0,0 +1,68 @@ +package dev.jlibghostty; + +import dev.jlibghostty.internal.GhosttyLibrary; + +import java.lang.foreign.MemorySegment; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.atomic.AtomicBoolean; + +public final class Terminal implements AutoCloseable { + private final GhosttyLibrary library; + private final MemorySegment handle; + private final AtomicBoolean closed = new AtomicBoolean(); + + private Terminal(GhosttyLibrary library, MemorySegment handle) { + this.library = library; + this.handle = handle; + } + + public static Terminal open(TerminalOptions options) { + GhosttyLibrary library = GhosttyLibrary.loadDefault(); + return new Terminal(library, library.terminalNew(options)); + } + + public void write(String vtData) { + write(vtData.getBytes(StandardCharsets.UTF_8)); + } + + public void write(byte[] vtData) { + ensureOpen(); + library.terminalWrite(handle, vtData); + } + + public void reset() { + ensureOpen(); + library.terminalReset(handle); + } + + public void resize(int columns, int rows, int cellWidthPx, int cellHeightPx) { + ensureOpen(); + library.terminalResize(handle, columns, rows, cellWidthPx, cellHeightPx); + } + + public TerminalSnapshot snapshot() { + ensureOpen(); + return new TerminalSnapshot( + library.terminalGetU16(handle, GhosttyLibrary.TERMINAL_DATA_COLS), + library.terminalGetU16(handle, GhosttyLibrary.TERMINAL_DATA_ROWS), + library.terminalGetU16(handle, GhosttyLibrary.TERMINAL_DATA_CURSOR_X), + library.terminalGetU16(handle, GhosttyLibrary.TERMINAL_DATA_CURSOR_Y), + library.terminalGetBoolean(handle, GhosttyLibrary.TERMINAL_DATA_CURSOR_VISIBLE), + library.terminalGetString(handle, GhosttyLibrary.TERMINAL_DATA_TITLE), + library.terminalGetString(handle, GhosttyLibrary.TERMINAL_DATA_PWD) + ); + } + + @Override + public void close() { + if (closed.compareAndSet(false, true)) { + library.terminalFree(handle); + } + } + + private void ensureOpen() { + if (closed.get()) { + throw new IllegalStateException("Terminal is closed"); + } + } +} diff --git a/src/main/java/dev/jlibghostty/TerminalOptions.java b/src/main/java/dev/jlibghostty/TerminalOptions.java new file mode 100644 index 0000000..206b900 --- /dev/null +++ b/src/main/java/dev/jlibghostty/TerminalOptions.java @@ -0,0 +1,19 @@ +package dev.jlibghostty; + +public record TerminalOptions(int columns, int rows, long maxScrollback) { + public TerminalOptions { + if (columns < 1 || columns > 65535) { + throw new IllegalArgumentException("columns must be between 1 and 65535"); + } + if (rows < 1 || rows > 65535) { + throw new IllegalArgumentException("rows must be between 1 and 65535"); + } + if (maxScrollback < 0) { + throw new IllegalArgumentException("maxScrollback must be non-negative"); + } + } + + public static TerminalOptions of(int columns, int rows) { + return new TerminalOptions(columns, rows, 10_000); + } +} diff --git a/src/main/java/dev/jlibghostty/TerminalSnapshot.java b/src/main/java/dev/jlibghostty/TerminalSnapshot.java new file mode 100644 index 0000000..c385540 --- /dev/null +++ b/src/main/java/dev/jlibghostty/TerminalSnapshot.java @@ -0,0 +1,12 @@ +package dev.jlibghostty; + +public record TerminalSnapshot( + int columns, + int rows, + int cursorX, + int cursorY, + boolean cursorVisible, + String title, + String workingDirectory +) { +} diff --git a/src/main/java/dev/jlibghostty/internal/GhosttyLibrary.java b/src/main/java/dev/jlibghostty/internal/GhosttyLibrary.java new file mode 100644 index 0000000..0b1d3de --- /dev/null +++ b/src/main/java/dev/jlibghostty/internal/GhosttyLibrary.java @@ -0,0 +1,297 @@ +package dev.jlibghostty.internal; + +import dev.jlibghostty.GhosttyException; +import dev.jlibghostty.TerminalOptions; + +import java.lang.foreign.Arena; +import java.lang.foreign.FunctionDescriptor; +import java.lang.foreign.GroupLayout; +import java.lang.foreign.Linker; +import java.lang.foreign.MemoryLayout; +import java.lang.foreign.MemorySegment; +import java.lang.foreign.SymbolLookup; +import java.lang.foreign.ValueLayout; +import java.lang.invoke.MethodHandle; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; + +import static java.lang.foreign.ValueLayout.ADDRESS; +import static java.lang.foreign.ValueLayout.JAVA_BOOLEAN; +import static java.lang.foreign.ValueLayout.JAVA_BYTE; +import static java.lang.foreign.ValueLayout.JAVA_INT; +import static java.lang.foreign.ValueLayout.JAVA_SHORT; + +public final class GhosttyLibrary { + public static final int TERMINAL_DATA_COLS = 1; + public static final int TERMINAL_DATA_ROWS = 2; + public static final int TERMINAL_DATA_CURSOR_X = 3; + public static final int TERMINAL_DATA_CURSOR_Y = 4; + public static final int TERMINAL_DATA_CURSOR_VISIBLE = 7; + public static final int TERMINAL_DATA_TITLE = 12; + public static final int TERMINAL_DATA_PWD = 13; + + private static final int GHOSTTY_SUCCESS = 0; + private static final int GHOSTTY_OUT_OF_SPACE = -3; + private static final int GHOSTTY_NO_VALUE = -4; + + private static final Linker LINKER = Linker.nativeLinker(); + private static final ValueLayout.OfLong C_SIZE_T = sizeTLayout(); + + private static final GroupLayout TERMINAL_OPTIONS = MemoryLayout.structLayout( + JAVA_SHORT.withName("cols"), + JAVA_SHORT.withName("rows"), + MemoryLayout.paddingLayout(4), + C_SIZE_T.withName("max_scrollback") + ); + + private static final GroupLayout GHOSTTY_STRING = MemoryLayout.structLayout( + ADDRESS.withName("ptr"), + C_SIZE_T.withName("len") + ); + + private final MethodHandle terminalNew; + private final MethodHandle terminalFree; + private final MethodHandle terminalReset; + private final MethodHandle terminalResize; + private final MethodHandle terminalVtWrite; + private final MethodHandle terminalGet; + private final MethodHandle pasteIsSafe; + private final MethodHandle pasteEncode; + + private GhosttyLibrary(Path libraryPath) { + try { + SymbolLookup symbols = SymbolLookup.libraryLookup(libraryPath, Arena.global()); + + terminalNew = downcall(symbols, "ghostty_terminal_new", + FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS, TERMINAL_OPTIONS)); + terminalFree = downcall(symbols, "ghostty_terminal_free", + FunctionDescriptor.ofVoid(ADDRESS)); + terminalReset = downcall(symbols, "ghostty_terminal_reset", + FunctionDescriptor.ofVoid(ADDRESS)); + terminalResize = downcall(symbols, "ghostty_terminal_resize", + FunctionDescriptor.of(JAVA_INT, ADDRESS, JAVA_SHORT, JAVA_SHORT, JAVA_INT, JAVA_INT)); + terminalVtWrite = downcall(symbols, "ghostty_terminal_vt_write", + FunctionDescriptor.ofVoid(ADDRESS, ADDRESS, C_SIZE_T)); + terminalGet = downcall(symbols, "ghostty_terminal_get", + FunctionDescriptor.of(JAVA_INT, ADDRESS, JAVA_INT, ADDRESS)); + pasteIsSafe = downcall(symbols, "ghostty_paste_is_safe", + FunctionDescriptor.of(JAVA_BOOLEAN, ADDRESS, C_SIZE_T)); + pasteEncode = downcall(symbols, "ghostty_paste_encode", + FunctionDescriptor.of(JAVA_INT, ADDRESS, C_SIZE_T, JAVA_BOOLEAN, ADDRESS, C_SIZE_T, ADDRESS)); + } catch (IllegalCallerException e) { + throw new IllegalStateException( + "FFM native access is disabled. Run with --enable-native-access=dev.jlibghostty " + + "when using the module path, or --enable-native-access=ALL-UNNAMED on the classpath.", + e + ); + } + } + + public static GhosttyLibrary loadDefault() { + return Holder.INSTANCE; + } + + public MemorySegment terminalNew(TerminalOptions options) { + try (Arena arena = Arena.ofConfined()) { + MemorySegment out = arena.allocate(ADDRESS); + MemorySegment nativeOptions = arena.allocate(TERMINAL_OPTIONS); + + nativeOptions.set(JAVA_SHORT, 0, (short) options.columns()); + nativeOptions.set(JAVA_SHORT, 2, (short) options.rows()); + nativeOptions.set(C_SIZE_T, 8, options.maxScrollback()); + + int result = (int) terminalNew.invoke(MemorySegment.NULL, out, nativeOptions); + checkResult("ghostty_terminal_new", result); + + MemorySegment terminal = out.get(ADDRESS, 0); + if (terminal.address() == 0) { + throw new IllegalStateException("ghostty_terminal_new returned a null terminal handle"); + } + return terminal; + } catch (Throwable t) { + return rethrow(t); + } + } + + public void terminalFree(MemorySegment terminal) { + try { + terminalFree.invoke(terminal); + } catch (Throwable t) { + rethrow(t); + } + } + + public void terminalReset(MemorySegment terminal) { + try { + terminalReset.invoke(terminal); + } catch (Throwable t) { + rethrow(t); + } + } + + public void terminalResize(MemorySegment terminal, int columns, int rows, int cellWidthPx, int cellHeightPx) { + try { + int result = (int) terminalResize.invoke( + terminal, + (short) columns, + (short) rows, + cellWidthPx, + cellHeightPx + ); + checkResult("ghostty_terminal_resize", result); + } catch (Throwable t) { + rethrow(t); + } + } + + public void terminalWrite(MemorySegment terminal, byte[] data) { + if (data.length == 0) { + return; + } + + try (Arena arena = Arena.ofConfined()) { + MemorySegment nativeData = arena.allocate(data.length, 1); + MemorySegment.copy(data, 0, nativeData, JAVA_BYTE, 0, data.length); + terminalVtWrite.invoke(terminal, nativeData, (long) data.length); + } catch (Throwable t) { + rethrow(t); + } + } + + public int terminalGetU16(MemorySegment terminal, int key) { + try (Arena arena = Arena.ofConfined()) { + MemorySegment out = arena.allocate(JAVA_SHORT); + int result = (int) terminalGet.invoke(terminal, key, out); + checkResult("ghostty_terminal_get", result); + return Short.toUnsignedInt(out.get(JAVA_SHORT, 0)); + } catch (Throwable t) { + return rethrow(t); + } + } + + public boolean terminalGetBoolean(MemorySegment terminal, int key) { + try (Arena arena = Arena.ofConfined()) { + MemorySegment out = arena.allocate(JAVA_BOOLEAN); + int result = (int) terminalGet.invoke(terminal, key, out); + checkResult("ghostty_terminal_get", result); + return out.get(JAVA_BOOLEAN, 0); + } catch (Throwable t) { + return rethrow(t); + } + } + + public String terminalGetString(MemorySegment terminal, int key) { + try (Arena arena = Arena.ofConfined()) { + MemorySegment out = arena.allocate(GHOSTTY_STRING); + int result = (int) terminalGet.invoke(terminal, key, out); + if (result == GHOSTTY_NO_VALUE) { + return ""; + } + checkResult("ghostty_terminal_get", result); + + MemorySegment ptr = out.get(ADDRESS, 0); + long len = out.get(C_SIZE_T, 8); + if (ptr.address() == 0 || len == 0) { + return ""; + } + + byte[] bytes = ptr.reinterpret(len).toArray(JAVA_BYTE); + return new String(bytes, StandardCharsets.UTF_8); + } catch (Throwable t) { + return rethrow(t); + } + } + + public boolean pasteIsSafe(byte[] data) { + if (data.length == 0) { + return true; + } + + try (Arena arena = Arena.ofConfined()) { + MemorySegment nativeData = arena.allocate(data.length, 1); + MemorySegment.copy(data, 0, nativeData, JAVA_BYTE, 0, data.length); + return (boolean) pasteIsSafe.invoke(nativeData, (long) data.length); + } catch (Throwable t) { + return rethrow(t); + } + } + + public byte[] pasteEncode(byte[] data, boolean bracketed) { + try (Arena arena = Arena.ofConfined()) { + MemorySegment nativeData = MemorySegment.NULL; + if (data.length > 0) { + nativeData = arena.allocate(data.length, 1); + MemorySegment.copy(data, 0, nativeData, JAVA_BYTE, 0, data.length); + } + + MemorySegment outWritten = arena.allocate(C_SIZE_T); + int result = (int) pasteEncode.invoke( + nativeData, + (long) data.length, + bracketed, + MemorySegment.NULL, + 0L, + outWritten + ); + if (result != GHOSTTY_SUCCESS && result != GHOSTTY_OUT_OF_SPACE) { + checkResult("ghostty_paste_encode", result); + } + + long required = outWritten.get(C_SIZE_T, 0); + if (required == 0) { + return new byte[0]; + } + + MemorySegment out = arena.allocate(required, 1); + result = (int) pasteEncode.invoke( + nativeData, + (long) data.length, + bracketed, + out, + required, + outWritten + ); + checkResult("ghostty_paste_encode", result); + + long written = outWritten.get(C_SIZE_T, 0); + return out.asSlice(0, written).toArray(JAVA_BYTE); + } catch (Throwable t) { + return rethrow(t); + } + } + + private static MethodHandle downcall(SymbolLookup symbols, String name, FunctionDescriptor descriptor) { + MemorySegment symbol = symbols.find(name) + .orElseThrow(() -> new UnsatisfiedLinkError("Missing libghostty-vt symbol: " + name)); + return LINKER.downcallHandle(symbol, descriptor); + } + + private static void checkResult(String operation, int result) { + if (result != GHOSTTY_SUCCESS) { + throw new GhosttyException(operation, result); + } + } + + @SuppressWarnings("unchecked") + private static T rethrow(Throwable t) { + if (t instanceof RuntimeException runtimeException) { + throw runtimeException; + } + if (t instanceof Error error) { + throw error; + } + throw new IllegalStateException("Unexpected libghostty-vt FFM failure", t); + } + + private static ValueLayout.OfLong sizeTLayout() { + ValueLayout layout = (ValueLayout) LINKER.canonicalLayouts().get("size_t"); + if (layout.byteSize() != Long.BYTES) { + throw new UnsupportedOperationException("jlibghostty currently supports 64-bit platforms only"); + } + return (ValueLayout.OfLong) layout; + } + + private static final class Holder { + private static final GhosttyLibrary INSTANCE = new GhosttyLibrary(NativeLibraryLoader.resolve()); + } +} diff --git a/src/main/java/dev/jlibghostty/internal/NativeLibraryLoader.java b/src/main/java/dev/jlibghostty/internal/NativeLibraryLoader.java new file mode 100644 index 0000000..7550503 --- /dev/null +++ b/src/main/java/dev/jlibghostty/internal/NativeLibraryLoader.java @@ -0,0 +1,85 @@ +package dev.jlibghostty.internal; + +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.Locale; + +public final class NativeLibraryLoader { + private static final String PROPERTY = "jlibghostty.library.path"; + private static final String ENVIRONMENT = "JLIBGHOSTTY_LIBRARY"; + + private NativeLibraryLoader() { + } + + public static Path resolve() { + String propertyOverride = System.getProperty(PROPERTY); + if (propertyOverride != null && !propertyOverride.isBlank()) { + return Path.of(propertyOverride).toAbsolutePath(); + } + + String environmentOverride = System.getenv(ENVIRONMENT); + if (environmentOverride != null && !environmentOverride.isBlank()) { + return Path.of(environmentOverride).toAbsolutePath(); + } + + String resource = "/dev/jlibghostty/native/" + platform() + "/" + System.mapLibraryName("ghostty-vt"); + URL url = NativeLibraryLoader.class.getResource(resource); + if (url == null) { + throw new UnsatisfiedLinkError( + "Bundled libghostty-vt not found at " + resource + + ". Set -D" + PROPERTY + "=/path/to/" + System.mapLibraryName("ghostty-vt") + + " or " + ENVIRONMENT + " to load an external library." + ); + } + + return extract(resource); + } + + private static Path extract(String resource) { + try (InputStream in = NativeLibraryLoader.class.getResourceAsStream(resource)) { + if (in == null) { + throw new UnsatisfiedLinkError("Bundled libghostty-vt resource disappeared: " + resource); + } + + Path dir = Files.createTempDirectory("jlibghostty-"); + Path library = dir.resolve(System.mapLibraryName("ghostty-vt")); + Files.copy(in, library, StandardCopyOption.REPLACE_EXISTING); + library.toFile().setReadable(true); + library.toFile().setExecutable(true); + library.toFile().deleteOnExit(); + dir.toFile().deleteOnExit(); + return library; + } catch (IOException e) { + throw new UncheckedIOException("Could not extract bundled libghostty-vt", e); + } + } + + private static String platform() { + return normalizeOs() + "-" + normalizeArch(); + } + + private static String normalizeOs() { + String os = System.getProperty("os.name").toLowerCase(Locale.ROOT); + if (os.contains("linux")) { + return "linux"; + } + if (os.contains("mac") || os.contains("darwin")) { + return "macos"; + } + throw new UnsupportedOperationException("Unsupported operating system for libghostty-vt: " + os); + } + + private static String normalizeArch() { + String arch = System.getProperty("os.arch").toLowerCase(Locale.ROOT); + return switch (arch) { + case "amd64", "x86_64" -> "x86_64"; + case "aarch64", "arm64" -> "aarch64"; + default -> throw new UnsupportedOperationException("Unsupported architecture for libghostty-vt: " + arch); + }; + } +} diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java new file mode 100644 index 0000000..f59984a --- /dev/null +++ b/src/main/java/module-info.java @@ -0,0 +1,3 @@ +module dev.jlibghostty { + exports dev.jlibghostty; +} diff --git a/src/test/java/dev/jlibghostty/GhosttySmokeTest.java b/src/test/java/dev/jlibghostty/GhosttySmokeTest.java new file mode 100644 index 0000000..3ccc847 --- /dev/null +++ b/src/test/java/dev/jlibghostty/GhosttySmokeTest.java @@ -0,0 +1,26 @@ +package dev.jlibghostty; + +public final class GhosttySmokeTest { + private GhosttySmokeTest() { + } + + public static void main(String[] args) { + if (!Ghostty.pasteIsSafe("hello")) { + throw new AssertionError("simple paste should be safe"); + } + if (Ghostty.pasteIsSafe("rm -rf /\n")) { + throw new AssertionError("newline paste should be unsafe"); + } + if (!"hello".equals(Ghostty.encodePaste("hello", false))) { + throw new AssertionError("simple paste encoding changed unexpectedly"); + } + + try (Terminal terminal = Ghostty.open(TerminalOptions.of(80, 24))) { + terminal.write("hello\r\n"); + TerminalSnapshot snapshot = terminal.snapshot(); + if (snapshot.columns() != 80 || snapshot.rows() != 24) { + throw new AssertionError("unexpected terminal size: " + snapshot); + } + } + } +}