diff --git a/.changepacks/config.json b/.changepacks/config.json index 9229667c..4eeb6d96 100644 --- a/.changepacks/config.json +++ b/.changepacks/config.json @@ -1,5 +1,5 @@ { - "ignore": ["**", "!packages/python/pyproject.toml", "!packages/dotnet/BraillifyNet/BraillifyNet.csproj", "!packages/dotnet/Braillify/Braillify.csproj", "!packages/node/package.json", "!packages/c/Cargo.toml", "!libs/braillify/Cargo.toml"], + "ignore": ["**", "!packages/python/pyproject.toml", "!packages/dotnet/BraillifyNet/BraillifyNet.csproj", "!packages/dotnet/Braillify/Braillify.csproj", "!packages/node/package.json", "!packages/c/Cargo.toml", "!packages/jvm/build.gradle.kts", "!libs/braillify/Cargo.toml"], "baseBranch": "main", "latestPackage": null, "publish": {} diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 871220c1..05f204fb 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -208,6 +208,50 @@ jobs: run: dotnet test --no-build -c Release --verbosity normal --framework net9.0 Braillify.Tests/Braillify.Tests.csproj working-directory: packages/dotnet + jvm-test: + name: JVM Test - ${{ matrix.native-target }} + runs-on: ${{ matrix.runner }} + permissions: + contents: read + strategy: + fail-fast: false + matrix: + include: + - runner: ubuntu-24.04 + native-target: linux-x86_64 + - runner: ubuntu-24.04-arm + native-target: linux-aarch64 + - runner: macos-15-intel + native-target: macos-x86_64 + - runner: macos-15 + native-target: macos-aarch64 + - runner: windows-2022 + native-target: windows-x86_64 + steps: + # pull_request_target에서도 PR 코드를 체크아웃해 실제 JVM 바인딩을 테스트한다. + - uses: actions/checkout@v7 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + allow-unsafe-pr-checkout: true + - name: Validate Gradle Wrapper + uses: gradle/actions/wrapper-validation@v4 + - name: Set up JDK 17 + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: "17" + - name: Setup Rust + uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + toolchain: stable + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + with: + cache-read-only: ${{ github.event_name == 'pull_request_target' }} + - name: Test JVM binding + run: ./gradlew test --no-daemon + working-directory: packages/jvm + deploy-landing: name: Deploy Landing runs-on: ubuntu-latest @@ -215,6 +259,7 @@ jobs: needs: - test - dotnet-test + - jvm-test steps: - uses: actions/checkout@v7 - uses: oven-sh/setup-bun@v2 @@ -262,6 +307,7 @@ jobs: needs: - test - dotnet-test + - jvm-test steps: - uses: actions/checkout@v7 - uses: changepacks/action@main @@ -962,3 +1008,391 @@ jobs: with: upload_url: ${{ fromJson(needs.changepacks.outputs.release_assets_urls)['packages/c/Cargo.toml'] }} asset_path: "c-artifacts/*.tar.gz" + + # jvm + # JVM 릴리스 job은 다른 바인딩 게시 흐름과 섞이지 않도록 파일 맨 아래에 둔다. + jvm-native-build: + name: JVM Native Build - ${{ matrix.native-target }} + runs-on: ${{ matrix.runner }} + if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && contains(needs.changepacks.outputs.changepacks, 'packages/jvm/build.gradle.kts') }} + needs: + - changepacks + strategy: + fail-fast: false + matrix: + include: + - runner: ubuntu-24.04 + native-target: linux-x86_64 + rust-target: x86_64-unknown-linux-gnu + library-name: libbraillify_jvm.so + glibc: "2.17" + - runner: ubuntu-24.04 + native-target: linux-aarch64 + rust-target: aarch64-unknown-linux-gnu + library-name: libbraillify_jvm.so + glibc: "2.17" + - runner: macos-15-intel + native-target: macos-x86_64 + rust-target: x86_64-apple-darwin + library-name: libbraillify_jvm.dylib + deployment-target: "10.13" + - runner: macos-15 + native-target: macos-aarch64 + rust-target: aarch64-apple-darwin + library-name: libbraillify_jvm.dylib + deployment-target: "11.0" + - runner: windows-2022 + native-target: windows-x86_64 + rust-target: x86_64-pc-windows-msvc + library-name: braillify_jvm.dll + steps: + - uses: actions/checkout@v7 + - name: Setup Rust + uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + toolchain: stable + target: ${{ matrix.rust-target }} + - name: Set up MSVC + if: runner.os == 'Windows' + uses: ilammy/msvc-dev-cmd@v1 + - name: Set up Zig + if: matrix.glibc + uses: mlugg/setup-zig@v2 + with: + version: 0.14.1 + - name: Install cargo-zigbuild + if: matrix.glibc + uses: taiki-e/install-action@v2 + env: + GITHUB_TOKEN: ${{ github.token }} + with: + tool: cargo-zigbuild + - name: Build Linux native library with glibc 2.17 compatibility + if: matrix.glibc + run: cargo zigbuild --release -p braillify-jvm --target ${{ matrix.rust-target }}.${{ matrix.glibc }} + - name: Build macOS or Windows native library + if: ${{ !matrix.glibc }} + run: cargo build --release -p braillify-jvm --target ${{ matrix.rust-target }} + env: + MACOSX_DEPLOYMENT_TARGET: ${{ matrix.deployment-target }} + - name: Inspect Linux ABI and dependencies + if: runner.os == 'Linux' + shell: bash + env: + LIBRARY: target/${{ matrix.rust-target }}/release/${{ matrix.library-name }} + run: | + readelf -d "$LIBRARY" + readelf --version-info "$LIBRARY" + if readelf --version-info "$LIBRARY" \ + | grep -oE 'GLIBC_[0-9]+\.[0-9]+' \ + | sort -Vu \ + | awk -F'[_.]' '$2 > 2 || ($2 == 2 && $3 > 17) { found = 1 } END { exit !found }'; then + echo "::error::$LIBRARY requires a GLIBC symbol newer than 2.17" + exit 1 + fi + if [ "${{ matrix.native-target }}" = "linux-x86_64" ]; then + ldd "$LIBRARY" + fi + - name: Inspect macOS dependencies + if: runner.os == 'macOS' + run: otool -L target/${{ matrix.rust-target }}/release/${{ matrix.library-name }} + - name: Inspect Windows dependencies + if: runner.os == 'Windows' + run: dumpbin /DEPENDENTS target/${{ matrix.rust-target }}/release/${{ matrix.library-name }} + - name: Upload native library + uses: actions/upload-artifact@v7 + with: + name: jvm-native-${{ matrix.native-target }} + path: target/${{ matrix.rust-target }}/release/${{ matrix.library-name }} + if-no-files-found: error + retention-days: 1 + + jvm-jar: + name: JVM Fat JAR + runs-on: ubuntu-latest + if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && contains(needs.changepacks.outputs.changepacks, 'packages/jvm/build.gradle.kts') }} + needs: + - changepacks + - jvm-native-build + steps: + - uses: actions/checkout@v7 + - name: Validate Gradle Wrapper + uses: gradle/actions/wrapper-validation@v4 + - name: Set up JDK 17 + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: "17" + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + - name: Download native libraries + uses: actions/download-artifact@v8 + with: + pattern: jvm-native-* + path: native-artifacts + - name: Stage native libraries + shell: bash + run: | + stage_native() { + local native_target="$1" + local library_name="$2" + local library + library=$(find "native-artifacts/jvm-native-$native_target" -type f -name "$library_name" -print -quit) + if [ -z "$library" ]; then + echo "::error::Missing $library_name for $native_target" + exit 1 + fi + ./packages/jvm/gradlew -p packages/jvm stageNativeLibrary \ + -PnativeTarget="$native_target" \ + -PnativeLibrary="$GITHUB_WORKSPACE/$library" \ + --no-daemon + } + stage_native linux-x86_64 libbraillify_jvm.so + stage_native linux-aarch64 libbraillify_jvm.so + stage_native macos-x86_64 libbraillify_jvm.dylib + stage_native macos-aarch64 libbraillify_jvm.dylib + stage_native windows-x86_64 braillify_jvm.dll + - name: Assemble fat JAR + run: ./gradlew assembleJvmJar --no-daemon + working-directory: packages/jvm + - name: Verify fat JAR + shell: bash + run: | + JAR=$(find packages/jvm/build/libs -maxdepth 1 -type f -name '*.jar' ! -name '*-sources.jar' ! -name '*-javadoc.jar' -print -quit) + if [ -z "$JAR" ]; then + echo "::error::JVM binary JAR was not created" + exit 1 + fi + jar tf "$JAR" | sort > jvm-jar-entries.txt + for entry in \ + META-INF/native/linux-x86_64/libbraillify_jvm.so \ + META-INF/native/linux-aarch64/libbraillify_jvm.so \ + META-INF/native/macos-x86_64/libbraillify_jvm.dylib \ + META-INF/native/macos-aarch64/libbraillify_jvm.dylib \ + META-INF/native/windows-x86_64/braillify_jvm.dll; do + grep -Fx "$entry" jvm-jar-entries.txt >/dev/null || { + echo "::error::Missing JAR entry: $entry" + exit 1 + } + done + if find packages/jvm/build/libs -maxdepth 1 -type f -name '*-debug*' -print -quit | grep -q .; then + echo "::error::Debug artifact found in JVM release output" + exit 1 + fi + sha256sum "$JAR" + - name: Upload fat JAR + uses: actions/upload-artifact@v7 + with: + name: jvm-fat-jar + path: packages/jvm/build/libs/*.jar + if-no-files-found: error + retention-days: 1 + + jvm-consumer-test: + name: JVM JAR Consumer - ${{ matrix.native-target }} / Java ${{ matrix.java-version }} + runs-on: ${{ matrix.runner }} + if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && contains(needs.changepacks.outputs.changepacks, 'packages/jvm/build.gradle.kts') }} + needs: + - changepacks + - jvm-jar + strategy: + fail-fast: false + matrix: + include: + - runner: ubuntu-24.04 + native-target: linux-x86_64 + java-version: "17" + path-separator: ":" + - runner: ubuntu-24.04-arm + native-target: linux-aarch64 + java-version: "17" + path-separator: ":" + - runner: macos-15-intel + native-target: macos-x86_64 + java-version: "17" + path-separator: ":" + - runner: macos-15 + native-target: macos-aarch64 + java-version: "17" + path-separator: ":" + - runner: windows-2022 + native-target: windows-x86_64 + java-version: "17" + path-separator: ";" + - runner: ubuntu-24.04 + native-target: linux-x86_64 + java-version: "8" + path-separator: ":" + - runner: ubuntu-24.04 + native-target: linux-x86_64 + java-version: "25" + path-separator: ":" + java-options: --enable-native-access=ALL-UNNAMED + steps: + - name: Set up Java ${{ matrix.java-version }} + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: ${{ matrix.java-version }} + - name: Download fat JAR + uses: actions/download-artifact@v8 + with: + name: jvm-fat-jar + path: jvm-dist + - name: Compile and run external consumer + shell: bash + run: | + JAR=$(find jvm-dist -maxdepth 1 -type f -name '*.jar' ! -name '*-sources.jar' ! -name '*-javadoc.jar' -print -quit) + if [ -z "$JAR" ]; then + echo "::error::JVM binary JAR artifact is missing" + exit 1 + fi + if [ "$RUNNER_OS" = "Windows" ]; then + JAR=$(cygpath -w "$JAR") + fi + printf '%s\n' \ + 'import io.github.kdyann.braillify.Braillify;' \ + 'public final class Consumer {' \ + ' public static void main(String[] args) {' \ + ' byte[] encoded = Braillify.encode("안녕하세요");' \ + ' String unicode = Braillify.translateToUnicode("안녕하세요");' \ + ' String font = Braillify.translateToBrailleFont("안녕하세요");' \ + ' if (encoded.length == 0 || unicode.isEmpty() || font.isEmpty()) {' \ + ' throw new AssertionError("Braillify returned an empty result");' \ + ' }' \ + ' for (int i = 0; i < unicode.length(); i++) {' \ + ' char cell = unicode.charAt(i);' \ + ' if (cell < 0x2800 || cell > 0x28ff) {' \ + ' throw new AssertionError("Non-braille Unicode result");' \ + ' }' \ + ' }' \ + ' }' \ + '}' > Consumer.java + javac -cp "$JAR" Consumer.java + java ${{ matrix.java-options }} -cp "$JAR${{ matrix.path-separator }}." Consumer + - name: Compile and run Kotlin consumer + if: matrix.native-target == 'linux-x86_64' && matrix.java-version == '17' + shell: bash + run: | + JAR=$(find jvm-dist -maxdepth 1 -type f -name '*.jar' ! -name '*-sources.jar' ! -name '*-javadoc.jar' -print -quit) + printf '%s\n' \ + 'import io.github.kdyann.braillify.Braillify' \ + 'fun main() {' \ + ' val encoded = Braillify.encode("안녕하세요")' \ + ' val unicode = Braillify.translateToUnicode("안녕하세요")' \ + ' val font = Braillify.translateToBrailleFont("안녕하세요")' \ + ' check(encoded.isNotEmpty() && unicode.isNotEmpty() && font.isNotEmpty())' \ + '}' > Consumer.kt + kotlinc -jvm-target 1.8 -cp "$JAR" Consumer.kt -include-runtime -d consumer-kotlin.jar + java -cp "$JAR:consumer-kotlin.jar" ConsumerKt + + jvm-publish: + name: JVM Publish to Maven Central + runs-on: ubuntu-latest + if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && contains(needs.changepacks.outputs.changepacks, 'packages/jvm/build.gradle.kts') }} + needs: + - changepacks + - jvm-native-build + - jvm-consumer-test + permissions: + contents: read + id-token: write + attestations: write + steps: + - uses: actions/checkout@v7 + - name: Validate Gradle Wrapper + uses: gradle/actions/wrapper-validation@v4 + - name: Set up JDK 17 + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: "17" + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + - name: Download native libraries + uses: actions/download-artifact@v8 + with: + pattern: jvm-native-* + path: native-artifacts + - name: Stage native libraries + shell: bash + run: | + stage_native() { + local native_target="$1" + local library_name="$2" + local library + library=$(find "native-artifacts/jvm-native-$native_target" -type f -name "$library_name" -print -quit) + if [ -z "$library" ]; then + echo "::error::Missing $library_name for $native_target" + exit 1 + fi + ./packages/jvm/gradlew -p packages/jvm stageNativeLibrary \ + -PnativeTarget="$native_target" \ + -PnativeLibrary="$GITHUB_WORKSPACE/$library" \ + --no-daemon + } + stage_native linux-x86_64 libbraillify_jvm.so + stage_native linux-aarch64 libbraillify_jvm.so + stage_native macos-x86_64 libbraillify_jvm.dylib + stage_native macos-aarch64 libbraillify_jvm.dylib + stage_native windows-x86_64 braillify_jvm.dll + - name: Check whether the JVM release already exists + id: jvm-release + shell: bash + run: | + VERSION=$(./gradlew properties --no-daemon --console=plain -q | sed -n 's/^version: //p' | head -1) + if [ -z "$VERSION" ]; then + echo "::error::Could not resolve the JVM publication version" + exit 1 + fi + POM_URL="https://repo1.maven.org/maven2/io/github/kdyann/braillify/$VERSION/braillify-$VERSION.pom" + if curl -fsIL "$POM_URL" >/dev/null; then + echo "::notice::io.github.kdyann:braillify:$VERSION is already on Maven Central. Nothing to publish." + echo "exists=true" >> "$GITHUB_OUTPUT" + else + echo "exists=false" >> "$GITHUB_OUTPUT" + fi + working-directory: packages/jvm + - name: Create signed Maven Central bundle + if: steps.jvm-release.outputs.exists != 'true' + run: ./gradlew createCentralBundle --no-daemon + working-directory: packages/jvm + env: + MAVEN_SIGNING_KEY: ${{ secrets.MAVEN_SIGNING_KEY }} + MAVEN_SIGNING_PASSWORD: ${{ secrets.MAVEN_SIGNING_PASSWORD }} + - name: Verify Maven Central bundle + if: steps.jvm-release.outputs.exists != 'true' + shell: bash + run: | + test -s packages/jvm/build/central/central-bundle.zip + unzip -l packages/jvm/build/central/central-bundle.zip + unzip -Z1 packages/jvm/build/central/central-bundle.zip > central-bundle-entries.txt + for suffix in .jar .pom -sources.jar -javadoc.jar .asc .md5 .sha1; do + grep -F -- "$suffix" central-bundle-entries.txt >/dev/null || { + echo "::error::Central bundle is missing an artifact ending in $suffix" + exit 1 + } + done + sha256sum packages/jvm/build/central/central-bundle.zip + - name: Generate artifact attestation + if: steps.jvm-release.outputs.exists != 'true' + uses: actions/attest-build-provenance@v4 + with: + subject-path: packages/jvm/build/central/central-bundle.zip + - name: Publish and validate on Maven Central Portal + if: steps.jvm-release.outputs.exists != 'true' + run: ./gradlew publishToCentralPortal --no-daemon + working-directory: packages/jvm + env: + MAVEN_CENTRAL_USERNAME: ${{ secrets.MAVEN_CENTRAL_USERNAME }} + MAVEN_CENTRAL_PASSWORD: ${{ secrets.MAVEN_CENTRAL_PASSWORD }} + MAVEN_SIGNING_KEY: ${{ secrets.MAVEN_SIGNING_KEY }} + MAVEN_SIGNING_PASSWORD: ${{ secrets.MAVEN_SIGNING_PASSWORD }} + - name: Upload Central bundle + if: steps.jvm-release.outputs.exists != 'true' + uses: actions/upload-artifact@v7 + with: + name: jvm-central-bundle + path: packages/jvm/build/central/central-bundle.zip + if-no-files-found: error + retention-days: 7 diff --git a/Cargo.lock b/Cargo.lock index 7cd6aca8..c98e00f6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -205,6 +205,15 @@ dependencies = [ "braillify", ] +[[package]] +name = "braillify-jvm" +version = "0.1.0" +dependencies = [ + "braillify", + "jni", + "rstest", +] + [[package]] name = "bstr" version = "1.12.3" @@ -222,6 +231,12 @@ version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + [[package]] name = "cast" version = "0.3.0" @@ -332,6 +347,16 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "combine" +version = "4.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +dependencies = [ + "bytes", + "memchr", +] + [[package]] name = "console" version = "0.16.4" @@ -713,6 +738,55 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn", +] + [[package]] name = "js-sys" version = "0.3.103" @@ -1451,6 +1525,22 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "similar" version = "2.7.0" @@ -1517,6 +1607,26 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" +[[package]] +name = "thiserror" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "thousands" version = "0.2.0" diff --git a/package.json b/package.json index 7b26dfdb..55b72f13 100644 --- a/package.json +++ b/package.json @@ -7,14 +7,16 @@ "scripts": { "lint": "oxlint . && cargo fmt --all -- --check && cargo clippy --workspace --all-targets -- -D warnings", "lint:fix": "oxlint . --fix && cargo fmt --all && cargo clippy --workspace --all-targets --fix --allow-dirty --allow-staged -- -D warnings && cargo clippy --workspace --all-targets -- -D warnings", - "test": "bun run test:rust && bun run test:c && bun run test:node && bun run test:python", + "test": "bun run test:rust && bun run test:c && bun run test:node && bun run test:python && bun run test:jvm", "test:rust": "bun scripts/test-rust.ts", "test:node": "bun test", "test:python": "cd py-test && uv run --locked pytest", "test:c": "bun scripts/test-c.ts", + "test:jvm": "cd packages/jvm && ./gradlew test", "test:testcase": "cargo test -p braillify test_by_testcase -- --nocapture", "preinstall": "uv sync && (cargo install wasm-pack || node -e \"process.exit(0)\") && (pip install maturin || \"process.exit(0)\")", "build": "cargo build --release -p braillify && cargo build --release -p braillify-c && bun -F braillify build && cd packages/python && maturin build --release --out dist", + "build:jvm": "cd packages/jvm && ./gradlew assembleJvmJar", "build:landing": "bun run build && (cargo test test_by_testcase || node -e \"process.exit(0)\") && bun -F landing build", "changepacks": "bunx @changepacks/cli", "dev": "bun -F landing dev" diff --git a/packages/jvm/.gitignore b/packages/jvm/.gitignore new file mode 100644 index 00000000..242c0cb6 --- /dev/null +++ b/packages/jvm/.gitignore @@ -0,0 +1,5 @@ +.gradle/ +build/ +native/ +*.class +hs_err_pid* diff --git a/packages/jvm/Cargo.toml b/packages/jvm/Cargo.toml new file mode 100644 index 00000000..4eb28d61 --- /dev/null +++ b/packages/jvm/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "braillify-jvm" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +description = "JNI bindings for braillify" +license = "Apache-2.0" +repository = "https://github.com/dev-five-git/braillify" + +[lib] +name = "braillify_jvm" +crate-type = ["cdylib", "rlib"] + +[dependencies] +braillify = { path = "../../libs/braillify", default-features = false } +jni = "=0.22.4" + +[dev-dependencies] +rstest = "0.26" diff --git a/packages/jvm/README.md b/packages/jvm/README.md new file mode 100644 index 00000000..3d249d0b --- /dev/null +++ b/packages/jvm/README.md @@ -0,0 +1,97 @@ +# braillify for Java and Kotlin + +한국어 텍스트를 한국 점자로 변환하는 braillify의 JVM 바인딩입니다. Rust 코어를 JNI로 직접 호출하며 Java와 Kotlin에서 같은 JAR을 사용합니다. + +## 설치 + +Maven Central 배포 후 Gradle에서는 다음과 같이 추가합니다. + +```kotlin +dependencies { + implementation("io.github.kdyann:braillify:BRAILLIFY_VERSION") +} +``` + +Maven: + +```xml + + io.github.kdyann + braillify + BRAILLIFY_VERSION + +``` + +## 사용법 + +Java: + +```java +import io.github.kdyann.braillify.Braillify; + +byte[] cells = Braillify.encode("안녕하세요"); +String unicode = Braillify.translateToUnicode("안녕하세요"); +String font = Braillify.translateToBrailleFont("안녕하세요"); +``` + +Kotlin: + +```kotlin +import io.github.kdyann.braillify.Braillify + +val cells: ByteArray = Braillify.encode("안녕하세요") +val unicode: String = Braillify.translateToUnicode("안녕하세요") +val font: String = Braillify.translateToBrailleFont("안녕하세요") +``` + +`null`은 `NullPointerException`으로 거부합니다. 규정상 변환할 수 없는 문자, NUL 문자, 올바르지 않은 UTF-16 surrogate는 `BraillifyException`을 발생시킵니다. Rust panic이나 JNI/네이티브 로딩 실패는 `BraillifyInternalException`으로 구분합니다. + +## 지원 환경 + +- Java 8 이상 (빌드 실행에는 JDK 17 이상 필요) +- Linux glibc x86_64 / aarch64 +- macOS x86_64 / Apple Silicon +- Windows x86_64 + +Android, Windows arm64, Linux musl, GraalVM Native Image는 현재 지원하지 않습니다. Android는 NDK 바이너리와 AAR 패키징을 별도 작업으로 제공할 예정입니다. + +## 로컬 빌드와 테스트 + +```bash +./gradlew test +./gradlew assembleJvmJar +``` + +`test`는 현재 호스트용 Rust JNI 라이브러리를 빌드하고 JAR 리소스로 넣은 뒤 실제 JVM 호출을 검사합니다. `assembleJvmJar` 결과는 `build/libs/braillify-.jar`입니다. + +크로스 컴파일한 release 라이브러리를 fat JAR에 넣을 때는 각 바이너리를 먼저 스테이징합니다. + +```bash +./gradlew stageNativeLibrary \ + -PnativeTarget=linux-x86_64 \ + -PnativeLibrary=/absolute/path/libbraillify_jvm.so +./gradlew assembleJvmJar +``` + +지원하는 `nativeTarget` 값은 `linux-x86_64`, `linux-aarch64`, `macos-x86_64`, `macos-aarch64`, `windows-x86_64`입니다. 다섯 target을 같은 build 디렉터리에 순서대로 스테이징한 뒤 조립하면 단일 fat JAR에 모두 포함됩니다. + +## 네이티브 로딩 문제 해결 + +JAR 안의 라이브러리는 class loader별 고유 임시 디렉터리에 콘텐츠 해시가 포함된 이름으로 추출됩니다. 임시 디렉터리가 `noexec`으로 마운트된 환경에서는 미리 설치한 라이브러리의 절대 경로를 지정할 수 있습니다. + +```bash +java -Dbraillify.native.path=/opt/braillify/libbraillify_jvm.so ... +``` + +디렉터리를 지정하면 현재 플랫폼에 맞는 표준 파일명을 그 안에서 찾습니다. 최신 JDK가 native access 경고 또는 제한을 적용하는 경우 애플리케이션 실행 옵션에 `--enable-native-access=ALL-UNNAMED`를 추가하세요. + +## 게시 산출물 + +`publishAllPublicationsToLocalStagingRepository`는 Maven 저장소 형태의 산출물을 `build/staging-repository`에 만듭니다. PGP 환경 변수를 설정한 뒤 `createCentralBundle`을 실행하면 `build/central/central-bundle.zip`이 생성됩니다. + +- `MAVEN_SIGNING_KEY` +- `MAVEN_SIGNING_PASSWORD` +- `MAVEN_CENTRAL_USERNAME` +- `MAVEN_CENTRAL_PASSWORD` + +`publishToCentralPortal`은 bundle을 Central Portal에 올리고 검증 결과가 나올 때까지 기다립니다. 같은 이름의 Gradle property도 환경 변수 대신 사용할 수 있습니다. diff --git a/packages/jvm/build.gradle.kts b/packages/jvm/build.gradle.kts new file mode 100644 index 00000000..257f59d7 --- /dev/null +++ b/packages/jvm/build.gradle.kts @@ -0,0 +1,342 @@ +import java.net.HttpURLConnection +import java.net.URL +import java.nio.charset.StandardCharsets +import java.security.MessageDigest +import java.util.Base64 +import java.util.Locale +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream + +plugins { + `java-library` + `maven-publish` + signing +} + +group = "io.github.kdyann" +version = "0.1.0" +description = "Korean text-to-braille conversion for Java and Kotlin" + +repositories { + mavenCentral() +} + +java { + withSourcesJar() + withJavadocJar() +} + +tasks.withType().configureEach { + options.release.set(8) + options.encoding = "UTF-8" +} + +dependencies { + testImplementation(platform("org.junit:junit-bom:5.13.4")) + testImplementation("org.junit.jupiter:junit-jupiter") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") +} + +val generatedNativeResources = layout.buildDirectory.dir("generated/native-resources") +val externalStageMarkers = layout.buildDirectory.dir("staged-native-markers") +sourceSets.main { + resources.srcDir(generatedNativeResources) +} + +fun normalizedHostPlatform(): String { + val os = System.getProperty("os.name").lowercase(Locale.ROOT) + val arch = System.getProperty("os.arch").lowercase(Locale.ROOT).replace(Regex("[^a-z0-9]"), "") + val normalizedOs = when { + os.contains("linux") -> "linux" + os.contains("mac") || os.contains("darwin") -> "macos" + os.contains("windows") -> "windows" + else -> throw GradleException("Unsupported JVM native build OS: ${System.getProperty("os.name")}") + } + val normalizedArch = when (arch) { + "x8664", "amd64" -> "x86_64" + "aarch64", "arm64" -> "aarch64" + else -> throw GradleException("Unsupported JVM native build architecture: ${System.getProperty("os.arch")}") + } + if (normalizedOs == "windows" && normalizedArch == "aarch64") { + throw GradleException("Windows aarch64 is not supported in the first JVM release") + } + return "$normalizedOs-$normalizedArch" +} + +fun libraryFileName(platform: String): String = when { + platform.startsWith("windows-") -> "braillify_jvm.dll" + platform.startsWith("macos-") -> "libbraillify_jvm.dylib" + else -> "libbraillify_jvm.so" +} + +val hostPlatform = normalizedHostPlatform() +val rustTargetDirectory = layout.buildDirectory.dir("rust-target") +val hostNativeLibrary = rustTargetDirectory.map { + it.file("debug/${libraryFileName(hostPlatform)}") +} + +val buildRustNative by tasks.registering(Exec::class) { + group = "build" + description = "Builds the host JNI library with Cargo." + workingDir = rootProject.projectDir + commandLine("cargo", "build", "-p", "braillify-jvm") + environment("CARGO_TARGET_DIR", rustTargetDirectory.get().asFile.absolutePath) + inputs.files( + fileTree("src") { include("**/*.rs") }, + file("Cargo.toml"), + file("../../Cargo.toml"), + file("../../Cargo.lock"), + file("../../libs/braillify"), + ) + outputs.file(hostNativeLibrary) + onlyIf { + !externalStageMarkers.get().file(hostPlatform).asFile.exists() + } +} + +val prepareNativeResources by tasks.registering(Copy::class) { + group = "build" + description = "Copies the host JNI library into its JAR resource path." + dependsOn(buildRustNative) + from(hostNativeLibrary) + into(generatedNativeResources.map { it.dir("META-INF/native/$hostPlatform") }) + // Cross-target release staging writes this marker. Never replace a staged + // release library with the host debug build while assembling the fat JAR. + onlyIf { + !externalStageMarkers.get() + .file(hostPlatform) + .asFile.exists() + } +} + +val stageNativeLibrary by tasks.registering { + group = "build" + description = "Stages a cross-compiled JNI library (-PnativeTarget and -PnativeLibrary)." + val target = providers.gradleProperty("nativeTarget") + val library = providers.gradleProperty("nativeLibrary") + inputs.property("nativeTarget", target) + inputs.file(library) + outputs.dir(generatedNativeResources) + doLast { + val targetValue = target.orNull + ?: throw GradleException("stageNativeLibrary requires -PnativeTarget") + val supported = setOf( + "linux-x86_64", "linux-aarch64", "macos-x86_64", "macos-aarch64", "windows-x86_64" + ) + if (targetValue !in supported) { + throw GradleException("Unsupported nativeTarget: $targetValue") + } + val source = library.orNull?.let(::file) + ?: throw GradleException("stageNativeLibrary requires -PnativeLibrary") + if (!source.isFile) { + throw GradleException("Native library does not exist: $source") + } + copy { + from(source) + into(generatedNativeResources.get().dir("META-INF/native/$targetValue")) + rename { libraryFileName(targetValue) } + } + externalStageMarkers.get() + .file(targetValue) + .asFile + .apply { + parentFile.mkdirs() + writeText("release\n") + } + } +} + +tasks.processResources { + dependsOn(prepareNativeResources) +} + +tasks.test { + dependsOn(prepareNativeResources, tasks.jar) + useJUnitPlatform() + systemProperty("java.library.path", "") + systemProperty("braillify.test.jar", tasks.jar.get().archiveFile.get().asFile.absolutePath) +} + +tasks.jar { + archiveBaseName.set("braillify") + manifest { + attributes( + "Automatic-Module-Name" to "io.github.kdyann.braillify", + "Implementation-Title" to "braillify", + "Implementation-Version" to project.version, + ) + } + duplicatesStrategy = DuplicatesStrategy.FAIL +} + +tasks.named("sourcesJar") { + dependsOn(prepareNativeResources) + exclude("META-INF/native/**") +} + +val assembleJvmJar by tasks.registering { + group = "build" + description = "Assembles the JVM fat JAR with all currently staged native libraries." + dependsOn(tasks.jar) +} + +publishing { + publications { + create("mavenJava") { + from(components["java"]) + artifactId = "braillify" + pom { + name.set("braillify") + description.set(project.description) + url.set("https://braillify.kr") + licenses { + license { + name.set("The Apache License, Version 2.0") + url.set("https://www.apache.org/licenses/LICENSE-2.0.txt") + distribution.set("repo") + } + } + developers { + developer { + id.set("kdyann") + name.set("JeongMin Oh") + email.set("owjs39@gmail.com") + } + } + scm { + connection.set("scm:git:https://github.com/dev-five-git/braillify.git") + developerConnection.set("scm:git:ssh://git@github.com/dev-five-git/braillify.git") + url.set("https://github.com/dev-five-git/braillify") + } + } + } + } + repositories { + maven { + name = "localStaging" + url = uri(layout.buildDirectory.dir("staging-repository")) + } + } +} + +fun secret(name: String): String? = providers.gradleProperty(name).orNull ?: System.getenv(name) +val signingKey = secret("MAVEN_SIGNING_KEY") +val signingPassword = secret("MAVEN_SIGNING_PASSWORD") +signing { + if (!signingKey.isNullOrBlank()) { + useInMemoryPgpKeys(signingKey, signingPassword) + sign(publishing.publications["mavenJava"]) + } +} + +fun digest(file: File, algorithm: String): String { + val messageDigest = MessageDigest.getInstance(algorithm) + file.inputStream().buffered().use { input -> + val buffer = ByteArray(8192) + while (true) { + val count = input.read(buffer) + if (count < 0) break + messageDigest.update(buffer, 0, count) + } + } + return messageDigest.digest().joinToString("") { "%02x".format(it) } +} + +val createCentralBundle by tasks.registering { + group = "publishing" + description = "Creates the Maven Central Portal deployment bundle." + dependsOn("publishAllPublicationsToLocalStagingRepository") + val repository = layout.buildDirectory.dir("staging-repository") + val output = layout.buildDirectory.file("central/central-bundle.zip") + inputs.dir(repository) + outputs.file(output) + doLast { + if (signingKey.isNullOrBlank()) { + throw GradleException("MAVEN_SIGNING_KEY is required to create a Central bundle") + } + val root = repository.get().asFile + val versionDirectory = root.resolve("io/github/kdyann/braillify/${project.version}") + if (!versionDirectory.isDirectory) { + throw GradleException("Maven publication directory is missing: $versionDirectory") + } + val originals = versionDirectory.walkTopDown() + .filter { it.isFile && !it.extension.matches(Regex("asc|md5|sha1|sha256|sha512")) } + .toList() + originals.forEach { source -> + mapOf("md5" to "MD5", "sha1" to "SHA-1", "sha256" to "SHA-256", "sha512" to "SHA-512") + .forEach { (extension, algorithm) -> + File(source.parentFile, "${source.name}.$extension").writeText(digest(source, algorithm)) + } + } + val bundle = output.get().asFile + bundle.parentFile.mkdirs() + ZipOutputStream(bundle.outputStream().buffered()).use { zip -> + versionDirectory.walkTopDown() + .filter(File::isFile) + .sortedBy { it.relativeTo(root).invariantSeparatorsPath } + .forEach { source -> + zip.putNextEntry(ZipEntry(source.relativeTo(root).invariantSeparatorsPath)) + source.inputStream().use { it.copyTo(zip) } + zip.closeEntry() + } + } + } +} + +val publishToCentralPortal by tasks.registering { + group = "publishing" + description = "Uploads the bundle to Central Portal and waits for validation." + dependsOn(createCentralBundle) + doLast { + val username = secret("MAVEN_CENTRAL_USERNAME") + ?: throw GradleException("MAVEN_CENTRAL_USERNAME is required") + val password = secret("MAVEN_CENTRAL_PASSWORD") + ?: throw GradleException("MAVEN_CENTRAL_PASSWORD is required") + val token = Base64.getEncoder().encodeToString("$username:$password".toByteArray(StandardCharsets.UTF_8)) + val bundle = layout.buildDirectory.file("central/central-bundle.zip").get().asFile + val boundary = "BraillifyBoundary${System.nanoTime()}" + val endpoint = URL("https://central.sonatype.com/api/v1/publisher/upload?publishingType=AUTOMATIC&name=braillify-${project.version}") + val connection = endpoint.openConnection() as HttpURLConnection + connection.requestMethod = "POST" + connection.connectTimeout = 30_000 + connection.readTimeout = 120_000 + connection.setRequestProperty("Authorization", "Bearer $token") + connection.setRequestProperty("Content-Type", "multipart/form-data; boundary=$boundary") + connection.doOutput = true + connection.outputStream.use { outputStream -> + outputStream.write("--$boundary\r\nContent-Disposition: form-data; name=\"bundle\"; filename=\"central-bundle.zip\"\r\nContent-Type: application/octet-stream\r\n\r\n".toByteArray(StandardCharsets.UTF_8)) + bundle.inputStream().use { it.copyTo(outputStream) } + outputStream.write("\r\n--$boundary--\r\n".toByteArray(StandardCharsets.UTF_8)) + } + val responseCode = connection.responseCode + val response = (if (responseCode < 400) connection.inputStream else connection.errorStream) + ?.bufferedReader()?.use { it.readText() }.orEmpty() + if (responseCode !in 200..299) { + throw GradleException("Central Portal upload failed ($responseCode): $response") + } + val deploymentId = response.trim().trim('"') + logger.lifecycle("Central Portal accepted deployment $deploymentId") + + repeat(120) { + Thread.sleep(5_000) + val statusConnection = URL("https://central.sonatype.com/api/v1/publisher/status?id=$deploymentId") + .openConnection() as HttpURLConnection + statusConnection.requestMethod = "POST" + statusConnection.connectTimeout = 30_000 + statusConnection.readTimeout = 30_000 + statusConnection.setRequestProperty("Authorization", "Bearer $token") + val statusCode = statusConnection.responseCode + val statusBody = (if (statusCode < 400) statusConnection.inputStream else statusConnection.errorStream) + ?.bufferedReader()?.use { it.readText() }.orEmpty() + if (statusCode !in 200..299) { + throw GradleException("Central Portal status failed ($statusCode): $statusBody") + } + logger.lifecycle("Central Portal status: $statusBody") + when { + statusBody.contains("PUBLISHED") -> return@doLast + statusBody.contains("FAILED") -> throw GradleException("Central Portal validation failed: $statusBody") + } + } + throw GradleException("Timed out waiting for Central Portal validation") + } +} diff --git a/packages/jvm/gradle/wrapper/gradle-wrapper.jar b/packages/jvm/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000..1b33c55b Binary files /dev/null and b/packages/jvm/gradle/wrapper/gradle-wrapper.jar differ diff --git a/packages/jvm/gradle/wrapper/gradle-wrapper.properties b/packages/jvm/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..78cb6e16 --- /dev/null +++ b/packages/jvm/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,8 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionSha256Sum=bd71102213493060956ec229d946beee57158dbd89d0e62b91bca0fa2c5f3531 +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/packages/jvm/gradlew b/packages/jvm/gradlew new file mode 100755 index 00000000..23d15a93 --- /dev/null +++ b/packages/jvm/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/packages/jvm/gradlew.bat b/packages/jvm/gradlew.bat new file mode 100644 index 00000000..5eed7ee8 --- /dev/null +++ b/packages/jvm/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/packages/jvm/settings.gradle.kts b/packages/jvm/settings.gradle.kts new file mode 100644 index 00000000..6b4abb9b --- /dev/null +++ b/packages/jvm/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "braillify-jvm" diff --git a/packages/jvm/src/lib.rs b/packages/jvm/src/lib.rs new file mode 100644 index 00000000..475b221b --- /dev/null +++ b/packages/jvm/src/lib.rs @@ -0,0 +1,248 @@ +//! JNI bridge for the desktop/server JVM binding. +//! +//! The bridge intentionally calls the Rust core directly. It owns no encoder +//! state and never lets a Rust panic unwind across the JNI ABI boundary. + +use std::ffi::c_void; +use std::ptr; +use std::slice; + +use jni::errors::ErrorPolicy; +use jni::objects::{JByteArray, JClass, JString}; +use jni::strings::JNIString; +use jni::sys::{JNI_VERSION_1_8, jbyteArray, jint, jstring}; +use jni::{Env, EnvUnowned}; + +const PUBLIC_EXCEPTION: &str = "io/github/kdyann/braillify/BraillifyException"; +const INTERNAL_EXCEPTION: &str = "io/github/kdyann/braillify/BraillifyInternalException"; + +#[derive(Debug)] +enum BridgeError { + Core(String), + InvalidUtf16, + Jni(jni::errors::Error), +} + +impl From for BridgeError { + fn from(error: jni::errors::Error) -> Self { + Self::Jni(error) + } +} + +fn decode_utf16_units(units: &[u16]) -> Result { + String::from_utf16(units).map_err(|_| BridgeError::InvalidUtf16) +} + +/// Reads Java UTF-16 code units through `GetStringChars` and decodes them +/// strictly. `JNIEnv::get_string` uses modified UTF-8 semantics; reading code +/// units directly is what lets this binding reject isolated surrogates instead +/// of silently replacing them. +fn get_string_strict(env: &mut Env<'_>, input: &JString<'_>) -> Result { + if input.is_null() { + return Err(BridgeError::Jni(jni::errors::Error::NullPtr( + "Braillify input", + ))); + } + + let raw_env = env.get_raw(); + let raw_string = input.as_raw(); + // SAFETY: `raw_env` and `raw_string` were supplied by the JVM for this + // native call. JNI guarantees the function table for the duration of it. + let length = unsafe { ((**raw_env).v1_1.GetStringLength)(raw_env, raw_string) }; + // SAFETY: same JNI-owned values as above. A null result means a pending JVM + // exception (normally OutOfMemoryError), which the caller preserves. + let chars = unsafe { ((**raw_env).v1_1.GetStringChars)(raw_env, raw_string, ptr::null_mut()) }; + if chars.is_null() { + return Err(BridgeError::Jni(jni::errors::Error::NullPtr( + "GetStringChars result", + ))); + } + + // Copy before releasing the JVM buffer, then strictly decode the copy. + // SAFETY: JNI returned `chars` with exactly `length` UTF-16 code units. + let units = unsafe { slice::from_raw_parts(chars.cast::(), length as usize) }.to_vec(); + // SAFETY: `chars` was obtained above from `GetStringChars` for `raw_string` + // and has not previously been released. + unsafe { ((**raw_env).v1_1.ReleaseStringChars)(raw_env, raw_string, chars) }; + decode_utf16_units(&units) +} + +fn throw_if_possible(env: &mut Env<'_>, class: &str, message: &str) { + // Pending VM exceptions (notably OutOfMemoryError) must be preserved. + if env.exception_check() { + return; + } + let _ = env.throw_new(JNIString::new(class), JNIString::new(message)); +} + +fn report_error(env: &mut Env<'_>, error: BridgeError) { + match error { + BridgeError::Core(message) => throw_if_possible(env, PUBLIC_EXCEPTION, &message), + BridgeError::InvalidUtf16 => throw_if_possible( + env, + PUBLIC_EXCEPTION, + "Input contains malformed UTF-16 (an unpaired surrogate)", + ), + BridgeError::Jni(jni::errors::Error::NullPtr("Braillify input")) => throw_if_possible( + env, + "java/lang/NullPointerException", + "text must not be null", + ), + BridgeError::Jni(_) => throw_if_possible( + env, + INTERNAL_EXCEPTION, + "The JVM native bridge could not complete the conversion", + ), + } +} + +fn report_panic(env: &mut Env<'_>) { + throw_if_possible( + env, + INTERNAL_EXCEPTION, + "The native braillify engine failed unexpectedly", + ); +} + +fn encode_impl(env: &mut Env<'_>, input: &JString<'_>) -> Result { + let text = get_string_strict(env, input)?; + let encoded = braillify::encode(&text).map_err(BridgeError::Core)?; + let array: JByteArray<'_> = env.byte_array_from_slice(&encoded)?; + Ok(array.into_raw()) +} + +fn translate_impl( + env: &mut Env<'_>, + input: &JString<'_>, + translate: fn(&str) -> Result, +) -> Result { + let text = get_string_strict(env, input)?; + let translated = translate(&text).map_err(BridgeError::Core)?; + Ok(env.new_string(translated)?.into_raw()) +} + +struct BridgePolicy; + +impl ErrorPolicy for BridgePolicy { + type Captures<'unowned_env_local: 'native_method, 'native_method> = (); + + fn on_error<'unowned_env_local: 'native_method, 'native_method>( + env: &mut Env<'unowned_env_local>, + _captures: &mut Self::Captures<'unowned_env_local, 'native_method>, + error: BridgeError, + ) -> jni::errors::Result { + report_error(env, error); + Ok(T::default()) + } + + fn on_panic<'unowned_env_local: 'native_method, 'native_method>( + env: &mut Env<'unowned_env_local>, + _captures: &mut Self::Captures<'unowned_env_local, 'native_method>, + _payload: Box, + ) -> jni::errors::Result { + report_panic(env); + Ok(T::default()) + } +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_io_github_kdyann_braillify_Braillify_encodeNative<'local>( + mut unowned_env: EnvUnowned<'local>, + _class: JClass<'local>, + input: JString<'local>, +) -> jbyteArray { + unowned_env + .with_env(|env| encode_impl(env, &input)) + .resolve::() +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_io_github_kdyann_braillify_Braillify_translateToUnicodeNative< + 'local, +>( + mut unowned_env: EnvUnowned<'local>, + _class: JClass<'local>, + input: JString<'local>, +) -> jstring { + unowned_env + .with_env(|env| translate_impl(env, &input, braillify::encode_to_unicode)) + .resolve::() +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_io_github_kdyann_braillify_Braillify_translateToBrailleFontNative< + 'local, +>( + mut unowned_env: EnvUnowned<'local>, + _class: JClass<'local>, + input: JString<'local>, +) -> jstring { + unowned_env + .with_env(|env| translate_impl(env, &input, braillify::encode_to_braille_font)) + .resolve::() +} + +/// Test-only JNI entry point used to prove that a panic is mapped to the +/// internal exception class. It is excluded from release artifacts. +#[cfg(debug_assertions)] +#[unsafe(no_mangle)] +pub extern "system" fn Java_io_github_kdyann_braillify_Braillify_panicForTestingNative<'local>( + mut unowned_env: EnvUnowned<'local>, + _class: JClass<'local>, +) { + unowned_env + .with_env(|_env| -> Result<(), BridgeError> { panic!("intentional JNI test panic") }) + .resolve::(); +} + +#[unsafe(no_mangle)] +pub extern "system" fn JNI_OnLoad(_vm: *mut jni::sys::JavaVM, _reserved: *mut c_void) -> jint { + JNI_VERSION_1_8 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[rstest::rstest] + #[case::isolated_high(&[0xD800])] + #[case::isolated_low(&[0xDC00])] + #[case::reversed_pair(&[0xDC00, 0xD800])] + fn malformed_utf16_is_rejected(#[case] units: &[u16]) { + assert!(matches!( + decode_utf16_units(units), + Err(BridgeError::InvalidUtf16) + )); + } + + #[test] + fn supplementary_pair_is_preserved() { + assert_eq!(decode_utf16_units(&[0xD83D, 0xDE00]).unwrap(), "😀"); + } + + #[test] + fn embedded_nul_is_preserved_by_utf16_decoder() { + assert_eq!( + decode_utf16_units(&[b'a' as u16, 0, b'b' as u16]).unwrap(), + "a\0b" + ); + } + + #[rstest::rstest] + #[case::bytes("안녕")] + #[case::unicode("hello")] + #[case::braille_font("점자")] + fn core_delegations_accept_supported_text(#[case] input: &str) { + assert!(braillify::encode(input).is_ok()); + assert!(braillify::encode_to_unicode(input).is_ok()); + assert!(braillify::encode_to_braille_font(input).is_ok()); + } + + #[test] + fn core_error_remains_an_error_at_bridge_boundary() { + assert!(matches!( + braillify::encode("😀").map_err(BridgeError::Core), + Err(BridgeError::Core(_)) + )); + } +} diff --git a/packages/jvm/src/main/java/io/github/kdyann/braillify/Braillify.java b/packages/jvm/src/main/java/io/github/kdyann/braillify/Braillify.java new file mode 100644 index 00000000..4fad4b79 --- /dev/null +++ b/packages/jvm/src/main/java/io/github/kdyann/braillify/Braillify.java @@ -0,0 +1,60 @@ +package io.github.kdyann.braillify; + +import java.util.Objects; + +/** Korean text-to-braille conversion backed by the braillify Rust engine. */ +public final class Braillify { + private Braillify() { + } + + /** + * Converts text to braille cell values (0 through 255). + * + * @param text text to convert + * @return one byte per braille cell + * @throws NullPointerException if {@code text} is {@code null} + * @throws BraillifyException if the core cannot convert the input + */ + public static byte[] encode(String text) { + Objects.requireNonNull(text, "text must not be null"); + NativeLibraryLoader.ensureLoaded(); + return encodeNative(text); + } + + /** + * Converts text to Unicode braille characters (U+2800 through U+28FF). + * + * @param text text to convert + * @return Unicode braille text + * @throws NullPointerException if {@code text} is {@code null} + * @throws BraillifyException if the core cannot convert the input + */ + public static String translateToUnicode(String text) { + Objects.requireNonNull(text, "text must not be null"); + NativeLibraryLoader.ensureLoaded(); + return translateToUnicodeNative(text); + } + + /** + * Converts text to the braille-font representation produced by the core engine. + * + * @param text text to convert + * @return the core engine's braille-font representation + * @throws NullPointerException if {@code text} is {@code null} + * @throws BraillifyException if the core cannot convert the input + */ + public static String translateToBrailleFont(String text) { + Objects.requireNonNull(text, "text must not be null"); + NativeLibraryLoader.ensureLoaded(); + return translateToBrailleFontNative(text); + } + + private static native byte[] encodeNative(String text); + + private static native String translateToUnicodeNative(String text); + + private static native String translateToBrailleFontNative(String text); + + // Only exported by debug native builds. Package-private for JUnit. + static native void panicForTestingNative(); +} diff --git a/packages/jvm/src/main/java/io/github/kdyann/braillify/BraillifyException.java b/packages/jvm/src/main/java/io/github/kdyann/braillify/BraillifyException.java new file mode 100644 index 00000000..f331ee25 --- /dev/null +++ b/packages/jvm/src/main/java/io/github/kdyann/braillify/BraillifyException.java @@ -0,0 +1,21 @@ +package io.github.kdyann.braillify; + +/** Thrown when input cannot be converted according to the braillify rules. */ +public class BraillifyException extends RuntimeException { + /** + * Creates an input/conversion error with a message. + * @param message error detail + */ + public BraillifyException(String message) { + super(message); + } + + /** + * Creates an input/conversion error with a message and cause. + * @param message error detail + * @param cause underlying failure + */ + public BraillifyException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/packages/jvm/src/main/java/io/github/kdyann/braillify/BraillifyInternalException.java b/packages/jvm/src/main/java/io/github/kdyann/braillify/BraillifyInternalException.java new file mode 100644 index 00000000..4ce372af --- /dev/null +++ b/packages/jvm/src/main/java/io/github/kdyann/braillify/BraillifyInternalException.java @@ -0,0 +1,21 @@ +package io.github.kdyann.braillify; + +/** Thrown when the native bridge fails independently of the caller's input. */ +public final class BraillifyInternalException extends RuntimeException { + /** + * Creates an internal native-bridge error with a message. + * @param message error detail + */ + public BraillifyInternalException(String message) { + super(message); + } + + /** + * Creates an internal native-bridge error with a message and cause. + * @param message error detail + * @param cause underlying failure + */ + public BraillifyInternalException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/packages/jvm/src/main/java/io/github/kdyann/braillify/NativeLibraryLoader.java b/packages/jvm/src/main/java/io/github/kdyann/braillify/NativeLibraryLoader.java new file mode 100644 index 00000000..e3f0f607 --- /dev/null +++ b/packages/jvm/src/main/java/io/github/kdyann/braillify/NativeLibraryLoader.java @@ -0,0 +1,193 @@ +package io.github.kdyann.braillify; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Locale; + +final class NativeLibraryLoader { + private static final Object LOAD_LOCK = new Object(); + private static volatile boolean loaded; + + private NativeLibraryLoader() { + } + + static void ensureLoaded() { + if (loaded) { + return; + } + + synchronized (LOAD_LOCK) { + if (loaded) { + return; + } + loadNativeLibrary(); + loaded = true; + } + } + + private static void loadNativeLibrary() { + String platform = currentPlatform(); + String fileName = libraryFileName(platform); + String override = System.getProperty("braillify.native.path"); + if (override != null && !override.trim().isEmpty()) { + Path path = Paths.get(override).toAbsolutePath(); + if (Files.isDirectory(path)) { + path = path.resolve(fileName); + } + loadAbsolute(path, "system property braillify.native.path"); + return; + } + + String resource = "/META-INF/native/" + platform + "/" + fileName; + try (InputStream stream = NativeLibraryLoader.class.getResourceAsStream(resource)) { + if (stream == null) { + throw unavailable("Native resource is missing: " + resource, platform, null); + } + byte[] bytes = readAllBytes(stream); + String digest = sha256(bytes).substring(0, 16); + Path directory = Files.createTempDirectory("braillify-" + digest + "-"); + directory.toFile().deleteOnExit(); + Path destination = directory.resolve(withDigest(fileName, digest)); + Path temporary = Files.createTempFile(directory, "extract-", ".tmp"); + temporary.toFile().deleteOnExit(); + Files.write(temporary, bytes); + moveAtomically(temporary, destination); + destination.toFile().deleteOnExit(); + loadAbsolute(destination, "JAR resource " + resource); + } catch (IOException error) { + throw unavailable("Could not extract native resource " + resource, platform, error); + } + } + + static String currentPlatform() { + return platform(System.getProperty("os.name"), System.getProperty("os.arch"), + System.getProperty("java.runtime.name"), System.getProperty("java.vm.name")); + } + + static String platform(String osName, String osArch, String runtimeName, String vmName) { + String os = normalize(osName); + String arch = normalize(osArch); + String runtime = normalize(runtimeName) + " " + normalize(vmName); + + if (runtime.contains("android") || runtime.contains("dalvik")) { + throw new UnsupportedOperationException( + "Android is not supported by the JVM JAR; use the future Android AAR binding"); + } + + String normalizedOs; + if (os.contains("linux")) { + normalizedOs = "linux"; + } else if (os.contains("mac") || os.contains("darwin")) { + normalizedOs = "macos"; + } else if (os.contains("windows")) { + normalizedOs = "windows"; + } else { + throw unsupported(osName, osArch); + } + + String normalizedArch; + if (arch.equals("x8664") || arch.equals("amd64")) { + normalizedArch = "x86_64"; + } else if (arch.equals("aarch64") || arch.equals("arm64")) { + normalizedArch = "aarch64"; + } else { + throw unsupported(osName, osArch); + } + + if (normalizedOs.equals("windows") && normalizedArch.equals("aarch64")) { + throw unsupported(osName, osArch); + } + return normalizedOs + "-" + normalizedArch; + } + + static String libraryFileName(String platform) { + if (platform.startsWith("windows-")) { + return "braillify_jvm.dll"; + } + if (platform.startsWith("macos-")) { + return "libbraillify_jvm.dylib"; + } + return "libbraillify_jvm.so"; + } + + private static String normalize(String value) { + return value == null ? "" : value.toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9]", ""); + } + + private static UnsupportedOperationException unsupported(String os, String arch) { + return new UnsupportedOperationException( + "Unsupported braillify JVM platform: os.name=" + os + ", os.arch=" + arch); + } + + private static void loadAbsolute(Path path, String source) { + if (!Files.isRegularFile(path)) { + throw unavailable("Native library does not exist at " + path + " (from " + source + ")", + safeCurrentPlatform(), null); + } + try { + System.load(path.toString()); + } catch (UnsatisfiedLinkError error) { + throw unavailable("Could not load native library " + path + " (from " + source + ")", + safeCurrentPlatform(), error); + } + } + + private static BraillifyInternalException unavailable(String message, String platform, Throwable cause) { + String detail = message + ". Detected platform: " + platform + + ". If temporary directories are mounted noexec, set -Dbraillify.native.path=/absolute/path/to/library"; + return cause == null ? new BraillifyInternalException(detail) + : new BraillifyInternalException(detail, cause); + } + + private static String safeCurrentPlatform() { + try { + return currentPlatform(); + } catch (RuntimeException ignored) { + return System.getProperty("os.name") + "-" + System.getProperty("os.arch"); + } + } + + private static byte[] readAllBytes(InputStream input) throws IOException { + java.io.ByteArrayOutputStream output = new java.io.ByteArrayOutputStream(); + byte[] buffer = new byte[8192]; + int read; + while ((read = input.read(buffer)) != -1) { + output.write(buffer, 0, read); + } + return output.toByteArray(); + } + + private static String sha256(byte[] bytes) { + try { + byte[] digest = MessageDigest.getInstance("SHA-256").digest(bytes); + StringBuilder hex = new StringBuilder(digest.length * 2); + for (byte value : digest) { + hex.append(String.format(Locale.ROOT, "%02x", value & 0xff)); + } + return hex.toString(); + } catch (NoSuchAlgorithmException impossible) { + throw new AssertionError("Every Java runtime must provide SHA-256", impossible); + } + } + + private static String withDigest(String fileName, String digest) { + int extension = fileName.lastIndexOf('.'); + return extension < 0 ? fileName + "-" + digest + : fileName.substring(0, extension) + "-" + digest + fileName.substring(extension); + } + + private static void moveAtomically(Path source, Path destination) throws IOException { + try { + Files.move(source, destination, StandardCopyOption.ATOMIC_MOVE); + } catch (AtomicMoveNotSupportedException ignored) { + Files.move(source, destination, StandardCopyOption.REPLACE_EXISTING); + } + } +} diff --git a/packages/jvm/src/test/java/io/github/kdyann/braillify/BraillifyTest.java b/packages/jvm/src/test/java/io/github/kdyann/braillify/BraillifyTest.java new file mode 100644 index 00000000..044fcc3a --- /dev/null +++ b/packages/jvm/src/test/java/io/github/kdyann/braillify/BraillifyTest.java @@ -0,0 +1,88 @@ +package io.github.kdyann.braillify; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import org.junit.jupiter.api.Test; + +class BraillifyTest { + @Test + void encodeAndUnicodeRepresentTheSameCells() { + byte[] cells = Braillify.encode("안녕하세요"); + String unicode = Braillify.translateToUnicode("안녕하세요"); + + int[] codePoints = unicode.codePoints().toArray(); + assertEquals(cells.length, codePoints.length); + for (int index = 0; index < cells.length; index++) { + assertEquals(0x2800 + Byte.toUnsignedInt(cells[index]), codePoints[index]); + } + } + + @Test + void brailleFontResultMatchesTheCoreUnicodeRepresentation() { + assertEquals(Braillify.translateToUnicode("점자"), + Braillify.translateToBrailleFont("점자")); + } + + @Test + void rejectsUnsupportedCharactersAsPublicErrors() { + assertThrows(BraillifyException.class, () -> Braillify.encode("😀")); + } + + @Test + void rejectsNullBeforeEnteringNativeCode() { + assertThrows(NullPointerException.class, () -> Braillify.encode(null)); + assertThrows(NullPointerException.class, () -> Braillify.translateToUnicode(null)); + assertThrows(NullPointerException.class, () -> Braillify.translateToBrailleFont(null)); + } + + @Test + void rejectsMalformedUtf16WithoutReplacement() { + assertThrows(BraillifyException.class, () -> Braillify.encode("\uD800")); + assertThrows(BraillifyException.class, () -> Braillify.encode("\uDC00")); + assertThrows(BraillifyException.class, () -> Braillify.encode("\uDC00\uD800")); + } + + @Test + void preservesEmbeddedNulUntilTheCoreRejectsIt() { + assertThrows(BraillifyException.class, () -> Braillify.encode("a\u0000b")); + } + + @Test + void nativePanicsBecomeInternalErrors() { + NativeLibraryLoader.ensureLoaded(); + assertThrows(BraillifyInternalException.class, Braillify::panicForTestingNative); + } + + @Test + void callsFromSeveralThreadsDoNotLeakEncoderState() throws Exception { + byte[] expected = Braillify.encode("안녕 hello 123"); + ExecutorService executor = Executors.newFixedThreadPool(8); + try { + List> calls = new ArrayList<>(); + for (int index = 0; index < 128; index++) { + calls.add(() -> Braillify.encode("안녕 hello 123")); + } + List> futures = executor.invokeAll(calls); + for (Future future : futures) { + assertArrayEquals(expected, future.get()); + } + } finally { + executor.shutdownNow(); + } + } + + @Test + void unicodeOutputContainsOnlyBrailleCodePoints() { + assertTrue(Braillify.translateToUnicode("hello 안녕 123").codePoints() + .allMatch(value -> value >= 0x2800 && value <= 0x28ff)); + } +} diff --git a/packages/jvm/src/test/java/io/github/kdyann/braillify/NativeLibraryLoaderTest.java b/packages/jvm/src/test/java/io/github/kdyann/braillify/NativeLibraryLoaderTest.java new file mode 100644 index 00000000..702f2b8d --- /dev/null +++ b/packages/jvm/src/test/java/io/github/kdyann/braillify/NativeLibraryLoaderTest.java @@ -0,0 +1,142 @@ +package io.github.kdyann.braillify; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.InputStream; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; +import java.util.jar.JarFile; +import java.util.stream.Stream; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +class NativeLibraryLoaderTest { + static Stream supportedPlatforms() { + return Stream.of( + Arguments.of("Linux", "amd64", "linux-x86_64"), + Arguments.of("Linux", "aarch64", "linux-aarch64"), + Arguments.of("Mac OS X", "x86_64", "macos-x86_64"), + Arguments.of("Darwin", "arm64", "macos-aarch64"), + Arguments.of("Windows 11", "amd64", "windows-x86_64")); + } + + @ParameterizedTest + @MethodSource("supportedPlatforms") + void normalizesSupportedPlatforms(String os, String arch, String expected) { + assertEquals(expected, NativeLibraryLoader.platform(os, arch, "OpenJDK Runtime", "OpenJDK VM")); + } + + @Test + void detectsAndroidBeforeLinux() { + UnsupportedOperationException error = assertThrows(UnsupportedOperationException.class, + () -> NativeLibraryLoader.platform("Linux", "aarch64", "Android Runtime", "Dalvik")); + assertTrue(error.getMessage().contains("Android")); + } + + @Test + void rejectsUnsupportedArchitectureWithObservedValues() { + UnsupportedOperationException error = assertThrows(UnsupportedOperationException.class, + () -> NativeLibraryLoader.platform("Linux", "riscv64", "OpenJDK", "OpenJDK")); + assertTrue(error.getMessage().contains("Linux")); + assertTrue(error.getMessage().contains("riscv64")); + } + + @Test + void mapsLibraryNames() { + assertEquals("libbraillify_jvm.so", NativeLibraryLoader.libraryFileName("linux-x86_64")); + assertEquals("libbraillify_jvm.dylib", NativeLibraryLoader.libraryFileName("macos-aarch64")); + assertEquals("braillify_jvm.dll", NativeLibraryLoader.libraryFileName("windows-x86_64")); + } + + @Test + void loadingIsIdempotent() { + NativeLibraryLoader.ensureLoaded(); + NativeLibraryLoader.ensureLoaded(); + } + + @Test + void isolatedClassLoadersCanEachCallTheFatJar() throws Exception { + URL jar = Paths.get(System.getProperty("braillify.test.jar")).toUri().toURL(); + String first = invokeFromIsolatedLoader(jar); + String second = invokeFromIsolatedLoader(jar); + assertEquals(first, second); + assertTrue(first.codePoints().allMatch(value -> value >= 0x2800 && value <= 0x28ff)); + } + + @Test + void loadsNativeLibraryFromFileOverride() throws Exception { + Path nativeLibrary = extractHostNativeLibrary("file-override"); + String result = invokeWithNativeOverride(nativeLibrary); + assertTrue(result.codePoints().allMatch(value -> value >= 0x2800 && value <= 0x28ff)); + } + + @Test + void loadsNativeLibraryFromDirectoryOverride() throws Exception { + Path nativeLibrary = extractHostNativeLibrary("directory-override"); + String result = invokeWithNativeOverride(nativeLibrary.getParent()); + assertTrue(result.codePoints().allMatch(value -> value >= 0x2800 && value <= 0x28ff)); + } + + @Test + void wrapsMissingNativeOverrideWithHelpfulException() throws Exception { + Path missing = Files.createTempDirectory("braillify-missing-").resolve("missing-native-library"); + InvocationTargetException error = assertThrows(InvocationTargetException.class, + () -> invokeWithNativeOverride(missing)); + Throwable cause = error.getCause(); + assertEquals("io.github.kdyann.braillify.BraillifyInternalException", cause.getClass().getName()); + assertTrue(cause.getMessage().contains("braillify.native.path")); + assertTrue(cause.getMessage().contains(missing.toAbsolutePath().toString())); + } + + private static String invokeFromIsolatedLoader(URL jar) throws Exception { + try (URLClassLoader loader = new URLClassLoader(new URL[] {jar}, null)) { + Class api = Class.forName("io.github.kdyann.braillify.Braillify", true, loader); + Method method = api.getMethod("translateToUnicode", String.class); + return (String) method.invoke(null, "안녕"); + } + } + + private static String invokeWithNativeOverride(Path override) throws Exception { + String property = "braillify.native.path"; + String previous = System.getProperty(property); + System.setProperty(property, override.toAbsolutePath().toString()); + try { + URL jar = Paths.get(System.getProperty("braillify.test.jar")).toUri().toURL(); + return invokeFromIsolatedLoader(jar); + } finally { + if (previous == null) { + System.clearProperty(property); + } else { + System.setProperty(property, previous); + } + } + } + + private static Path extractHostNativeLibrary(String prefix) throws Exception { + Path jarPath = Paths.get(System.getProperty("braillify.test.jar")); + String platform = NativeLibraryLoader.currentPlatform(); + String fileName = NativeLibraryLoader.libraryFileName(platform); + String resource = "META-INF/native/" + platform + "/" + fileName; + Path directory = Files.createTempDirectory("braillify-" + prefix + "-"); + Path destination = directory.resolve(fileName); + try (JarFile jar = new JarFile(jarPath.toFile())) { + if (jar.getJarEntry(resource) == null) { + throw new AssertionError("Missing test native resource: " + resource); + } + try (InputStream input = jar.getInputStream(jar.getJarEntry(resource))) { + Files.copy(input, destination, StandardCopyOption.REPLACE_EXISTING); + } + } + return destination; + } +}