diff --git a/.agents/skills/connect-share-prism-e2e/SKILL.md b/.agents/skills/connect-share-prism-e2e/SKILL.md new file mode 100644 index 000000000..42f684423 --- /dev/null +++ b/.agents/skills/connect-share-prism-e2e/SKILL.md @@ -0,0 +1,247 @@ +--- +name: connect-share-prism-e2e +description: Drive and diagnose Connect Share with two real Prism Launcher clients. Use for installing a local Connect Share Fabric build, launching distinct host and guest identities, verifying confirmed-friend presence and singleplayer join approval, testing libp2p-direct versus Connect fallback routes, debugging Minecraft status or login failures, or preserving new reusable Connect Share E2E knowledge. +--- + +# Connect Share Prism E2E + +Use the repository's opt-in live harness to prove the complete friend-to-world +flow. Treat discovery, activity, privacy-permitted status, approval, and +Minecraft login as separate gates; success at an earlier gate never proves a +later one. + +The commands below use Fabric 26.2 as the reference target. For another +supported loader/version artifact, preserve the same evidence gates and follow +`docs/connect-share-testing.md` for the complete matrix and loader-specific +packaging steps. + +## Prepare safely + +1. Read the root `AGENTS.md` and `share/AGENTS.md` completely. +2. Work in the active isolated Connect Share worktree. Never modify a separate + active/root worktree or discard user changes. +3. Inspect the current branch, diff, Prism instances, saved friend stores, and + running Minecraft processes before relying on earlier session notes. +4. Run only one Gradle invocation in a worktree at a time. Concurrent test tasks + corrupt their shared `build/test-results` state. +5. Keep profile paths, endpoint tokens, capabilities, account identifiers, and + friend cards out of committed files and tool summaries. + +## Build and install + +Build the current 26.2 artifact: + +```sh +./gradlew :share:fabric-26-2:connectShareJar --no-parallel +``` + +Locate the final unclassified JAR under `share/fabric-26.2/build/libs/`. Install +that exact artifact into both instances' `minecraft/mods/` directories. Remove +or replace older Connect Share JARs so each instance loads exactly one. Compare +SHA-256 digests for the build output and both installed copies. + +Match replacement JARs by basename at the immediate `mods/` level. Never run a +`connect-share-*.jar` regex against the full absolute path: these test instance +directory names also contain `connect-share`, so a greedy match can remove +Fabric API, Fabric Language Kotlin, or Kotlin for Forge along with the mod. + +Confirm each fresh `latest.log` contains both Fabric Loader startup and a +`connect-share` mod entry. Fabric Language Kotlin is declared as a mod +dependency; do not infer a successful load merely from the file being present. + +## Launch the two identities + +Use Prism's command-line controls; do not automate Minecraft UI clicks: + +```sh +prismlauncher --launch --profile \ + --world --show-window + +prismlauncher --launch --offline \ + --server 127.0.0.1: --show-window +``` + +`--offline ` is authoritative for the guest. Do not edit +`InstanceAccountId` while Prism is running because Prism rewrites it. + +Wait until the host log records its local player joining and `Connect Share +friend gateway is ready`. The integrated server object exists before the local +client connection is ready; the mod must publish only when both exist and must +advertise `HOSTING_WORLD` only from an actual `ShareState.Sharing`. + +Do not treat a matching JVM PID as launch success. Snapshot `latest.log` before +launch and require both a newer mtime and the expected world/runtime markers. +Prism can otherwise leave an old JVM occupying the instance while its log no +longer advances. Resolve exactly one process by the instance's working +directory before stopping it; never terminate Java processes by name alone. + +## Run the opt-in live harness + +The executable harness is +`share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/PrismFriendJoinE2ETest.kt`. +Start it after the host world is ready: + +```sh +LIVE_DATA= \ +LIVE_TARGET_FILE= \ +LIVE_HOST_LOG= \ +LIVE_GUEST_LOG= \ +LIVE_PLAYER_NAME= \ +./gradlew :share:fabric-common:test \ + --tests '*PrismFriendJoinE2ETest*' --rerun-tasks --no-parallel +``` + +The harness keeps its pre-launch log snapshot immutable across Prism's +`latest.log` rotation. Keep `LIVE_GUEST_LOG` pointed at that active path: a new +or replaced log containing an advancement line is post-launch evidence and +must not be absorbed into a later baseline before the poll observes it. + +The test must remain running while the external guest uses the join target +written to `LIVE_TARGET_FILE` (`LIVE_PORT_FILE` remains a direct-only +compatibility alias). Set `LIVE_FORCE_CONNECT_FALLBACK=true` to close the +guest's direct node after authenticated approval while retaining the +discovered LAN route; the real direct attempt must then fail, the harness must +assert a Connect target, and the client must complete a real login rather than +merely emit a selector message. It proves, in order: + +1. mDNS discovers the saved confirmed friend's peer identity. +2. Authenticated friend control reports `HOSTING_WORLD`. +3. When the host exposes its world name, a dedicated direct proxy answers a + real Minecraft status probe; otherwise privacy-filtered activity remains the + authority and raw status is intentionally skipped. +4. The libp2p friend join request reaches the host and is approved. +5. A fresh gameplay proxy is opened. +6. A real guest login causes a new ` joined the game` host-log line and + a new `Loaded ... advancements` guest-log line before the gameplay proxy is + released. + +The current `DirectP2pProxy` is one-shot. A status probe consumes its target; +always use a different proxy for gameplay and keep the gameplay target alive +until login completes. + +## Verify the player-facing UX + +Treat visual QA as a keyboard-only Prism test, not as a source review: + +1. Open every Connect Share state with Tab, Shift-Tab, Enter, and Escape. Widget + insertion order is Minecraft's focus order, so verify both directions and + keep the primary action reachable before secondary or destructive actions. +2. Capture the Minecraft window at its normal size, then resize it to 640x400 + points and capture the same dense states again. On macOS, read the Java + window's position and size through System Events, then pass those point + coordinates to `screencapture -R`; Retina output is expected to have twice + the pixel dimensions. +3. Inspect title, pause, Friends, add-link, manage, Privacy, setup (collapsed and + expanded), active status, compatibility, blocked-list, and endpoint states. + Require visible hierarchy, non-overlapping footers, readable translated + copy, consistent Back/Escape behavior, and exactly one obvious primary + action. +4. Give every `EditBox` a persistent nearby label. Minecraft hides an empty + field's hint while the field is focused, so a hint alone becomes a blank + white rectangle during the most important input moment. +5. Keep a split vanilla pause-menu row at 100 + 4 + 100 logical pixels and use + short labels that fit each half. Keep title-menu affordances compact and + live-update request/readiness counts without covering the panorama. +6. Exercise Manage → Back → Add from link. The new-request form must be empty + and use safe connection defaults; an existing friend's name or advanced + route choices must never leak into another relationship draft. The one + exception is Add → Connection options → Done, which preserves the active + draft. On the pause menu, tab order must reach Share and Friends before Save + and Quit even though the vanilla disconnect button was created first. + +Screenshot appearance is evidence, not a golden test. Keep deterministic +layout and presentation decisions in pure Kotlin tests so visual fixes remain +portable across every loader and supported Minecraft API. + +For no-click automation, temporarily enable automatic joining only for the +already confirmed test friend. Restore `canJoinAutomatically` to `false` and +restart the host after the run. A deterministic test must separately cover the +normal pending request, host approval, and one-shot admission path. + +A vanilla no-mod Connect client does not carry the signed direct peer proof +used by the friend-control path. If its authenticated Connect UUID does not +match the stored offline friend UUID, auto-accept must fail closed and create a +normal pending admission. Do not relax that security boundary for automation. +For an unattended local proof, a temporary uncommitted attach driver may call +the installed `ShareViewModel` only after asserting exactly one pending request, +then invoke the existing `allow` action. Report only boolean/stage results; +never print the identity or request ID, never add a production test bypass, and +delete the driver after restoring the original policy. + +## Diagnose by gate + +- **Mod load:** inspect both fresh logs for the exact version and startup error. +- **Discovery:** use `dns-sd -B _minekube-connect-share._tcp local`; expect both + persistent peer IDs. mDNS presence does not prove friend authentication. + Apply the route-retention and mDNS-refresh invariant in `share/AGENTS.md` + before interpreting discovery order or address changes. +- **Runtime readiness:** use `jcmd GC.class_histogram` to look for + `ShareState$Sharing`, `ActiveTransport`, `PublishedVanillaTransport`, and + `ShareCoordinator$ActiveShare` when ordinary logs are insufficient. +- **Activity/privacy:** query through the saved friend relationship. Pending or + unknown peers must not receive presence or world details. +- **Status:** open its own target only when the host exposes online, playing, + and current-world details. The gateway intentionally closes status otherwise; + use authenticated activity plus a real approved login as the privacy-safe + proof. A Connect endpoint fallback status or public DNS response does not + prove the integrated world is reachable. +- **Login:** require both a guest `Loaded ... advancements` line and a host + ` joined the game` line. + +Recognize these established failure signatures: + +- Publishing from only `hasSingleplayerServer()` can race a null Minecraft + client connection. Require the integrated server and client connection. +- Installing Minecraft's captured Netty initializer after socket activation + requires replaying `channelActive` to the late handlers before the first + Minecraft bytes. Keep the focused gateway lifecycle test. +- `Invalid session` for an explicitly offline libp2p guest means vanilla Mojang + authentication ran too early. Create Minecraft's standard offline profile in + `handleHello`; never downgrade an `ONLINE` direct session. +- A Java 17 guest that reaches server login, immediately disconnects, and makes + the host flood Yamux `IllegalReferenceCountException`/freed-buffer warnings + has crossed every control-plane gate but failed gameplay transport. Preserve + the multi-window `DirectP2pNodeTest` regression and use jvm-libp2p's tested + Mplex default; do not retry or hide the post-login disconnect. +- A Forge guest that reaches login and receives `Unexpected custom data from + client` or `Illegal packet received, terminating connection` without muxer + errors has failed loader negotiation or thread-side classification. The + always-on Share gateway must run Minecraft handlers on a Forge + logical-server thread group, and approved offline admission must enter + Forge's native `NEGOTIATING` state instead of calling accepted-login early. + A passing proof logs the guest's modded-server handshake, server join, and + guest advancements with none of those rejection lines. +- A host `lost connection: Disconnected` line alone is incomplete evidence. + Inspect the guest log or screen and whether the owner of the one-shot proxy + closed it. +- A vanilla Connect guest showing only `Timed out` after the host approval + window means the control-plane admission deadline collided with Minecraft's + own connection timeout. Keep the Connect session decision shorter than the + vanilla deadline, cancel its pending admission when that budget expires, and + require the guest log to contain the actionable denial rather than treating + generic timeout as acceptable evidence. + +For recovery product proof, stop sharing and close the source profile before +export/import. Use disposable profile copies, compare only expected file hashes +or redacted relationship counts, and never print the backup path, password, +archive bytes, private identities, friend capabilities, or endpoint token. +Verify wrong-password and one-byte-damaged imports leave every live allowlisted +file hash unchanged. Never launch the source and restored copy simultaneously: +an offline backup transfers one stable identity and is not multi-device sync. + +## Finish and retain knowledge + +Run focused regression tests first, then: + +```sh +./gradlew clean build --no-parallel +``` + +Before claiming completion, confirm the worktree is clean or intentionally +changed, installed JAR digests match, temporary auto-approval is restored, and +both intended Prism profiles are in a safe state. + +When a live run reveals a stable, non-obvious rule, update this skill and the +appropriate concise invariant in `share/AGENTS.md`. Record commands, gates, +failure signatures, and authoritative files - not transient PIDs, ports, local +absolute paths, endpoint secrets, or raw debugging noise. diff --git a/.agents/skills/connect-share-prism-e2e/agents/openai.yaml b/.agents/skills/connect-share-prism-e2e/agents/openai.yaml new file mode 100644 index 000000000..879dad998 --- /dev/null +++ b/.agents/skills/connect-share-prism-e2e/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Connect Share Prism E2E" + short_description: "Drive and diagnose two-client Prism join tests" + default_prompt: "Use $connect-share-prism-e2e to run and diagnose the Connect Share two-client Prism E2E." diff --git a/.github/workflows/connect-share-release.yml b/.github/workflows/connect-share-release.yml new file mode 100644 index 000000000..6bff82612 --- /dev/null +++ b/.github/workflows/connect-share-release.yml @@ -0,0 +1,211 @@ +name: Release Connect Share + +on: + workflow_dispatch: + inputs: + release_tag: + description: Existing GitHub release tag that receives the verified mod artifacts + required: true + type: string + release_type: + description: Marketplace release channel + required: true + default: beta + type: choice + options: [release, beta, alpha] + +permissions: + contents: write + id-token: write + attestations: write + artifact-metadata: write + +concurrency: + group: connect-share-release-${{ inputs.release_tag }} + cancel-in-progress: false + +jobs: + build-and-publish: + runs-on: ubuntu-latest + env: + RELEASE_TAG: ${{ inputs.release_tag }} + RELEASE_TYPE: ${{ inputs.release_type }} + MODRINTH_PROJECT_ID: ${{ vars.CONNECT_SHARE_MODRINTH_PROJECT_ID }} + CURSEFORGE_PROJECT_ID: ${{ vars.CONNECT_SHARE_CURSEFORGE_PROJECT_ID }} + + steps: + - name: Checkout release tag + uses: actions/checkout@v4 + with: + ref: ${{ inputs.release_tag }} + fetch-depth: 0 + + - name: Set up JDK 25 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '25' + cache: gradle + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Build and verify every supported adapter + run: >- + ./gradlew + :share:fabric-1-20-1:build + :share:fabric-1-21-1:build + :share:fabric-1-21-11:build + :share:fabric-26-2:build + :share:forge-1-20-1:build + :share:neoforge-1-21-1:build + --no-parallel + + - name: Stage unambiguous artifacts and checksums + run: | + set -euo pipefail + mkdir -p dist + for minecraft in 1.20.1 1.21.1 1.21.11 26.2; do + project="fabric-$minecraft" + source="$(find "share/$project/build/libs" -maxdepth 1 -type f \ + -name "connect-share-fabric-$minecraft-*.jar" \ + ! -name '*-sources.jar' ! -name '*-dev-*.jar' \ + ! -name '*-unshaded.jar' ! -name '*-parent-shadow.jar' \ + -print -quit)" + test -n "$source" + cp "$source" "dist/connect-share-fabric-$minecraft-$RELEASE_TAG.jar" + done + for spec in 'forge-1.20.1:forge-1.20.1' 'neoforge-1.21.1:neoforge-1.21.1'; do + project="${spec%%:*}" + coordinate="${spec#*:}" + source="$(find "share/$project/build/libs" -maxdepth 1 -type f \ + -name "connect-share-$coordinate-*.jar" \ + ! -name '*-sources.jar' ! -name '*-dev-*.jar' \ + ! -name '*-unshaded.jar' ! -name '*-parent-shadow.jar' \ + -print -quit)" + test -n "$source" + cp "$source" "dist/connect-share-$coordinate-$RELEASE_TAG.jar" + done + sha256sum dist/*.jar > dist/SHA256SUMS-connect-share.txt + + - name: Attest artifact provenance + uses: actions/attest@v4 + with: + subject-path: | + dist/*.jar + dist/SHA256SUMS-connect-share.txt + + - name: Verify marketplace configuration + env: + MODRINTH_TOKEN: ${{ secrets.CONNECT_SHARE_MODRINTH_TOKEN }} + CURSEFORGE_TOKEN: ${{ secrets.CONNECT_SHARE_CURSEFORGE_TOKEN }} + run: | + set -euo pipefail + test -n "${MODRINTH_PROJECT_ID:-}" || { echo '::error::CONNECT_SHARE_MODRINTH_PROJECT_ID is unset'; exit 1; } + test -n "${CURSEFORGE_PROJECT_ID:-}" || { echo '::error::CONNECT_SHARE_CURSEFORGE_PROJECT_ID is unset'; exit 1; } + test -n "${MODRINTH_TOKEN:-}" || { echo '::error::CONNECT_SHARE_MODRINTH_TOKEN is unset'; exit 1; } + test -n "${CURSEFORGE_TOKEN:-}" || { echo '::error::CONNECT_SHARE_CURSEFORGE_TOKEN is unset'; exit 1; } + + - name: Upload verified artifacts to GitHub Release + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" >/dev/null + gh release upload "$RELEASE_TAG" dist/*.jar dist/SHA256SUMS-connect-share.txt \ + --repo "$GITHUB_REPOSITORY" --clobber + + - name: Publish verified artifacts to Modrinth + env: + MODRINTH_TOKEN: ${{ secrets.CONNECT_SHARE_MODRINTH_TOKEN }} + run: | + set -euo pipefail + for spec in \ + 'fabric:1.20.1' 'fabric:1.21.1' 'fabric:1.21.11' 'fabric:26.2' \ + 'forge:1.20.1' 'neoforge:1.21.1'; do + loader="${spec%%:*}" + minecraft="${spec#*:}" + file="dist/connect-share-$loader-$minecraft-$RELEASE_TAG.jar" + part="connect_share_${loader}_${minecraft//./_}" + if test "$loader" = fabric; then + dependencies='[{"project_id":"P7dR8mSH","dependency_type":"required"},{"project_id":"Ha28R6CL","dependency_type":"required"}]' + else + dependencies='[{"project_id":"ordsPcFz","dependency_type":"required"}]' + fi + jq -n \ + --arg project "$MODRINTH_PROJECT_ID" \ + --arg name "Connect Share $RELEASE_TAG for $loader $minecraft" \ + --arg version "$RELEASE_TAG-$loader-$minecraft" \ + --arg type "$RELEASE_TYPE" \ + --arg loader "$loader" \ + --arg game "$minecraft" \ + --arg part "$part" \ + --argjson dependencies "$dependencies" \ + --arg changelog "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/releases/tag/$RELEASE_TAG" \ + '{project_id:$project,name:$name,version_number:$version, + changelog:$changelog,version_type:$type,loaders:[$loader], + game_versions:[$game],featured:true,status:"listed", + environment:"client_only_server_optional",file_parts:[$part], + primary_file:$part,dependencies:$dependencies}' > modrinth.json + curl --fail-with-body --silent --show-error \ + -H "Authorization: $MODRINTH_TOKEN" \ + -H "User-Agent: minekube/connect-java ($GITHUB_SERVER_URL/$GITHUB_REPOSITORY)" \ + -F "data=@modrinth.json;type=application/json" \ + -F "$part=@$file;type=application/java-archive" \ + https://api.modrinth.com/v2/version >/dev/null + done + + - name: Publish verified artifacts to CurseForge + env: + CURSEFORGE_TOKEN: ${{ secrets.CONNECT_SHARE_CURSEFORGE_TOKEN }} + run: | + set -euo pipefail + for spec in \ + 'fabric:1.20.1' 'fabric:1.21.1' 'fabric:1.21.11' 'fabric:26.2' \ + 'forge:1.20.1' 'neoforge:1.21.1'; do + loader="${spec%%:*}" + minecraft="${spec#*:}" + file="dist/connect-share-$loader-$minecraft-$RELEASE_TAG.jar" + case "$loader" in + fabric) + loader_name=Fabric + relations='[{"projectID":"306612","type":"requiredDependency"},{"projectID":"308769","type":"requiredDependency"}]' + ;; + forge) + loader_name=Forge + relations='[{"projectID":"351264","type":"requiredDependency"}]' + ;; + neoforge) + loader_name=NeoForge + relations='[{"projectID":"351264","type":"requiredDependency"}]' + ;; + esac + jq -n \ + --arg name "Connect Share $RELEASE_TAG for $loader_name $minecraft" \ + --arg game "$minecraft" \ + --arg loader "$loader_name" \ + --arg type "$RELEASE_TYPE" \ + --argjson relations "$relations" \ + --arg changelog "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/releases/tag/$RELEASE_TAG" \ + '{displayName:$name,changelog:$changelog,changelogType:"markdown", + gameVersionNames:[$game,$loader],releaseType:$type, + relations:{projects:$relations}}' > curseforge.json + curl --fail-with-body --silent --show-error \ + -H "X-Api-Token: $CURSEFORGE_TOKEN" \ + -F "metadata=@curseforge.json;type=application/json" \ + -F "file=@$file;type=application/java-archive" \ + "https://minecraft.curseforge.com/api/projects/$CURSEFORGE_PROJECT_ID/upload-file" \ + >/dev/null + done + + - name: Verify GitHub release assets + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" \ + --json assets --jq '.assets[].name' > release-assets.txt + for file in dist/*.jar dist/SHA256SUMS-connect-share.txt; do + grep -Fx "$(basename "$file")" release-assets.txt >/dev/null + gh attestation verify "$file" --repo "$GITHUB_REPOSITORY" >/dev/null + done diff --git a/.github/workflows/pullrequest.yml b/.github/workflows/pullrequest.yml index cb335fde3..e35c0cf0f 100644 --- a/.github/workflows/pullrequest.yml +++ b/.github/workflows/pullrequest.yml @@ -11,13 +11,10 @@ jobs: strategy: fail-fast: false matrix: - # Run the full build (incl. the per-platform plugin startup tests) on more than one JDK so a - # JDK-version-dependent DI/reflective startup regression is caught in CI. 17 is the primary - # (release) toolchain and the only one that publishes artifacts; 21 is the highest JDK - # Gradle 8.5 can run on. The Java-26-class reflective/DI failures (Guice 7 provisioning, - # Libp2pEndpointRuntime constructor arity) are additionally guarded by signature-level - # reflective tests that are independent of the running JDK, so they are covered even though - # the build cannot run on JDK 26 until Gradle is upgraded. + # Run the legacy Connect build (incl. the per-platform plugin startup tests) on more than + # one JDK so JDK-version-dependent DI/reflective startup regressions are caught in CI. + # Fabric Share modules are skipped here and built by dedicated jobs because each Minecraft + # version has its own JVM floor and toolchain. java: ['17', '21'] steps: @@ -37,7 +34,7 @@ jobs: uses: gradle/actions/setup-gradle@v3 - name: Build - run: ./gradlew build + run: ./gradlew -Pskip-share=true build - name: Archive artifacts (Connect Bungee) uses: actions/upload-artifact@v4 @@ -59,3 +56,130 @@ jobs: with: name: Connect Velocity path: velocity/build/libs/connect-velocity.jar + + share-anchor-versions: + name: Connect Share / ${{ matrix.loader }} ${{ matrix.minecraft }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - minecraft: 1.20.1 + project: fabric-1-20-1 + directory: fabric-1.20.1 + loader: Fabric + artifact: connect-share-fabric-1.20.1-*.jar + - minecraft: 1.21.1 + project: fabric-1-21-1 + directory: fabric-1.21.1 + loader: Fabric + artifact: connect-share-fabric-1.21.1-*.jar + - minecraft: 1.20.1 + project: forge-1-20-1 + directory: forge-1.20.1 + loader: Forge + artifact: connect-share-forge-1.20.1-*.jar + - minecraft: 1.21.1 + project: neoforge-1-21-1 + directory: neoforge-1.21.1 + loader: NeoForge + artifact: connect-share-neoforge-1.21.1-*.jar + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '21' + cache: gradle + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Build and verify packaged Connect Share + run: ./gradlew :share:${{ matrix.project }}:build + + - name: Archive Connect Share + uses: actions/upload-artifact@v4 + with: + name: Connect Share ${{ matrix.loader }} ${{ matrix.minecraft }} + path: | + share/${{ matrix.directory }}/build/libs/${{ matrix.artifact }} + !share/${{ matrix.directory }}/build/libs/*-sources.jar + !share/${{ matrix.directory }}/build/libs/*-dev-*.jar + !share/${{ matrix.directory }}/build/libs/*-dev-shadow.jar + !share/${{ matrix.directory }}/build/libs/*-unshaded.jar + !share/${{ matrix.directory }}/build/libs/*-parent-shadow.jar + + share-1-21-11: + name: Connect Share / Minecraft 1.21.11 + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '21' + cache: gradle + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Build Connect Share for Minecraft 1.21.11 + run: ./gradlew :share:fabric-1-21-11:build + + - name: Archive Connect Share for Minecraft 1.21.11 + uses: actions/upload-artifact@v4 + with: + name: Connect Share Fabric 1.21.11 + path: | + share/fabric-1.21.11/build/libs/connect-share-fabric-1.21.11-*.jar + !share/fabric-1.21.11/build/libs/*-sources.jar + !share/fabric-1.21.11/build/libs/*-dev-*.jar + !share/fabric-1.21.11/build/libs/*-unshaded.jar + !share/fabric-1.21.11/build/libs/*-parent-shadow.jar + + share-26-2: + name: Connect Share / Minecraft 26.2 + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up JDK 25 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '25' + cache: gradle + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Build Connect Share for Minecraft 26.2 + run: ./gradlew :share:fabric-26-2:build + + - name: Archive Connect Share for Minecraft 26.2 + uses: actions/upload-artifact@v4 + with: + name: Connect Share Fabric 26.2 + path: | + share/fabric-26.2/build/libs/connect-share-fabric-26.2-*.jar + !share/fabric-26.2/build/libs/*-sources.jar + !share/fabric-26.2/build/libs/*-dev-*.jar + !share/fabric-26.2/build/libs/*-unshaded.jar + !share/fabric-26.2/build/libs/*-parent-shadow.jar diff --git a/.github/workflows/release-repair.yml b/.github/workflows/release-repair.yml index 8caef243f..013508719 100644 --- a/.github/workflows/release-repair.yml +++ b/.github/workflows/release-repair.yml @@ -198,6 +198,11 @@ jobs: fi echo "java-version=$JAVA_VERSION" >> "$GITHUB_OUTPUT" + if grep -Eq -- '-Pskip-share=true' "$TAG_WORKFLOW"; then + echo "skip_share=true" >> "$GITHUB_OUTPUT" + else + echo "skip_share=false" >> "$GITHUB_OUTPUT" + fi echo "$RELEASE_TAG pins JDK $JAVA_VERSION; Gradle comes from the tag's own wrapper:" grep distributionUrl gradle/wrapper/gradle-wrapper.properties @@ -222,7 +227,15 @@ jobs: # The same build the tag's own release path ran. A repaired release must # not carry weaker provenance than one published on the normal path. - name: Build - run: ./gradlew build + env: + SKIP_SHARE: ${{ steps.toolchain.outputs.skip_share }} + run: | + set -euo pipefail + if [ "$SKIP_SHARE" = true ]; then + ./gradlew -Pskip-share=true build + else + ./gradlew build + fi # Asset names follow the convention of the tag's OWN release workflow: # tags up to 0.7.0 published version-suffixed jars, 0.7.1 onwards publish diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 543757b26..7271985e1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -39,12 +39,12 @@ jobs: uses: gradle/actions/setup-gradle@v3 - name: Build - run: ./gradlew build + run: ./gradlew -Pskip-share=true build - name: Get version id: version run: | - VERSION=$(./gradlew properties -q | grep "^version:" | awk '{print $2}') + VERSION=$(./gradlew -Pskip-share=true properties -q | grep "^version:" | awk '{print $2}') echo "version=$VERSION" >> $GITHUB_OUTPUT echo "Building version: $VERSION" diff --git a/.gitignore b/.gitignore index c37f57550..2a941fe07 100644 --- a/.gitignore +++ b/.gitignore @@ -211,6 +211,7 @@ nbdist/ .gradle **/build/ !src/**/build/ +**/run/ # Ignore Gradle GUI config gradle-app.setting @@ -232,4 +233,4 @@ gradle-app.setting # End of https://www.gitignore.io/api/git,java,gradle,eclipse,netbeans,jetbrains+all -/core/src/main/resources/languages/ \ No newline at end of file +/core/src/main/resources/languages/ diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 727e2bea9..f87262aa8 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.14.0" + ".": "0.15.0" } diff --git a/AGENTS.md b/AGENTS.md index 9ba1712da..4edd68a2f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -206,6 +206,16 @@ curl -I -L --fail https://github.com/minekube/connect-java/releases/download/ getVerifiedBedrockIdentity(ConnectPlayer player) { return Optional.empty(); } + + /** + * Returns the verifier-created v2 Bedrock principal for this player's current session. + * Callers must not log or serialize identity/link accessors. + */ + default Optional getVerifiedBedrockPrincipal(ConnectPlayer player) { + return Optional.empty(); + } } diff --git a/api/src/main/java/com/minekube/connect/api/player/bedrock/BedrockIdentityProfiles.java b/api/src/main/java/com/minekube/connect/api/player/bedrock/BedrockIdentityProfiles.java index f7ca88150..d29aa1be6 100644 --- a/api/src/main/java/com/minekube/connect/api/player/bedrock/BedrockIdentityProfiles.java +++ b/api/src/main/java/com/minekube/connect/api/player/bedrock/BedrockIdentityProfiles.java @@ -10,6 +10,8 @@ public final class BedrockIdentityProfiles { /** Private transport property carrying endpoint and organization scope for legacy Watch. */ public static final String SCOPE_PROPERTY_NAME = "minekube:bedrock_identity_scope"; + /** Reserved property name: v2 is accepted only from authenticated wire field 12. */ + public static final String PRINCIPAL_V2_PROPERTY_NAME = "minekube:bedrock_principal_v2"; private BedrockIdentityProfiles() { } @@ -42,6 +44,7 @@ public static GameProfile withoutEnvelope(GameProfile profile) { */ public static boolean isPublic(GameProfile.Property property) { return !BedrockIdentityVerifier.PROPERTY_NAME.equals(property.getName()) && - !SCOPE_PROPERTY_NAME.equals(property.getName()); + !SCOPE_PROPERTY_NAME.equals(property.getName()) && + !PRINCIPAL_V2_PROPERTY_NAME.equals(property.getName()); } } diff --git a/api/src/main/java/com/minekube/connect/api/player/principal/BedrockPrincipalVerifier.java b/api/src/main/java/com/minekube/connect/api/player/principal/BedrockPrincipalVerifier.java new file mode 100644 index 000000000..ec04442c8 --- /dev/null +++ b/api/src/main/java/com/minekube/connect/api/player/principal/BedrockPrincipalVerifier.java @@ -0,0 +1,8 @@ +package com.minekube.connect.api.player.principal; + +/** Strict Bedrock signed-principal v2 verifier. */ +public interface BedrockPrincipalVerifier { + VerifiedBedrockPrincipal verifyAndConsume( + SignedPrincipalEnvelope envelope, + TrustedProposalContext expected) throws PrincipalVerificationException; +} diff --git a/api/src/main/java/com/minekube/connect/api/player/principal/BedrockPrincipalVerifierFactory.java b/api/src/main/java/com/minekube/connect/api/player/principal/BedrockPrincipalVerifierFactory.java new file mode 100644 index 000000000..57e0e0a4e --- /dev/null +++ b/api/src/main/java/com/minekube/connect/api/player/principal/BedrockPrincipalVerifierFactory.java @@ -0,0 +1,13 @@ +package com.minekube.connect.api.player.principal; + +import java.util.Objects; + +/** The sole public construction entry point for a Bedrock principal verifier. */ +public final class BedrockPrincipalVerifierFactory { + private BedrockPrincipalVerifierFactory() {} + + public static BedrockPrincipalVerifier create(VerifierConfiguration configuration) { + return new DefaultBedrockPrincipalVerifier( + Objects.requireNonNull(configuration, "configuration")); + } +} diff --git a/api/src/main/java/com/minekube/connect/api/player/principal/CanonicalXuid.java b/api/src/main/java/com/minekube/connect/api/player/principal/CanonicalXuid.java new file mode 100644 index 000000000..723996a63 --- /dev/null +++ b/api/src/main/java/com/minekube/connect/api/player/principal/CanonicalXuid.java @@ -0,0 +1,31 @@ +package com.minekube.connect.api.player.principal; + +import java.util.Objects; + +/** A canonical positive decimal XUID verified by the SDK. */ +public final class CanonicalXuid { + private final transient String value; + + CanonicalXuid(String value) { + this.value = Objects.requireNonNull(value, "value"); + } + + public String value() { + return value; + } + + @Override + public boolean equals(Object other) { + return other instanceof CanonicalXuid && value.equals(((CanonicalXuid) other).value); + } + + @Override + public int hashCode() { + return value.hashCode(); + } + + @Override + public String toString() { + return "CanonicalXuid[redacted]"; + } +} diff --git a/api/src/main/java/com/minekube/connect/api/player/principal/DefaultBedrockPrincipalVerifier.java b/api/src/main/java/com/minekube/connect/api/player/principal/DefaultBedrockPrincipalVerifier.java new file mode 100644 index 000000000..63a0c4fd8 --- /dev/null +++ b/api/src/main/java/com/minekube/connect/api/player/principal/DefaultBedrockPrincipalVerifier.java @@ -0,0 +1,560 @@ +package com.minekube.connect.api.player.principal; + +import java.math.BigInteger; +import java.nio.ByteBuffer; +import java.nio.charset.CharacterCodingException; +import java.nio.charset.CodingErrorAction; +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.KeyFactory; +import java.security.MessageDigest; +import java.security.PublicKey; +import java.security.Signature; +import java.security.spec.EdECPoint; +import java.security.spec.EdECPublicKeySpec; +import java.security.spec.NamedParameterSpec; +import java.time.Clock; +import java.time.Instant; +import java.util.Base64; +import java.util.HashMap; +import java.util.Iterator; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +final class DefaultBedrockPrincipalVerifier implements BedrockPrincipalVerifier { + static final String WIRE_TYPE = "connect-bedrock-principal+jws;v=2"; + static final String CAPABILITY = "bedrock-verified-principal-v2"; + private static final int MAX_HEADER_BYTES = 2 * 1024; + private static final int MAX_PAYLOAD_BYTES = 12 * 1024; + private static final BigInteger ED25519_FIELD_PRIME = BigInteger.ONE.shiftLeft(255) + .subtract(BigInteger.valueOf(19)); + private static final BigInteger ED25519_D = BigInteger.valueOf(-121665) + .multiply(BigInteger.valueOf(121666).modInverse(ED25519_FIELD_PRIME)) + .mod(ED25519_FIELD_PRIME); + private static final BigInteger ED25519_SQRT_M1 = BigInteger.valueOf(2) + .modPow(ED25519_FIELD_PRIME.subtract(BigInteger.ONE).shiftRight(2), + ED25519_FIELD_PRIME); + private static final BigInteger ED25519_SQRT_EXP = ED25519_FIELD_PRIME + .add(BigInteger.valueOf(3)).shiftRight(3); + private static final long MAX_UNIX_TIMESTAMP = 253_402_300_799L; + private static final Set HEADER_FIELDS = Set.of("alg", "typ", "kid"); + private static final Set PAYLOAD_FIELDS = Set.of( + "version", "issuer", "trust_domain", "audience", "subject_kind", + "canonical_xuid", "canonical_unlinked_uuid", "linked_java", + "bedrock_display_name", "endpoint_id", "organization_id", + "connect_session_id", "connect_session_nonce", "policy_revision", + "source_protocol", "source_protocol_version", "iat", "nbf", "exp", + "jti", "verification_method"); + private static final Set REQUIRED_PAYLOAD_FIELDS = Set.of( + "version", "issuer", "trust_domain", "audience", "subject_kind", + "canonical_xuid", "canonical_unlinked_uuid", "bedrock_display_name", + "endpoint_id", "organization_id", "connect_session_id", + "connect_session_nonce", "policy_revision", "source_protocol", + "source_protocol_version", "iat", "nbf", "exp", "jti", + "verification_method"); + private static final Set LINK_FIELDS = Set.of("uuid", "name", "provenance"); + private static final Set PROVENANCE_FIELDS = + Set.of("provider", "record_id", "revision", "verified_at"); + + private final Map keys; + private final Clock clock; + private final ReplayCache replay; + + DefaultBedrockPrincipalVerifier(VerifierConfiguration configuration) { + this.clock = configuration.clock(); + this.replay = new ReplayCache(configuration.replayCapacity(), clock); + Map parsed = new HashMap<>(); + configuration.publicKeys().forEach((kid, key) -> parsed.put(kid, parsePublicKey(key))); + this.keys = Map.copyOf(parsed); + } + + @Override + public VerifiedBedrockPrincipal verifyAndConsume( + SignedPrincipalEnvelope envelope, + TrustedProposalContext expected) throws PrincipalVerificationException { + if (envelope == null || expected == null) throw reject(PrincipalError.MALFORMED); + ParsedEnvelope parsed = parse(envelope.compact()); + Claims claims = parsed.claims; + if (!validTrust(claims.issuer, 128) + || !validTrust(claims.trustDomain, 256) + || !validTrust(claims.audience, 256) + || !validTrust(expected.issuer(), 128) + || !validTrust(expected.trustDomain(), 256) + || !validTrust(expected.audience(), 256) + || !same(claims.issuer, expected.issuer()) + || !same(claims.trustDomain, expected.trustDomain()) + || !same(claims.audience, expected.audience())) { + throw reject(PrincipalError.TRUST); + } + + PublicKey key = keys.get(parsed.kid); + if (key == null) throw reject(PrincipalError.TRUST); + if (!verifySignature(key, parsed.signingInput, parsed.signature)) { + throw reject(PrincipalError.SIGNATURE); + } + + byte[] nonce = decodeCanonical16(claims.connectSessionNonce); + decodeCanonical16(claims.jti); + if (!validBinding(claims.sourceProtocol, claims.sourceProtocolVersion, + claims.endpointId, claims.organizationId, claims.connectSessionId) + || !validBinding(expected.sourceProtocol(), expected.sourceProtocolVersion(), + expected.endpointId(), expected.organizationId(), expected.connectSessionId()) + || !MessageDigest.isEqual(nonce, expected.connectSessionNonce()) + || !same(claims.endpointId, expected.endpointId()) + || !same(claims.organizationId, expected.organizationId()) + || !same(claims.connectSessionId, expected.connectSessionId()) + || !same(claims.sourceProtocol, expected.sourceProtocol()) + || claims.sourceProtocolVersion != expected.sourceProtocolVersion() + || claims.policyRevision != expected.policyRevision() + || expected.policyRevision() <= 0) { + throw reject(PrincipalError.BINDING_MISMATCH); + } + + validateTime(claims, clock.instant().getEpochSecond()); + ImmutableVerifiedBedrockPrincipal principal = principal(claims, parsed.kid, expected); + replay.consume(new ReplayValue( + claims.trustDomain, claims.issuer, claims.jti, parsed.kid, + claims.endpointId, claims.connectSessionId, nonce, claims.exp + 5)); + return principal; + } + + private static ParsedEnvelope parse(String compact) throws PrincipalVerificationException { + try { + String[] parts = compact.split("\\.", -1); + if (parts.length != 3 || parts[0].isEmpty() || parts[1].isEmpty() || parts[2].isEmpty()) { + throw malformed(); + } + byte[] headerBytes = decodeCanonical(parts[0], MAX_HEADER_BYTES); + byte[] payloadBytes = decodeCanonical(parts[1], MAX_PAYLOAD_BYTES); + byte[] signature = decodeCanonical(parts[2], 64); + if (signature.length != 64) throw malformed(); + Map header = StrictJson.parseObject(utf8(headerBytes), headerBytes.length); + exact(header, HEADER_FIELDS, HEADER_FIELDS); + if (!"EdDSA".equals(string(header, "alg")) + || !WIRE_TYPE.equals(string(header, "typ"))) throw malformed(); + String kid = bounded(string(header, "kid"), 1, 128); + + Map payload = StrictJson.parseObject(utf8(payloadBytes), payloadBytes.length); + exact(payload, PAYLOAD_FIELDS, REQUIRED_PAYLOAD_FIELDS); + Claims claims = Claims.from(payload); + return new ParsedEnvelope(kid, claims, + (parts[0] + "." + parts[1]).getBytes(StandardCharsets.US_ASCII), signature); + } catch (IllegalArgumentException | CharacterCodingException ignored) { + throw reject(PrincipalError.MALFORMED); + } + } + + private static ImmutableVerifiedBedrockPrincipal principal( + Claims claims, + String kid, + TrustedProposalContext expected) throws PrincipalVerificationException { + if (claims.version != 2 + || claims.canonicalXuid.length() > 19 + || claims.canonicalXuid.isEmpty() + || claims.canonicalXuid.charAt(0) == '0') { + throw reject(PrincipalError.IDENTITY); + } + long xuid; + try { + xuid = Long.parseUnsignedLong(claims.canonicalXuid); + } catch (NumberFormatException ignored) { + throw reject(PrincipalError.IDENTITY); + } + if (Long.compareUnsigned(xuid, 0L) <= 0 + || !Long.toUnsignedString(xuid).equals(claims.canonicalXuid)) { + throw reject(PrincipalError.IDENTITY); + } + UUID unlinked = canonicalUuid(claims.canonicalUnlinkedUuid, PrincipalError.IDENTITY); + UUID expectedUuid = new UUID(0L, xuid); + if (!unlinked.equals(expectedUuid) + || claims.bedrockDisplayName.isEmpty() + || utf8Length(claims.bedrockDisplayName) > 64 + || !validVerificationMethod(claims.verificationMethod)) { + throw reject(PrincipalError.IDENTITY); + } + + SubjectKind kind = SubjectKind.fromWireName(claims.subjectKind); + VerifiedLinkedJavaIdentity linked = null; + if (kind == SubjectKind.BEDROCK_XUID) { + if (claims.linkedJava != null) throw reject(PrincipalError.LINK); + } else { + if (claims.linkedJava == null) throw reject(PrincipalError.LINK); + Linked link = claims.linkedJava; + UUID javaUuid = canonicalUuid(link.uuid, PrincipalError.LINK); + if (!validJavaName(link.name) + || !"moxy_account_link_v1".equals(link.provider) + || link.recordId.isEmpty() + || utf8Length(link.recordId) > 128 + || link.revision <= 0 + || !numericDate(link.verifiedAt) + || Math.abs(link.verifiedAt - claims.iat) > 5) { + throw reject(PrincipalError.LINK); + } + linked = new VerifiedLinkedJavaIdentity(javaUuid, link.name, + new LinkProvenance(link.provider, link.recordId, link.revision, + Instant.ofEpochSecond(link.verifiedAt))); + } + return new ImmutableVerifiedBedrockPrincipal( + kind, new CanonicalXuid(claims.canonicalXuid), unlinked, linked, + claims.bedrockDisplayName, + new VerificationEvidence(kid, claims.verificationMethod, + Instant.ofEpochSecond(claims.iat), Instant.ofEpochSecond(claims.nbf), + Instant.ofEpochSecond(claims.exp)), + new PrincipalBindings(expected.issuer(), expected.trustDomain(), expected.audience(), + expected.endpointId(), expected.organizationId(), expected.connectSessionId(), + expected.connectSessionNonce(), expected.sourceProtocol(), + expected.sourceProtocolVersion(), expected.policyRevision())); + } + + private static void validateTime(Claims claims, long now) throws PrincipalVerificationException { + if (!numericDate(claims.iat) || !numericDate(claims.nbf) || !numericDate(claims.exp) + || claims.nbf > claims.iat || claims.iat > claims.exp + || claims.exp - claims.iat > 30 + || claims.nbf > now + 5 || claims.iat > now + 5 || claims.exp < now - 5) { + throw reject(PrincipalError.TIME); + } + } + + private static boolean numericDate(long value) { + return value >= 0 && value <= MAX_UNIX_TIMESTAMP; + } + + private static boolean validVerificationMethod(String value) { + return "minecraft_legacy_chain+client_jwt+ecdh_v1".equals(value) + || "minecraft_full_jwks+client_jwt+ecdh_v1".equals(value); + } + + private static boolean validJavaName(String value) { + if (value.isEmpty() || value.length() > 16) return false; + for (int index = 0; index < value.length(); index++) { + char character = value.charAt(index); + if (!(character >= 'A' && character <= 'Z') + && !(character >= 'a' && character <= 'z') + && !(character >= '0' && character <= '9') + && character != '_') return false; + } + return true; + } + + private static UUID canonicalUuid(String value, PrincipalError error) + throws PrincipalVerificationException { + try { + UUID uuid = UUID.fromString(value); + if (value.length() != 36 || !uuid.toString().equals(value)) throw new IllegalArgumentException(); + return uuid; + } catch (IllegalArgumentException ignored) { + throw reject(error); + } + } + + private static boolean validBinding( + String protocol, int version, String endpoint, String organization, String session) { + return "bedrock".equals(protocol) && version >= 1 + && boundedBinding(endpoint) && boundedBinding(organization) && boundedBinding(session); + } + + private static boolean boundedBinding(String value) { + return value != null && utf8Length(value) >= 1 && utf8Length(value) <= 128; + } + + private static boolean validTrust(String value, int maximum) { + return value != null && utf8Length(value) >= 1 && utf8Length(value) <= maximum; + } + + private static boolean same(String left, String right) { + return MessageDigest.isEqual( + left.getBytes(StandardCharsets.UTF_8), right.getBytes(StandardCharsets.UTF_8)); + } + + private static boolean verifySignature(PublicKey key, byte[] input, byte[] signature) + throws PrincipalVerificationException { + try { + Signature verifier = Signature.getInstance("Ed25519"); + verifier.initVerify(key); + verifier.update(input); + return verifier.verify(signature); + } catch (GeneralSecurityException ignored) { + throw reject(PrincipalError.INTERNAL); + } + } + + private static byte[] decodeCanonical16(String value) throws PrincipalVerificationException { + if (value.length() != 22) throw reject(PrincipalError.MALFORMED); + try { + byte[] decoded = decodeCanonical(value, 16); + if (decoded.length != 16) throw malformed(); + return decoded; + } catch (IllegalArgumentException ignored) { + throw reject(PrincipalError.MALFORMED); + } + } + + private static byte[] decodeCanonical(String value, int maximum) { + if (value.indexOf('=') >= 0) throw malformed(); + byte[] decoded = Base64.getUrlDecoder().decode(value); + if (decoded.length > maximum + || !Base64.getUrlEncoder().withoutPadding().encodeToString(decoded).equals(value)) { + throw malformed(); + } + return decoded; + } + + private static String utf8(byte[] value) throws CharacterCodingException { + return StandardCharsets.UTF_8.newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT) + .decode(ByteBuffer.wrap(value)).toString(); + } + + private static int utf8Length(String value) { + return value.getBytes(StandardCharsets.UTF_8).length; + } + + private static PublicKey parsePublicKey(byte[] raw) { + try { + byte[] y = raw.clone(); + boolean xOdd = (y[31] & 0x80) != 0; + y[31] &= 0x7f; + for (int left = 0, right = y.length - 1; left < right; left++, right--) { + byte swap = y[left]; + y[left] = y[right]; + y[right] = swap; + } + BigInteger yCoordinate = new BigInteger(1, y); + if (yCoordinate.compareTo(ED25519_FIELD_PRIME) >= 0 + || !validEd25519Point(yCoordinate, xOdd)) { + throw new IllegalArgumentException("invalid verifier public key"); + } + return KeyFactory.getInstance("Ed25519").generatePublic(new EdECPublicKeySpec( + NamedParameterSpec.ED25519, new EdECPoint(xOdd, new BigInteger(1, y)))); + } catch (GeneralSecurityException | RuntimeException ignored) { + throw new IllegalArgumentException("invalid verifier public key"); + } + } + + private static boolean validEd25519Point(BigInteger y, boolean xOdd) { + BigInteger ySquared = y.multiply(y).mod(ED25519_FIELD_PRIME); + BigInteger denominator = ED25519_D.multiply(ySquared).add(BigInteger.ONE) + .mod(ED25519_FIELD_PRIME); + if (denominator.signum() == 0) return false; + BigInteger xSquared = ySquared.subtract(BigInteger.ONE) + .multiply(denominator.modInverse(ED25519_FIELD_PRIME)) + .mod(ED25519_FIELD_PRIME); + BigInteger x = xSquared.modPow(ED25519_SQRT_EXP, ED25519_FIELD_PRIME); + if (!x.multiply(x).mod(ED25519_FIELD_PRIME).equals(xSquared)) { + x = x.multiply(ED25519_SQRT_M1).mod(ED25519_FIELD_PRIME); + } + if (!x.multiply(x).mod(ED25519_FIELD_PRIME).equals(xSquared) + || x.signum() == 0) { + return false; + } + if (x.testBit(0) != xOdd) x = ED25519_FIELD_PRIME.subtract(x); + return !smallOrder(x, y); + } + + private static boolean smallOrder(BigInteger x, BigInteger y) { + for (int count = 0; count < 3; count++) { + BigInteger product = ED25519_D.multiply(x).multiply(x).multiply(y).multiply(y) + .mod(ED25519_FIELD_PRIME); + BigInteger nextX = x.multiply(y).shiftLeft(1) + .multiply(BigInteger.ONE.add(product).modInverse(ED25519_FIELD_PRIME)) + .mod(ED25519_FIELD_PRIME); + BigInteger nextY = y.multiply(y).add(x.multiply(x)) + .multiply(BigInteger.ONE.subtract(product).mod(ED25519_FIELD_PRIME) + .modInverse(ED25519_FIELD_PRIME)) + .mod(ED25519_FIELD_PRIME); + x = nextX; + y = nextY; + } + return x.signum() == 0 && y.equals(BigInteger.ONE); + } + + private static void exact(Map object, Set allowed, Set required) { + if (!allowed.containsAll(object.keySet()) || !object.keySet().containsAll(required)) { + throw malformed(); + } + } + + private static String string(Map object, String name) { + Object value = object.get(name); + if (!(value instanceof String)) throw malformed(); + return (String) value; + } + + private static long integer(Map object, String name) { + Object value = object.get(name); + if (!(value instanceof Long)) throw malformed(); + return (Long) value; + } + + @SuppressWarnings("unchecked") + private static Map object(Map parent, String name) { + Object value = parent.get(name); + if (!(value instanceof Map)) throw malformed(); + return (Map) value; + } + + private static String bounded(String value, int minimum, int maximum) { + int length = utf8Length(value); + if (length < minimum || length > maximum) throw malformed(); + return value; + } + + private static IllegalArgumentException malformed() { + return new IllegalArgumentException(PrincipalError.MALFORMED.name()); + } + + private static PrincipalVerificationException reject(PrincipalError error) { + return new PrincipalVerificationException(error); + } + + private static final class ParsedEnvelope { + private final String kid; + private final Claims claims; + private final byte[] signingInput; + private final byte[] signature; + + private ParsedEnvelope(String kid, Claims claims, byte[] signingInput, byte[] signature) { + this.kid = kid; + this.claims = claims; + this.signingInput = signingInput; + this.signature = signature; + } + } + + private static final class Claims { + private int version; + private String issuer; + private String trustDomain; + private String audience; + private String subjectKind; + private String canonicalXuid; + private String canonicalUnlinkedUuid; + private Linked linkedJava; + private String bedrockDisplayName; + private String endpointId; + private String organizationId; + private String connectSessionId; + private String connectSessionNonce; + private long policyRevision; + private String sourceProtocol; + private int sourceProtocolVersion; + private long iat; + private long nbf; + private long exp; + private String jti; + private String verificationMethod; + + private static Claims from(Map value) { + Claims claims = new Claims(); + long version = integer(value, "version"); + long protocolVersion = integer(value, "source_protocol_version"); + if (version < Integer.MIN_VALUE || version > Integer.MAX_VALUE + || protocolVersion < Integer.MIN_VALUE || protocolVersion > Integer.MAX_VALUE) { + throw malformed(); + } + claims.version = (int) version; + claims.issuer = bounded(string(value, "issuer"), 1, 128); + claims.trustDomain = bounded(string(value, "trust_domain"), 1, 256); + claims.audience = bounded(string(value, "audience"), 1, 256); + claims.subjectKind = string(value, "subject_kind"); + claims.canonicalXuid = string(value, "canonical_xuid"); + claims.canonicalUnlinkedUuid = string(value, "canonical_unlinked_uuid"); + claims.bedrockDisplayName = string(value, "bedrock_display_name"); + claims.endpointId = bounded(string(value, "endpoint_id"), 1, 128); + claims.organizationId = bounded(string(value, "organization_id"), 1, 128); + claims.connectSessionId = bounded(string(value, "connect_session_id"), 1, 128); + claims.connectSessionNonce = string(value, "connect_session_nonce"); + claims.policyRevision = integer(value, "policy_revision"); + claims.sourceProtocol = string(value, "source_protocol"); + claims.sourceProtocolVersion = (int) protocolVersion; + claims.iat = integer(value, "iat"); + claims.nbf = integer(value, "nbf"); + claims.exp = integer(value, "exp"); + claims.jti = string(value, "jti"); + claims.verificationMethod = string(value, "verification_method"); + if (value.containsKey("linked_java")) claims.linkedJava = Linked.from(object(value, "linked_java")); + return claims; + } + } + + private static final class Linked { + private String uuid; + private String name; + private String provider; + private String recordId; + private long revision; + private long verifiedAt; + + private static Linked from(Map value) { + exact(value, LINK_FIELDS, LINK_FIELDS); + Map provenance = object(value, "provenance"); + exact(provenance, PROVENANCE_FIELDS, PROVENANCE_FIELDS); + Linked linked = new Linked(); + linked.uuid = string(value, "uuid"); + linked.name = string(value, "name"); + linked.provider = string(provenance, "provider"); + linked.recordId = string(provenance, "record_id"); + linked.revision = integer(provenance, "revision"); + linked.verifiedAt = integer(provenance, "verified_at"); + return linked; + } + } + + private static final class ReplayValue { + private final String trustDomain; + private final String issuer; + private final String jti; + @SuppressWarnings("unused") private final String kid; + @SuppressWarnings("unused") private final String endpointId; + @SuppressWarnings("unused") private final String sessionId; + @SuppressWarnings("unused") private final byte[] nonce; + private final long expiresAt; + + private ReplayValue(String trustDomain, String issuer, String jti, String kid, + String endpointId, String sessionId, byte[] nonce, long expiresAt) { + this.trustDomain = trustDomain; + this.issuer = issuer; + this.jti = jti; + this.kid = kid; + this.endpointId = endpointId; + this.sessionId = sessionId; + this.nonce = nonce.clone(); + this.expiresAt = expiresAt; + } + } + + private static final class ReplayCache { + private final int capacity; + private final Clock clock; + private final Map entries = new HashMap<>(); + + private ReplayCache(int capacity, Clock clock) { + this.capacity = capacity; + this.clock = clock; + } + + private synchronized void consume(ReplayValue value) throws PrincipalVerificationException { + long now = clock.instant().getEpochSecond(); + String key = value.trustDomain + '\0' + value.issuer + '\0' + value.jti; + ReplayValue existing = entries.get(key); + if (existing != null) { + if (now <= existing.expiresAt) throw reject(PrincipalError.REPLAY); + entries.remove(key); + } + if (entries.size() >= capacity) { + int removed = 0; + Iterator> iterator = entries.entrySet().iterator(); + while (iterator.hasNext() && removed < 64) { + if (now > iterator.next().getValue().expiresAt) { + iterator.remove(); + removed++; + } + } + } + if (entries.size() >= capacity) throw reject(PrincipalError.CAPACITY); + entries.put(key, value); + } + } +} diff --git a/api/src/main/java/com/minekube/connect/api/player/principal/EffectiveGameProfile.java b/api/src/main/java/com/minekube/connect/api/player/principal/EffectiveGameProfile.java new file mode 100644 index 000000000..6c5c3f079 --- /dev/null +++ b/api/src/main/java/com/minekube/connect/api/player/principal/EffectiveGameProfile.java @@ -0,0 +1,12 @@ +package com.minekube.connect.api.player.principal; + +import java.util.Objects; +import java.util.UUID; + +/** The single game profile selected by a verified principal. */ +public record EffectiveGameProfile(UUID uuid, String name) { + public EffectiveGameProfile { + Objects.requireNonNull(uuid, "uuid"); + Objects.requireNonNull(name, "name"); + } +} diff --git a/api/src/main/java/com/minekube/connect/api/player/principal/ImmutableVerifiedBedrockPrincipal.java b/api/src/main/java/com/minekube/connect/api/player/principal/ImmutableVerifiedBedrockPrincipal.java new file mode 100644 index 000000000..e74590be8 --- /dev/null +++ b/api/src/main/java/com/minekube/connect/api/player/principal/ImmutableVerifiedBedrockPrincipal.java @@ -0,0 +1,53 @@ +package com.minekube.connect.api.player.principal; + +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; + +final class ImmutableVerifiedBedrockPrincipal implements VerifiedBedrockPrincipal { + private final transient SubjectKind subjectKind; + private final transient CanonicalXuid xuid; + private final transient UUID canonicalUnlinkedUuid; + private final transient VerifiedLinkedJavaIdentity linkedJava; + private final transient String bedrockDisplayName; + private final transient VerificationEvidence verification; + private final transient PrincipalBindings bindings; + + ImmutableVerifiedBedrockPrincipal( + SubjectKind subjectKind, + CanonicalXuid xuid, + UUID canonicalUnlinkedUuid, + VerifiedLinkedJavaIdentity linkedJava, + String bedrockDisplayName, + VerificationEvidence verification, + PrincipalBindings bindings) { + this.subjectKind = Objects.requireNonNull(subjectKind, "subjectKind"); + this.xuid = Objects.requireNonNull(xuid, "xuid"); + this.canonicalUnlinkedUuid = Objects.requireNonNull(canonicalUnlinkedUuid, "canonicalUnlinkedUuid"); + this.linkedJava = linkedJava; + this.bedrockDisplayName = Objects.requireNonNull(bedrockDisplayName, "bedrockDisplayName"); + this.verification = Objects.requireNonNull(verification, "verification"); + this.bindings = Objects.requireNonNull(bindings, "bindings"); + } + + @Override public SubjectKind subjectKind() { return subjectKind; } + @Override public CanonicalXuid xuid() { return xuid; } + @Override public UUID canonicalUnlinkedUuid() { return canonicalUnlinkedUuid; } + @Override public Optional linkedJava() { return Optional.ofNullable(linkedJava); } + @Override public String bedrockDisplayName() { return bedrockDisplayName; } + @Override public VerificationEvidence verification() { return verification; } + @Override public PrincipalBindings bindings() { return bindings; } + + @Override + public EffectiveGameProfile effectiveGameProfile() { + return linkedJava == null + ? new EffectiveGameProfile(canonicalUnlinkedUuid, bedrockDisplayName) + : new EffectiveGameProfile(linkedJava.uuid(), linkedJava.name()); + } + + @Override + public String toString() { + return "VerifiedBedrockPrincipal[subjectKind=" + subjectKind + ", linked=" + + (linkedJava != null) + ", verification=" + verification + "]"; + } +} diff --git a/api/src/main/java/com/minekube/connect/api/player/principal/LinkProvenance.java b/api/src/main/java/com/minekube/connect/api/player/principal/LinkProvenance.java new file mode 100644 index 000000000..a176d7fc9 --- /dev/null +++ b/api/src/main/java/com/minekube/connect/api/player/principal/LinkProvenance.java @@ -0,0 +1,29 @@ +package com.minekube.connect.api.player.principal; + +import java.time.Instant; +import java.util.Objects; + +/** Non-credential provenance for an independently verified Java account link. */ +public final class LinkProvenance { + private final transient String provider; + private final transient String recordId; + private final long revision; + private final Instant verifiedAt; + + public LinkProvenance(String provider, String recordId, long revision, Instant verifiedAt) { + this.provider = Objects.requireNonNull(provider, "provider"); + this.recordId = Objects.requireNonNull(recordId, "recordId"); + this.revision = revision; + this.verifiedAt = Objects.requireNonNull(verifiedAt, "verifiedAt"); + } + + public String provider() { return provider; } + public String recordId() { return recordId; } + public long revision() { return revision; } + public Instant verifiedAt() { return verifiedAt; } + + @Override + public String toString() { + return "LinkProvenance[revision=" + revision + ", verifiedAt=" + verifiedAt + "]"; + } +} diff --git a/api/src/main/java/com/minekube/connect/api/player/principal/PrincipalBindings.java b/api/src/main/java/com/minekube/connect/api/player/principal/PrincipalBindings.java new file mode 100644 index 000000000..c44b18903 --- /dev/null +++ b/api/src/main/java/com/minekube/connect/api/player/principal/PrincipalBindings.java @@ -0,0 +1,83 @@ +package com.minekube.connect.api.player.principal; + +import java.util.Arrays; +import java.util.Objects; + +/** Authenticated proposal bindings matched by a verified principal. */ +public class PrincipalBindings { + private final String issuer; + private final String trustDomain; + private final String audience; + private final String endpointId; + private final String organizationId; + private final String connectSessionId; + private final transient byte[] connectSessionNonce; + private final String sourceProtocol; + private final int sourceProtocolVersion; + private final long policyRevision; + + public PrincipalBindings( + String issuer, + String trustDomain, + String audience, + String endpointId, + String organizationId, + String connectSessionId, + byte[] connectSessionNonce, + String sourceProtocol, + int sourceProtocolVersion, + long policyRevision) { + this.issuer = Objects.requireNonNull(issuer, "issuer"); + this.trustDomain = Objects.requireNonNull(trustDomain, "trustDomain"); + this.audience = Objects.requireNonNull(audience, "audience"); + this.endpointId = Objects.requireNonNull(endpointId, "endpointId"); + this.organizationId = Objects.requireNonNull(organizationId, "organizationId"); + this.connectSessionId = Objects.requireNonNull(connectSessionId, "connectSessionId"); + this.connectSessionNonce = Objects.requireNonNull(connectSessionNonce, "connectSessionNonce").clone(); + this.sourceProtocol = Objects.requireNonNull(sourceProtocol, "sourceProtocol"); + this.sourceProtocolVersion = sourceProtocolVersion; + this.policyRevision = policyRevision; + } + + public String issuer() { return issuer; } + public String trustDomain() { return trustDomain; } + public String audience() { return audience; } + public String endpointId() { return endpointId; } + public String organizationId() { return organizationId; } + public String connectSessionId() { return connectSessionId; } + public byte[] connectSessionNonce() { return connectSessionNonce.clone(); } + public String sourceProtocol() { return sourceProtocol; } + public int sourceProtocolVersion() { return sourceProtocolVersion; } + public long policyRevision() { return policyRevision; } + + @Override + public String toString() { + return "PrincipalBindings[issuer=" + issuer + ", trustDomain=" + trustDomain + + ", audience=" + audience + ", sourceProtocol=" + sourceProtocol + + ", sourceProtocolVersion=" + sourceProtocolVersion + + ", policyRevision=" + policyRevision + "]"; + } + + @Override + public boolean equals(Object other) { + if (!(other instanceof PrincipalBindings)) return false; + PrincipalBindings that = (PrincipalBindings) other; + return sourceProtocolVersion == that.sourceProtocolVersion + && policyRevision == that.policyRevision + && issuer.equals(that.issuer) + && trustDomain.equals(that.trustDomain) + && audience.equals(that.audience) + && endpointId.equals(that.endpointId) + && organizationId.equals(that.organizationId) + && connectSessionId.equals(that.connectSessionId) + && Arrays.equals(connectSessionNonce, that.connectSessionNonce) + && sourceProtocol.equals(that.sourceProtocol); + } + + @Override + public int hashCode() { + return Objects.hash(issuer, trustDomain, audience, endpointId, organizationId, + connectSessionId, Arrays.hashCode(connectSessionNonce), sourceProtocol, + sourceProtocolVersion, policyRevision); + } +} diff --git a/api/src/main/java/com/minekube/connect/api/player/principal/PrincipalError.java b/api/src/main/java/com/minekube/connect/api/player/principal/PrincipalError.java new file mode 100644 index 000000000..8405c6d56 --- /dev/null +++ b/api/src/main/java/com/minekube/connect/api/player/principal/PrincipalError.java @@ -0,0 +1,18 @@ +package com.minekube.connect.api.player.principal; + +/** Stable, privacy-safe Bedrock principal v2 verification categories. */ +public enum PrincipalError { + MALFORMED, + TRUST, + SIGNATURE, + BINDING_MISMATCH, + TIME, + IDENTITY, + LINK, + REPLAY, + CAPACITY, + METADATA_UNAVAILABLE, + KEY_REVOKED, + READINESS, + INTERNAL +} diff --git a/api/src/main/java/com/minekube/connect/api/player/principal/PrincipalVerificationException.java b/api/src/main/java/com/minekube/connect/api/player/principal/PrincipalVerificationException.java new file mode 100644 index 000000000..09374bd59 --- /dev/null +++ b/api/src/main/java/com/minekube/connect/api/player/principal/PrincipalVerificationException.java @@ -0,0 +1,17 @@ +package com.minekube.connect.api.player.principal; + +import java.util.Objects; + +/** A privacy-safe verification rejection whose only contract is its bounded category. */ +public final class PrincipalVerificationException extends Exception { + private final PrincipalError error; + + public PrincipalVerificationException(PrincipalError error) { + super(Objects.requireNonNull(error, "error").name(), null, false, false); + this.error = error; + } + + public PrincipalError error() { + return error; + } +} diff --git a/api/src/main/java/com/minekube/connect/api/player/principal/SignedPrincipalEnvelope.java b/api/src/main/java/com/minekube/connect/api/player/principal/SignedPrincipalEnvelope.java new file mode 100644 index 000000000..906dcf8ec --- /dev/null +++ b/api/src/main/java/com/minekube/connect/api/player/principal/SignedPrincipalEnvelope.java @@ -0,0 +1,32 @@ +package com.minekube.connect.api.player.principal; + +import java.nio.charset.StandardCharsets; +import java.util.Objects; + +/** Opaque, bounded compact-JWS input. */ +public final class SignedPrincipalEnvelope { + public static final int MAX_COMPACT_BYTES = 16 * 1024; + private final transient String compact; + + private SignedPrincipalEnvelope(String compact) { + this.compact = compact; + } + + public static SignedPrincipalEnvelope of(String compact) { + Objects.requireNonNull(compact, "compact"); + int size = compact.getBytes(StandardCharsets.UTF_8).length; + if (size == 0 || size > MAX_COMPACT_BYTES) { + throw new IllegalArgumentException(PrincipalError.MALFORMED.name()); + } + return new SignedPrincipalEnvelope(compact); + } + + String compact() { + return compact; + } + + @Override + public String toString() { + return "SignedPrincipalEnvelope[redacted]"; + } +} diff --git a/api/src/main/java/com/minekube/connect/api/player/principal/StrictJson.java b/api/src/main/java/com/minekube/connect/api/player/principal/StrictJson.java new file mode 100644 index 000000000..0007816af --- /dev/null +++ b/api/src/main/java/com/minekube/connect/api/player/principal/StrictJson.java @@ -0,0 +1,184 @@ +package com.minekube.connect.api.player.principal; + +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.Map; + +/** Minimal closed-object JSON parser for the frozen principal envelope. */ +final class StrictJson { + private final String input; + private int position; + private int stringBytes; + + private StrictJson(String input) { + this.input = input; + } + + static Map parseObject(String input, int decodedBytes) { + StrictJson parser = new StrictJson(input); + Map object = parser.object(1); + parser.space(); + if (parser.position != input.length() + || parser.stringBytes > 24_576 + || decodedBytes + parser.stringBytes > 65_536) { + throw malformed(); + } + return object; + } + + private Map object(int depth) { + if (depth > 4) throw malformed(); + expect('{'); + space(); + Map result = new LinkedHashMap<>(); + if (take('}')) return result; + while (true) { + if (result.size() == 32) throw malformed(); + String name = string(); + if (result.containsKey(name)) throw malformed(); + space(); + expect(':'); + space(); + result.put(name, value(depth)); + space(); + if (take('}')) return result; + expect(','); + space(); + } + } + + private Object value(int depth) { + if (position >= input.length()) throw malformed(); + char value = input.charAt(position); + if (value == '"') return string(); + if (value == '{') return object(depth + 1); + if (value == 't') return literal("true", Boolean.TRUE); + if (value == 'f') return literal("false", Boolean.FALSE); + if (value == 'n') return literal("null", null); + if (value == '[') throw malformed(); + return number(); + } + + private Object literal(String literal, Object value) { + if (!input.regionMatches(position, literal, 0, literal.length())) throw malformed(); + position += literal.length(); + return value; + } + + private Long number() { + int start = position; + if (take('-') && position == input.length()) throw malformed(); + if (take('0')) { + if (position < input.length() && Character.isDigit(input.charAt(position))) throw malformed(); + } else { + int digits = position; + while (position < input.length() && input.charAt(position) >= '0' + && input.charAt(position) <= '9') position++; + if (position == digits) throw malformed(); + } + if (position < input.length()) { + char next = input.charAt(position); + if (next == '.' || next == 'e' || next == 'E' || next == '+') throw malformed(); + } + try { + return Long.valueOf(input.substring(start, position)); + } catch (NumberFormatException ignored) { + throw malformed(); + } + } + + private String string() { + expect('"'); + StringBuilder result = new StringBuilder(); + while (position < input.length()) { + char value = input.charAt(position++); + if (value == '"') { + String decoded = result.toString(); + if (decoded.indexOf('\0') >= 0) throw malformed(); + stringBytes += decoded.getBytes(StandardCharsets.UTF_8).length; + return decoded; + } + if (value < 0x20) throw malformed(); + if (value == '\\') { + if (position == input.length()) throw malformed(); + char escape = input.charAt(position++); + switch (escape) { + case '"': result.append('"'); break; + case '\\': result.append('\\'); break; + case '/': result.append('/'); break; + case 'b': result.append('\b'); break; + case 'f': result.append('\f'); break; + case 'n': result.append('\n'); break; + case 'r': result.append('\r'); break; + case 't': result.append('\t'); break; + case 'u': appendUnicode(result); break; + default: throw malformed(); + } + continue; + } + if (Character.isHighSurrogate(value)) { + if (position == input.length() || !Character.isLowSurrogate(input.charAt(position))) { + throw malformed(); + } + result.append(value).append(input.charAt(position++)); + } else if (Character.isLowSurrogate(value)) { + throw malformed(); + } else { + result.append(value); + } + } + throw malformed(); + } + + private void appendUnicode(StringBuilder result) { + char high = hex16(); + if (Character.isHighSurrogate(high)) { + if (position + 2 > input.length() + || input.charAt(position) != '\\' + || input.charAt(position + 1) != 'u') throw malformed(); + position += 2; + char low = hex16(); + if (!Character.isLowSurrogate(low)) throw malformed(); + result.append(high).append(low); + } else if (Character.isLowSurrogate(high)) { + throw malformed(); + } else { + result.append(high); + } + } + + private char hex16() { + if (position + 4 > input.length()) throw malformed(); + int result = 0; + for (int index = 0; index < 4; index++) { + int digit = Character.digit(input.charAt(position++), 16); + if (digit < 0) throw malformed(); + result = (result << 4) | digit; + } + return (char) result; + } + + private void expect(char value) { + if (!take(value)) throw malformed(); + } + + private boolean take(char value) { + if (position < input.length() && input.charAt(position) == value) { + position++; + return true; + } + return false; + } + + private void space() { + while (position < input.length()) { + char value = input.charAt(position); + if (value != ' ' && value != '\n' && value != '\r' && value != '\t') return; + position++; + } + } + + private static IllegalArgumentException malformed() { + return new IllegalArgumentException(PrincipalError.MALFORMED.name()); + } +} diff --git a/api/src/main/java/com/minekube/connect/api/player/principal/SubjectKind.java b/api/src/main/java/com/minekube/connect/api/player/principal/SubjectKind.java new file mode 100644 index 000000000..65258bbfc --- /dev/null +++ b/api/src/main/java/com/minekube/connect/api/player/principal/SubjectKind.java @@ -0,0 +1,24 @@ +package com.minekube.connect.api.player.principal; + +/** The closed Bedrock principal v2 subject set. */ +public enum SubjectKind { + BEDROCK_XUID("bedrock_xuid"), + BEDROCK_LINKED_JAVA("bedrock_linked_java"); + + private final String wireName; + + SubjectKind(String wireName) { + this.wireName = wireName; + } + + public String wireName() { + return wireName; + } + + static SubjectKind fromWireName(String value) throws PrincipalVerificationException { + for (SubjectKind kind : values()) { + if (kind.wireName.equals(value)) return kind; + } + throw new PrincipalVerificationException(PrincipalError.IDENTITY); + } +} diff --git a/api/src/main/java/com/minekube/connect/api/player/principal/TrustedProposalContext.java b/api/src/main/java/com/minekube/connect/api/player/principal/TrustedProposalContext.java new file mode 100644 index 000000000..7b27f6c0a --- /dev/null +++ b/api/src/main/java/com/minekube/connect/api/player/principal/TrustedProposalContext.java @@ -0,0 +1,19 @@ +package com.minekube.connect.api.player.principal; + +/** Trusted bindings constructed from the authenticated proposal/session path. */ +public final class TrustedProposalContext extends PrincipalBindings { + public TrustedProposalContext( + String issuer, + String trustDomain, + String audience, + String endpointId, + String organizationId, + String connectSessionId, + byte[] connectSessionNonce, + String sourceProtocol, + int sourceProtocolVersion, + long policyRevision) { + super(issuer, trustDomain, audience, endpointId, organizationId, connectSessionId, + connectSessionNonce, sourceProtocol, sourceProtocolVersion, policyRevision); + } +} diff --git a/api/src/main/java/com/minekube/connect/api/player/principal/VerificationEvidence.java b/api/src/main/java/com/minekube/connect/api/player/principal/VerificationEvidence.java new file mode 100644 index 000000000..431ca98c0 --- /dev/null +++ b/api/src/main/java/com/minekube/connect/api/player/principal/VerificationEvidence.java @@ -0,0 +1,20 @@ +package com.minekube.connect.api.player.principal; + +import java.time.Instant; +import java.util.Objects; + +/** Bounded, non-secret evidence about a successful verification. */ +public record VerificationEvidence( + String kid, + String verificationMethod, + Instant issuedAt, + Instant notBefore, + Instant expiresAt) { + public VerificationEvidence { + Objects.requireNonNull(kid, "kid"); + Objects.requireNonNull(verificationMethod, "verificationMethod"); + Objects.requireNonNull(issuedAt, "issuedAt"); + Objects.requireNonNull(notBefore, "notBefore"); + Objects.requireNonNull(expiresAt, "expiresAt"); + } +} diff --git a/api/src/main/java/com/minekube/connect/api/player/principal/VerifiedBedrockPrincipal.java b/api/src/main/java/com/minekube/connect/api/player/principal/VerifiedBedrockPrincipal.java new file mode 100644 index 000000000..f5163def2 --- /dev/null +++ b/api/src/main/java/com/minekube/connect/api/player/principal/VerifiedBedrockPrincipal.java @@ -0,0 +1,15 @@ +package com.minekube.connect.api.player.principal; + +import java.util.Optional; +import java.util.UUID; + +/** A Bedrock principal returned only after complete v2 verification and replay consumption. */ +public sealed interface VerifiedBedrockPrincipal extends VerifiedPrincipal + permits ImmutableVerifiedBedrockPrincipal { + CanonicalXuid xuid(); + UUID canonicalUnlinkedUuid(); + Optional linkedJava(); + String bedrockDisplayName(); + VerificationEvidence verification(); + PrincipalBindings bindings(); +} diff --git a/api/src/main/java/com/minekube/connect/api/player/principal/VerifiedLinkedJavaIdentity.java b/api/src/main/java/com/minekube/connect/api/player/principal/VerifiedLinkedJavaIdentity.java new file mode 100644 index 000000000..7fb1a525c --- /dev/null +++ b/api/src/main/java/com/minekube/connect/api/player/principal/VerifiedLinkedJavaIdentity.java @@ -0,0 +1,26 @@ +package com.minekube.connect.api.player.principal; + +import java.util.Objects; +import java.util.UUID; + +/** Immutable Java identity selected only after link provenance verification. */ +public final class VerifiedLinkedJavaIdentity { + private final transient UUID uuid; + private final transient String name; + private final transient LinkProvenance provenance; + + public VerifiedLinkedJavaIdentity(UUID uuid, String name, LinkProvenance provenance) { + this.uuid = Objects.requireNonNull(uuid, "uuid"); + this.name = Objects.requireNonNull(name, "name"); + this.provenance = Objects.requireNonNull(provenance, "provenance"); + } + + public UUID uuid() { return uuid; } + public String name() { return name; } + public LinkProvenance provenance() { return provenance; } + + @Override + public String toString() { + return "VerifiedLinkedJavaIdentity[redacted]"; + } +} diff --git a/api/src/main/java/com/minekube/connect/api/player/principal/VerifiedPrincipal.java b/api/src/main/java/com/minekube/connect/api/player/principal/VerifiedPrincipal.java new file mode 100644 index 000000000..9f33334db --- /dev/null +++ b/api/src/main/java/com/minekube/connect/api/player/principal/VerifiedPrincipal.java @@ -0,0 +1,7 @@ +package com.minekube.connect.api.player.principal; + +/** A verifier-created principal. Host code cannot implement this sealed hierarchy. */ +public sealed interface VerifiedPrincipal permits VerifiedBedrockPrincipal { + SubjectKind subjectKind(); + EffectiveGameProfile effectiveGameProfile(); +} diff --git a/api/src/main/java/com/minekube/connect/api/player/principal/VerifierConfiguration.java b/api/src/main/java/com/minekube/connect/api/player/principal/VerifierConfiguration.java new file mode 100644 index 000000000..531c73289 --- /dev/null +++ b/api/src/main/java/com/minekube/connect/api/player/principal/VerifierConfiguration.java @@ -0,0 +1,68 @@ +package com.minekube.connect.api.player.principal; + +import java.time.Clock; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; + +/** Immutable verifier dependencies. Static keys are keyed by the frozen non-secret JWS kid. */ +public final class VerifierConfiguration { + private final Map publicKeys; + private final Clock clock; + private final int replayCapacity; + + private VerifierConfiguration(Builder builder) { + Map keys = new LinkedHashMap<>(); + builder.publicKeys.forEach((kid, key) -> keys.put(kid, key.clone())); + this.publicKeys = Collections.unmodifiableMap(keys); + this.clock = builder.clock; + this.replayCapacity = builder.replayCapacity; + } + + public static Builder builder() { + return new Builder(); + } + + Map publicKeys() { + Map copy = new LinkedHashMap<>(); + publicKeys.forEach((kid, key) -> copy.put(kid, key.clone())); + return copy; + } + + Clock clock() { return clock; } + int replayCapacity() { return replayCapacity; } + + public static final class Builder { + private final Map publicKeys = new LinkedHashMap<>(); + private Clock clock = Clock.systemUTC(); + private int replayCapacity = 65_536; + + public Builder publicKey(String kid, byte[] rawEd25519PublicKey) { + Objects.requireNonNull(kid, "kid"); + Objects.requireNonNull(rawEd25519PublicKey, "rawEd25519PublicKey"); + if (kid.isEmpty() || kid.length() > 128 || rawEd25519PublicKey.length != 32) { + throw new IllegalArgumentException("invalid verifier public key"); + } + publicKeys.put(kid, rawEd25519PublicKey.clone()); + return this; + } + + public Builder clock(Clock clock) { + this.clock = Objects.requireNonNull(clock, "clock"); + return this; + } + + public Builder replayCapacity(int replayCapacity) { + if (replayCapacity <= 0 || replayCapacity > 65_536) { + throw new IllegalArgumentException("replayCapacity must be between 1 and 65536"); + } + this.replayCapacity = replayCapacity; + return this; + } + + public VerifierConfiguration build() { + return new VerifierConfiguration(this); + } + } +} diff --git a/build-logic/build.gradle.kts b/build-logic/build.gradle.kts index 538ee4d06..241f8f710 100644 --- a/build-logic/build.gradle.kts +++ b/build-logic/build.gradle.kts @@ -1,4 +1,5 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile +import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { `kotlin-dsl` @@ -9,16 +10,16 @@ repositories { } dependencies { - implementation("net.kyori", "indra-common", "2.0.6") - implementation("org.jfrog.buildinfo", "build-info-extractor-gradle", "4.26.1") + implementation("net.kyori.indra.git:net.kyori.indra.git.gradle.plugin:4.0.0") + implementation("com.jfrog.artifactory:com.jfrog.artifactory.gradle.plugin:6.0.4") implementation("com.gradleup.shadow:shadow-gradle-plugin:8.3.11") } java { - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 } -tasks.withType { - kotlinOptions.jvmTarget = "11" +tasks.withType().configureEach { + compilerOptions.jvmTarget.set(JvmTarget.JVM_17) } diff --git a/build-logic/src/main/kotlin/Versions.kt b/build-logic/src/main/kotlin/Versions.kt index 5a6597ccc..9c7e5357e 100644 --- a/build-logic/src/main/kotlin/Versions.kt +++ b/build-logic/src/main/kotlin/Versions.kt @@ -40,8 +40,18 @@ object Versions { const val protocVersion = "3.19.4" const val bstatsVersion = "3.0.2" const val gsonVersion = "2.8.6" - const val jvmLibp2pVersion = "1.3.2-RELEASE" + const val jvmLibp2pVersion = "1.3.5-RELEASE" const val kotlinStdlibVersion = "1.9.22" + const val loomVersion = "1.17.17" + const val fabricLoaderVersion = "0.19.3" + const val fabricApi12111Version = "0.141.6+1.21.11" + const val fabricApi1211Version = "0.116.15+1.21.1" + const val fabricApi1201Version = "0.92.11+1.20.1" + const val fabricApi262Version = "0.156.0+26.2" + const val fabricLanguageKotlinVersion = "1.13.13+kotlin.2.4.10" + const val kotlinVersion = "2.4.10" + const val coroutinesVersion = "1.11.0" + const val arrowVersion = "2.2.3" const val checkerQual = "3.19.0" } diff --git a/build-logic/src/main/kotlin/connect.base-conventions.gradle.kts b/build-logic/src/main/kotlin/connect.base-conventions.gradle.kts index a64bf8267..4d0026fd3 100644 --- a/build-logic/src/main/kotlin/connect.base-conventions.gradle.kts +++ b/build-logic/src/main/kotlin/connect.base-conventions.gradle.kts @@ -16,7 +16,7 @@ tasks { "id" to "connect", "name" to "connect", "version" to fullVersion(), - "description" to project.description, + "description" to (project.description ?: ""), "url" to "https://minekube.com", "author" to "Minekube" ) @@ -28,8 +28,8 @@ tasks { } java { - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 withSourcesJar() } diff --git a/build-logic/src/main/kotlin/connect.publish-conventions.gradle.kts b/build-logic/src/main/kotlin/connect.publish-conventions.gradle.kts index b20b5b515..6a2e74ff1 100644 --- a/build-logic/src/main/kotlin/connect.publish-conventions.gradle.kts +++ b/build-logic/src/main/kotlin/connect.publish-conventions.gradle.kts @@ -22,7 +22,6 @@ artifactory { publish { repository { setRepoKey(if (isSnapshot()) "maven-snapshots" else "maven-releases") - setMavenCompatible(true) } defaults { publications("mavenJava") @@ -31,4 +30,4 @@ artifactory { setPublishIvy(false) } } -} \ No newline at end of file +} diff --git a/build-logic/src/main/kotlin/connect.shadow-conventions.gradle.kts b/build-logic/src/main/kotlin/connect.shadow-conventions.gradle.kts index c9e9ea104..bd54037f9 100644 --- a/build-logic/src/main/kotlin/connect.shadow-conventions.gradle.kts +++ b/build-logic/src/main/kotlin/connect.shadow-conventions.gradle.kts @@ -6,6 +6,19 @@ plugins { id("com.gradleup.shadow") } +val connectLibp2pRuntime = configurations.create("connectLibp2pRuntime") { + isCanBeConsumed = false + isCanBeResolved = true + description = "Child-only libp2p runtime used by self-contained Connect artifacts" +} + +dependencies { + add( + connectLibp2pRuntime.name, + "io.libp2p:jvm-libp2p:${Versions.jvmLibp2pVersion}", + ) +} + tasks { named("jar") { archiveClassifier.set("unshaded") @@ -33,6 +46,24 @@ tasks { addRelocations(project, sJar) } } + register("libp2pRuntimeJar") { + group = "build" + description = "Builds the child-only Connect libp2p runtime payload" + configurations = listOf(connectLibp2pRuntime) + archiveFileName.set("libp2p-runtime.jar") + destinationDirectory.set(layout.buildDirectory.dir("connect-runtime")) + mergeServiceFiles() + exclude( + "META-INF/*.SF", + "META-INF/*.DSA", + "META-INF/*.RSA", + "META-INF/INDEX.LIST", + // jvm-libp2p uses Bouncy Castle's conventional Ed25519/EC + // primitives, never its post-quantum algorithm families. + "org/bouncycastle/pqc/**", + "META-INF/versions/*/org/bouncycastle/pqc/**", + ) + } named("build") { dependsOn(shadowJar) } @@ -112,5 +143,5 @@ fun addRelocations(project: Project, shadowJar: ShadowJar) { fun callAddRelocations(configuration: Configuration, shadowJar: ShadowJar) = configuration.dependencies.forEach { if (it is ProjectDependency) - addRelocations(it.dependencyProject, shadowJar) + addRelocations(shadowJar.project.project(it.path), shadowJar) } diff --git a/build-logic/src/main/kotlin/extensions.kt b/build-logic/src/main/kotlin/extensions.kt index 08cb34b2f..f23e83008 100644 --- a/build-logic/src/main/kotlin/extensions.kt +++ b/build-logic/src/main/kotlin/extensions.kt @@ -28,7 +28,6 @@ import org.gradle.api.Project import org.gradle.api.artifacts.ExternalModuleDependency import org.gradle.api.artifacts.ProjectDependency import org.gradle.kotlin.dsl.the -import java.io.ByteArrayOutputStream /** * Calculates the version from git tags. @@ -46,13 +45,12 @@ fun Project.gitVersion(): String { // Try to get version from git describe return try { - val stdout = ByteArrayOutputStream() - exec { - commandLine("git", "describe", "--tags", "--always", "--dirty") - standardOutput = stdout - isIgnoreExitValue = true - } - val describe = stdout.toString().trim() + val process = ProcessBuilder("git", "describe", "--tags", "--always", "--dirty") + .directory(rootDir) + .redirectErrorStream(true) + .start() + val describe = process.inputStream.bufferedReader().use { it.readText() }.trim() + process.waitFor() if (describe.isEmpty()) { "0.0.0-SNAPSHOT" @@ -124,7 +122,7 @@ fun Project.fullVersion(): String { } fun Project.lastCommitHash(): String? = - the().commit()?.name?.substring(0, 7) + the().commit().orNull?.name?.substring(0, 7) // retrieved from https://wiki.jenkins-ci.org/display/JENKINS/Building+a+software+project // some properties might be specific to Jenkins diff --git a/build.gradle.kts b/build.gradle.kts index 498ec73e1..7ad94f382 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,7 +1,8 @@ plugins { `java-library` id("connect.build-logic") - id("io.freefair.lombok") version "8.6" apply false + id("io.freefair.lombok") version "9.2.0" apply false + id("org.jetbrains.kotlin.jvm") apply false } allprojects { @@ -11,27 +12,36 @@ allprojects { "Connects the server/proxy to the global Connect network to reach more players while also supporting online mode server, bungee or velocity mode. Visit https://minekube.com/connect" } -val deployProjects = setOf( - projects.api, - // for future Connect integration + Fabric - projects.core, - projects.bungee, - projects.spigot, - projects.velocity -).map { it.dependencyProject } +val deployProjectPaths = setOf( + ":api", + ":core", + ":bungee", + ":spigot", + ":velocity", +) + +val shareProjectPaths = setOf( + ":share", + ":share:common", + ":share:fabric-common", + ":share:fabric-1-21-11", + ":share:fabric-26-2", +) //todo re-add checkstyle when we switch back to 2 space indention // and take a look again at spotbugs someday subprojects { - apply { - plugin("java-library") - plugin("io.freefair.lombok") - plugin("connect.build-logic") - } + if (path !in shareProjectPaths) { + apply { + plugin("java-library") + plugin("io.freefair.lombok") + plugin("connect.build-logic") + } - when (this) { - in deployProjects -> plugins.apply("connect.shadow-conventions") - else -> plugins.apply("connect.base-conventions") + when (path) { + in deployProjectPaths -> plugins.apply("connect.shadow-conventions") + else -> plugins.apply("connect.base-conventions") + } } } diff --git a/bungee/src/main/java/com/minekube/connect/listener/BungeeLateReassertListener.java b/bungee/src/main/java/com/minekube/connect/listener/BungeeLateReassertListener.java index 5d7ee5033..6d867b6b3 100644 --- a/bungee/src/main/java/com/minekube/connect/listener/BungeeLateReassertListener.java +++ b/bungee/src/main/java/com/minekube/connect/listener/BungeeLateReassertListener.java @@ -84,9 +84,8 @@ private void reassert(PendingConnection connection, ConnectPlayer player) { } if (connection.isOnlineMode()) { connection.setOnlineMode(false); - logger.debug("Re-asserted offline mode for Connect session {} at pre-login; another " - + "plugin had changed it (set login-reassert.enabled to false to allow that)", - player.getUsername()); + logger.debug("Re-asserted offline mode for a Connect session at pre-login; another " + + "plugin had changed it (set login-reassert.enabled to false to allow that)"); } if (!config.getLoginReassert().isRestoreFullProfile()) { return; diff --git a/core/build.gradle.kts b/core/build.gradle.kts index 894ea3f4d..826d4dab6 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -56,11 +56,16 @@ tasks.test { relocate("org.bstats") -configure { - val constantsFile = "src/main/java/com/minekube/connect/util/Constants.java" - replaceToken("\${connectVersion}", fullVersion(), constantsFile) - replaceToken("\${branch}", branchName(), constantsFile) - replaceToken("\${buildNumber}", buildNumber(), constantsFile) +sourceSets { + main { + extensions.configure { + javaSources { + property("connectVersion", fullVersion()) + property("branch", branchName()) + property("buildNumber", buildNumber().toString()) + } + } + } } protobuf { diff --git a/core/src/main/java/com/minekube/connect/util/Constants.java b/core/src/main/java-templates/com/minekube/connect/util/Constants.java.peb similarity index 89% rename from core/src/main/java/com/minekube/connect/util/Constants.java rename to core/src/main/java-templates/com/minekube/connect/util/Constants.java.peb index 31c972381..e24eb0727 100644 --- a/core/src/main/java/com/minekube/connect/util/Constants.java +++ b/core/src/main/java-templates/com/minekube/connect/util/Constants.java.peb @@ -26,9 +26,9 @@ package com.minekube.connect.util; public final class Constants { - public static final String VERSION = "${connectVersion}"; - public static final int BUILD_NUMBER = Integer.parseInt("${buildNumber}"); - public static final String GIT_BRANCH = "${branch}"; + public static final String VERSION = "{{ connectVersion }}"; + public static final int BUILD_NUMBER = Integer.parseInt("{{ buildNumber }}"); + public static final String GIT_BRANCH = "{{ branch }}"; public static final int METRICS_ID = 14794; public static final char COLOR_CHAR = '§'; diff --git a/core/src/main/java/com/minekube/connect/ConnectPlatform.java b/core/src/main/java/com/minekube/connect/ConnectPlatform.java index 2e07a643a..2430691d3 100644 --- a/core/src/main/java/com/minekube/connect/ConnectPlatform.java +++ b/core/src/main/java/com/minekube/connect/ConnectPlatform.java @@ -52,6 +52,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.UUID; +import java.util.concurrent.atomic.AtomicBoolean; public class ConnectPlatform { private static final String DOMAIN_SUFFIX = ".play.minekube.net"; @@ -65,6 +66,9 @@ public class ConnectPlatform { private ConnectConfig config; private Injector guice; + private boolean embedded; + private boolean runtimeEnabled; + private final AtomicBoolean disabled = new AtomicBoolean(); public ConnectPlatform( ConnectApi api, @@ -101,16 +105,38 @@ public void init( ConfigHolder configHolder, PacketHandlers packetHandlers) { - if (!Files.isDirectory(dataDirectory)) { - try { - Files.createDirectory(dataDirectory); - } catch (IOException exception) { - logger.error("Failed to create the data folder", exception); - throw new RuntimeException("Failed to create the data folder", exception); - } + ensureDataDirectory(dataDirectory); + ConnectConfig loadedConfig = configLoader.load(); + initialize(loadedConfig, configHolder, packetHandlers); + } + + public void initEmbedded( + Path dataDirectory, + ConnectConfig config, + ConfigHolder configHolder, + PacketHandlers packetHandlers) { + ensureDataDirectory(dataDirectory); + embedded = true; + initialize(config, configHolder, packetHandlers); + } + + private void ensureDataDirectory(Path dataDirectory) { + if (Files.isDirectory(dataDirectory)) { + return; } + try { + Files.createDirectories(dataDirectory); + } catch (IOException exception) { + logger.error("Failed to create the data folder", exception); + throw new RuntimeException("Failed to create the data folder", exception); + } + } - config = configLoader.load(); + private void initialize( + ConnectConfig initializedConfig, + ConfigHolder configHolder, + PacketHandlers packetHandlers) { + config = initializedConfig; if (config.isDebug()) { logger.enableDebug(); logger.debug("Debug mode enabled"); @@ -146,8 +172,11 @@ public boolean enable(Module... postInitializeModules) { } this.guice = guice.createChildInjector(new PostInitializeModule(postInitializeModules)); + runtimeEnabled = true; - guice.getInstance(Metrics.class); + if (!embedded) { + guice.getInstance(Metrics.class); + } logger.info("Endpoint name: " + config.getEndpoint()); if (config.getSuperEndpoints() != null && !config.getSuperEndpoints().isEmpty()) { @@ -155,21 +184,28 @@ public boolean enable(Module... postInitializeModules) { } logger.info("Your public address: " + config.getEndpoint() + DOMAIN_SUFFIX); - // Check for updates asynchronously - guice.getInstance(UpdateChecker.class).checkForUpdates(); + if (!embedded) { + // Check for updates asynchronously + guice.getInstance(UpdateChecker.class).checkForUpdates(); + } return true; } public boolean disable() { + if (!disabled.compareAndSet(false, true)) { + return true; + } try { - try { - guice.getInstance(Libp2pEndpoint.class).stop(); - } catch (ConfigurationException ignored) { + if (runtimeEnabled || !embedded) { + try { + guice.getInstance(Libp2pEndpoint.class).stop(); + } catch (ConfigurationException ignored) { + } + guice.getInstance(WatchHealthServer.class).stop(); + guice.getInstance(WatcherRegister.class).stop(); + guice.getInstance(Tunneler.class).close(); } - guice.getInstance(WatchHealthServer.class).stop(); - guice.getInstance(WatcherRegister.class).stop(); - guice.getInstance(Tunneler.class).close(); } finally { try { admissionCoordinator.close(); diff --git a/core/src/main/java/com/minekube/connect/api/SimpleConnectApi.java b/core/src/main/java/com/minekube/connect/api/SimpleConnectApi.java index e8b1f15c5..0adc63b06 100644 --- a/core/src/main/java/com/minekube/connect/api/SimpleConnectApi.java +++ b/core/src/main/java/com/minekube/connect/api/SimpleConnectApi.java @@ -32,6 +32,7 @@ import com.minekube.connect.api.logger.ConnectLogger; import com.minekube.connect.api.player.ConnectPlayer; import com.minekube.connect.api.player.bedrock.BedrockIdentityClaims; +import com.minekube.connect.api.player.principal.VerifiedBedrockPrincipal; import com.minekube.connect.bedrock.VerifiedBedrockIdentityRegistry; import java.util.Collection; import java.util.Map; @@ -105,6 +106,16 @@ public Optional getVerifiedBedrockIdentity(ConnectPlayer : verifiedBedrockIdentities.get(player); } + @Override + public Optional getVerifiedBedrockPrincipal(ConnectPlayer player) { + if (player == null || players.get(player.getUniqueId()) != player) { + return Optional.empty(); + } + return verifiedBedrockIdentities == null + ? Optional.empty() + : verifiedBedrockIdentities.getPrincipal(player); + } + /** * This method is invoked when the player is no longer on the server, but the related platform- * dependant event hasn't fired yet diff --git a/core/src/main/java/com/minekube/connect/bedrock/BedrockAdmissionCoordinator.java b/core/src/main/java/com/minekube/connect/bedrock/BedrockAdmissionCoordinator.java index 08b9ca7c0..8201d27c8 100644 --- a/core/src/main/java/com/minekube/connect/bedrock/BedrockAdmissionCoordinator.java +++ b/core/src/main/java/com/minekube/connect/bedrock/BedrockAdmissionCoordinator.java @@ -7,6 +7,7 @@ import com.minekube.connect.api.player.ConnectPlayer; import com.minekube.connect.api.player.GameProfile; import com.minekube.connect.api.player.bedrock.BedrockIdentityProfiles; +import com.minekube.connect.api.player.principal.VerifiedBedrockPrincipal; import com.minekube.connect.player.ConnectPlayerImpl; import com.minekube.connect.watch.SessionProposal; import java.util.ArrayList; @@ -30,6 +31,7 @@ public final class BedrockAdmissionCoordinator implements AutoCloseable { private static final long ADMISSION_TTL_SECONDS = 30; private final VerifiedBedrockIdentityRegistry identities; + private final BedrockPrincipalConsumer principalConsumer; private final ScheduledExecutorService cleanupExecutor; private final Map admissions = new HashMap<>(); private final Map latestBySession = new HashMap<>(); @@ -38,14 +40,28 @@ public final class BedrockAdmissionCoordinator implements AutoCloseable { private boolean closed; @Inject + public BedrockAdmissionCoordinator( + VerifiedBedrockIdentityRegistry identities, + BedrockPrincipalConsumer principalConsumer) { + this(identities, principalConsumer, newCleanupExecutor()); + } + public BedrockAdmissionCoordinator(VerifiedBedrockIdentityRegistry identities) { - this(identities, newCleanupExecutor()); + this(identities, null, newCleanupExecutor()); } BedrockAdmissionCoordinator( VerifiedBedrockIdentityRegistry identities, ScheduledExecutorService cleanupExecutor) { + this(identities, null, cleanupExecutor); + } + + private BedrockAdmissionCoordinator( + VerifiedBedrockIdentityRegistry identities, + BedrockPrincipalConsumer principalConsumer, + ScheduledExecutorService cleanupExecutor) { this.identities = Objects.requireNonNull(identities, "identities"); + this.principalConsumer = principalConsumer; this.cleanupExecutor = Objects.requireNonNull(cleanupExecutor, "cleanupExecutor"); } @@ -85,7 +101,12 @@ public synchronized ConnectPlayer stage(SessionProposal proposal) { admission.state != AdmissionState.PENDING) { throw new IllegalStateException("Bedrock admission has expired or been superseded"); } - ConnectPlayer publicPlayer = publicPlayer(playerFor(admission.raw)); + if (principalConsumer != null) { + admission.principal = principalConsumer.verify(admission.raw).orElse(null); + } + ConnectPlayer publicPlayer = admission.principal == null + ? publicPlayer(playerFor(admission.raw)) + : playerFor(admission.raw, admission.principal); admission.player = publicPlayer; players.put(publicPlayer, token); return publicPlayer; @@ -116,8 +137,10 @@ public BedrockIdentityEnforcer.Decision verify( } BedrockIdentityEnforcer.Decision decision; try { - decision = enforcer.verifyAdmissionSnapshot( - player, rawProfile, endpointId, endpointOrgId, protocol); + decision = admission.principal == null + ? enforcer.verifyAdmissionSnapshot( + player, rawProfile, endpointId, endpointOrgId, protocol) + : BedrockIdentityEnforcer.Decision.allowed(null); } catch (RuntimeException | Error e) { synchronized (this) { removeAdmission(admission); @@ -138,6 +161,9 @@ public BedrockIdentityEnforcer.Decision verify( if (decision.verifiedClaims() != null) { identities.record(player, token.generation, decision.verifiedClaims()); } + if (admission.principal != null) { + identities.recordPrincipal(player, token.generation, admission.principal); + } return decision; } } @@ -193,6 +219,16 @@ public static ConnectPlayer playerFor(Session session) { ""); } + private static ConnectPlayer playerFor(Session session, VerifiedBedrockPrincipal principal) { + Player player = session.getPlayer(); + var effective = principal.effectiveGameProfile(); + GameProfile original = profile(player); + GameProfile selected = new GameProfile( + effective.name(), effective.uuid(), BedrockIdentityProfiles.withoutEnvelope(original).getProperties()); + return new ConnectPlayerImpl( + session.getId(), selected, new Auth(session.getAuth().getPassthrough()), ""); + } + private static GameProfile profile(Player player) { return new GameProfile( player.getProfile().getName(), @@ -273,6 +309,7 @@ private static final class Admission { private final AdmissionToken token; private final String sessionId; private ConnectPlayer player; + private VerifiedBedrockPrincipal principal; private ScheduledFuture cleanup; private AdmissionState state = AdmissionState.PENDING; diff --git a/core/src/main/java/com/minekube/connect/bedrock/BedrockPrincipalAdmissionException.java b/core/src/main/java/com/minekube/connect/bedrock/BedrockPrincipalAdmissionException.java new file mode 100644 index 000000000..a8d014e80 --- /dev/null +++ b/core/src/main/java/com/minekube/connect/bedrock/BedrockPrincipalAdmissionException.java @@ -0,0 +1,18 @@ +package com.minekube.connect.bedrock; + +import com.minekube.connect.api.player.principal.PrincipalError; +import java.util.Objects; + +/** Privacy-safe boundary exception used before a platform profile is applied. */ +public final class BedrockPrincipalAdmissionException extends RuntimeException { + private final PrincipalError error; + + BedrockPrincipalAdmissionException(PrincipalError error) { + super(Objects.requireNonNull(error, "error").name(), null, false, false); + this.error = error; + } + + public PrincipalError error() { + return error; + } +} diff --git a/core/src/main/java/com/minekube/connect/bedrock/BedrockPrincipalConfiguration.java b/core/src/main/java/com/minekube/connect/bedrock/BedrockPrincipalConfiguration.java new file mode 100644 index 000000000..9dc8f404d --- /dev/null +++ b/core/src/main/java/com/minekube/connect/bedrock/BedrockPrincipalConfiguration.java @@ -0,0 +1,61 @@ +package com.minekube.connect.bedrock; + +import com.minekube.connect.config.ConnectConfig.BedrockPrincipalConfig; +import java.net.URI; + +/** Closed local generation-2 configuration view. */ +final class BedrockPrincipalConfiguration { + static final String METADATA_PATH = "/.well-known/minekube-connect/bedrock-principal-v2.json"; + + private final boolean capable; + private final boolean required; + + private BedrockPrincipalConfiguration(boolean capable, boolean required) { + this.capable = capable; + this.required = required; + } + + static BedrockPrincipalConfiguration from(BedrockPrincipalConfig config) { + if (config == null) return new BedrockPrincipalConfiguration(false, false); + boolean required = config.getConfigGeneration() == 2 && "require".equals(config.getMode()); + boolean capable = required + && bounded(config.getIssuer(), 128) + && bounded(config.getTrustDomain(), 256) + && bounded(config.getAudience(), 256) + && validOrigin(config.getMetadataOrigin(), config.getTrustDomain()) + && METADATA_PATH.equals(config.getMetadataPath()); + return new BedrockPrincipalConfiguration(capable, required); + } + + boolean isCapable() { + return capable; + } + + boolean isRequired() { + return required; + } + + private static boolean bounded(String value, int maximum) { + return value != null && !value.isEmpty() + && value.getBytes(java.nio.charset.StandardCharsets.UTF_8).length <= maximum; + } + + private static boolean validOrigin(String value, String trustDomain) { + if (value == null) return false; + try { + URI origin = URI.create(value); + boolean valid = "https".equals(origin.getScheme()) + && origin.getHost() != null + && origin.getRawUserInfo() == null + && origin.getPort() == -1 + && (origin.getRawPath() == null || origin.getRawPath().isEmpty()) + && origin.getRawQuery() == null + && origin.getRawFragment() == null; + if (!valid) return false; + return !"urn:minekube:connect:production".equals(trustDomain) + || "https://connect.minekube.com".equals(value); + } catch (RuntimeException ignored) { + return false; + } + } +} diff --git a/core/src/main/java/com/minekube/connect/bedrock/BedrockPrincipalConsumer.java b/core/src/main/java/com/minekube/connect/bedrock/BedrockPrincipalConsumer.java new file mode 100644 index 000000000..8f0e82136 --- /dev/null +++ b/core/src/main/java/com/minekube/connect/bedrock/BedrockPrincipalConsumer.java @@ -0,0 +1,153 @@ +package com.minekube.connect.bedrock; + +import com.google.inject.Inject; +import com.google.inject.Singleton; +import com.google.protobuf.ByteString; +import com.minekube.connect.api.player.bedrock.BedrockIdentityProfiles; +import com.minekube.connect.api.player.principal.BedrockPrincipalVerifier; +import com.minekube.connect.api.player.principal.BedrockPrincipalVerifierFactory; +import com.minekube.connect.api.player.principal.PrincipalError; +import com.minekube.connect.api.player.principal.PrincipalVerificationException; +import com.minekube.connect.api.player.principal.SignedPrincipalEnvelope; +import com.minekube.connect.api.player.principal.TrustedProposalContext; +import com.minekube.connect.api.player.principal.VerifiedBedrockPrincipal; +import com.minekube.connect.api.player.principal.VerifierConfiguration; +import com.minekube.connect.config.ConfigHolder; +import com.minekube.connect.config.ConnectConfig; +import java.nio.ByteBuffer; +import java.nio.charset.CharacterCodingException; +import java.nio.charset.CodingErrorAction; +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.util.Base64; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.function.Supplier; +import minekube.connect.v1alpha1.WatchServiceOuterClass.Session; +import minekube.connect.v1alpha1.WatchServiceOuterClass.SessionProtocol; + +/** Consumes the frozen opaque Watch/libp2p v2 fields before host profile application. */ +@Singleton +public final class BedrockPrincipalConsumer { + private static final int MAX_ENVELOPE_BYTES = 16 * 1024; + private final Supplier config; + private final Clock clock; + private BedrockPrincipalVerifier verifier; + + @Inject + public BedrockPrincipalConsumer(ConfigHolder configHolder) { + this(Objects.requireNonNull(configHolder, "configHolder")::get, Clock.systemUTC()); + } + + BedrockPrincipalConsumer(ConnectConfig config, Clock clock) { + this(() -> Objects.requireNonNull(config, "config"), clock); + } + + private BedrockPrincipalConsumer(Supplier config, Clock clock) { + this.config = Objects.requireNonNull(config, "config"); + this.clock = Objects.requireNonNull(clock, "clock"); + } + + public Optional verify(Session session) { + Objects.requireNonNull(session, "session"); + if (hasInjectedProperty(session)) { + throw new BedrockPrincipalAdmissionException(PrincipalError.BINDING_MISMATCH); + } + ConnectConfig currentConfig; + BedrockPrincipalConfiguration principalConfiguration; + try { + currentConfig = config(); + principalConfiguration = BedrockPrincipalConfiguration.from( + currentConfig.getBedrockPrincipal()); + } catch (RuntimeException ignored) { + throw new BedrockPrincipalAdmissionException(PrincipalError.READINESS); + } + if (session.getSignedBedrockPrincipalV2().isEmpty()) { + if (principalConfiguration.isRequired()) { + throw new BedrockPrincipalAdmissionException(PrincipalError.READINESS); + } + return Optional.empty(); + } + if (!principalConfiguration.isCapable()) { + throw new BedrockPrincipalAdmissionException(PrincipalError.READINESS); + } + if (session.getProtocol() != SessionProtocol.SESSION_PROTOCOL_BEDROCK + || session.getConnectSessionNonce().size() != 16 + || session.getSourceProtocolVersion() < 1 + || session.getPolicyRevision() <= 0 + || session.getEndpointId().isEmpty() + || session.getOrganizationId().isEmpty() + || session.getId().isEmpty()) { + throw new BedrockPrincipalAdmissionException(PrincipalError.BINDING_MISMATCH); + } + ConnectConfig.BedrockPrincipalConfig principalConfig = currentConfig.getBedrockPrincipal(); + TrustedProposalContext expected = new TrustedProposalContext( + principalConfig.getIssuer(), principalConfig.getTrustDomain(), principalConfig.getAudience(), + session.getEndpointId(), session.getOrganizationId(), session.getId(), + session.getConnectSessionNonce().toByteArray(), "bedrock", + session.getSourceProtocolVersion(), session.getPolicyRevision()); + try { + ByteString envelope = session.getSignedBedrockPrincipalV2(); + if (envelope.size() > MAX_ENVELOPE_BYTES) { + throw new BedrockPrincipalAdmissionException(PrincipalError.MALFORMED); + } + return Optional.of(verifier().verifyAndConsume( + SignedPrincipalEnvelope.of(strictUtf8(envelope.toByteArray())), + expected)); + } catch (PrincipalVerificationException error) { + throw new BedrockPrincipalAdmissionException(error.error()); + } catch (IllegalArgumentException | CharacterCodingException ignored) { + throw new BedrockPrincipalAdmissionException(PrincipalError.MALFORMED); + } + } + + private synchronized BedrockPrincipalVerifier verifier() { + if (verifier != null) return verifier; + ConnectConfig.BedrockPrincipalConfig principalConfig; + try { + principalConfig = config().getBedrockPrincipal(); + } catch (RuntimeException ignored) { + throw new BedrockPrincipalAdmissionException(PrincipalError.READINESS); + } + if (principalConfig == null) { + throw new BedrockPrincipalAdmissionException(PrincipalError.READINESS); + } + VerifierConfiguration.Builder configuration = VerifierConfiguration.builder().clock(clock); + Map pins = principalConfig.getPublicKeys(); + if (pins == null || pins.isEmpty()) { + throw new BedrockPrincipalAdmissionException(PrincipalError.METADATA_UNAVAILABLE); + } + try { + pins.forEach((kid, encoded) -> { + byte[] decoded = Base64.getUrlDecoder().decode(encoded); + if (!Base64.getUrlEncoder().withoutPadding().encodeToString(decoded).equals(encoded)) { + throw new IllegalArgumentException(); + } + configuration.publicKey(kid, decoded); + }); + verifier = BedrockPrincipalVerifierFactory.create(configuration.build()); + return verifier; + } catch (RuntimeException ignored) { + throw new BedrockPrincipalAdmissionException(PrincipalError.TRUST); + } + } + + private ConnectConfig config() { + return Objects.requireNonNull(config.get(), "config"); + } + + private static boolean hasInjectedProperty(Session session) { + return session.hasPlayer() && session.getPlayer().hasProfile() + && session.getPlayer().getProfile().getPropertiesList().stream() + .anyMatch(property -> BedrockIdentityProfiles.PRINCIPAL_V2_PROPERTY_NAME + .equals(property.getName())); + } + + private static String strictUtf8(byte[] value) throws CharacterCodingException { + return StandardCharsets.UTF_8.newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT) + .decode(ByteBuffer.wrap(value)).toString(); + } +} diff --git a/core/src/main/java/com/minekube/connect/bedrock/BedrockPrincipalReadiness.java b/core/src/main/java/com/minekube/connect/bedrock/BedrockPrincipalReadiness.java new file mode 100644 index 000000000..e8b6aef90 --- /dev/null +++ b/core/src/main/java/com/minekube/connect/bedrock/BedrockPrincipalReadiness.java @@ -0,0 +1,156 @@ +package com.minekube.connect.bedrock; + +import com.google.protobuf.ByteString; +import com.minekube.connect.api.player.principal.BedrockPrincipalVerifierFactory; +import com.minekube.connect.api.player.principal.VerifierConfiguration; +import com.minekube.connect.config.ConnectConfig; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Clock; +import java.util.ArrayList; +import java.util.Base64; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import minekube.connect.v1alpha1.WatchServiceOuterClass.PrincipalError; +import minekube.connect.v1alpha1.WatchServiceOuterClass.ReadinessAttestation; +import minekube.connect.v1alpha1.WatchServiceOuterClass.ReadinessChallenge; +import minekube.connect.v1alpha1.WatchServiceOuterClass.TunnelTransport; + +/** Honest generation-2 capability and challenge answers for the signed-principal consumer. */ +public final class BedrockPrincipalReadiness { + public static final String CAPABILITY = "bedrock-verified-principal-v2"; + private static final String MODE = "require"; + private static final String CORE_VECTOR_SHA256 = + "4f2a442ee71bfd35af2ef1f3944489d17551aa77fed2c08220f2aa77032b6196"; + + public enum Transport { + WATCH(TunnelTransport.Type.TYPE_WEBSOCKET), + LIBP2P(TunnelTransport.Type.TYPE_LIBP2P); + + private final TunnelTransport.Type wireType; + + Transport(TunnelTransport.Type wireType) { + this.wireType = wireType; + } + } + + private final ConnectConfig config; + private final Clock clock; + + public BedrockPrincipalReadiness(ConnectConfig config) { + this(config, Clock.systemUTC()); + } + + BedrockPrincipalReadiness(ConnectConfig config, Clock clock) { + this.config = Objects.requireNonNull(config, "config"); + this.clock = Objects.requireNonNull(clock, "clock"); + } + + public boolean isReady() { + try { + ConnectConfig.BedrockPrincipalConfig principal = config.getBedrockPrincipal(); + return BedrockPrincipalConfiguration.from(principal).isCapable() + && usablePins(principal.getPublicKeys()); + } catch (RuntimeException ignored) { + return false; + } + } + + public byte[] revision() { + if (!isReady()) return new byte[0]; + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + ConnectConfig.BedrockPrincipalConfig principal = config.getBedrockPrincipal(); + update(digest, Integer.toString(principal.getConfigGeneration())); + update(digest, principal.getMode()); + update(digest, principal.getIssuer()); + update(digest, principal.getTrustDomain()); + update(digest, principal.getAudience()); + update(digest, principal.getMetadataOrigin()); + update(digest, principal.getMetadataPath()); + update(digest, CORE_VECTOR_SHA256); + principal.getPublicKeys().entrySet().stream() + .sorted(Map.Entry.comparingByKey(Comparator.naturalOrder())) + .forEach(entry -> { + update(digest, entry.getKey()); + update(digest, entry.getValue()); + }); + return digest.digest(); + } catch (NoSuchAlgorithmException impossible) { + throw new IllegalStateException("SHA-256 unavailable"); + } + } + + public List capabilities(List configuredCapabilities, Transport transport) { + Objects.requireNonNull(transport, "transport"); + List capabilities = new ArrayList<>(configuredCapabilities); + capabilities.removeIf(CAPABILITY::equals); + if (isReady()) capabilities.add(CAPABILITY); + return List.copyOf(capabilities); + } + + public ReadinessAttestation attest(ReadinessChallenge challenge, Transport transport) { + Objects.requireNonNull(challenge, "challenge"); + Objects.requireNonNull(transport, "transport"); + long now = clock.instant().getEpochSecond(); + boolean valid = validChallenge(challenge, transport, now); + boolean ready = valid && isReady(); + ReadinessAttestation.Builder answer = ReadinessAttestation.newBuilder() + .setChallenge(challenge) + .setCapability(CAPABILITY) + .setMode(MODE) + .setObservedAtUnix(now); + byte[] revision = revision(); + if (revision.length == 32) answer.setReadinessRevision(ByteString.copyFrom(revision)); + if (ready) { + return answer.setResult(ReadinessAttestation.Result.RESULT_READY).build(); + } + return answer.setResult(ReadinessAttestation.Result.RESULT_NOT_READY) + .setReason(PrincipalError.PRINCIPAL_ERROR_READINESS) + .build(); + } + + private static boolean validChallenge(ReadinessChallenge challenge, Transport transport, long now) { + return !challenge.getRequestId().isEmpty() + && challenge.getNonce().size() == 16 + && !challenge.getEndpointId().isEmpty() + && !challenge.getOrganizationId().isEmpty() + && !challenge.getConnectorInstanceId().isEmpty() + && !challenge.getLeaseId().isEmpty() + && challenge.getTransport() == transport.wireType + && challenge.getPolicyRevision() > 0 + && challenge.getExpiresAtUnix() - challenge.getIssuedAtUnix() == 30 + && now >= challenge.getIssuedAtUnix() + && now <= challenge.getExpiresAtUnix(); + } + + private static boolean usablePins(Map pins) { + if (pins == null || pins.isEmpty()) return false; + try { + VerifierConfiguration.Builder configuration = VerifierConfiguration.builder(); + for (Map.Entry pin : pins.entrySet()) { + if (pin.getKey() == null || pin.getValue() == null) return false; + byte[] decoded = Base64.getUrlDecoder().decode(pin.getValue()); + if (decoded.length != 32 || !Base64.getUrlEncoder().withoutPadding() + .encodeToString(decoded).equals(pin.getValue())) return false; + configuration.publicKey(pin.getKey(), decoded); + } + BedrockPrincipalVerifierFactory.create(configuration.build()); + return true; + } catch (RuntimeException ignored) { + return false; + } + } + + private static void update(MessageDigest digest, String value) { + byte[] bytes = value.getBytes(StandardCharsets.UTF_8); + digest.update((byte) (bytes.length >>> 24)); + digest.update((byte) (bytes.length >>> 16)); + digest.update((byte) (bytes.length >>> 8)); + digest.update((byte) bytes.length); + digest.update(bytes); + } +} diff --git a/core/src/main/java/com/minekube/connect/bedrock/VerifiedBedrockIdentityRegistry.java b/core/src/main/java/com/minekube/connect/bedrock/VerifiedBedrockIdentityRegistry.java index 6d0124289..91d9b8125 100644 --- a/core/src/main/java/com/minekube/connect/bedrock/VerifiedBedrockIdentityRegistry.java +++ b/core/src/main/java/com/minekube/connect/bedrock/VerifiedBedrockIdentityRegistry.java @@ -3,6 +3,7 @@ import com.google.inject.Singleton; import com.minekube.connect.api.player.ConnectPlayer; import com.minekube.connect.api.player.bedrock.BedrockIdentityClaims; +import com.minekube.connect.api.player.principal.VerifiedBedrockPrincipal; import java.util.IdentityHashMap; import java.util.Map; import java.util.Objects; @@ -34,13 +35,39 @@ synchronized void record( } } + synchronized void recordPrincipal( + ConnectPlayer player, + long generation, + VerifiedBedrockPrincipal principal) { + ensureOpen(); + Objects.requireNonNull(player, "player"); + Objects.requireNonNull(principal, "principal"); + if (!player.getSessionId().equals(principal.bindings().connectSessionId())) { + throw new IllegalArgumentException("Bedrock principal session mismatch"); + } + VerifiedIdentity current = identities.get(player); + if (current == null || current.generation <= generation) { + identities.put(player, new VerifiedIdentity( + generation, player.getSessionId(), null, principal)); + } + } + public synchronized Optional get(ConnectPlayer player) { Objects.requireNonNull(player, "player"); VerifiedIdentity identity = identities.get(player); if (identity == null || !player.getSessionId().equals(identity.sessionId)) { return Optional.empty(); } - return Optional.of(identity.claims); + return Optional.ofNullable(identity.claims); + } + + public synchronized Optional getPrincipal(ConnectPlayer player) { + Objects.requireNonNull(player, "player"); + VerifiedIdentity identity = identities.get(player); + if (identity == null || !player.getSessionId().equals(identity.sessionId)) { + return Optional.empty(); + } + return Optional.ofNullable(identity.principal); } public synchronized void remove(ConnectPlayer player) { @@ -67,14 +94,24 @@ private static final class VerifiedIdentity { private final long generation; private final String sessionId; private final BedrockIdentityClaims claims; + private final VerifiedBedrockPrincipal principal; private VerifiedIdentity( long generation, String sessionId, BedrockIdentityClaims claims) { + this(generation, sessionId, claims, null); + } + + private VerifiedIdentity( + long generation, + String sessionId, + BedrockIdentityClaims claims, + VerifiedBedrockPrincipal principal) { this.generation = generation; this.sessionId = sessionId; this.claims = claims; + this.principal = principal; } } } diff --git a/core/src/main/java/com/minekube/connect/config/ConnectConfig.java b/core/src/main/java/com/minekube/connect/config/ConnectConfig.java index 85d7aaa0e..2577313a9 100644 --- a/core/src/main/java/com/minekube/connect/config/ConnectConfig.java +++ b/core/src/main/java/com/minekube/connect/config/ConnectConfig.java @@ -28,6 +28,10 @@ import com.minekube.connect.util.Utils; import java.util.Collections; import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; +import java.util.regex.Pattern; import lombok.Getter; /** @@ -36,6 +40,9 @@ */ @Getter public class ConnectConfig { + private static final Pattern ENDPOINT_PATTERN = + Pattern.compile("^[a-z0-9][a-z0-9-]{2,62}$"); + private String defaultLocale; private MetricsConfig metrics; @@ -47,7 +54,7 @@ public class ConnectConfig { * The endpoint name of this instance that is registered when calling the watch service for * listening for sessions for this endpoint. */ - private final String endpoint = Utils.randomString(5); // default to random name + private final String endpoint; /** * Whether cracked players should be allowed to join. @@ -60,6 +67,12 @@ public class ConnectConfig { */ private BedrockIdentityConfig bedrockIdentity = new BedrockIdentityConfig(); + /** + * Generation-2 signed-principal settings. A missing section remains generation zero and + * cannot advertise v2, preserving existing configuration files without migration. + */ + private BedrockPrincipalConfig bedrockPrincipal = new BedrockPrincipalConfig(); + /** * Optional parent endpoint names sent to WatchService as Connect-Endpoint-Parents. * Connect Java does not expose a public endpoint-control API. @@ -68,6 +81,27 @@ public class ConnectConfig { private static final String ENDPOINT_ENV = System.getenv("CONNECT_ENDPOINT"); + public ConnectConfig() { + this(Utils.randomString(5)); + } + + private ConnectConfig(String endpoint) { + this.endpoint = endpoint; + } + + public static ConnectConfig embedded(String endpoint, boolean allowOfflineModePlayers) { + String value = Objects.requireNonNull(endpoint, "endpoint"); + if (!ENDPOINT_PATTERN.matcher(value).matches()) { + throw new IllegalArgumentException("Invalid Connect endpoint name"); + } + ConnectConfig config = new ConnectConfig(value); + config.allowOfflineModePlayers = allowOfflineModePlayers; + config.metrics = new MetricsConfig(); + config.metrics.disabled = true; + config.metrics.uuid = UUID.randomUUID().toString(); + return config; + } + public String getEndpoint() { if (ENDPOINT_ENV != null && !ENDPOINT_ENV.isEmpty()) { return ENDPOINT_ENV; @@ -127,4 +161,24 @@ public static class BedrockIdentityConfig { */ private String expectedPolicy = "trusted_bedrock_xuid"; } + + @Getter + public static class BedrockPrincipalConfig { + /** Exact local configuration generation. Only generation 2 can become v2-capable. */ + private int configGeneration; + /** Exact v2 enforcement mode. Only require can advertise v2 readiness. */ + private String mode = "disabled"; + /** Locally trusted issuer. */ + private String issuer = ""; + /** Locally trusted administrative key namespace. */ + private String trustDomain = ""; + /** Locally trusted singleton audience. */ + private String audience = ""; + /** Pinned HTTPS metadata origin, without a path. */ + private String metadataOrigin = ""; + /** Frozen metadata path. */ + private String metadataPath = "/.well-known/minekube-connect/bedrock-principal-v2.json"; + /** Optional static Ed25519 pins keyed by kid are configured by host integration. */ + private Map publicKeys = Collections.emptyMap(); + } } diff --git a/core/src/main/java/com/minekube/connect/identity/EndpointTokenStore.java b/core/src/main/java/com/minekube/connect/identity/EndpointTokenStore.java new file mode 100644 index 000000000..756e7afde --- /dev/null +++ b/core/src/main/java/com/minekube/connect/identity/EndpointTokenStore.java @@ -0,0 +1,151 @@ +/* + * Copyright (c) 2019-2022 GeyserMC. http://geysermc.org + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + * @author GeyserMC + * @link https://github.com/GeyserMC/Floodgate + */ + +package com.minekube.connect.identity; + +import static java.nio.file.StandardCopyOption.ATOMIC_MOVE; +import static java.nio.file.StandardCopyOption.REPLACE_EXISTING; +import static java.nio.file.attribute.PosixFilePermission.OWNER_READ; +import static java.nio.file.attribute.PosixFilePermission.OWNER_WRITE; + +import com.google.gson.Gson; +import com.google.gson.JsonParseException; +import com.minekube.connect.util.Utils; +import java.io.IOException; +import java.io.Reader; +import java.io.Writer; +import java.nio.charset.StandardCharsets; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; + +public final class EndpointTokenStore { + public static final String ENV_TOKEN = "CONNECT_TOKEN"; + + private static final Gson GSON = new Gson(); + private static final Set OWNER_ONLY = + Set.of(OWNER_READ, OWNER_WRITE); + + public Optional load( + Path tokenFile, + Map environment + ) throws IOException { + Objects.requireNonNull(tokenFile, "tokenFile"); + Objects.requireNonNull(environment, "environment"); + + String environmentToken = environment.get(ENV_TOKEN); + if (environmentToken != null) { + return Optional.of(validate(environmentToken)); + } + if (!Files.exists(tokenFile)) { + return Optional.empty(); + } + + try (Reader reader = Files.newBufferedReader(tokenFile, StandardCharsets.UTF_8)) { + TokenDocument document = GSON.fromJson(reader, TokenDocument.class); + if (document == null) { + throw new IllegalArgumentException("Connect token file is empty"); + } + return Optional.of(validate(document.token)); + } catch (JsonParseException exception) { + throw new IOException("Connect token file is not valid JSON", exception); + } + } + + public String loadOrCreate( + Path tokenFile, + Map environment + ) throws IOException { + Optional existing = load(tokenFile, environment); + if (existing.isPresent()) { + return existing.get(); + } + + String token = generate(); + save(tokenFile, token); + return token; + } + + public void save(Path tokenFile, String token) throws IOException { + Objects.requireNonNull(tokenFile, "tokenFile"); + String validToken = validate(token); + Path target = tokenFile.toAbsolutePath(); + Path parent = Objects.requireNonNull(target.getParent(), "tokenFile parent"); + Files.createDirectories(parent); + + Path temporary = Files.createTempFile(parent, target.getFileName() + ".", ".tmp"); + try { + try (Writer writer = Files.newBufferedWriter(temporary, StandardCharsets.UTF_8)) { + GSON.toJson(new TokenDocument(validToken), writer); + } + applyOwnerOnlyPermissions(temporary); + try { + Files.move(temporary, target, ATOMIC_MOVE, REPLACE_EXISTING); + } catch (AtomicMoveNotSupportedException exception) { + Files.move(temporary, target, REPLACE_EXISTING); + } + } finally { + Files.deleteIfExists(temporary); + } + } + + public String generate() { + return "T-" + Utils.randomSecureString(20); + } + + public static String redact(String token) { + return ""; + } + + private static String validate(String token) { + if (token == null + || token.isBlank() + || !token.startsWith("T-") + || token.length() == 2 + || token.chars().anyMatch(Character::isWhitespace)) { + throw new IllegalArgumentException( + "Connect token must start with T- and contain a non-blank value"); + } + return token; + } + + private static void applyOwnerOnlyPermissions(Path file) throws IOException { + if (file.getFileSystem().supportedFileAttributeViews().contains("posix")) { + Files.setPosixFilePermissions(file, OWNER_ONLY); + } + } + + private static final class TokenDocument { + private final String token; + + private TokenDocument(String token) { + this.token = token; + } + } +} diff --git a/core/src/main/java/com/minekube/connect/module/CommonModule.java b/core/src/main/java/com/minekube/connect/module/CommonModule.java index 95d9f689c..fc890d691 100644 --- a/core/src/main/java/com/minekube/connect/module/CommonModule.java +++ b/core/src/main/java/com/minekube/connect/module/CommonModule.java @@ -25,14 +25,11 @@ package com.minekube.connect.module; -import static com.google.common.base.Preconditions.checkNotNull; - -import com.google.gson.Gson; -import com.google.gson.annotations.SerializedName; import com.google.inject.AbstractModule; import com.google.inject.Provides; import com.google.inject.Singleton; import com.google.inject.multibindings.Multibinder; +import com.google.inject.multibindings.OptionalBinder; import com.google.inject.name.Named; import com.minekube.connect.api.ConnectApi; import com.minekube.connect.api.SimpleConnectApi; @@ -41,11 +38,13 @@ import com.minekube.connect.api.packet.PacketHandlers; import com.minekube.connect.bedrock.BedrockIdentityKeyProvider; import com.minekube.connect.bedrock.BedrockIdentityReadiness; +import com.minekube.connect.bedrock.BedrockPrincipalReadiness; import com.minekube.connect.config.ConfigHolder; import com.minekube.connect.config.ConfigLoader; import com.minekube.connect.config.ConfigLoader.EndpointNameGenerator; import com.minekube.connect.config.ConnectConfig; import com.minekube.connect.inject.CommonPlatformInjector; +import com.minekube.connect.identity.EndpointTokenStore; import com.minekube.connect.packet.PacketHandlersImpl; import com.minekube.connect.platform.util.PlatformUtils; import com.minekube.connect.tunnel.TunnelClientTransport; @@ -55,14 +54,10 @@ import com.minekube.connect.util.HttpUtils; import com.minekube.connect.util.LanguageManager; import com.minekube.connect.util.Metrics; -import com.minekube.connect.util.Utils; -import java.io.FileWriter; +import com.minekube.connect.watch.AllowAllSessionAdmissionGate; +import com.minekube.connect.watch.SessionAdmissionGate; import java.io.IOException; -import java.io.Reader; -import java.io.Writer; -import java.nio.file.Files; import java.nio.file.Path; -import java.util.Optional; import java.util.concurrent.TimeUnit; import lombok.RequiredArgsConstructor; import okhttp3.OkHttpClient; @@ -84,6 +79,9 @@ protected void configure() { Multibinder.newSetBinder(binder(), TunnelClientTransport.class); transports.addBinding().to(WebSocketTunnelTransport.class); transports.addBinding().to(Libp2pTunnelTransport.class); + OptionalBinder.newOptionalBinder(binder(), SessionAdmissionGate.class) + .setDefault() + .to(AllowAllSessionAdmissionGate.class); } @Provides @@ -137,19 +135,25 @@ public BedrockIdentityReadiness bedrockIdentityReadiness( return new BedrockIdentityReadiness(configHolder.get(), keyProvider); } + @Provides + @Singleton + public BedrockPrincipalReadiness bedrockPrincipalReadiness(ConfigHolder configHolder) { + return new BedrockPrincipalReadiness(configHolder.get()); + } + + @Provides + @Singleton + public EndpointTokenStore endpointTokenStore() { + return new EndpointTokenStore(); + } + @Provides @Singleton @Named("connectToken") - public String connectToken() throws IOException { - Path tokenFile = dataDirectory.resolve("token.json"); - - Optional token = Token.load(tokenFile); - if (!token.isPresent()) { - String t = Token.generate(); - Token.save(tokenFile, t); - token = Optional.of(t); - } - return token.get(); + public String connectToken(EndpointTokenStore endpointTokenStore) throws IOException { + return endpointTokenStore.loadOrCreate( + dataDirectory.resolve("token.json"), + System.getenv()); } @Provides @@ -196,37 +200,4 @@ public OkHttpClient watchOkHttpClient( .build(); } - @RequiredArgsConstructor - private static class Token { - @SerializedName("token") final String token; - - static Optional load(Path tokenFile) throws IOException { - String TOKEN_ENV = System.getenv("CONNECT_TOKEN"); - if (TOKEN_ENV != null && !TOKEN_ENV.isEmpty()) { - return Optional.of(TOKEN_ENV); - } else { - if (Files.exists(tokenFile)) { - // Read existing token file - try (Reader reader = Files.newBufferedReader(tokenFile)) { - return Optional.ofNullable(new Gson().fromJson(reader, Token.class)) - .map(t -> t.token); - } - } - return Optional.empty(); - } - } - - static void save(Path tokenFile, String token) throws IOException { - checkNotNull(tokenFile); - checkNotNull(token); - tokenFile.toFile().getParentFile().mkdirs(); // In case our data directory doesn't exist yet - try (Writer writer = new FileWriter(tokenFile.toFile())) { - new Gson().toJson(new Token(token), writer); - } - } - - static String generate() { - return "T-" + Utils.randomSecureString(20); - } - } } diff --git a/core/src/main/java/com/minekube/connect/network/netty/LocalChannelInboundHandler.java b/core/src/main/java/com/minekube/connect/network/netty/LocalChannelInboundHandler.java index 355f2d511..eb4ed4987 100644 --- a/core/src/main/java/com/minekube/connect/network/netty/LocalChannelInboundHandler.java +++ b/core/src/main/java/com/minekube/connect/network/netty/LocalChannelInboundHandler.java @@ -100,7 +100,8 @@ public static void onChannelClosed( } if (api.setPendingRemove(context.getPlayer())) { - if (!context.getPlayer().getUsername().isEmpty()) { // might be just a ping request + if (!context.getSessionProposal().hasBedrockPrincipalV2() + && !context.getPlayer().getUsername().isEmpty()) { // might be just a ping request logger.translatedInfo("connect.ingame.disconnect_name", context.getPlayer().getUsername()); } @@ -195,7 +196,9 @@ public void channelInactive(@NotNull ChannelHandlerContext ctx) throws Exception } private String playerName() { - return context.getPlayer().getUsername(); + return context.getSessionProposal().hasBedrockPrincipalV2() + ? "" + : context.getPlayer().getUsername(); } private String sessionId() { diff --git a/core/src/main/java/com/minekube/connect/network/netty/LocalSession.java b/core/src/main/java/com/minekube/connect/network/netty/LocalSession.java index 051caf556..73980f0b6 100644 --- a/core/src/main/java/com/minekube/connect/network/netty/LocalSession.java +++ b/core/src/main/java/com/minekube/connect/network/netty/LocalSession.java @@ -201,7 +201,7 @@ public void initChannel(@NotNull LocalChannelWithSessionContext channel) { } logger.debug("Connecting {} to local downstream server {}", - context.player.getUsername(), targetAddress); + logPlayer(context), targetAddress); bootstrap .remoteAddress(targetAddress) .connect() @@ -221,11 +221,17 @@ public void initChannel(@NotNull LocalChannelWithSessionContext channel) { }); } + private static String logPlayer(Context context) { + return context.sessionProposal.hasBedrockPrincipalV2() + ? "" + : context.player.getUsername(); + } + private void exceptionCaught(Throwable cause, ConnectPlayer player) { if (admissionCoordinator != null) { admissionCoordinator.discard(player); } - cause.printStackTrace(); + logger.warn("Connect local session failed (category={})", cause.getClass().getSimpleName()); // Reject session proposal in case we are still able to. sessionProposal.reject(StatusProto.fromThrowable(cause)); } diff --git a/core/src/main/java/com/minekube/connect/register/WatcherRegister.java b/core/src/main/java/com/minekube/connect/register/WatcherRegister.java index 88029157d..bc721a29e 100644 --- a/core/src/main/java/com/minekube/connect/register/WatcherRegister.java +++ b/core/src/main/java/com/minekube/connect/register/WatcherRegister.java @@ -26,7 +26,9 @@ package com.minekube.connect.register; import com.google.inject.Inject; +import com.google.protobuf.Any; import com.google.rpc.Code; +import com.google.rpc.LocalizedMessage; import com.google.rpc.Status; import com.minekube.connect.api.SimpleConnectApi; import com.minekube.connect.api.inject.PlatformInjector; @@ -40,6 +42,8 @@ import com.minekube.connect.util.Utils; import com.minekube.connect.util.backoff.BackOff; import com.minekube.connect.util.backoff.ExponentialBackOff; +import com.minekube.connect.watch.SessionAdmissionDecision; +import com.minekube.connect.watch.SessionAdmissionGate; import com.minekube.connect.watch.SessionProposal; import com.minekube.connect.watch.SessionProposal.State; import com.minekube.connect.watch.WatchBootstrap; @@ -49,7 +53,11 @@ import io.netty.util.concurrent.DefaultThreadFactory; import java.io.IOException; import java.time.Duration; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CompletionStage; import java.util.concurrent.Executors; +import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; @@ -68,6 +76,7 @@ public class WatcherRegister { @Inject private Libp2pEndpoint libp2pEndpoint; @Inject private BedrockIdentityReadiness bedrockIdentityReadiness; @Inject private BedrockAdmissionCoordinator admissionCoordinator; + @Inject private SessionAdmissionGate sessionAdmissionGate; // volatile: written from injection thread (start/stop) and read from the // scheduler thread (retry) and OkHttp dispatcher (WatcherImpl callbacks). @@ -237,6 +246,8 @@ private void reject(SessionProposal proposal, Status reason) { private class WatcherImpl implements Watcher { private volatile boolean ignoreTerminalEvents; + private final Set pendingAdmissions = + ConcurrentHashMap.newKeySet(); @Override public void onOpen(WatchBootstrap bootstrap) { @@ -292,16 +303,27 @@ public void onProposal(SessionProposal proposal) { return; } + PendingAdmission pending = new PendingAdmission(proposal); + pendingAdmissions.add(pending); + CompletionStage decision; try { - tunneler.prepare(proposal.getSession()); - new LocalSession(logger, api, tunneler, - platformInjector.getServerSocketAddress(), - proposal, - admissionCoordinator - ).connect(); - } catch (RuntimeException | Error e) { - reject(proposal, StatusProto.fromThrowable(e)); - throw e; + decision = sessionAdmissionGate.request(proposal); + } catch (RuntimeException failure) { + dispatchAdmission(pending, null, failure); + return; + } + if (decision == null) { + dispatchAdmission( + pending, + null, + new IllegalStateException("Session admission gate returned null")); + return; + } + try { + decision.whenComplete((result, failure) -> + dispatchAdmission(pending, result, failure)); + } catch (RuntimeException failure) { + dispatchAdmission(pending, null, failure); } } @@ -316,6 +338,7 @@ public void onError(Throwable t) { : " (cause: " + t.getCause().toString() + ")" ) ); + cancelPendingAdmissions(); cancelResetBackOffTimer(); retry(); } @@ -325,6 +348,7 @@ public void onCompleted() { if (!acceptTerminalEvent()) { return; } + cancelPendingAdmissions(); cancelResetBackOffTimer(); retry(); } @@ -357,6 +381,106 @@ void ignoreTerminalEvents() { ignoreTerminalEvents = true; cancelResetBackOffTimer(); } + cancelPendingAdmissions(); + } + + private void dispatchAdmission( + PendingAdmission pending, + SessionAdmissionDecision decision, + Throwable failure + ) { + ScheduledExecutorService executor = scheduler; + if (executor == null || executor.isShutdown()) { + pending.rejectStopped(); + return; + } + try { + executor.execute(() -> pending.complete(decision, failure)); + } catch (RejectedExecutionException ignored) { + pending.rejectStopped(); + } + } + + private void cancelPendingAdmissions() { + for (PendingAdmission pending : pendingAdmissions) { + pending.rejectStopped(); + } + } + + private final class PendingAdmission { + private final SessionProposal proposal; + private final AtomicBoolean completed = new AtomicBoolean(); + + private PendingAdmission(SessionProposal proposal) { + this.proposal = proposal; + } + + private void complete( + SessionAdmissionDecision decision, + Throwable failure + ) { + if (!completed.compareAndSet(false, true)) { + return; + } + pendingAdmissions.remove(this); + + if (!started.get() || ignoreTerminalEvents) { + rejectStoppedProposal(); + return; + } + if (proposal.getState() != State.ACCEPTED) { + return; + } + if (failure != null || decision == null) { + logger.error("Session admission failed before tunnel creation"); + reject(proposal, Status.newBuilder() + .setCode(Code.INTERNAL_VALUE) + .setMessage("Session admission failed") + .build()); + return; + } + if (!decision.isAllowed() && !decision.isDeferredToLocalLogin()) { + String safeMessage = decision.getSafeMessage(); + reject(proposal, Status.newBuilder() + .setCode(Code.PERMISSION_DENIED_VALUE) + .setMessage(safeMessage) + .addDetails(Any.pack(LocalizedMessage.newBuilder() + .setLocale("en-US") + .setMessage(safeMessage) + .build())) + .build()); + return; + } + + try { + tunneler.prepare(proposal.getSession()); + new LocalSession(logger, api, tunneler, + platformInjector.getServerSocketAddress(), + proposal, + admissionCoordinator + ).connect(); + } catch (RuntimeException | Error failureDuringTunnelCreation) { + reject(proposal, StatusProto.fromThrowable(failureDuringTunnelCreation)); + throw failureDuringTunnelCreation; + } + } + + private void rejectStopped() { + if (!completed.compareAndSet(false, true)) { + return; + } + pendingAdmissions.remove(this); + rejectStoppedProposal(); + } + + private void rejectStoppedProposal() { + if (proposal.getState() == State.ACCEPTED) { + reject(proposal, Status.newBuilder() + .setCode(Code.CANCELLED_VALUE) + .setMessage("Session admission stopped") + .build()); + } + } } private boolean acceptOpen() { diff --git a/core/src/main/java/com/minekube/connect/tunnel/p2p/DirectP2pAuthMode.java b/core/src/main/java/com/minekube/connect/tunnel/p2p/DirectP2pAuthMode.java new file mode 100644 index 000000000..7754d97ea --- /dev/null +++ b/core/src/main/java/com/minekube/connect/tunnel/p2p/DirectP2pAuthMode.java @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2021-2022 Minekube. https://minekube.com + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package com.minekube.connect.tunnel.p2p; + +public enum DirectP2pAuthMode { + ONLINE, + OFFLINE +} diff --git a/core/src/main/java/com/minekube/connect/tunnel/p2p/DirectP2pDiscoveredShare.java b/core/src/main/java/com/minekube/connect/tunnel/p2p/DirectP2pDiscoveredShare.java new file mode 100644 index 000000000..59a7bfbc9 --- /dev/null +++ b/core/src/main/java/com/minekube/connect/tunnel/p2p/DirectP2pDiscoveredShare.java @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2021-2022 Minekube. https://minekube.com + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package com.minekube.connect.tunnel.p2p; + +import java.util.Objects; + +public final class DirectP2pDiscoveredShare { + private final String displayName; + private final String peerId; + private final String address; + private final String invitation; + + public DirectP2pDiscoveredShare( + String displayName, + String peerId, + String address, + String invitation) { + this.displayName = Objects.requireNonNull(displayName, "displayName"); + this.peerId = Objects.requireNonNull(peerId, "peerId"); + this.address = Objects.requireNonNull(address, "address"); + this.invitation = Objects.requireNonNull(invitation, "invitation"); + } + + public String displayName() { + return displayName; + } + + public String peerId() { + return peerId; + } + + public String address() { + return address; + } + + public String invitation() { + return invitation; + } + + @Override + public String toString() { + return "DirectP2pDiscoveredShare{displayName='" + displayName + + "', peerId='" + peerId + + "', address=, invitation=}"; + } +} diff --git a/core/src/main/java/com/minekube/connect/tunnel/p2p/DirectP2pDiscoveryListener.java b/core/src/main/java/com/minekube/connect/tunnel/p2p/DirectP2pDiscoveryListener.java new file mode 100644 index 000000000..0251286f7 --- /dev/null +++ b/core/src/main/java/com/minekube/connect/tunnel/p2p/DirectP2pDiscoveryListener.java @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2021-2022 Minekube. https://minekube.com + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package com.minekube.connect.tunnel.p2p; + +@FunctionalInterface +public interface DirectP2pDiscoveryListener { + void onDiscovered(DirectP2pDiscoveredShare share); +} diff --git a/core/src/main/java/com/minekube/connect/tunnel/p2p/DirectP2pHostConfig.java b/core/src/main/java/com/minekube/connect/tunnel/p2p/DirectP2pHostConfig.java new file mode 100644 index 000000000..cff7ec575 --- /dev/null +++ b/core/src/main/java/com/minekube/connect/tunnel/p2p/DirectP2pHostConfig.java @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2021-2022 Minekube. https://minekube.com + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package com.minekube.connect.tunnel.p2p; + +import java.util.Objects; + +public final class DirectP2pHostConfig { + private final String shareId; + private final String capability; + private final String displayName; + private final boolean internetDirectEnabled; + + public DirectP2pHostConfig( + String shareId, + String capability, + String displayName, + boolean internetDirectEnabled) { + this.shareId = requireText(shareId, "shareId"); + this.capability = requireText(capability, "capability"); + this.displayName = requireText(displayName, "displayName"); + this.internetDirectEnabled = internetDirectEnabled; + } + + public String shareId() { + return shareId; + } + + public String capability() { + return capability; + } + + public String displayName() { + return displayName; + } + + public boolean internetDirectEnabled() { + return internetDirectEnabled; + } + + @Override + public String toString() { + return "DirectP2pHostConfig{shareId='" + shareId + + "', capability=, displayName='" + displayName + + "', internetDirectEnabled=" + internetDirectEnabled + "}"; + } + + private static String requireText(String value, String name) { + Objects.requireNonNull(value, name); + if (value.isEmpty()) { + throw new IllegalArgumentException(name + " must not be empty"); + } + return value; + } +} diff --git a/core/src/main/java/com/minekube/connect/tunnel/p2p/DirectP2pHostHandler.java b/core/src/main/java/com/minekube/connect/tunnel/p2p/DirectP2pHostHandler.java new file mode 100644 index 000000000..9eaaecfd6 --- /dev/null +++ b/core/src/main/java/com/minekube/connect/tunnel/p2p/DirectP2pHostHandler.java @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2021-2022 Minekube. https://minekube.com + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package com.minekube.connect.tunnel.p2p; + +import java.net.Socket; + +@FunctionalInterface +public interface DirectP2pHostHandler { + Socket openLocalSession(DirectP2pSession session) throws Exception; +} diff --git a/core/src/main/java/com/minekube/connect/tunnel/p2p/DirectP2pHostInfo.java b/core/src/main/java/com/minekube/connect/tunnel/p2p/DirectP2pHostInfo.java new file mode 100644 index 000000000..4265fffc3 --- /dev/null +++ b/core/src/main/java/com/minekube/connect/tunnel/p2p/DirectP2pHostInfo.java @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2021-2022 Minekube. https://minekube.com + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package com.minekube.connect.tunnel.p2p; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +public final class DirectP2pHostInfo { + private final String peerId; + private final byte[] publicKey; + private final List lanAddresses; + private final List internetAddresses; + + public DirectP2pHostInfo( + String peerId, + byte[] publicKey, + List lanAddresses, + List internetAddresses) { + this.peerId = Objects.requireNonNull(peerId, "peerId"); + this.publicKey = Objects.requireNonNull(publicKey, "publicKey").clone(); + this.lanAddresses = immutableCopy(lanAddresses); + this.internetAddresses = immutableCopy(internetAddresses); + } + + public String peerId() { + return peerId; + } + + public byte[] publicKey() { + return publicKey.clone(); + } + + public List lanAddresses() { + return lanAddresses; + } + + public List internetAddresses() { + return internetAddresses; + } + + @Override + public String toString() { + return "DirectP2pHostInfo{peerId='" + peerId + + "', publicKey=, lanAddresses=, " + + "internetAddresses=}"; + } + + private static List immutableCopy(List addresses) { + return Collections.unmodifiableList(new ArrayList<>( + Objects.requireNonNull(addresses, "addresses"))); + } +} diff --git a/core/src/main/java/com/minekube/connect/tunnel/p2p/DirectP2pNode.java b/core/src/main/java/com/minekube/connect/tunnel/p2p/DirectP2pNode.java new file mode 100644 index 000000000..50ef7f13f --- /dev/null +++ b/core/src/main/java/com/minekube/connect/tunnel/p2p/DirectP2pNode.java @@ -0,0 +1,223 @@ +/* + * Copyright (c) 2021-2022 Minekube. https://minekube.com + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package com.minekube.connect.tunnel.p2p; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.nio.file.Path; +import java.time.Duration; +import java.util.Objects; + +/** + * Parent-loaded JDK-only facade for the isolated Connect Share libp2p runtime. + */ +public final class DirectP2pNode implements AutoCloseable { + private Object runtime; + private Method peerId; + private Method publicKey; + private Method startHost; + private Method sign; + private Method publish; + private Method publishWithDiscoveryInvitation; + private Method inspect; + private Method startDiscovery; + private Method openProxy; + private Method close; + + public DirectP2pNode() { + initialize(null); + } + + public DirectP2pNode(Path identityFile) { + initialize(Objects.requireNonNull(identityFile, "identityFile")); + } + + private void initialize(Path identityFile) { + try { + Class runtimeClass = Class.forName( + "com.minekube.connect.tunnel.p2p.DirectP2pNodeRuntime", + true, + Libp2pRuntimeLoader.classLoader()); + java.lang.reflect.Constructor constructor = identityFile == null + ? runtimeClass.getDeclaredConstructor() + : runtimeClass.getDeclaredConstructor(Path.class); + constructor.setAccessible(true); + runtime = identityFile == null + ? constructor.newInstance() + : constructor.newInstance(identityFile); + peerId = accessible(runtimeClass.getDeclaredMethod("peerId")); + publicKey = accessible(runtimeClass.getDeclaredMethod("publicKey")); + startHost = accessible(runtimeClass.getDeclaredMethod( + "startHost", + DirectP2pHostConfig.class, + DirectP2pHostHandler.class)); + sign = accessible(runtimeClass.getDeclaredMethod("sign", byte[].class)); + publish = accessible(runtimeClass.getDeclaredMethod( + "publish", + String.class)); + publishWithDiscoveryInvitation = accessible(runtimeClass.getDeclaredMethod( + "publish", + String.class, + String.class)); + inspect = accessible(runtimeClass.getDeclaredMethod( + "inspect", + String.class, + Duration.class)); + startDiscovery = accessible(runtimeClass.getDeclaredMethod( + "startDiscovery", + DirectP2pDiscoveryListener.class)); + openProxy = accessible(runtimeClass.getDeclaredMethod( + "openProxy", + String.class, + String.class, + String.class, + DirectP2pAuthMode.class, + Duration.class)); + close = accessible(runtimeClass.getDeclaredMethod("close")); + } catch (Exception | LinkageError e) { + throw new IllegalStateException( + "Could not initialize the isolated Connect Share direct runtime", + e); + } + } + + public synchronized String peerId() { + return invoke(peerId, String.class); + } + + public synchronized byte[] publicKey() { + return invoke(publicKey, byte[].class); + } + + public synchronized DirectP2pHostInfo startHost( + DirectP2pHostConfig config, + DirectP2pHostHandler handler) { + return invoke(startHost, DirectP2pHostInfo.class, + Objects.requireNonNull(config, "config"), + Objects.requireNonNull(handler, "handler")); + } + + public synchronized byte[] sign(byte[] payload) { + return invoke(sign, byte[].class, Objects.requireNonNull(payload, "payload")); + } + + public synchronized void publish(String invitation) { + invoke(publish, Void.class, Objects.requireNonNull(invitation, "invitation")); + } + + public synchronized void publish( + String invitation, + String discoveryInvitation) { + invoke( + publishWithDiscoveryInvitation, + Void.class, + Objects.requireNonNull(invitation, "invitation"), + Objects.requireNonNull(discoveryInvitation, "discoveryInvitation")); + } + + public synchronized DirectP2pDiscoveredShare inspect( + String address, + Duration timeout) { + rejectRelayAddress(address); + return invoke( + inspect, + DirectP2pDiscoveredShare.class, + address, + Objects.requireNonNull(timeout, "timeout")); + } + + public synchronized void startDiscovery(DirectP2pDiscoveryListener listener) { + invoke( + startDiscovery, + Void.class, + Objects.requireNonNull(listener, "listener")); + } + + public synchronized DirectP2pProxy openProxy( + String address, + String shareId, + String capability, + DirectP2pAuthMode authMode, + Duration timeout) { + rejectRelayAddress(address); + return invoke( + openProxy, + DirectP2pProxy.class, + address, + shareId, + capability, + authMode, + timeout); + } + + @Override + public synchronized void close() { + if (runtime == null) { + return; + } + try { + close.invoke(runtime); + } catch (IllegalAccessException e) { + throw new IllegalStateException("Could not close Connect Share direct runtime", e); + } catch (InvocationTargetException e) { + throw propagate("Could not close Connect Share direct runtime", e); + } finally { + runtime = null; + } + } + + private T invoke(Method method, Class resultType, Object... arguments) { + if (runtime == null) { + throw new IllegalStateException("Connect Share direct runtime is closed"); + } + try { + Object result = method.invoke(runtime, arguments); + return resultType == Void.class ? null : resultType.cast(result); + } catch (IllegalAccessException e) { + throw new IllegalStateException("Could not access Connect Share direct runtime", e); + } catch (InvocationTargetException e) { + throw propagate("Connect Share direct operation failed", e); + } + } + + private static Method accessible(Method method) { + method.setAccessible(true); + return method; + } + + private static RuntimeException propagate(String message, InvocationTargetException failure) { + Throwable cause = failure.getCause() == null ? failure : failure.getCause(); + if (cause instanceof RuntimeException) { + return (RuntimeException) cause; + } + return new IllegalStateException(message, cause); + } + + public static void rejectRelayAddress(String address) { + Objects.requireNonNull(address, "address"); + if (address.contains("/p2p-circuit") || address.contains("/circuit/")) { + throw new IllegalArgumentException( + "Connect is the only supported relay for Connect Share"); + } + } +} diff --git a/core/src/main/java/com/minekube/connect/tunnel/p2p/DirectP2pNodeRuntime.java b/core/src/main/java/com/minekube/connect/tunnel/p2p/DirectP2pNodeRuntime.java new file mode 100644 index 000000000..f1682a3b0 --- /dev/null +++ b/core/src/main/java/com/minekube/connect/tunnel/p2p/DirectP2pNodeRuntime.java @@ -0,0 +1,1155 @@ +/* + * Copyright (c) 2021-2022 Minekube. https://minekube.com + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package com.minekube.connect.tunnel.p2p; + +import com.minekube.connect.tunnel.p2p.impl.Libp2pTunnelTransportRuntime; +import io.libp2p.core.Connection; +import io.libp2p.core.Host; +import io.libp2p.core.PeerId; +import io.libp2p.core.PeerInfo; +import io.libp2p.core.Stream; +import io.libp2p.core.StreamPromise; +import io.libp2p.core.crypto.KeyKt; +import io.libp2p.core.crypto.KeyType; +import io.libp2p.core.crypto.PrivKey; +import io.libp2p.core.multiformats.Multiaddr; +import io.libp2p.core.multiformats.MultiaddrComponent; +import io.libp2p.core.multiformats.Protocol; +import io.libp2p.core.multistream.StrictProtocolBinding; +import io.libp2p.discovery.mdns.JmDNS; +import io.libp2p.discovery.mdns.ServiceInfo; +import io.libp2p.discovery.mdns.impl.DNSRecord; +import io.libp2p.protocol.ProtocolHandler; +import io.netty.buffer.ByteBuf; +import io.netty.buffer.ByteBufUtil; +import io.netty.buffer.Unpooled; +import io.netty.channel.ChannelHandlerContext; +import io.netty.channel.SimpleChannelInboundHandler; +import io.netty.handler.codec.ByteToMessageDecoder; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.Inet4Address; +import java.net.InetAddress; +import java.net.Inet6Address; +import java.net.InetSocketAddress; +import java.net.NetworkInterface; +import java.net.ServerSocket; +import java.net.Socket; +import java.net.SocketException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Enumeration; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; +import kotlin.Pair; + +/** + * Child-loaded implementation. No method signature may expose libp2p, Netty, + * Kotlin, or kotlinx types to {@link DirectP2pNode}. + */ +final class DirectP2pNodeRuntime { + static final String TUNNEL_PROTOCOL_ID = "/minekube/connect/share/tunnel/1.0.0"; + static final String INFO_PROTOCOL_ID = "/minekube/connect/share/info/1.0.0"; + private static final int PREFACE_MAGIC = 0x43534831; // CSH1 + private static final int WIRE_VERSION = 1; + private static final int MAX_PREFACE_SIZE = 4096; + private static final int MAX_INFO_SIZE = 32 * 1024; + private static final String MDNS_SERVICE = "_minekube-connect-share._tcp.local."; + private static final int MDNS_QUERY_INTERVAL_SECONDS = 5; + private static final long START_TIMEOUT_SECONDS = 10; + private static final byte[] ED25519_X509_PREFIX = new byte[] { + 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, + 0x70, 0x03, 0x21, 0x00 + }; + + private final PrivKey privateKey; + private final List proxies = new CopyOnWriteArrayList<>(); + private final Set discoveredInvitations = + Collections.newSetFromMap(new ConcurrentHashMap<>()); + private final java.util.Set mdnsInspections = + Collections.newSetFromMap(new ConcurrentHashMap<>()); + private Host host; + private DirectP2pHostConfig hostConfig; + private DirectP2pHostHandler hostHandler; + private volatile String invitation; + private volatile String discoveryInvitation; + private JmDNS discovery; + private DirectP2pDiscoveryListener discoveryListener; + private boolean started; + private boolean closed; + + DirectP2pNodeRuntime() { + Pair pair = KeyKt.generateKeyPair(KeyType.ED25519); + this.privateKey = pair.getFirst(); + } + + DirectP2pNodeRuntime(Path identityFile) throws IOException { + this.privateKey = EndpointPeerIdentity + .loadOrCreate(Objects.requireNonNull(identityFile, "identityFile")) + .privateKey(); + } + + synchronized String peerId() { + ensureOpen(); + return PeerId.fromPubKey(privateKey.publicKey()).toBase58(); + } + + synchronized byte[] publicKey() { + ensureOpen(); + return x509PublicKey(privateKey.publicKey().raw()); + } + + synchronized DirectP2pHostInfo startHost( + DirectP2pHostConfig config, + DirectP2pHostHandler handler) { + ensureOpen(); + if (hostConfig != null) { + if (!hostConfig.shareId().equals(config.shareId()) + || !hostConfig.capability().equals(config.capability())) { + throw new IllegalStateException( + "Connect Share direct host identity cannot change while running"); + } + } + hostConfig = Objects.requireNonNull(config, "config"); + hostHandler = Objects.requireNonNull(handler, "handler"); + if (host == null) { + host = Libp2pTunnelTransportRuntime.createHost( + privateKey, + "/ip4/0.0.0.0/tcp/0"); + installProtocols(host); + startHostIfNeeded(); + } else if (host.listenAddresses().isEmpty()) { + await( + host.getNetwork().listen( + Multiaddr.fromString("/ip4/0.0.0.0/tcp/0")), + START_TIMEOUT_SECONDS, + "listen for Connect Share direct hosting"); + } + + int port = listenTcpPort(host); + String peerId = host.getPeerId().toBase58(); + List lanAddresses = addresses(port, peerId, false); + List internetAddresses = config.internetDirectEnabled() + ? addresses(port, peerId, true) + : Collections.emptyList(); + if (lanAddresses.isEmpty()) { + lanAddresses = Collections.singletonList( + "/ip4/127.0.0.1/tcp/" + port + "/p2p/" + peerId); + } + return new DirectP2pHostInfo( + peerId, + x509PublicKey(privateKey.publicKey().raw()), + lanAddresses, + internetAddresses); + } + + synchronized byte[] sign(byte[] payload) { + ensureOpen(); + return privateKey.sign(Arrays.copyOf(payload, payload.length)); + } + + synchronized void publish(String invitation) { + publish(invitation, invitation); + } + + synchronized void publish(String invitation, String discoveryInvitation) { + ensureOpen(); + if (hostConfig == null || host == null) { + throw new IllegalStateException("Connect Share direct host is not started"); + } + this.invitation = requireInvitation(invitation); + this.discoveryInvitation = requireInvitation(discoveryInvitation); + startMdns(); + } + + synchronized DirectP2pDiscoveredShare inspect( + String address, + Duration timeout) { + ensureOpen(); + DirectP2pNode.rejectRelayAddress(address); + ensureGuestHost(false); + Multiaddr multiaddr = Multiaddr.fromString(address); + PeerId peerId = multiaddr.getPeerId(); + if (peerId == null) { + throw new IllegalArgumentException( + "direct address must include /p2p/"); + } + Connection connection = await( + host.getNetwork().connect(peerId, multiaddr), + timeout, + "dial the Connect Share metadata service"); + StreamPromise promise = host.newStream( + Collections.singletonList(INFO_PROTOCOL_ID), + connection); + InfoController controller = await( + promise.getController(), + timeout, + "negotiate the Connect Share metadata protocol"); + InfoResponse response = await( + controller.response, + timeout, + "read Connect Share metadata"); + return new DirectP2pDiscoveredShare( + response.displayName, + peerId.toBase58(), + address, + response.invitation); + } + + synchronized void startDiscovery(DirectP2pDiscoveryListener listener) { + ensureOpen(); + if (discoveryListener != null) { + throw new IllegalStateException("Connect Share LAN discovery is already started"); + } + discoveryListener = Objects.requireNonNull(listener, "listener"); + ensureGuestHost(true); + startMdns(); + } + + synchronized DirectP2pProxy openProxy( + String address, + String shareId, + String capability, + DirectP2pAuthMode authMode, + Duration timeout) { + ensureOpen(); + DirectP2pNode.rejectRelayAddress(address); + Objects.requireNonNull(shareId, "shareId"); + Objects.requireNonNull(capability, "capability"); + Objects.requireNonNull(authMode, "authMode"); + Objects.requireNonNull(timeout, "timeout"); + if (timeout.isZero() || timeout.isNegative()) { + throw new IllegalArgumentException("direct dial timeout must be positive"); + } + + ensureGuestHost(false); + ProxyRuntime proxy = null; + try { + proxy = new ProxyRuntime( + host, + address, + new DirectPreface(shareId, capability, authMode), + timeout); + proxy.start(); + proxies.add(proxy); + ProxyRuntime active = proxy; + return new DirectP2pProxy(proxy.localAddress(), () -> { + active.close(); + proxies.remove(active); + }); + } catch (Exception e) { + if (proxy != null) { + proxy.close(); + } + throw e instanceof RuntimeException + ? (RuntimeException) e + : new IllegalStateException( + "Could not bind the direct Minecraft proxy", + e); + } + } + + synchronized void close() { + if (closed) { + return; + } + closed = true; + if (discovery != null) { + discovery.stop(); + discovery = null; + } + for (ProxyRuntime proxy : proxies) { + proxy.close(); + } + proxies.clear(); + if (host != null && started) { + await(host.stop(), START_TIMEOUT_SECONDS, "stop Connect Share direct host"); + } + host = null; + started = false; + } + + private void ensureGuestHost(boolean listenerRequired) { + if (host == null) { + host = listenerRequired + ? Libp2pTunnelTransportRuntime.createHost( + privateKey, + "/ip4/0.0.0.0/tcp/0") + : Libp2pTunnelTransportRuntime.createHost(privateKey); + installProtocols(host); + startHostIfNeeded(); + } else if (listenerRequired && host.listenAddresses().isEmpty()) { + await( + host.getNetwork().listen( + Multiaddr.fromString("/ip4/0.0.0.0/tcp/0")), + START_TIMEOUT_SECONDS, + "listen for Connect Share LAN discovery"); + } + } + + private void installProtocols(Host target) { + target.addProtocolHandler(new TunnelProtocolBinding()); + target.addProtocolHandler(new InfoProtocolBinding()); + } + + private synchronized void startMdns() { + if (discovery != null) { + return; + } + InetAddress address = MdnsAddressSelector.systemAddress(); + // JmDNS derives a host name with InetAddress#getHostName when none is + // supplied. That can issue an unbounded reverse-DNS lookup and made + // share startup hang for a full minute on otherwise healthy LANs. + // The authenticated peer ID already gives this process a stable, + // collision-resistant local name without touching DNS. + String peerId = host.getPeerId().toBase58(); + JmDNS started = JmDNS.create(address, mdnsHostName(peerId)); + try { + started.start(); + List ipv4Addresses = address instanceof Inet4Address + ? Collections.singletonList((Inet4Address) address) + : Collections.emptyList(); + List ipv6Addresses = address instanceof Inet6Address + ? Collections.singletonList((Inet6Address) address) + : Collections.emptyList(); + started.registerService(ServiceInfo.create( + MDNS_SERVICE, + peerId, + listenTcpPort(host), + peerId, + ipv4Addresses, + ipv6Addresses)); + started.addAnswerListener( + MDNS_SERVICE, + MDNS_QUERY_INTERVAL_SECONDS, + this::onMdnsAnswers); + discovery = started; + } catch (IOException | RuntimeException failure) { + started.stop(); + throw new IllegalStateException( + "Could not start Connect Share LAN discovery", + failure); + } + } + + static String mdnsHostName(String peerId) { + Objects.requireNonNull(peerId, "peerId"); + int prefixLength = Math.min(32, peerId.length()); + return "connect-share-" + peerId.substring(0, prefixLength); + } + + private void onMdnsAnswers(List answers) { + Host current = host; + if (current == null) { + return; + } + String localPeerId = current.getPeerId().toBase58(); + List addresses = new ArrayList<>(); + for (DNSRecord answer : answers) { + if (answer instanceof DNSRecord.Address) { + addresses.add((DNSRecord.Address) answer); + } + } + if (addresses.isEmpty()) { + return; + } + for (DNSRecord answer : answers) { + if (!(answer instanceof DNSRecord.Service)) { + continue; + } + DNSRecord.Service service = (DNSRecord.Service) answer; + for (DNSRecord candidate : answers) { + if (!(candidate instanceof DNSRecord.Text) + || !candidate.getName().equalsIgnoreCase(service.getName())) { + continue; + } + String peerId; + try { + peerId = decodeMdnsPeerId(((DNSRecord.Text) candidate).getText()); + } catch (RuntimeException ignored) { + continue; + } + if (localPeerId.equals(peerId)) { + continue; + } + String inspection = peerId + ':' + service.getPort(); + if (!mdnsInspections.add(inspection)) { + continue; + } + List candidates = new ArrayList<>(); + for (DNSRecord.Address record : addresses) { + InetAddress discoveredAddress = record.getAddress(); + String protocol = discoveredAddress instanceof Inet4Address + ? "ip4" + : "ip6"; + try { + candidates.add(Multiaddr.fromString( + "/" + protocol + "/" + discoveredAddress.getHostAddress() + + "/tcp/" + service.getPort())); + } catch (RuntimeException ignored) { + // Ignore unusable scoped or malformed answer records. + } + } + if (candidates.isEmpty()) { + mdnsInspections.remove(inspection); + continue; + } + Thread inspectionThread = new Thread(() -> { + try { + onMdnsPeer(new PeerInfo( + PeerId.fromBase58(peerId), + candidates)); + } finally { + mdnsInspections.remove(inspection); + } + }, "connect-share-mdns-answer"); + inspectionThread.setDaemon(true); + inspectionThread.start(); + } + } + } + + static String decodeMdnsPeerId(byte[] text) { + Objects.requireNonNull(text, "text"); + if (text.length == 0) { + throw new IllegalArgumentException("mDNS peer ID is empty"); + } + int offset = Byte.toUnsignedInt(text[0]) == text.length - 1 ? 1 : 0; + String peerId = new String( + text, + offset, + text.length - offset, + StandardCharsets.UTF_8); + PeerId.fromBase58(peerId); + return peerId; + } + + private void onMdnsPeer(PeerInfo peer) { + Host current = host; + DirectP2pDiscoveryListener listener = discoveryListener; + if (current == null || listener == null + || current.getPeerId().equals(peer.getPeerId())) { + return; + } + Thread inspectThread = new Thread(() -> { + for (Multiaddr candidate : peer.getAddresses()) { + String address = candidate.withP2P(peer.getPeerId()).toString(); + try { + DirectP2pDiscoveredShare found = + inspect(address, Duration.ofSeconds(3)); + if (shouldNotifyDiscovery(discoveredInvitations, found)) { + listener.onDiscovered(found); + } + return; + } catch (RuntimeException ignored) { + // Try the next address announced for this LAN peer. + } + } + }, "connect-share-mdns-inspect"); + inspectThread.setDaemon(true); + inspectThread.start(); + } + + static boolean shouldNotifyDiscovery( + Set discovered, + DirectP2pDiscoveredShare share) { + return discovered.add(share.invitation() + '\u0000' + share.address()); + } + + private synchronized void startHostIfNeeded() { + if (!started) { + await(host.start(), START_TIMEOUT_SECONDS, "start Connect Share direct host"); + started = true; + } + } + + private void accept(Stream stream, DirectPreface preface) { + DirectP2pHostConfig config = hostConfig; + DirectP2pHostHandler handler = hostHandler; + if (config == null || handler == null + || !config.shareId().equals(preface.shareId) + || !config.capability().equals(preface.capability)) { + stream.close(); + return; + } + try { + DirectP2pSession session = new DirectP2pSession( + stream.remotePeerId().toBase58(), + preface.authMode, + route(stream), + UUID.randomUUID().toString()); + Socket socket = handler.openLocalSession(session); + if (socket == null || !socket.isConnected() || socket.isClosed()) { + closeQuietly(socket); + stream.close(); + return; + } + SocketBridge.install(stream, socket, "connect-share-direct-host"); + } catch (Exception e) { + stream.close(); + } + } + + private void ensureOpen() { + if (closed) { + throw new IllegalStateException("Connect Share direct runtime is closed"); + } + } + + private static int listenTcpPort(Host host) { + for (Multiaddr address : host.listenAddresses()) { + MultiaddrComponent tcp = address.getFirstComponent(Protocol.TCP); + if (tcp != null) { + return Integer.parseInt(tcp.getStringValue()); + } + } + throw new IllegalStateException("Connect Share direct host has no TCP listener"); + } + + private static DirectP2pRoute route(Stream stream) { + Multiaddr remote = stream.getConnection().remoteAddress(); + MultiaddrComponent ip = remote.getFirstComponent(Protocol.IP4); + if (ip == null) { + ip = remote.getFirstComponent(Protocol.IP6); + } + if (ip == null) { + return DirectP2pRoute.INTERNET; + } + try { + InetAddress address = InetAddress.getByName(ip.getStringValue()); + return address.isLoopbackAddress() + || address.isLinkLocalAddress() + || address.isSiteLocalAddress() + ? DirectP2pRoute.LAN + : DirectP2pRoute.INTERNET; + } catch (IOException ignored) { + return DirectP2pRoute.INTERNET; + } + } + + private static List addresses(int port, String peerId, boolean internetOnly) { + List result = new ArrayList<>(); + if (!internetOnly) { + result.add("/ip4/127.0.0.1/tcp/" + port + "/p2p/" + peerId); + } + try { + Enumeration interfaces = NetworkInterface.getNetworkInterfaces(); + while (interfaces.hasMoreElements()) { + NetworkInterface network = interfaces.nextElement(); + if (!network.isUp()) { + continue; + } + Enumeration addresses = network.getInetAddresses(); + while (addresses.hasMoreElements()) { + InetAddress address = addresses.nextElement(); + if (!(address instanceof Inet4Address) + || address.isAnyLocalAddress() + || address.isLoopbackAddress() + || address.isMulticastAddress()) { + continue; + } + boolean publicAddress = !address.isLoopbackAddress() + && !address.isLinkLocalAddress() + && !address.isSiteLocalAddress(); + if (internetOnly != publicAddress) { + continue; + } + result.add("/ip4/" + address.getHostAddress() + + "/tcp/" + port + "/p2p/" + peerId); + } + } + } catch (SocketException e) { + throw new IllegalStateException("Could not enumerate direct network addresses", e); + } + return Collections.unmodifiableList(result); + } + + private static byte[] x509PublicKey(byte[] raw) { + byte[] encoded = Arrays.copyOf( + ED25519_X509_PREFIX, + ED25519_X509_PREFIX.length + raw.length); + System.arraycopy(raw, 0, encoded, ED25519_X509_PREFIX.length, raw.length); + return encoded; + } + + private static String requireInvitation(String value) { + Objects.requireNonNull(value, "invitation"); + if (!value.startsWith("minekube://share/") + || value.length() > MAX_INFO_SIZE) { + throw new IllegalArgumentException("Connect Share invitation is invalid"); + } + return value; + } + + private byte[] encodeInfoResponse() { + String currentInvitation = discoveryInvitation; + DirectP2pHostConfig currentConfig = hostConfig; + if (currentInvitation == null || currentConfig == null) { + return null; + } + try { + String safeDisplayName = currentConfig.displayName() + .replace('\n', ' ') + .replace('\r', ' '); + byte[] body = (safeDisplayName + "\n" + currentInvitation) + .getBytes(java.nio.charset.StandardCharsets.UTF_8); + if (body.length > MAX_INFO_SIZE) { + throw new IllegalArgumentException( + "Connect Share discovery metadata is too large"); + } + ByteArrayOutputStream frame = new ByteArrayOutputStream(); + writeVarint(frame, body.length); + frame.write(body); + return frame.toByteArray(); + } catch (IOException e) { + throw new IllegalStateException( + "Could not encode Connect Share discovery metadata", + e); + } + } + + private static InfoResponse decodeInfoResponse(byte[] body) { + try { + String value = new String(body, java.nio.charset.StandardCharsets.UTF_8); + int separator = value.indexOf('\n'); + if (separator <= 0 || separator == value.length() - 1) { + throw new IllegalArgumentException( + "Connect Share discovery metadata is invalid"); + } + String displayName = value.substring(0, separator); + String invitation = requireInvitation(value.substring(separator + 1)); + return new InfoResponse(displayName, invitation); + } catch (RuntimeException e) { + throw new IllegalArgumentException( + "Connect Share discovery metadata is invalid", + e); + } + } + + private static byte[] encodePreface(DirectPreface preface) { + try { + ByteArrayOutputStream body = new ByteArrayOutputStream(); + try (DataOutputStream out = new DataOutputStream(body)) { + out.writeInt(PREFACE_MAGIC); + out.writeInt(WIRE_VERSION); + out.writeUTF(preface.shareId); + out.writeUTF(preface.capability); + out.writeByte(preface.authMode.ordinal()); + } + if (body.size() > MAX_PREFACE_SIZE) { + throw new IllegalArgumentException("Connect Share direct preface is too large"); + } + ByteArrayOutputStream frame = new ByteArrayOutputStream(); + writeVarint(frame, body.size()); + body.writeTo(frame); + return frame.toByteArray(); + } catch (IOException e) { + throw new IllegalStateException("Could not encode Connect Share direct preface", e); + } + } + + private static DirectPreface decodePreface(byte[] body) { + try (DataInputStream input = new DataInputStream(new ByteArrayInputStream(body))) { + if (input.readInt() != PREFACE_MAGIC) { + throw new IllegalArgumentException("Invalid Connect Share direct preface"); + } + int version = input.readInt(); + if (version != WIRE_VERSION) { + throw new IllegalArgumentException("Unsupported Connect Share direct version"); + } + String shareId = input.readUTF(); + String capability = input.readUTF(); + int authMode = input.readUnsignedByte(); + if (authMode >= DirectP2pAuthMode.values().length || input.available() != 0) { + throw new IllegalArgumentException("Invalid Connect Share direct authentication mode"); + } + return new DirectPreface( + shareId, + capability, + DirectP2pAuthMode.values()[authMode]); + } catch (IOException e) { + throw new IllegalArgumentException("Invalid Connect Share direct preface", e); + } + } + + private static void writeVarint(ByteArrayOutputStream output, int value) { + int current = value; + while ((current & ~0x7f) != 0) { + output.write((current & 0x7f) | 0x80); + current >>>= 7; + } + output.write(current); + } + + private static int readFrameLength(ByteBuf input, int maximum) { + input.markReaderIndex(); + int length = 0; + int shift = 0; + for (int index = 0; index < 5; index++) { + if (!input.isReadable()) { + input.resetReaderIndex(); + return -1; + } + int current = input.readUnsignedByte(); + length |= (current & 0x7f) << shift; + if ((current & 0x80) == 0) { + if (length <= 0 || length > maximum) { + throw new IllegalArgumentException( + "Connect Share frame size is invalid: " + length); + } + return length; + } + shift += 7; + } + throw new IllegalArgumentException("Connect Share frame length overflow"); + } + + private static T await( + CompletableFuture future, + Duration timeout, + String action) { + try { + return future.get(timeout.toMillis(), TimeUnit.MILLISECONDS); + } catch (InterruptedException e) { + future.cancel(true); + Thread.currentThread().interrupt(); + throw new IllegalStateException("Failed to " + action, e); + } catch (TimeoutException e) { + future.cancel(true); + throw new IllegalStateException("Timed out while trying to " + action, e); + } catch (Exception e) { + throw new IllegalStateException("Failed to " + action, e); + } + } + + private static T await( + CompletableFuture future, + long timeoutSeconds, + String action) { + return await(future, Duration.ofSeconds(timeoutSeconds), action); + } + + private static void closeQuietly(Socket socket) { + if (socket == null) { + return; + } + try { + socket.close(); + } catch (IOException ignored) { + // Best effort after a stream closes. + } + } + + private final class TunnelProtocolBinding extends StrictProtocolBinding { + private TunnelProtocolBinding() { + super(TUNNEL_PROTOCOL_ID, new TunnelProtocolHandler()); + } + } + + private final class InfoProtocolBinding + extends StrictProtocolBinding { + private InfoProtocolBinding() { + super(INFO_PROTOCOL_ID, new InfoProtocolHandler()); + } + } + + private final class InfoProtocolHandler + extends ProtocolHandler { + private InfoProtocolHandler() { + super(Long.MAX_VALUE, Long.MAX_VALUE); + } + + @Override + protected CompletableFuture onStartInitiator(Stream stream) { + return CompletableFuture.completedFuture(new InfoController(stream)); + } + + @Override + protected CompletableFuture onStartResponder(Stream stream) { + byte[] response = encodeInfoResponse(); + CompletableFuture.runAsync(() -> { + if (response == null) { + stream.close(); + } else { + stream.writeAndFlush(Unpooled.wrappedBuffer(response)); + stream.closeWrite(); + } + }); + return CompletableFuture.completedFuture(null); + } + } + + private static final class InfoController { + private final CompletableFuture response = + new CompletableFuture<>(); + + private InfoController(Stream stream) { + InfoResponseDecoder decoder = new InfoResponseDecoder(); + stream.pushHandler(decoder); + stream.pushHandler(new InfoResponseHandler(stream, decoder, response)); + } + } + + private static final class InfoResponseHandler + extends SimpleChannelInboundHandler { + private final Stream stream; + private final InfoResponseDecoder decoder; + private final CompletableFuture response; + + private InfoResponseHandler( + Stream stream, + InfoResponseDecoder decoder, + CompletableFuture response) { + this.stream = stream; + this.decoder = decoder; + this.response = response; + } + + @Override + protected void channelRead0(ChannelHandlerContext context, InfoResponse message) { + response.complete(message); + context.pipeline().remove(this); + context.pipeline().remove(decoder); + stream.close(); + } + + @Override + public void channelInactive(ChannelHandlerContext context) throws Exception { + response.completeExceptionally( + new IllegalStateException("Connect Share metadata stream closed")); + super.channelInactive(context); + } + + @Override + public void exceptionCaught(ChannelHandlerContext context, Throwable cause) { + response.completeExceptionally(cause); + stream.close(); + context.close(); + } + } + + private static final class InfoResponseDecoder extends ByteToMessageDecoder { + @Override + protected void decode( + ChannelHandlerContext context, + ByteBuf input, + List output) { + int size = readFrameLength(input, MAX_INFO_SIZE); + if (size < 0) { + return; + } + if (input.readableBytes() < size) { + input.resetReaderIndex(); + return; + } + byte[] frame = new byte[size]; + input.readBytes(frame); + output.add(decodeInfoResponse(frame)); + } + } + + private static final class InfoResponse { + private final String displayName; + private final String invitation; + + private InfoResponse(String displayName, String invitation) { + this.displayName = displayName; + this.invitation = invitation; + } + } + + private final class TunnelProtocolHandler extends ProtocolHandler { + private TunnelProtocolHandler() { + super(Long.MAX_VALUE, Long.MAX_VALUE); + } + + @Override + protected CompletableFuture onStartInitiator(Stream stream) { + return CompletableFuture.completedFuture(null); + } + + @Override + protected CompletableFuture onStartResponder(Stream stream) { + DirectPrefaceDecoder decoder = new DirectPrefaceDecoder(); + stream.pushHandler(decoder); + stream.pushHandler(new DirectPrefaceHandler(stream, decoder)); + return CompletableFuture.completedFuture(null); + } + } + + private final class DirectPrefaceHandler + extends SimpleChannelInboundHandler { + private final Stream stream; + private final DirectPrefaceDecoder decoder; + + private DirectPrefaceHandler(Stream stream, DirectPrefaceDecoder decoder) { + this.stream = stream; + this.decoder = decoder; + } + + @Override + protected void channelRead0(ChannelHandlerContext context, DirectPreface preface) { + context.pipeline().remove(this); + context.pipeline().remove(decoder); + accept(stream, preface); + } + + @Override + public void exceptionCaught(ChannelHandlerContext context, Throwable cause) { + stream.close(); + context.close(); + } + } + + private static final class DirectPrefaceDecoder extends ByteToMessageDecoder { + @Override + protected void decode( + ChannelHandlerContext context, + ByteBuf input, + List output) { + int size = readFrameLength(input, MAX_PREFACE_SIZE); + if (size < 0) { + return; + } + if (input.readableBytes() < size) { + input.resetReaderIndex(); + return; + } + byte[] frame = new byte[size]; + input.readBytes(frame); + output.add(decodePreface(frame)); + } + } + + private static final class DirectPreface { + private final String shareId; + private final String capability; + private final DirectP2pAuthMode authMode; + + private DirectPreface( + String shareId, + String capability, + DirectP2pAuthMode authMode) { + this.shareId = Objects.requireNonNull(shareId, "shareId"); + this.capability = Objects.requireNonNull(capability, "capability"); + this.authMode = Objects.requireNonNull(authMode, "authMode"); + } + } + + private static final class ProxyRuntime implements AutoCloseable { + private final Host host; + private final String address; + private final DirectPreface preface; + private final Duration timeout; + private final ServerSocket listener; + private final AtomicBoolean closed = new AtomicBoolean(); + private volatile Socket client; + private volatile Stream stream; + + private ProxyRuntime( + Host host, + String address, + DirectPreface preface, + Duration timeout) throws IOException { + this.host = host; + this.address = Objects.requireNonNull(address, "address"); + this.preface = Objects.requireNonNull(preface, "preface"); + this.timeout = Objects.requireNonNull(timeout, "timeout"); + listener = new ServerSocket(); + listener.bind(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 1); + } + + private InetSocketAddress localAddress() { + return (InetSocketAddress) listener.getLocalSocketAddress(); + } + + private void start() { + Multiaddr multiaddr = Multiaddr.fromString(address); + PeerId peerId = multiaddr.getPeerId(); + if (peerId == null) { + throw new IllegalArgumentException( + "direct address must include /p2p/"); + } + Connection connection = await( + host.getNetwork().connect(peerId, multiaddr), + timeout, + "dial the Connect Share host"); + StreamPromise promise = host.newStream( + Collections.singletonList(TUNNEL_PROTOCOL_ID), + connection); + stream = await( + promise.getStream(), + timeout, + "open the Connect Share direct stream"); + await( + stream.getProtocol(), + timeout, + "negotiate the Connect Share direct protocol"); + + Thread thread = new Thread(this::acceptAndBridge, "connect-share-direct-guest"); + thread.setDaemon(true); + thread.start(); + } + + private void acceptAndBridge() { + try { + client = listener.accept(); + listener.close(); + SocketBridge.install( + stream, + client, + "connect-share-direct-guest", + encodePreface(preface)); + } catch (Exception failure) { + close(); + } + } + + @Override + public void close() { + if (!closed.compareAndSet(false, true)) { + return; + } + try { + listener.close(); + } catch (IOException ignored) { + // Best effort during share shutdown. + } + closeQuietly(client); + Stream active = stream; + if (active != null) { + active.close(); + } + } + } + + private static final class SocketBridge { + private SocketBridge() { + } + + private static void install(Stream stream, Socket socket, String threadName) + throws IOException { + install(stream, socket, threadName, null); + } + + private static void install( + Stream stream, + Socket socket, + String threadName, + byte[] initialFrame) throws IOException { + AtomicBoolean closed = new AtomicBoolean(); + stream.pushHandler(new StreamToSocketHandler(stream, socket, closed)); + if (initialFrame != null) { + stream.writeAndFlush(Unpooled.wrappedBuffer(initialFrame)); + } + Thread outbound = new Thread( + () -> copySocketToStream(stream, socket, closed), + threadName); + outbound.setDaemon(true); + outbound.start(); + } + + private static void copySocketToStream( + Stream stream, + Socket socket, + AtomicBoolean closed) { + byte[] buffer = new byte[16 * 1024]; + try { + InputStream input = socket.getInputStream(); + int read; + while ((read = input.read(buffer)) >= 0) { + if (read > 0) { + stream.writeAndFlush( + Unpooled.wrappedBuffer(Arrays.copyOf(buffer, read))); + } + } + stream.closeWrite(); + } catch (IOException ignored) { + close(stream, socket, closed); + } + } + + private static void close(Stream stream, Socket socket, AtomicBoolean closed) { + if (closed.compareAndSet(false, true)) { + closeQuietly(socket); + stream.close(); + } + } + } + + private static final class StreamToSocketHandler + extends SimpleChannelInboundHandler { + private final Stream stream; + private final Socket socket; + private final AtomicBoolean closed; + + private StreamToSocketHandler( + Stream stream, + Socket socket, + AtomicBoolean closed) { + this.stream = stream; + this.socket = socket; + this.closed = closed; + } + + @Override + protected void channelRead0(ChannelHandlerContext context, ByteBuf message) + throws IOException { + socket.getOutputStream().write( + ByteBufUtil.getBytes( + message, + message.readerIndex(), + message.readableBytes(), + true)); + socket.getOutputStream().flush(); + } + + @Override + public void channelInactive(ChannelHandlerContext context) throws Exception { + SocketBridge.close(stream, socket, closed); + super.channelInactive(context); + } + + @Override + public void exceptionCaught(ChannelHandlerContext context, Throwable cause) { + SocketBridge.close(stream, socket, closed); + context.close(); + } + } +} diff --git a/core/src/main/java/com/minekube/connect/tunnel/p2p/DirectP2pProxy.java b/core/src/main/java/com/minekube/connect/tunnel/p2p/DirectP2pProxy.java new file mode 100644 index 000000000..dc0da90d7 --- /dev/null +++ b/core/src/main/java/com/minekube/connect/tunnel/p2p/DirectP2pProxy.java @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2021-2022 Minekube. https://minekube.com + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package com.minekube.connect.tunnel.p2p; + +import java.net.InetSocketAddress; +import java.util.Objects; +import java.util.concurrent.atomic.AtomicBoolean; + +public final class DirectP2pProxy implements AutoCloseable { + private final InetSocketAddress localAddress; + private final Runnable close; + private final AtomicBoolean closed = new AtomicBoolean(); + + public DirectP2pProxy(InetSocketAddress localAddress, Runnable close) { + this.localAddress = Objects.requireNonNull(localAddress, "localAddress"); + this.close = Objects.requireNonNull(close, "close"); + if (!localAddress.getAddress().isLoopbackAddress()) { + throw new IllegalArgumentException("direct proxy must bind to loopback"); + } + } + + public InetSocketAddress localAddress() { + return localAddress; + } + + @Override + public void close() { + if (closed.compareAndSet(false, true)) { + close.run(); + } + } +} diff --git a/core/src/main/java/com/minekube/connect/tunnel/p2p/DirectP2pRoute.java b/core/src/main/java/com/minekube/connect/tunnel/p2p/DirectP2pRoute.java new file mode 100644 index 000000000..3bf49cad8 --- /dev/null +++ b/core/src/main/java/com/minekube/connect/tunnel/p2p/DirectP2pRoute.java @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2021-2022 Minekube. https://minekube.com + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package com.minekube.connect.tunnel.p2p; + +public enum DirectP2pRoute { + LAN, + INTERNET +} diff --git a/core/src/main/java/com/minekube/connect/tunnel/p2p/DirectP2pSession.java b/core/src/main/java/com/minekube/connect/tunnel/p2p/DirectP2pSession.java new file mode 100644 index 000000000..758a5962d --- /dev/null +++ b/core/src/main/java/com/minekube/connect/tunnel/p2p/DirectP2pSession.java @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2021-2022 Minekube. https://minekube.com + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package com.minekube.connect.tunnel.p2p; + +import java.util.Objects; + +public final class DirectP2pSession { + private final String peerId; + private final DirectP2pAuthMode authMode; + private final DirectP2pRoute route; + private final String connectionId; + + public DirectP2pSession( + String peerId, + DirectP2pAuthMode authMode, + DirectP2pRoute route, + String connectionId) { + this.peerId = Objects.requireNonNull(peerId, "peerId"); + this.authMode = Objects.requireNonNull(authMode, "authMode"); + this.route = Objects.requireNonNull(route, "route"); + this.connectionId = Objects.requireNonNull(connectionId, "connectionId"); + } + + public String peerId() { + return peerId; + } + + public DirectP2pAuthMode authMode() { + return authMode; + } + + public DirectP2pRoute route() { + return route; + } + + public String connectionId() { + return connectionId; + } +} diff --git a/core/src/main/java/com/minekube/connect/tunnel/p2p/Libp2pEndpoint.java b/core/src/main/java/com/minekube/connect/tunnel/p2p/Libp2pEndpoint.java index b44909195..7e4dda828 100644 --- a/core/src/main/java/com/minekube/connect/tunnel/p2p/Libp2pEndpoint.java +++ b/core/src/main/java/com/minekube/connect/tunnel/p2p/Libp2pEndpoint.java @@ -30,6 +30,7 @@ import com.minekube.connect.api.logger.ConnectLogger; import com.minekube.connect.bedrock.BedrockAdmissionCoordinator; import com.minekube.connect.bedrock.BedrockIdentityReadiness; +import com.minekube.connect.bedrock.BedrockPrincipalReadiness; import com.minekube.connect.config.ConnectConfig; import com.minekube.connect.platform.util.PlatformUtils; import java.lang.reflect.Constructor; @@ -58,6 +59,7 @@ public Libp2pEndpoint( PlatformInjector platformInjector, SimpleConnectApi api, BedrockIdentityReadiness bedrockIdentityReadiness, + BedrockPrincipalReadiness bedrockPrincipalReadiness, BedrockAdmissionCoordinator admissionCoordinator) { this.logger = logger; try { @@ -74,6 +76,7 @@ public Libp2pEndpoint( PlatformInjector.class, SimpleConnectApi.class, BedrockIdentityReadiness.class, + BedrockPrincipalReadiness.class, BedrockAdmissionCoordinator.class); constructor.setAccessible(true); this.runtime = constructor.newInstance( @@ -85,6 +88,7 @@ public Libp2pEndpoint( platformInjector, api, bedrockIdentityReadiness, + bedrockPrincipalReadiness, admissionCoordinator); this.startMethod = runtimeClass.getDeclaredMethod("start"); this.startBootstrapMethod = runtimeClass.getDeclaredMethod( diff --git a/core/src/main/java/com/minekube/connect/tunnel/p2p/Libp2pEndpointRuntime.java b/core/src/main/java/com/minekube/connect/tunnel/p2p/Libp2pEndpointRuntime.java index e1870c4ae..c25c21e91 100644 --- a/core/src/main/java/com/minekube/connect/tunnel/p2p/Libp2pEndpointRuntime.java +++ b/core/src/main/java/com/minekube/connect/tunnel/p2p/Libp2pEndpointRuntime.java @@ -29,6 +29,7 @@ import com.minekube.connect.api.inject.PlatformInjector; import com.minekube.connect.api.logger.ConnectLogger; import com.minekube.connect.bedrock.BedrockIdentityReadiness; +import com.minekube.connect.bedrock.BedrockPrincipalReadiness; import com.minekube.connect.bedrock.BedrockIdentityReadiness.Transport; import com.minekube.connect.bedrock.BedrockAdmissionCoordinator; import com.minekube.connect.config.ConnectConfig; @@ -80,6 +81,7 @@ final class Libp2pEndpointRuntime { private final PlatformInjector platformInjector; private final SimpleConnectApi api; private final BedrockIdentityReadiness bedrockIdentityReadiness; + private final BedrockPrincipalReadiness bedrockPrincipalReadiness; private final BedrockAdmissionCoordinator admissionCoordinator; private final String endpointInstanceId = newEndpointInstanceId(); private final AtomicLong sequence = new AtomicLong(); @@ -104,6 +106,7 @@ final class Libp2pEndpointRuntime { PlatformInjector platformInjector, SimpleConnectApi api, BedrockIdentityReadiness bedrockIdentityReadiness, + BedrockPrincipalReadiness bedrockPrincipalReadiness, BedrockAdmissionCoordinator admissionCoordinator) { this.dataDirectory = dataDirectory; this.connectConfig = connectConfig; @@ -113,6 +116,7 @@ final class Libp2pEndpointRuntime { this.platformInjector = platformInjector; this.api = api; this.bedrockIdentityReadiness = bedrockIdentityReadiness; + this.bedrockPrincipalReadiness = bedrockPrincipalReadiness; this.admissionCoordinator = admissionCoordinator; } @@ -126,7 +130,7 @@ final class Libp2pEndpointRuntime { SimpleConnectApi api, BedrockIdentityReadiness bedrockIdentityReadiness) { this(dataDirectory, connectConfig, connectToken, platformUtils, logger, platformInjector, api, - bedrockIdentityReadiness, null); + bedrockIdentityReadiness, null, null); } @Inject @@ -279,6 +283,7 @@ private ActiveRegistration registerOnce(OfflineMode offlineMode, EndpointAuthTyp PeerRegistrationClient client = null; try { Stream stream = openRegisterStream(address); + List capabilities = principalCapabilities(); PeerRegistrationHandshake handshake = new PeerRegistrationHandshake( identity, connectConfig.getEndpoint(), @@ -289,11 +294,10 @@ private ActiveRegistration registerOnce(OfflineMode offlineMode, EndpointAuthTyp : connectConfig.getSuperEndpoints(), offlineMode, authType, - bedrockIdentityReadiness.capabilities( - libp2pConfig.capabilities(), - Transport.LIBP2P), + capabilities, + framedPrincipalCapabilities(capabilities), this::currentCapacity); - client = new PeerRegistrationClient(handshake); + client = new PeerRegistrationClient(handshake, bedrockPrincipalReadiness); PeerRegisterResult result = await(client.install( stream, this::refreshObservedAddrs, @@ -315,6 +319,22 @@ private ActiveRegistration registerOnce(OfflineMode offlineMode, EndpointAuthTyp : lastError; } + private List principalCapabilities() { + List legacy = bedrockIdentityReadiness.capabilities( + libp2pConfig.capabilities(), Transport.LIBP2P); + legacy.removeIf(BedrockPrincipalReadiness.CAPABILITY::equals); + return List.copyOf(legacy); + } + + private List framedPrincipalCapabilities(List legacy) { + if (bedrockPrincipalReadiness == null || !bedrockPrincipalReadiness.isReady()) { + return legacy; + } + List framed = new ArrayList<>(legacy); + framed.add(BedrockPrincipalReadiness.CAPABILITY); + return List.copyOf(framed); + } + static List registerAttemptAddresses(List registerAddrs, int attemptsPerAddress) { if (registerAddrs == null || registerAddrs.isEmpty()) { return Collections.emptyList(); diff --git a/core/src/main/java/com/minekube/connect/tunnel/p2p/Libp2pRuntime.java b/core/src/main/java/com/minekube/connect/tunnel/p2p/Libp2pRuntime.java index 13895b45e..f3ebcc091 100644 --- a/core/src/main/java/com/minekube/connect/tunnel/p2p/Libp2pRuntime.java +++ b/core/src/main/java/com/minekube/connect/tunnel/p2p/Libp2pRuntime.java @@ -37,4 +37,12 @@ public static int minimumJavaFeatureVersion() { public static String hostClassName() { return "io.libp2p.core.Host"; } + + /** + * Releases the isolated runtime class loader and its extracted payload. + * A later Connect start creates a fresh isolated runtime. + */ + public static void close() { + Libp2pRuntimeLoader.close(); + } } diff --git a/core/src/main/java/com/minekube/connect/tunnel/p2p/Libp2pRuntimeLoader.java b/core/src/main/java/com/minekube/connect/tunnel/p2p/Libp2pRuntimeLoader.java index 97fcc39c8..350af2d55 100644 --- a/core/src/main/java/com/minekube/connect/tunnel/p2p/Libp2pRuntimeLoader.java +++ b/core/src/main/java/com/minekube/connect/tunnel/p2p/Libp2pRuntimeLoader.java @@ -22,10 +22,18 @@ package com.minekube.connect.tunnel.p2p; +import java.io.IOException; +import java.io.InputStream; import java.net.MalformedURLException; import java.net.URL; import java.net.URLClassLoader; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; import java.security.CodeSource; +import java.security.DigestInputStream; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; import java.util.ArrayList; import java.util.Arrays; import java.util.HashSet; @@ -34,6 +42,7 @@ import java.util.Set; final class Libp2pRuntimeLoader { + private static final String RUNTIME_RESOURCE = "META-INF/connect/libp2p-runtime.jar"; private static final List CHILD_FIRST_PREFIXES = Arrays.asList( "com.minekube.connect.tunnel.p2p.", "io.libp2p.", @@ -41,37 +50,96 @@ final class Libp2pRuntimeLoader { "kotlin.", "kotlinx."); private static final Set PARENT_FIRST_CLASSES = new HashSet<>(Arrays.asList( + "com.minekube.connect.tunnel.p2p.DirectP2pAuthMode", + "com.minekube.connect.tunnel.p2p.DirectP2pDiscoveredShare", + "com.minekube.connect.tunnel.p2p.DirectP2pDiscoveryListener", + "com.minekube.connect.tunnel.p2p.DirectP2pHostConfig", + "com.minekube.connect.tunnel.p2p.DirectP2pHostHandler", + "com.minekube.connect.tunnel.p2p.DirectP2pHostInfo", + "com.minekube.connect.tunnel.p2p.DirectP2pNode", + "com.minekube.connect.tunnel.p2p.DirectP2pProxy", + "com.minekube.connect.tunnel.p2p.DirectP2pRoute", + "com.minekube.connect.tunnel.p2p.DirectP2pSession", "com.minekube.connect.tunnel.p2p.Libp2pEndpoint", "com.minekube.connect.tunnel.p2p.Libp2pRuntime", "com.minekube.connect.tunnel.p2p.Libp2pRuntimeLoader", "com.minekube.connect.tunnel.p2p.Libp2pTunnelTransport")); - private static volatile ClassLoader classLoader; + private static volatile ChildFirstRuntimeClassLoader classLoader; + private static Path runtimePayload; + private static boolean shutdownHookInstalled; private Libp2pRuntimeLoader() { } static ClassLoader classLoader() { - ClassLoader existing = classLoader; + ChildFirstRuntimeClassLoader existing = classLoader; if (existing != null) { return existing; } synchronized (Libp2pRuntimeLoader.class) { existing = classLoader; if (existing == null) { - existing = new ChildFirstRuntimeClassLoader(runtimeUrls(), Libp2pRuntimeLoader.class.getClassLoader()); + RuntimeLocation runtime = runtimeLocation(); + existing = new ChildFirstRuntimeClassLoader( + runtime.urls, + Libp2pRuntimeLoader.class.getClassLoader()); classLoader = existing; + runtimePayload = runtime.payload; + installShutdownHook(); } return existing; } } - private static URL[] runtimeUrls() { - Set urls = new LinkedHashSet<>(); - CodeSource codeSource = Libp2pRuntimeLoader.class.getProtectionDomain().getCodeSource(); - if (codeSource != null && codeSource.getLocation() != null) { - urls.add(codeSource.getLocation()); + static void close() { + ChildFirstRuntimeClassLoader closing; + Path payload; + synchronized (Libp2pRuntimeLoader.class) { + closing = classLoader; + payload = runtimePayload; + classLoader = null; + runtimePayload = null; + } + if (closing != null) { + try { + closing.close(); + } catch (IOException ignored) { + // Closing is best effort during platform shutdown. + } } + if (payload != null) { + try { + deleteRuntimePayload(payload); + } catch (IOException ignored) { + // The operating system can clear a stale temporary payload later. + } + } + } + + private static RuntimeLocation runtimeLocation() { + InputStream packaged = Libp2pRuntimeLoader.class + .getClassLoader() + .getResourceAsStream(RUNTIME_RESOURCE); + if (packaged == null) { + return new RuntimeLocation(developmentRuntimeUrls(), null); + } + try (InputStream input = packaged) { + Path payload = extractRuntimePayload(input); + Set urls = new LinkedHashSet<>(); + urls.add(payload.toUri().toURL()); + codeSourceUrl().ifPresent(urls::add); + return new RuntimeLocation(urls.toArray(new URL[0]), payload); + } catch (IOException e) { + throw new IllegalStateException( + "Could not extract the isolated Connect libp2p runtime", + e); + } + } + + private static URL[] developmentRuntimeUrls() { + Set urls = new LinkedHashSet<>(); + codeSourceUrl().ifPresent(urls::add); ClassLoader parent = Libp2pRuntimeLoader.class.getClassLoader(); if (parent instanceof URLClassLoader) { urls.addAll(Arrays.asList(((URLClassLoader) parent).getURLs())); @@ -81,6 +149,65 @@ private static URL[] runtimeUrls() { return urls.toArray(new URL[0]); } + static Path extractRuntimePayload(InputStream input) throws IOException { + Path directory = Files.createTempDirectory("minekube-connect-libp2p-"); + Path partial = directory.resolve("libp2p-runtime.part"); + MessageDigest digest; + try { + digest = MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 is unavailable", e); + } + try (DigestInputStream source = new DigestInputStream(input, digest)) { + Files.copy(source, partial, StandardCopyOption.REPLACE_EXISTING); + } catch (Throwable failure) { + Files.deleteIfExists(partial); + Files.deleteIfExists(directory); + throw failure; + } + + String hash = hexadecimal(digest.digest()); + Path target = directory.resolve("libp2p-runtime-" + hash + ".jar"); + try { + Files.move(partial, target, StandardCopyOption.ATOMIC_MOVE); + } catch (java.nio.file.AtomicMoveNotSupportedException ignored) { + Files.move(partial, target); + } + return target; + } + + static void deleteRuntimePayload(Path payload) throws IOException { + Files.deleteIfExists(payload); + Path directory = payload.getParent(); + if (directory != null) { + Files.deleteIfExists(directory); + } + } + + private static java.util.Optional codeSourceUrl() { + CodeSource codeSource = Libp2pRuntimeLoader.class.getProtectionDomain().getCodeSource(); + return codeSource == null + ? java.util.Optional.empty() + : java.util.Optional.ofNullable(codeSource.getLocation()); + } + + private static String hexadecimal(byte[] bytes) { + StringBuilder value = new StringBuilder(bytes.length * 2); + for (byte current : bytes) { + value.append(String.format("%02x", current & 0xff)); + } + return value.toString(); + } + + private static synchronized void installShutdownHook() { + if (shutdownHookInstalled) { + return; + } + Runtime.getRuntime().addShutdownHook( + new Thread(Libp2pRuntimeLoader::close, "Connect libp2p runtime cleanup")); + shutdownHookInstalled = true; + } + private static List classPathUrls() { List urls = new ArrayList<>(); String classPath = System.getProperty("java.class.path", ""); @@ -97,6 +224,16 @@ private static List classPathUrls() { return urls; } + private static final class RuntimeLocation { + private final URL[] urls; + private final Path payload; + + private RuntimeLocation(URL[] urls, Path payload) { + this.urls = urls; + this.payload = payload; + } + } + private static final class ChildFirstRuntimeClassLoader extends URLClassLoader { static { ClassLoader.registerAsParallelCapable(); diff --git a/core/src/main/java/com/minekube/connect/tunnel/p2p/Libp2pSessionMapper.java b/core/src/main/java/com/minekube/connect/tunnel/p2p/Libp2pSessionMapper.java index 97d312fdc..a335f8f6d 100644 --- a/core/src/main/java/com/minekube/connect/tunnel/p2p/Libp2pSessionMapper.java +++ b/core/src/main/java/com/minekube/connect/tunnel/p2p/Libp2pSessionMapper.java @@ -63,6 +63,12 @@ static Session toWatchSession(SessionOffer offer) { .setPlayer(player) .setAuth(Authentication.newBuilder().setPassthrough(passthrough)) .setProtocolValue(offer.getProtocolValue()) + .setEndpointId(offer.getEndpointId()) + .setOrganizationId(offer.getEndpointOrgId()) + .setConnectSessionNonce(offer.getConnectSessionNonce()) + .setSourceProtocolVersion(offer.getSourceProtocolVersion()) + .setPolicyRevision(offer.getPolicyRevision()) + .setSignedBedrockPrincipalV2(offer.getSignedBedrockPrincipalV2()) .addTunnelTransports(TunnelTransport.newBuilder() .setType(TunnelTransport.Type.TYPE_LIBP2P) .setAddress("same-stream")) diff --git a/core/src/main/java/com/minekube/connect/tunnel/p2p/MdnsAddressSelector.java b/core/src/main/java/com/minekube/connect/tunnel/p2p/MdnsAddressSelector.java new file mode 100644 index 000000000..1ed42df4e --- /dev/null +++ b/core/src/main/java/com/minekube/connect/tunnel/p2p/MdnsAddressSelector.java @@ -0,0 +1,150 @@ +/* + * Copyright (c) 2021-2022 Minekube. https://minekube.com + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package com.minekube.connect.tunnel.p2p; + +import java.net.Inet4Address; +import java.net.InetAddress; +import java.net.NetworkInterface; +import java.net.SocketException; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.Enumeration; +import java.util.List; +import java.util.Objects; + +final class MdnsAddressSelector { + private MdnsAddressSelector() { + } + + static InetAddress systemAddress() { + List candidates = new ArrayList<>(); + try { + Enumeration interfaces = NetworkInterface.getNetworkInterfaces(); + while (interfaces.hasMoreElements()) { + NetworkInterface network = interfaces.nextElement(); + Enumeration addresses = network.getInetAddresses(); + while (addresses.hasMoreElements()) { + candidates.add(new Candidate( + addresses.nextElement(), + network.isUp(), + network.supportsMulticast(), + network.isLoopback(), + network.isPointToPoint(), + network.isVirtual(), + network.getIndex())); + } + } + } catch (SocketException e) { + throw new IllegalStateException("Could not select an mDNS network interface", e); + } + return select(candidates); + } + + static InetAddress select(List candidates) { + return candidates.stream() + .filter(MdnsAddressSelector::usable) + .min(Comparator + .comparingInt((Candidate candidate) -> scopeRank(candidate.address())) + .thenComparing(Candidate::virtual) + .thenComparingInt(Candidate::interfaceIndex)) + .map(Candidate::address) + .orElse(null); + } + + private static boolean usable(Candidate candidate) { + InetAddress address = candidate.address(); + return candidate.up() + && candidate.multicast() + && !candidate.loopback() + && !candidate.pointToPoint() + && address instanceof Inet4Address + && !address.isAnyLocalAddress() + && !address.isLoopbackAddress() + && !address.isMulticastAddress(); + } + + private static int scopeRank(InetAddress address) { + if (address.isSiteLocalAddress()) { + return 0; + } + if (address.isLinkLocalAddress()) { + return 1; + } + return 2; + } + + static final class Candidate { + private final InetAddress address; + private final boolean up; + private final boolean multicast; + private final boolean loopback; + private final boolean pointToPoint; + private final boolean virtual; + private final int interfaceIndex; + + Candidate( + InetAddress address, + boolean up, + boolean multicast, + boolean loopback, + boolean pointToPoint, + boolean virtual, + int interfaceIndex) { + this.address = Objects.requireNonNull(address, "address"); + this.up = up; + this.multicast = multicast; + this.loopback = loopback; + this.pointToPoint = pointToPoint; + this.virtual = virtual; + this.interfaceIndex = interfaceIndex; + } + + InetAddress address() { + return address; + } + + boolean up() { + return up; + } + + boolean multicast() { + return multicast; + } + + boolean loopback() { + return loopback; + } + + boolean pointToPoint() { + return pointToPoint; + } + + boolean virtual() { + return virtual; + } + + int interfaceIndex() { + return interfaceIndex; + } + } +} diff --git a/core/src/main/java/com/minekube/connect/tunnel/p2p/P2PFrameCodec.java b/core/src/main/java/com/minekube/connect/tunnel/p2p/P2PFrameCodec.java index 1c1098d1b..f2eb40e4f 100644 --- a/core/src/main/java/com/minekube/connect/tunnel/p2p/P2PFrameCodec.java +++ b/core/src/main/java/com/minekube/connect/tunnel/p2p/P2PFrameCodec.java @@ -34,6 +34,11 @@ public final class P2PFrameCodec { public static final int MAX_CONTROL_FRAME_SIZE = 1 << 20; + public static final int MAX_KIND_PREFIXED_FRAME_SIZE = 4096; + public static final byte RENEWAL_COMMIT = 0x01; + public static final byte RENEWAL_RESULT = 0x02; + public static final byte READINESS_CHALLENGE = 0x03; + public static final byte READINESS_ATTESTATION = 0x04; private P2PFrameCodec() { } @@ -66,6 +71,59 @@ public static T read( return parser.parseFrom(payload); } + public static void writeKindPrefixed(OutputStream out, byte kind, MessageLite message) + throws IOException { + requireKind(kind); + byte[] payload = message.toByteArray(); + int length = 1 + payload.length; + if (length > MAX_KIND_PREFIXED_FRAME_SIZE) { + throw new IllegalArgumentException("kind-prefixed frame exceeds maximum size"); + } + writeVarint(out, length); + out.write(kind); + out.write(payload); + out.flush(); + } + + public static KindPrefixedFrame readKindPrefixed(InputStream in) throws IOException { + long length = readVarint(in); + if (length < 1) { + throw new IllegalArgumentException("kind-prefixed frame has no kind"); + } + if (length > MAX_KIND_PREFIXED_FRAME_SIZE) { + throw new IllegalArgumentException("kind-prefixed frame exceeds maximum size"); + } + int kind = in.read(); + if (kind < 0) throw new EOFException("truncated kind-prefixed frame"); + requireKind((byte) kind); + byte[] payload = in.readNBytes((int) length - 1); + if (payload.length != length - 1) { + throw new EOFException("truncated kind-prefixed frame payload"); + } + return new KindPrefixedFrame((byte) kind, payload); + } + + public record KindPrefixedFrame(byte kind, byte[] protobuf) { + public KindPrefixedFrame { + protobuf = protobuf.clone(); + } + + @Override + public byte[] protobuf() { + return protobuf.clone(); + } + + public T parse(Parser parser) throws IOException { + return parser.parseFrom(protobuf); + } + } + + private static void requireKind(byte kind) { + if (kind < RENEWAL_COMMIT || kind > READINESS_ATTESTATION) { + throw new IllegalArgumentException("unknown kind-prefixed frame kind"); + } + } + private static void writeVarint(OutputStream out, int value) throws IOException { long v = value & 0xffffffffL; while (v >= 0x80) { diff --git a/core/src/main/java/com/minekube/connect/tunnel/p2p/P2PFrameDecoder.java b/core/src/main/java/com/minekube/connect/tunnel/p2p/P2PFrameDecoder.java index ef6d8fde8..876d257eb 100644 --- a/core/src/main/java/com/minekube/connect/tunnel/p2p/P2PFrameDecoder.java +++ b/core/src/main/java/com/minekube/connect/tunnel/p2p/P2PFrameDecoder.java @@ -37,6 +37,9 @@ final class P2PFrameDecoder extends ByteToMessageDecoder P2PFrameDecoder(Parser parser, int maxFrameSize) { this.parser = parser; this.maxFrameSize = maxFrameSize; + // Negotiation may put the final naked result and first kind-prefixed frame in one read. + // Deliver one frame before decoding the remainder so the handler can switch codecs. + setSingleDecode(true); } @Override diff --git a/core/src/main/java/com/minekube/connect/tunnel/p2p/PeerRegistrationClient.java b/core/src/main/java/com/minekube/connect/tunnel/p2p/PeerRegistrationClient.java index c9dc80f2f..caa84ce34 100644 --- a/core/src/main/java/com/minekube/connect/tunnel/p2p/PeerRegistrationClient.java +++ b/core/src/main/java/com/minekube/connect/tunnel/p2p/PeerRegistrationClient.java @@ -24,11 +24,14 @@ package com.minekube.connect.tunnel.p2p; import com.google.protobuf.MessageLite; +import com.minekube.connect.bedrock.BedrockPrincipalReadiness; import io.libp2p.core.Stream; +import io.netty.buffer.ByteBuf; import io.netty.buffer.Unpooled; import io.netty.channel.ChannelHandler; import io.netty.channel.ChannelHandlerContext; import io.netty.channel.SimpleChannelInboundHandler; +import io.netty.handler.codec.ByteToMessageDecoder; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.util.List; @@ -43,15 +46,22 @@ import java.util.function.Supplier; import minekube.connect.v1alpha1.ConnectLibp2P.PeerRegisterChallenge; import minekube.connect.v1alpha1.ConnectLibp2P.PeerRegisterResult; +import minekube.connect.v1alpha1.WatchServiceOuterClass.ReadinessChallenge; final class PeerRegistrationClient { private final PeerRegistrationHandshake handshake; private final ScheduledExecutorService renewExecutor; + private final BedrockPrincipalReadiness readiness; private final CompletableFuture closed = new CompletableFuture<>(); private volatile Stream stream; PeerRegistrationClient(PeerRegistrationHandshake handshake) { + this(handshake, null); + } + + PeerRegistrationClient(PeerRegistrationHandshake handshake, BedrockPrincipalReadiness readiness) { this.handshake = Objects.requireNonNull(handshake, "handshake"); + this.readiness = readiness; this.renewExecutor = Executors.newSingleThreadScheduledExecutor(runnable -> { Thread thread = new Thread(runnable, "connect-libp2p-registration-renew"); thread.setDaemon(true); @@ -115,10 +125,11 @@ private void installResultHandler( PeerRegisterResult.parser(), P2PFrameCodec.MAX_CONTROL_FRAME_SIZE); ctx.pipeline().addLast(resultDecoder); - ctx.pipeline().addLast(new ResultHandler(stream, challenge, observedAddrsSupplier, sequence, result)); + ctx.pipeline().addLast(new ResultHandler( + stream, resultDecoder, challenge, observedAddrsSupplier, sequence, result)); } - private static void writeFrame(Stream stream, MessageLite message) { + private synchronized void writeFrame(Stream stream, MessageLite message) { try { ByteArrayOutputStream out = new ByteArrayOutputStream(); P2PFrameCodec.write(out, message); @@ -128,6 +139,16 @@ private static void writeFrame(Stream stream, MessageLite message) { } } + private synchronized void writeKindFrame(Stream stream, byte kind, MessageLite message) { + try { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + P2PFrameCodec.writeKindPrefixed(out, kind, message); + stream.writeAndFlush(Unpooled.wrappedBuffer(out.toByteArray())); + } catch (IOException e) { + throw new IllegalStateException("encode kind-prefixed libp2p registration frame", e); + } + } + private final class ChallengeHandler extends SimpleChannelInboundHandler { private final Stream stream; private final ChannelHandler decoder; @@ -183,19 +204,25 @@ static long renewDelayMillis(PeerRegisterChallenge challenge) { private final class ResultHandler extends SimpleChannelInboundHandler { private final Stream stream; + private final ChannelHandler legacyDecoder; private final PeerRegisterChallenge challenge; private final Supplier> observedAddrsSupplier; private final AtomicLong sequence; private final CompletableFuture result; private volatile ScheduledFuture ackTimeout; + private volatile boolean offerAttempted; + private volatile boolean framed; + private volatile boolean awaitingResult = true; private ResultHandler( Stream stream, + ChannelHandler legacyDecoder, PeerRegisterChallenge challenge, Supplier> observedAddrsSupplier, long sequence, CompletableFuture result) { this.stream = stream; + this.legacyDecoder = legacyDecoder; this.challenge = challenge; this.observedAddrsSupplier = observedAddrsSupplier; this.sequence = new AtomicLong(sequence); @@ -204,8 +231,36 @@ private ResultHandler( @Override protected void channelRead0(ChannelHandlerContext ctx, PeerRegisterResult msg) { + handleResult(ctx, msg); + } + + private void handleResult(ChannelHandlerContext ctx, PeerRegisterResult msg) { + if (!awaitingResult) { + failRegistration(new IllegalArgumentException( + "unexpected duplicate libp2p registration result")); + ctx.close(); + return; + } + awaitingResult = false; cancelAckTimeout(); + if (msg.hasModeResult() + && msg.getModeResult().getVersion() == 2 + && !msg.getModeResult().getAccepted() + && framed) { + framed = false; + failRegistration(new IllegalStateException("libp2p registration framing rejected")); + return; + } result.complete(msg); + if (!framed && offerAttempted && msg.hasModeResult() + && msg.getModeResult().getVersion() == 2 + && msg.getModeResult().getAccepted()) { + framed = true; + ctx.pipeline().addLast(new KindFrameDecoder()); + ctx.pipeline().addLast(new KindFrameHandler(this)); + ctx.pipeline().remove(this); + ctx.pipeline().remove(legacyDecoder); + } scheduleRenew(); } @@ -213,11 +268,22 @@ private void scheduleRenew() { renewExecutor.schedule(() -> { if (!stream.closeFuture().isDone()) { try { - writeFrame(stream, handshake.commit( + boolean offer = !framed && !offerAttempted + && readiness != null && readiness.isReady(); + MessageLite commit = handshake.commit( challenge, observedAddrsSupplier.get(), sequence.incrementAndGet(), - System.currentTimeMillis())); + System.currentTimeMillis(), + offer, + framed); + awaitingResult = true; + if (framed) { + writeKindFrame(stream, P2PFrameCodec.RENEWAL_COMMIT, commit); + } else { + writeFrame(stream, commit); + if (offer) offerAttempted = true; + } scheduleAckTimeout(); } catch (RuntimeException e) { failRegistration(e); @@ -265,6 +331,79 @@ public void channelInactive(ChannelHandlerContext ctx) throws Exception { } } + private final class KindFrameHandler + extends SimpleChannelInboundHandler { + private final ResultHandler registration; + + private KindFrameHandler(ResultHandler registration) { + this.registration = registration; + } + + @Override + protected void channelRead0( + ChannelHandlerContext ctx, P2PFrameCodec.KindPrefixedFrame frame) throws Exception { + if (frame.kind() == P2PFrameCodec.RENEWAL_RESULT) { + registration.handleResult(ctx, frame.parse(PeerRegisterResult.parser())); + return; + } + if (frame.kind() == P2PFrameCodec.READINESS_CHALLENGE && readiness != null) { + ReadinessChallenge challenge = frame.parse(ReadinessChallenge.parser()); + writeKindFrame(stream, P2PFrameCodec.READINESS_ATTESTATION, + readiness.attest(challenge, BedrockPrincipalReadiness.Transport.LIBP2P)); + return; + } + registration.failRegistration(new IllegalArgumentException( + "unexpected kind-prefixed registration frame")); + } + + @Override + public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) { + registration.failRegistration(cause); + ctx.close(); + } + } + + private static final class KindFrameDecoder extends ByteToMessageDecoder { + @Override + protected void decode(ChannelHandlerContext ctx, ByteBuf in, List out) { + in.markReaderIndex(); + long length = 0; + int shift = 0; + boolean complete = false; + for (int index = 0; index < 10; index++) { + if (!in.isReadable()) { + in.resetReaderIndex(); + return; + } + int value = in.readUnsignedByte(); + if (index == 9 && value > 1) { + throw new IllegalArgumentException("kind-prefixed frame length overflow"); + } + length |= (long) (value & 0x7f) << shift; + if ((value & 0x80) == 0) { + complete = true; + break; + } + shift += 7; + } + if (!complete || length < 1 || length > P2PFrameCodec.MAX_KIND_PREFIXED_FRAME_SIZE) { + throw new IllegalArgumentException("invalid kind-prefixed frame length"); + } + if (in.readableBytes() < length) { + in.resetReaderIndex(); + return; + } + byte kind = in.readByte(); + if (kind < P2PFrameCodec.RENEWAL_COMMIT + || kind > P2PFrameCodec.READINESS_ATTESTATION) { + throw new IllegalArgumentException("unknown kind-prefixed frame kind"); + } + byte[] payload = new byte[(int) length - 1]; + in.readBytes(payload); + out.add(new P2PFrameCodec.KindPrefixedFrame(kind, payload)); + } + } + static long renewAckTimeoutMillis(PeerRegisterChallenge challenge) { long renewDelay = renewDelayMillis(challenge); if (challenge.getKvTtlMs() > 0) { diff --git a/core/src/main/java/com/minekube/connect/tunnel/p2p/PeerRegistrationHandshake.java b/core/src/main/java/com/minekube/connect/tunnel/p2p/PeerRegistrationHandshake.java index 9194887f1..79db06b72 100644 --- a/core/src/main/java/com/minekube/connect/tunnel/p2p/PeerRegistrationHandshake.java +++ b/core/src/main/java/com/minekube/connect/tunnel/p2p/PeerRegistrationHandshake.java @@ -38,6 +38,7 @@ import minekube.connect.v1alpha1.ConnectLibp2P.PeerRegisterChallenge; import minekube.connect.v1alpha1.ConnectLibp2P.PeerRegisterCommit; import minekube.connect.v1alpha1.ConnectLibp2P.PeerRegisterInit; +import minekube.connect.v1alpha1.ConnectLibp2P.RegistrationModeOffer; final class PeerRegistrationHandshake { static final String BEDROCK_IDENTITY_V1_CAPABILITY = "bedrock-identity-v1"; @@ -49,6 +50,7 @@ final class PeerRegistrationHandshake { private final OfflineMode offlineMode; private final EndpointAuthType authType; private final List capabilities; + private final List framedCapabilities; private final Supplier capacitySupplier; PeerRegistrationHandshake( @@ -85,9 +87,54 @@ final class PeerRegistrationHandshake { List parentEndpoints, OfflineMode offlineMode, List capabilities, + List framedCapabilities, + PeerCapacity capacity) { + this(identity, endpoint, token, endpointInstanceId, parentEndpoints, offlineMode, + EndpointAuthType.ENDPOINT_AUTH_TYPE_UNSPECIFIED, capabilities, + framedCapabilities, () -> capacity); + } + + PeerRegistrationHandshake( + EndpointPeerIdentity identity, + String endpoint, + String token, + String endpointInstanceId, + List parentEndpoints, + OfflineMode offlineMode, + EndpointAuthType authType, + List capabilities, + List framedCapabilities, + PeerCapacity capacity) { + this(identity, endpoint, token, endpointInstanceId, parentEndpoints, offlineMode, + authType, capabilities, framedCapabilities, () -> capacity); + } + + PeerRegistrationHandshake( + EndpointPeerIdentity identity, + String endpoint, + String token, + String endpointInstanceId, + List parentEndpoints, + OfflineMode offlineMode, + List capabilities, + Supplier capacitySupplier) { + this(identity, endpoint, token, endpointInstanceId, parentEndpoints, offlineMode, + EndpointAuthType.ENDPOINT_AUTH_TYPE_UNSPECIFIED, capabilities, capabilities, + capacitySupplier); + } + + PeerRegistrationHandshake( + EndpointPeerIdentity identity, + String endpoint, + String token, + String endpointInstanceId, + List parentEndpoints, + OfflineMode offlineMode, + EndpointAuthType authType, + List capabilities, Supplier capacitySupplier) { this(identity, endpoint, token, endpointInstanceId, parentEndpoints, offlineMode, - EndpointAuthType.ENDPOINT_AUTH_TYPE_UNSPECIFIED, capabilities, capacitySupplier); + authType, capabilities, capabilities, capacitySupplier); } PeerRegistrationHandshake( @@ -99,6 +146,7 @@ final class PeerRegistrationHandshake { OfflineMode offlineMode, EndpointAuthType authType, List capabilities, + List framedCapabilities, Supplier capacitySupplier) { this.identity = Objects.requireNonNull(identity, "identity"); this.endpoint = Objects.requireNonNull(endpoint, "endpoint"); @@ -108,6 +156,8 @@ final class PeerRegistrationHandshake { this.offlineMode = Objects.requireNonNull(offlineMode, "offlineMode"); this.authType = Objects.requireNonNull(authType, "authType"); this.capabilities = new ArrayList<>(Objects.requireNonNull(capabilities, "capabilities")); + this.framedCapabilities = new ArrayList<>(Objects.requireNonNull( + framedCapabilities, "framedCapabilities")); this.capacitySupplier = Objects.requireNonNull(capacitySupplier, "capacitySupplier"); } @@ -131,6 +181,25 @@ PeerRegisterInit init(List observedAddrs) { } PeerRegisterCommit commit(PeerRegisterChallenge challenge, List addrs, long sequence, long nowUnixMs) { + return commit(challenge, addrs, sequence, nowUnixMs, false, false); + } + + PeerRegisterCommit commit( + PeerRegisterChallenge challenge, + List addrs, + long sequence, + long nowUnixMs, + boolean offerKindPrefixedV1) { + return commit(challenge, addrs, sequence, nowUnixMs, offerKindPrefixedV1, false); + } + + PeerRegisterCommit commit( + PeerRegisterChallenge challenge, + List addrs, + long sequence, + long nowUnixMs, + boolean offerKindPrefixedV1, + boolean useFramedCapabilities) { long ttlMs = challenge.getKvTtlMs() > 0 ? challenge.getKvTtlMs() : 45_000; List recordAddrs = recordRelayCircuitAddrs(challenge, addrs); if (recordAddrs.isEmpty() && challenge.getRelayAddrsList().isEmpty()) { @@ -148,7 +217,7 @@ PeerRegisterCommit commit(PeerRegisterChallenge challenge, List addrs, l .setPublisherPeerId(challenge.getPublisherPeerId()) .setRegion(challenge.getRegion()) .addAllAddrs(recordAddrs) - .addAllCapabilities(capabilities) + .addAllCapabilities(useFramedCapabilities ? framedCapabilities : capabilities) .setCapacity(capacity()) .setOfflineMode(offlineMode) .setAuthType(authType) @@ -158,10 +227,15 @@ PeerRegisterCommit commit(PeerRegisterChallenge challenge, List addrs, l .setExpiresAtUnixMs(nowUnixMs + ttlMs) .setNonce(challenge.getNonce()) .build(); - return PeerRegisterCommit.newBuilder() + PeerRegisterCommit.Builder commit = PeerRegisterCommit.newBuilder() .setRecord(record) - .setSignature(ByteString.copyFrom(identity.sign(PeerRecordSigningPayload.bytes(record)))) - .build(); + .setSignature(ByteString.copyFrom(identity.sign(PeerRecordSigningPayload.bytes(record)))); + if (offerKindPrefixedV1) { + commit.setModeOffer(RegistrationModeOffer.newBuilder() + .setVersion(2) + .setFraming("kind-prefixed-v1")); + } + return commit.build(); } private List recordRelayCircuitAddrs(PeerRegisterChallenge challenge, List reservedAddrs) { diff --git a/core/src/main/java/com/minekube/connect/tunnel/p2p/impl/Libp2pTunnelTransportRuntime.java b/core/src/main/java/com/minekube/connect/tunnel/p2p/impl/Libp2pTunnelTransportRuntime.java index 95c17c691..cc61f788e 100644 --- a/core/src/main/java/com/minekube/connect/tunnel/p2p/impl/Libp2pTunnelTransportRuntime.java +++ b/core/src/main/java/com/minekube/connect/tunnel/p2p/impl/Libp2pTunnelTransportRuntime.java @@ -118,7 +118,11 @@ private static Host createHost( RelayBindings relay = relayBindings(privateKey, relayAddrs, relayService); HostBuilder builder = new HostBuilder(HostBuilder.DefaultMode.None) .secureChannel(NoiseXXSecureChannel::new) - .muxer(StreamMuxerProtocol::getYamux) + // jvm-libp2p's Yamux window queue can double-release Netty 4.2 + // buffers on Java 17 during normal Minecraft-sized transfers. + // Mplex is the library's tested default and both Share peers + // are under our control, so prefer the stable muxer here. + .muxer(StreamMuxerProtocol::getMplex) .secureTransport((priv, protocols) -> QuicTransport.Ed25519(priv, protocols, new QuicConfig())); if (relay == null) { diff --git a/core/src/main/java/com/minekube/connect/watch/AllowAllSessionAdmissionGate.java b/core/src/main/java/com/minekube/connect/watch/AllowAllSessionAdmissionGate.java new file mode 100644 index 000000000..f240ac66f --- /dev/null +++ b/core/src/main/java/com/minekube/connect/watch/AllowAllSessionAdmissionGate.java @@ -0,0 +1,16 @@ +package com.minekube.connect.watch; + +import com.google.inject.Singleton; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; + +/** + * Preserves the existing plugin behavior when a platform does not install a private gate. + */ +@Singleton +public final class AllowAllSessionAdmissionGate implements SessionAdmissionGate { + @Override + public CompletionStage request(SessionProposal proposal) { + return CompletableFuture.completedFuture(SessionAdmissionDecision.allow()); + } +} diff --git a/core/src/main/java/com/minekube/connect/watch/SessionAdmissionDecision.java b/core/src/main/java/com/minekube/connect/watch/SessionAdmissionDecision.java new file mode 100644 index 000000000..97896ef8c --- /dev/null +++ b/core/src/main/java/com/minekube/connect/watch/SessionAdmissionDecision.java @@ -0,0 +1,55 @@ +package com.minekube.connect.watch; + +import java.util.Objects; + +/** + * A safe, asynchronous admission outcome for a Connect session proposal. + */ +public final class SessionAdmissionDecision { + private static final SessionAdmissionDecision ALLOW = + new SessionAdmissionDecision(Outcome.ALLOW, ""); + private static final SessionAdmissionDecision DEFER_TO_LOCAL_LOGIN = + new SessionAdmissionDecision(Outcome.DEFER_TO_LOCAL_LOGIN, ""); + + private final Outcome outcome; + private final String safeMessage; + + private SessionAdmissionDecision(Outcome outcome, String safeMessage) { + this.outcome = outcome; + this.safeMessage = safeMessage; + } + + public static SessionAdmissionDecision allow() { + return ALLOW; + } + + public static SessionAdmissionDecision deferToLocalLogin() { + return DEFER_TO_LOCAL_LOGIN; + } + + public static SessionAdmissionDecision deny(String safeMessage) { + String message = Objects.requireNonNull(safeMessage, "safeMessage").trim(); + if (message.isEmpty()) { + throw new IllegalArgumentException("safeMessage must not be empty"); + } + return new SessionAdmissionDecision(Outcome.DENY, message); + } + + public boolean isAllowed() { + return outcome == Outcome.ALLOW; + } + + public boolean isDeferredToLocalLogin() { + return outcome == Outcome.DEFER_TO_LOCAL_LOGIN; + } + + public String getSafeMessage() { + return safeMessage; + } + + private enum Outcome { + ALLOW, + DEFER_TO_LOCAL_LOGIN, + DENY + } +} diff --git a/core/src/main/java/com/minekube/connect/watch/SessionAdmissionGate.java b/core/src/main/java/com/minekube/connect/watch/SessionAdmissionGate.java new file mode 100644 index 000000000..c1a02dce9 --- /dev/null +++ b/core/src/main/java/com/minekube/connect/watch/SessionAdmissionGate.java @@ -0,0 +1,11 @@ +package com.minekube.connect.watch; + +import java.util.concurrent.CompletionStage; + +/** + * Decides whether a structurally valid Connect session may allocate tunnel resources. + */ +@FunctionalInterface +public interface SessionAdmissionGate { + CompletionStage request(SessionProposal proposal); +} diff --git a/core/src/main/java/com/minekube/connect/watch/SessionProposal.java b/core/src/main/java/com/minekube/connect/watch/SessionProposal.java index 54d101842..a4eafe5f3 100644 --- a/core/src/main/java/com/minekube/connect/watch/SessionProposal.java +++ b/core/src/main/java/com/minekube/connect/watch/SessionProposal.java @@ -49,6 +49,7 @@ public class SessionProposal { private final String endpointOrgId; @Getter private final SessionProtocol protocol; + private final boolean bedrockPrincipalV2; private final java.util.concurrent.atomic.AtomicReference state = new java.util.concurrent.atomic.AtomicReference<>(State.ACCEPTED); @@ -70,12 +71,15 @@ public SessionProposal( String endpointId, String endpointOrgId, AdmissionToken admissionToken) { + this.bedrockPrincipalV2 = session != null && !session.getSignedBedrockPrincipalV2().isEmpty(); this.session = withoutPrivateIdentity(session); this.admissionToken = admissionToken; this.reject = reject; Scope scope = parseScope(session); - this.endpointId = firstNonEmpty(endpointId, scope.endpoint_id); - this.endpointOrgId = firstNonEmpty(endpointOrgId, scope.endpoint_org_id); + this.endpointId = firstNonEmpty(endpointId, + firstNonEmpty(session == null ? "" : session.getEndpointId(), scope.endpoint_id)); + this.endpointOrgId = firstNonEmpty(endpointOrgId, + firstNonEmpty(session == null ? "" : session.getOrganizationId(), scope.endpoint_org_id)); this.protocol = session == null ? SessionProtocol.SESSION_PROTOCOL_UNSPECIFIED : session.getProtocol(); @@ -96,11 +100,15 @@ public State getState() { return state.get(); } + /** Whether the private authenticated proposal carried a v2 principal envelope. */ + public boolean hasBedrockPrincipalV2() { + return bedrockPrincipalV2; + } + @Override public String toString() { - return "SessionProposal{" + - "session=" + session + - '}'; + return "SessionProposal[sessionId=" + (session == null ? "" : session.getId()) + + ", protocol=" + protocol + ", bedrockPrincipalV2=" + bedrockPrincipalV2 + ']'; } private static Scope parseScope(Session session) { @@ -126,26 +134,35 @@ private static Session withoutPrivateIdentity(Session session) { if (!hasPrivateIdentity(session)) { return session; } - var profile = session.getPlayer().getProfile().toBuilder().clearProperties(); - for (var property : session.getPlayer().getProfile().getPropertiesList()) { - if (!BedrockIdentityVerifier.PROPERTY_NAME.equals(property.getName()) && - !BedrockIdentityProfiles.SCOPE_PROPERTY_NAME.equals(property.getName())) { - profile.addProperties(property); + var sanitized = session.toBuilder() + .clearConnectSessionNonce() + .clearSignedBedrockPrincipalV2(); + if (session.hasPlayer() && session.getPlayer().hasProfile()) { + var profile = session.getPlayer().getProfile().toBuilder().clearProperties(); + for (var property : session.getPlayer().getProfile().getPropertiesList()) { + if (!BedrockIdentityVerifier.PROPERTY_NAME.equals(property.getName()) && + !BedrockIdentityProfiles.SCOPE_PROPERTY_NAME.equals(property.getName()) && + !BedrockIdentityProfiles.PRINCIPAL_V2_PROPERTY_NAME.equals(property.getName())) { + profile.addProperties(property); + } } + sanitized.setPlayer(session.getPlayer().toBuilder().setProfile(profile)); } - return session.toBuilder() - .setPlayer(session.getPlayer().toBuilder().setProfile(profile)) - .build(); + return sanitized.build(); } private static boolean hasPrivateIdentity(Session session) { - if (session == null || !session.hasPlayer() || !session.getPlayer().hasProfile()) { + if (session == null) { return false; } + if (!session.getConnectSessionNonce().isEmpty() + || !session.getSignedBedrockPrincipalV2().isEmpty()) return true; + if (!session.hasPlayer() || !session.getPlayer().hasProfile()) return false; return session.getPlayer().getProfile().getPropertiesList().stream() .anyMatch(property -> BedrockIdentityVerifier.PROPERTY_NAME.equals(property.getName()) || - BedrockIdentityProfiles.SCOPE_PROPERTY_NAME.equals(property.getName())); + BedrockIdentityProfiles.SCOPE_PROPERTY_NAME.equals(property.getName()) || + BedrockIdentityProfiles.PRINCIPAL_V2_PROPERTY_NAME.equals(property.getName())); } private static String firstNonEmpty(String preferred, String fallback) { diff --git a/core/src/main/java/com/minekube/connect/watch/WatchClient.java b/core/src/main/java/com/minekube/connect/watch/WatchClient.java index 4fa6e51f3..a3d03553f 100644 --- a/core/src/main/java/com/minekube/connect/watch/WatchClient.java +++ b/core/src/main/java/com/minekube/connect/watch/WatchClient.java @@ -32,6 +32,7 @@ import com.minekube.connect.bedrock.BedrockIdentityReadiness; import com.minekube.connect.bedrock.BedrockIdentityReadiness.Transport; import com.minekube.connect.bedrock.BedrockAdmissionCoordinator; +import com.minekube.connect.bedrock.BedrockPrincipalReadiness; import com.minekube.connect.config.ConnectConfig; import java.io.IOException; import minekube.connect.v1alpha1.WatchServiceOuterClass.SessionRejection; @@ -52,7 +53,6 @@ public class WatchClient { private static final String ENDPOINT_OFFLINE_MODE_HEADER = ENDPOINT_HEADER + "-Offline-Mode"; private static final String ENDPOINT_PARENTS_HEADER = ENDPOINT_HEADER + "-Parents"; private static final String CAPABILITIES_HEADER = "Connect-Capabilities"; - private static final String BEDROCK_IDENTITY_V1_CAPABILITY = "bedrock-identity-v1"; private static final String WATCH_URL = System.getenv().getOrDefault( "CONNECT_WATCH_URL", "wss://watch-connect.minekube.net"); @@ -60,22 +60,35 @@ public class WatchClient { private final ConnectConfig config; private final BedrockIdentityReadiness bedrockIdentityReadiness; private final BedrockAdmissionCoordinator admissionCoordinator; + private final BedrockPrincipalReadiness bedrockPrincipalReadiness; @Inject public WatchClient( @Named("watchHttpClient") OkHttpClient httpClient, ConnectConfig config, BedrockIdentityReadiness bedrockIdentityReadiness, + BedrockPrincipalReadiness bedrockPrincipalReadiness, BedrockAdmissionCoordinator admissionCoordinator) { this.httpClient = httpClient; this.config = config; this.bedrockIdentityReadiness = bedrockIdentityReadiness; + this.bedrockPrincipalReadiness = bedrockPrincipalReadiness; this.admissionCoordinator = admissionCoordinator; } + public WatchClient( + @Named("watchHttpClient") OkHttpClient httpClient, + ConnectConfig config, + BedrockIdentityReadiness bedrockIdentityReadiness, + BedrockAdmissionCoordinator admissionCoordinator) { + this(httpClient, config, bedrockIdentityReadiness, + new BedrockPrincipalReadiness(config), admissionCoordinator); + } + public WatchClient(@Named("watchHttpClient") OkHttpClient httpClient, ConnectConfig config) { this(httpClient, config, new BedrockIdentityReadiness( - config, new BedrockIdentityKeyProvider(config, new OkHttpClient())), null); + config, new BedrockIdentityKeyProvider(config, new OkHttpClient())), + new BedrockPrincipalReadiness(config), null); } public WebSocket watch(Watcher watcher) { @@ -83,7 +96,10 @@ public WebSocket watch(Watcher watcher) { .url(WATCH_URL) .header(ENDPOINT_HEADER, config.getEndpoint()); if (bedrockIdentityReadiness.observe(Transport.WATCH)) { - request.header(CAPABILITIES_HEADER, BEDROCK_IDENTITY_V1_CAPABILITY); + request.header(CAPABILITIES_HEADER, BedrockIdentityReadiness.CAPABILITY); + } + if (bedrockPrincipalReadiness.isReady()) { + request.addHeader(CAPABILITIES_HEADER, BedrockPrincipalReadiness.CAPABILITY); } if (config.getAllowOfflineModePlayers() != null) { @@ -144,6 +160,19 @@ public void onMessage(@NotNull WebSocket webSocket, @NotNull ByteString bytes) { return; } + if (res.hasReadinessChallenge()) { + webSocket.send(ByteString.of(WatchRequest.newBuilder() + .setReadinessAttestation(bedrockPrincipalReadiness.attest( + res.getReadinessChallenge(), + BedrockPrincipalReadiness.Transport.WATCH)) + .build().toByteArray())); + return; + } + if (!res.hasSession()) { + webSocket.close(1002, "unknown Watch response payload"); + return; + } + String sessionId = res.getSession().getId(); java.util.function.Consumer rejectProposal = reason -> { Builder responseRejection = SessionRejection.newBuilder() diff --git a/core/src/main/proto/com/minekube/connect/v1alpha1/connect_libp2p.proto b/core/src/main/proto/com/minekube/connect/v1alpha1/connect_libp2p.proto index 04940d5c9..d73b79941 100644 --- a/core/src/main/proto/com/minekube/connect/v1alpha1/connect_libp2p.proto +++ b/core/src/main/proto/com/minekube/connect/v1alpha1/connect_libp2p.proto @@ -80,12 +80,25 @@ message PeerRegisterChallenge { message PeerRegisterCommit { EndpointPeerRecord record = 1; bytes signature = 2; + RegistrationModeOffer mode_offer = 3; } message PeerRegisterResult { string endpoint_id = 1; string endpoint_hash = 2; uint64 kv_revision = 3; + RegistrationModeResult mode_result = 4; +} + +message RegistrationModeOffer { + uint32 version = 1; + string framing = 2; +} + +message RegistrationModeResult { + uint32 version = 1; + bool accepted = 2; + PrincipalError reason = 3; } message SessionOffer { @@ -98,6 +111,10 @@ message SessionOffer { string endpoint_org_id = 7; // Mirrors the authenticated legacy Session protocol discriminator. SessionProtocol protocol = 8; + bytes connect_session_nonce = 9; + int32 source_protocol_version = 10; + int64 policy_revision = 11; + bytes signed_bedrock_principal_v2 = 12; } message SessionPlayer { diff --git a/core/src/main/proto/com/minekube/connect/v1alpha1/watch_service.proto b/core/src/main/proto/com/minekube/connect/v1alpha1/watch_service.proto index 800c7a352..e1ef51c92 100644 --- a/core/src/main/proto/com/minekube/connect/v1alpha1/watch_service.proto +++ b/core/src/main/proto/com/minekube/connect/v1alpha1/watch_service.proto @@ -16,19 +16,21 @@ service WatchService { } message WatchRequest { - // Sending this message rejects a session proposed by the WatchService. This message should be sent to inform - // the WatchService that the server will not try to make a take the proposed session. The only purpose of - // this message is to provide quicker feedback to the player that he will not be connected with an optional - // localized reason. See https://github.com/grpc/grpc/blob/master/src/proto/grpc/status/status.proto. - // If the session is not rejected the watcher should establish the connection for the proposed session. - // If neither of these actions happen the proposal times out out and the player receives a connection timeout - // error indicating that the endpoint is currently unavailable. - SessionRejection session_rejection = 1; + oneof payload { + // Sending this message rejects a session proposed by the WatchService. + SessionRejection session_rejection = 1; + // The readiness answer for a challenge sent on this authenticated Watch lease. + ReadinessAttestation readiness_attestation = 2; + } } message WatchResponse { - // The proposed session that intents to connect. - Session session = 1; + oneof payload { + // The proposed session that intents to connect. + Session session = 1; + // A readiness challenge bound to this authenticated Watch lease. + ReadinessChallenge readiness_challenge = 2; + } } message SessionRejection { @@ -61,6 +63,65 @@ message Session { // The player protocol determined by the authenticated Connect Edge. // Legacy senders omit this field and decode as SESSION_PROTOCOL_UNSPECIFIED. SessionProtocol protocol = 6; + // The selected endpoint ID from the final authenticated proposal snapshot. + string endpoint_id = 7; + // The selected endpoint organization ID from the final authenticated proposal snapshot. + string organization_id = 8; + // A per-session 16-byte random nonce. Legacy senders omit this field. + bytes connect_session_nonce = 9; + // The authenticated source protocol version negotiated by the Connect Edge. + int32 source_protocol_version = 10; + // The positive endpoint policy revision from the final proposal snapshot. + int64 policy_revision = 11; + // The compact Bedrock principal v2 JWS. V1 identity metadata remains separate. + bytes signed_bedrock_principal_v2 = 12; +} + +// PrincipalError is the internal, bounded Bedrock principal v2 failure category. +enum PrincipalError { + PRINCIPAL_ERROR_UNSPECIFIED = 0; + PRINCIPAL_ERROR_MALFORMED = 1; + PRINCIPAL_ERROR_TRUST = 2; + PRINCIPAL_ERROR_SIGNATURE = 3; + PRINCIPAL_ERROR_BINDING_MISMATCH = 4; + PRINCIPAL_ERROR_TIME = 5; + PRINCIPAL_ERROR_IDENTITY = 6; + PRINCIPAL_ERROR_LINK = 7; + PRINCIPAL_ERROR_REPLAY = 8; + PRINCIPAL_ERROR_CAPACITY = 9; + PRINCIPAL_ERROR_METADATA_UNAVAILABLE = 10; + PRINCIPAL_ERROR_KEY_REVOKED = 11; + PRINCIPAL_ERROR_READINESS = 12; + PRINCIPAL_ERROR_INTERNAL = 13; +} + +message ReadinessChallenge { + string request_id = 1; + bytes nonce = 2; + string endpoint_id = 3; + string organization_id = 4; + string connector_instance_id = 5; + string lease_id = 6; + TunnelTransport.Type transport = 7; + int64 policy_revision = 8; + int64 issued_at_unix = 9; + int64 expires_at_unix = 10; +} + +message ReadinessAttestation { + enum Result { + RESULT_UNSPECIFIED = 0; + RESULT_READY = 1; + RESULT_NOT_READY = 2; + } + + ReadinessChallenge challenge = 1; + string capability = 2; + string mode = 3; + bytes readiness_revision = 4; + int64 observed_at_unix = 5; + Result result = 6; + PrincipalError reason = 7; } message TunnelTransport { diff --git a/core/src/main/resources/config.yml b/core/src/main/resources/config.yml index 6b80aacf1..c604d1755 100644 --- a/core/src/main/resources/config.yml +++ b/core/src/main/resources/config.yml @@ -44,6 +44,19 @@ bedrock-identity: # Required exact policy: linked_java_only or trusted_bedrock_xuid. expected-policy: trusted_bedrock_xuid +# Signed Bedrock principal v2 is additive to the legacy identity path above. New generated +# configurations require it; files created before this section existed remain generation 0, +# preserve their legacy behavior, and never advertise v2 until explicitly upgraded. +bedrock-principal: + config-generation: 2 + mode: require + issuer: minekube-connect + trust-domain: urn:minekube:connect:production + audience: urn:minekube:connect:bedrock-principal:v2 + metadata-origin: https://connect.minekube.com + metadata-path: /.well-known/minekube-connect/bedrock-principal-v2.json + public-keys: {} + # The default locale for Connect. By default, Connect uses the system locale #default-locale: en_US @@ -56,5 +69,6 @@ metrics: # The unique id that should be consistent for a server/proxy. uuid: ${metrics.uuid} -# Do not change this +# The legacy config file format remains version 1; v2 generation is controlled by +# bedrock-principal.config-generation above. config-version: 1 diff --git a/core/src/main/resources/proxy-config.yml b/core/src/main/resources/proxy-config.yml index 4a7bbb782..ef219e0b9 100644 --- a/core/src/main/resources/proxy-config.yml +++ b/core/src/main/resources/proxy-config.yml @@ -77,6 +77,19 @@ bedrock-identity: # Required exact policy: linked_java_only or trusted_bedrock_xuid. expected-policy: trusted_bedrock_xuid +# Signed Bedrock principal v2 is additive to the legacy identity path above. New generated +# configurations require it; files created before this section existed remain generation 0, +# preserve their legacy behavior, and never advertise v2 until explicitly upgraded. +bedrock-principal: + config-generation: 2 + mode: require + issuer: minekube-connect + trust-domain: urn:minekube:connect:production + audience: urn:minekube:connect:bedrock-principal:v2 + metadata-origin: https://connect.minekube.com + metadata-path: /.well-known/minekube-connect/bedrock-principal-v2.json + public-keys: {} + # bStats is a stat tracker that is entirely anonymous and tracks only basic information # about Connect, such as how many people are online, how many servers are using Connect, # what OS is being used, etc. You can learn more about bStats here: https://bstats.org/. @@ -86,5 +99,6 @@ metrics: # The unique id that should be consistent for a server/proxy. uuid: ${metrics.uuid} -# Do not change this +# The legacy config file format remains version 1; v2 generation is controlled by +# bedrock-principal.config-generation above. config-version: 1 diff --git a/core/src/test/java/com/minekube/connect/EmbeddedConnectPlatformTest.java b/core/src/test/java/com/minekube/connect/EmbeddedConnectPlatformTest.java new file mode 100644 index 000000000..2646b1065 --- /dev/null +++ b/core/src/test/java/com/minekube/connect/EmbeddedConnectPlatformTest.java @@ -0,0 +1,203 @@ +package com.minekube.connect; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.google.inject.Injector; +import com.minekube.connect.api.ConnectApi; +import com.minekube.connect.api.logger.ConnectLogger; +import com.minekube.connect.api.packet.PacketHandlers; +import com.minekube.connect.bedrock.BedrockAdmissionCoordinator; +import com.minekube.connect.bedrock.VerifiedBedrockIdentityRegistry; +import com.minekube.connect.config.ConfigHolder; +import com.minekube.connect.config.ConnectConfig; +import com.minekube.connect.inject.CommonPlatformInjector; +import com.minekube.connect.module.PostInitializeModule; +import com.minekube.connect.module.WatcherModule; +import com.minekube.connect.register.WatchHealthServer; +import com.minekube.connect.register.WatcherRegister; +import com.minekube.connect.tunnel.Tunneler; +import com.minekube.connect.tunnel.p2p.Libp2pEndpoint; +import java.nio.file.Files; +import java.nio.file.Path; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.InOrder; + +class EmbeddedConnectPlatformTest { + @TempDir + Path tempDir; + + @Test + void embeddedConfigUsesExplicitEndpointAndOfflineCompatibility() { + ConnectConfig config = ConnectConfig.embedded("amber-fox", true); + + assertEquals("amber-fox", config.getEndpoint()); + assertEquals(Boolean.TRUE, config.getAllowOfflineModePlayers()); + assertTrue(config.getMetrics().isDisabled()); + } + + @Test + void embeddedInitializationDoesNotLoadOrCreateConfigFile() { + Fixture fixture = fixture(true); + Path dataDirectory = tempDir.resolve("share"); + ConnectConfig config = ConnectConfig.embedded("amber-fox", true); + ConfigHolder configHolder = new ConfigHolder(); + PacketHandlers packetHandlers = mock(PacketHandlers.class); + + fixture.platform.initEmbedded(dataDirectory, config, configHolder, packetHandlers); + + assertTrue(Files.isDirectory(dataDirectory)); + assertFalse(Files.exists(dataDirectory.resolve("config.yml"))); + assertSame(config, configHolder.get()); + } + + @Test + void watcherModulesAreInstalledOnlyAfterPlatformInjectionSucceeds() throws Exception { + Fixture failed = fixture(false); + failed.platform.initEmbedded( + tempDir.resolve("failed"), + ConnectConfig.embedded("amber-fox", true), + new ConfigHolder(), + mock(PacketHandlers.class)); + + assertFalse(failed.platform.enable(new WatcherModule())); + assertTrue(failed.platform.disable()); + + verify(failed.configInjector, never()) + .createChildInjector(any(PostInitializeModule.class)); + verify(failed.watcher, never()).start(); + verify(failed.watcher, never()).stop(); + + Fixture successful = fixture(true); + successful.platform.initEmbedded( + tempDir.resolve("successful"), + ConnectConfig.embedded("amber-fox", true), + new ConfigHolder(), + mock(PacketHandlers.class)); + doAnswer(invocation -> { + successful.watcher.start(); + return successful.enabledInjector; + }).when(successful.configInjector) + .createChildInjector(any(PostInitializeModule.class)); + + assertTrue(successful.platform.enable(new WatcherModule())); + + InOrder order = inOrder(successful.platformInjector, successful.watcher); + order.verify(successful.platformInjector).inject(); + order.verify(successful.watcher).start(); + } + + @Test + void embeddedDisableClosesEveryRuntimeComponentExactlyOnce() { + Fixture fixture = fixture(true); + fixture.platform.initEmbedded( + tempDir.resolve("disable"), + ConnectConfig.embedded("amber-fox", true), + new ConfigHolder(), + mock(PacketHandlers.class)); + assertTrue(fixture.platform.enable(new WatcherModule())); + + assertTrue(fixture.platform.disable()); + assertTrue(fixture.platform.disable()); + + verify(fixture.libp2p, times(1)).stop(); + verify(fixture.healthServer, times(1)).stop(); + verify(fixture.watcher, times(1)).stop(); + verify(fixture.tunneler, times(1)).close(); + verify(fixture.platformInjector, times(1)).shutdown(); + } + + private Fixture fixture(boolean injectionSucceeds) { + ConnectApi api = mock(ConnectApi.class); + CommonPlatformInjector platformInjector = mock(CommonPlatformInjector.class); + ConnectLogger logger = mock(ConnectLogger.class); + Injector parentInjector = mock(Injector.class); + Injector configInjector = mock(Injector.class); + Injector enabledInjector = mock(Injector.class); + BedrockAdmissionCoordinator admissionCoordinator = + new BedrockAdmissionCoordinator(new VerifiedBedrockIdentityRegistry()); + WatcherRegister watcher = mock(WatcherRegister.class); + WatchHealthServer healthServer = mock(WatchHealthServer.class); + Libp2pEndpoint libp2p = mock(Libp2pEndpoint.class); + Tunneler tunneler = mock(Tunneler.class); + + try { + when(platformInjector.inject()).thenReturn(injectionSucceeds); + } catch (Exception exception) { + throw new AssertionError(exception); + } + when(parentInjector.createChildInjector(any(com.google.inject.Module.class))) + .thenReturn(configInjector); + when(configInjector.createChildInjector(any(PostInitializeModule.class))) + .thenReturn(enabledInjector); + for (Injector injector : new Injector[]{configInjector, enabledInjector}) { + when(injector.getInstance(Libp2pEndpoint.class)).thenReturn(libp2p); + when(injector.getInstance(WatchHealthServer.class)).thenReturn(healthServer); + when(injector.getInstance(WatcherRegister.class)).thenReturn(watcher); + when(injector.getInstance(Tunneler.class)).thenReturn(tunneler); + when(injector.getInstance(CommonPlatformInjector.class)).thenReturn(platformInjector); + } + + ConnectPlatform platform = new ConnectPlatform( + api, + platformInjector, + logger, + parentInjector, + admissionCoordinator); + return new Fixture( + platform, + platformInjector, + configInjector, + enabledInjector, + watcher, + healthServer, + libp2p, + tunneler, + admissionCoordinator); + } + + private static final class Fixture { + private final ConnectPlatform platform; + private final CommonPlatformInjector platformInjector; + private final Injector configInjector; + private final Injector enabledInjector; + private final WatcherRegister watcher; + private final WatchHealthServer healthServer; + private final Libp2pEndpoint libp2p; + private final Tunneler tunneler; + private final BedrockAdmissionCoordinator admissionCoordinator; + + private Fixture( + ConnectPlatform platform, + CommonPlatformInjector platformInjector, + Injector configInjector, + Injector enabledInjector, + WatcherRegister watcher, + WatchHealthServer healthServer, + Libp2pEndpoint libp2p, + Tunneler tunneler, + BedrockAdmissionCoordinator admissionCoordinator + ) { + this.platform = platform; + this.platformInjector = platformInjector; + this.configInjector = configInjector; + this.enabledInjector = enabledInjector; + this.watcher = watcher; + this.healthServer = healthServer; + this.libp2p = libp2p; + this.tunneler = tunneler; + this.admissionCoordinator = admissionCoordinator; + } + } +} diff --git a/core/src/test/java/com/minekube/connect/bedrock/BedrockPrincipalConsumerTest.java b/core/src/test/java/com/minekube/connect/bedrock/BedrockPrincipalConsumerTest.java new file mode 100644 index 000000000..79b8fee3c --- /dev/null +++ b/core/src/test/java/com/minekube/connect/bedrock/BedrockPrincipalConsumerTest.java @@ -0,0 +1,159 @@ +package com.minekube.connect.bedrock; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.google.protobuf.ByteString; +import com.minekube.connect.api.player.principal.PrincipalError; +import com.minekube.connect.config.ConnectConfig; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Base64; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; +import minekube.connect.v1alpha1.WatchServiceOuterClass.Authentication; +import minekube.connect.v1alpha1.WatchServiceOuterClass.GameProfile; +import minekube.connect.v1alpha1.WatchServiceOuterClass.Player; +import minekube.connect.v1alpha1.WatchServiceOuterClass.Session; +import minekube.connect.v1alpha1.WatchServiceOuterClass.SessionProtocol; +import org.junit.jupiter.api.Test; + +class BedrockPrincipalConsumerTest { + @Test + void verifiesWireEnvelopeAndAppliesOnlyEffectiveLinkedProfile() throws Exception { + JsonObject vector = vector("valid-linked"); + BedrockPrincipalConsumer consumer = consumer(vector); + Session session = session(vector); + + var principal = consumer.verify(session).orElseThrow(); + assertEquals(UUID.fromString("123e4567-e89b-12d3-a456-426614174000"), + principal.effectiveGameProfile().uuid()); + assertEquals("JavaOne", principal.effectiveGameProfile().name()); + + VerifiedBedrockIdentityRegistry registry = new VerifiedBedrockIdentityRegistry(); + BedrockAdmissionCoordinator coordinator = new BedrockAdmissionCoordinator(registry, consumer(vector)); + try { + var proposal = coordinator.proposal(session, ignored -> {}, "", ""); + var player = coordinator.stage(proposal); + assertEquals(principal.effectiveGameProfile().uuid(), player.getUniqueId()); + assertEquals(principal.effectiveGameProfile().name(), player.getUsername()); + assertTrue(player.getGameProfile().getProperties().isEmpty()); + assertTrue(registry.getPrincipal(player).isEmpty()); + + var decision = coordinator.verify(player, proposal.getAdmissionToken(), + new BedrockIdentityEnforcer( + config(), org.mockito.Mockito.mock(com.minekube.connect.api.logger.ConnectLogger.class), + () -> Instant.ofEpochSecond(vector.get("verification_time_unix").getAsLong())), + "", "", SessionProtocol.SESSION_PROTOCOL_BEDROCK); + assertTrue(decision.allowed()); + assertTrue(registry.getPrincipal(player).isPresent()); + } finally { + coordinator.close(); + } + } + + @Test + void malformedCompanionBindingFailsBeforeProfileApplication() throws Exception { + JsonObject vector = vector("valid-unlinked"); + Session malformed = session(vector).toBuilder().clearConnectSessionNonce().build(); + BedrockPrincipalAdmissionException error = assertThrows( + BedrockPrincipalAdmissionException.class, + () -> consumer(vector).verify(malformed)); + assertEquals(PrincipalError.BINDING_MISMATCH, error.error()); + assertFalse(error.toString().contains(vector.get("compact_jws").getAsString())); + } + + @Test + void requireModeRejectsSessionsMissingV2Principal() throws Exception { + JsonObject vector = vector("valid-unlinked"); + BedrockPrincipalAdmissionException error = assertThrows( + BedrockPrincipalAdmissionException.class, + () -> consumer(vector).verify(session(vector).toBuilder() + .clearSignedBedrockPrincipalV2().build())); + assertEquals(PrincipalError.READINESS, error.error()); + } + + @Test + void oversizedV2EnvelopeIsRejected() throws Exception { + JsonObject vector = vector("valid-unlinked"); + BedrockPrincipalAdmissionException error = assertThrows( + BedrockPrincipalAdmissionException.class, + () -> consumer(vector).verify(session(vector).toBuilder() + .setSignedBedrockPrincipalV2(ByteString.copyFrom(new byte[16 * 1024 + 1])) + .build())); + assertEquals(PrincipalError.MALFORMED, error.error()); + } + + private static BedrockPrincipalConsumer consumer(JsonObject vector) { + return new BedrockPrincipalConsumer(config(), Clock.fixed( + Instant.ofEpochSecond(vector.get("verification_time_unix").getAsLong()), ZoneOffset.UTC)); + } + + private static ConnectConfig config() { + ConnectConfig config = new ConnectConfig(); + Object principal = config.getBedrockPrincipal(); + set(principal, "configGeneration", 2); + set(principal, "mode", "require"); + set(principal, "issuer", "minekube-connect-test"); + set(principal, "trustDomain", "urn:minekube:connect:test:corpus-v2"); + set(principal, "audience", "urn:minekube:connect:test:bedrock-principal:v2"); + set(principal, "metadataOrigin", "https://metadata.example"); + set(principal, "publicKeys", Map.of( + "connect-v2-test", "diQm8c6MI-Zwn1nie8hq4wqf3mYLuI96uJBC6NHCTDg")); + return config; + } + + private static Session session(JsonObject vector) { + JsonObject context = vector.getAsJsonObject("trusted_context"); + return Session.newBuilder() + .setId(context.get("connect_session_id").getAsString()) + .setPlayer(Player.newBuilder() + .setAddr("127.0.0.1") + .setProfile(GameProfile.newBuilder() + .setId("00000000-0000-0000-0000-000000000099") + .setName("UntrustedCarrier"))) + .setAuth(Authentication.newBuilder().setPassthrough(false)) + .setProtocol(SessionProtocol.SESSION_PROTOCOL_BEDROCK) + .setEndpointId(context.get("endpoint_id").getAsString()) + .setOrganizationId(context.get("organization_id").getAsString()) + .setConnectSessionNonce(ByteString.copyFrom(Base64.getUrlDecoder() + .decode(context.get("connect_session_nonce").getAsString()))) + .setSourceProtocolVersion(context.get("source_protocol_version").getAsInt()) + .setPolicyRevision(context.get("policy_revision").getAsLong()) + .setSignedBedrockPrincipalV2(ByteString.copyFromUtf8( + vector.get("compact_jws").getAsString())) + .build(); + } + + private JsonObject vector(String name) { + try (var reader = new InputStreamReader(Objects.requireNonNull(getClass().getResourceAsStream( + "/bedrock-principal-v2/core-vectors.json")), StandardCharsets.UTF_8)) { + for (var value : JsonParser.parseReader(reader).getAsJsonArray()) { + if (name.equals(value.getAsJsonObject().get("name").getAsString())) { + return value.getAsJsonObject(); + } + } + throw new AssertionError("missing vector " + name); + } catch (java.io.IOException error) { + throw new AssertionError(error); + } + } + + private static void set(Object target, String name, Object value) { + try { + var field = target.getClass().getDeclaredField(name); + field.setAccessible(true); + field.set(target, value); + } catch (ReflectiveOperationException error) { + throw new AssertionError(error); + } + } +} diff --git a/core/src/test/java/com/minekube/connect/bedrock/BedrockPrincipalGenerationConfigTest.java b/core/src/test/java/com/minekube/connect/bedrock/BedrockPrincipalGenerationConfigTest.java new file mode 100644 index 000000000..bcc086a63 --- /dev/null +++ b/core/src/test/java/com/minekube/connect/bedrock/BedrockPrincipalGenerationConfigTest.java @@ -0,0 +1,118 @@ +package com.minekube.connect.bedrock; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; + +import com.minekube.connect.api.logger.ConnectLogger; +import com.minekube.connect.config.ConfigLoader; +import com.minekube.connect.config.ConnectConfig; +import com.minekube.connect.config.ProxyConnectConfig; +import java.nio.file.Files; +import java.nio.file.Path; +import okhttp3.OkHttpClient; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class BedrockPrincipalGenerationConfigTest { + @TempDir Path tempDir; + + @Test + void newlyGeneratedServerAndProxyConfigsDefaultV2ToRequire() throws Exception { + ConnectConfig server = load(ConnectConfig.class, tempDir.resolve("server")); + ProxyConnectConfig proxy = load(ProxyConnectConfig.class, tempDir.resolve("proxy")); + for (ConnectConfig config : new ConnectConfig[] {server, proxy}) { + assertEquals(2, config.getBedrockPrincipal().getConfigGeneration()); + assertEquals("require", config.getBedrockPrincipal().getMode()); + assertEquals("minekube-connect", config.getBedrockPrincipal().getIssuer()); + assertEquals("urn:minekube:connect:production", config.getBedrockPrincipal().getTrustDomain()); + assertEquals("urn:minekube:connect:bedrock-principal:v2", + config.getBedrockPrincipal().getAudience()); + } + } + + @Test + void generationOneFileRemainsByteIdenticalAndNonAdvertising() throws Exception { + Path directory = tempDir.resolve("legacy"); + Files.createDirectories(directory); + Path file = directory.resolve("config.yml"); + String legacy = String.join("\n", + "endpoint: legacy", + "allow-offline-mode-players: false", + "bedrock-identity:", + " enforcement: warn", + " metadata-url: https://watch-connect.minekube.net/.well-known/minekube-connect/bedrock-identity-keys.json", + " expected-issuer: minekube-connect", + " expected-policy: trusted_bedrock_xuid", + "metrics:", + " disabled: true", + " uuid: 00000000-0000-0000-0000-000000000000", + "config-version: 1", + ""); + Files.writeString(file, legacy); + + ConnectConfig config = load(ConnectConfig.class, directory); + assertEquals(legacy, Files.readString(file)); + assertEquals(0, config.getBedrockPrincipal().getConfigGeneration()); + assertFalse(BedrockPrincipalConfiguration.from(config.getBedrockPrincipal()).isCapable()); + assertEquals("warn", config.getBedrockIdentity().getEnforcement()); + } + + @Test + void onlyExactGenerationTwoRequireIsCapable() { + ConnectConfig.BedrockPrincipalConfig config = new ConnectConfig().getBedrockPrincipal(); + assertFalse(BedrockPrincipalConfiguration.from(config).isCapable()); + TestFields.set(config, "configGeneration", 2); + TestFields.set(config, "mode", "require"); + TestFields.set(config, "issuer", "minekube-connect"); + TestFields.set(config, "trustDomain", "urn:minekube:connect:production"); + TestFields.set(config, "audience", "urn:minekube:connect:bedrock-principal:v2"); + TestFields.set(config, "metadataOrigin", "https://connect.minekube.com"); + assertTrue(BedrockPrincipalConfiguration.from(config).isCapable()); + TestFields.set(config, "mode", "warn"); + assertFalse(BedrockPrincipalConfiguration.from(config).isCapable()); + TestFields.set(config, "mode", "REQUIRE"); + assertFalse(BedrockPrincipalConfiguration.from(config).isCapable()); + TestFields.set(config, "mode", "require"); + TestFields.set(config, "configGeneration", 3); + assertFalse(BedrockPrincipalConfiguration.from(config).isCapable()); + } + + @Test + void malformedOriginIsNotCapable() { + ConnectConfig.BedrockPrincipalConfig config = new ConnectConfig().getBedrockPrincipal(); + TestFields.set(config, "configGeneration", 2); + TestFields.set(config, "mode", "require"); + TestFields.set(config, "issuer", "minekube-connect"); + TestFields.set(config, "trustDomain", "urn:minekube:connect:production"); + TestFields.set(config, "audience", "urn:minekube:connect:bedrock-principal:v2"); + TestFields.set(config, "metadataOrigin", null); + assertFalse(BedrockPrincipalConfiguration.from(config).isCapable()); + } + + private T load(Class type, Path directory) throws Exception { + Files.createDirectories(directory); + return new ConfigLoader(directory, type, + new ConfigLoader.EndpointNameGenerator(new OkHttpClient.Builder() + .addInterceptor(chain -> new okhttp3.Response.Builder() + .request(chain.request()).protocol(okhttp3.Protocol.HTTP_1_1) + .code(200).message("OK") + .body(okhttp3.ResponseBody.create( + okhttp3.MediaType.get("text/plain"), "generated")) + .build()) + .build()), mock(ConnectLogger.class)).load(); + } + + private static final class TestFields { + static void set(Object target, String name, Object value) { + try { + var field = target.getClass().getDeclaredField(name); + field.setAccessible(true); + field.set(target, value); + } catch (ReflectiveOperationException error) { + throw new AssertionError(error); + } + } + } +} diff --git a/core/src/test/java/com/minekube/connect/bedrock/BedrockPrincipalReadinessTest.java b/core/src/test/java/com/minekube/connect/bedrock/BedrockPrincipalReadinessTest.java new file mode 100644 index 000000000..fa43d5c6f --- /dev/null +++ b/core/src/test/java/com/minekube/connect/bedrock/BedrockPrincipalReadinessTest.java @@ -0,0 +1,114 @@ +package com.minekube.connect.bedrock; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.google.protobuf.ByteString; +import com.minekube.connect.config.ConnectConfig; +import java.lang.reflect.Field; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Base64; +import java.util.Map; +import minekube.connect.v1alpha1.WatchServiceOuterClass.PrincipalError; +import minekube.connect.v1alpha1.WatchServiceOuterClass.ReadinessAttestation; +import minekube.connect.v1alpha1.WatchServiceOuterClass.ReadinessChallenge; +import minekube.connect.v1alpha1.WatchServiceOuterClass.TunnelTransport; +import org.junit.jupiter.api.Test; + +class BedrockPrincipalReadinessTest { + private static final long NOW = 1_722_470_400L; + + @Test + void advertisesOnlyGenerationTwoRequireWithUsableStaticPin() throws Exception { + ConnectConfig ready = configured("require", 2, validPins()); + BedrockPrincipalReadiness readiness = readiness(ready); + + assertTrue(readiness.isReady()); + assertEquals(32, readiness.revision().length); + assertEquals(BedrockPrincipalReadiness.CAPABILITY, + readiness.capabilities(java.util.List.of(), BedrockPrincipalReadiness.Transport.WATCH).get(0)); + + assertFalse(readiness(configured("warn", 2, validPins())).isReady()); + assertFalse(readiness(configured("require", 1, validPins())).isReady()); + assertFalse(readiness(configured("require", 2, Map.of())).isReady()); + assertFalse(readiness(configured("require", 2, Map.of("kid", "not-base64"))).isReady()); + } + + @Test + void doesNotAdvertiseAnUnusableEd25519PublicKey() throws Exception { + byte[] invalid = new byte[32]; + assertFalse(readiness(configured("require", 2, Map.of("kid", + Base64.getUrlEncoder().withoutPadding().encodeToString(invalid)))).isReady()); + } + + @Test + void malformedPrincipalConfigFailsClosed() throws Exception { + ConnectConfig config = configured("require", 2, validPins()); + set(config, "bedrockPrincipal", null); + assertFalse(readiness(config).isReady()); + } + + @Test + void attestationEchoesValidChallengeAndFailsClosedForWrongTransport() throws Exception { + BedrockPrincipalReadiness readiness = readiness(configured("require", 2, validPins())); + ReadinessChallenge challenge = challenge(TunnelTransport.Type.TYPE_WEBSOCKET); + + ReadinessAttestation answer = readiness.attest(challenge, BedrockPrincipalReadiness.Transport.WATCH); + assertEquals(challenge, answer.getChallenge()); + assertEquals(BedrockPrincipalReadiness.CAPABILITY, answer.getCapability()); + assertEquals("require", answer.getMode()); + assertEquals(32, answer.getReadinessRevision().size()); + assertEquals(NOW, answer.getObservedAtUnix()); + assertEquals(ReadinessAttestation.Result.RESULT_READY, answer.getResult()); + assertEquals(PrincipalError.PRINCIPAL_ERROR_UNSPECIFIED, answer.getReason()); + + ReadinessAttestation refused = readiness.attest(challenge, BedrockPrincipalReadiness.Transport.LIBP2P); + assertEquals(ReadinessAttestation.Result.RESULT_NOT_READY, refused.getResult()); + assertEquals(PrincipalError.PRINCIPAL_ERROR_READINESS, refused.getReason()); + } + + private static ReadinessChallenge challenge(TunnelTransport.Type transport) { + return ReadinessChallenge.newBuilder() + .setRequestId("request") + .setNonce(ByteString.copyFrom(new byte[16])) + .setEndpointId("endpoint-id") + .setOrganizationId("organization-id") + .setConnectorInstanceId("instance-id") + .setLeaseId("lease-id") + .setTransport(transport) + .setPolicyRevision(7) + .setIssuedAtUnix(NOW - 1) + .setExpiresAtUnix(NOW + 29) + .build(); + } + + private static Map validPins() { + return Map.of("kid-1", "diQm8c6MI-Zwn1nie8hq4wqf3mYLuI96uJBC6NHCTDg"); + } + + private static ConnectConfig configured(String mode, int generation, Map pins) throws Exception { + ConnectConfig config = new ConnectConfig(); + set(config.getBedrockPrincipal(), "configGeneration", generation); + set(config.getBedrockPrincipal(), "mode", mode); + set(config.getBedrockPrincipal(), "issuer", "minekube-connect"); + set(config.getBedrockPrincipal(), "trustDomain", "urn:minekube:connect:production"); + set(config.getBedrockPrincipal(), "audience", "urn:minekube:connect:bedrock-principal:v2"); + set(config.getBedrockPrincipal(), "metadataOrigin", "https://connect.minekube.com"); + set(config.getBedrockPrincipal(), "publicKeys", pins); + return config; + } + + private static BedrockPrincipalReadiness readiness(ConnectConfig config) { + return new BedrockPrincipalReadiness( + config, Clock.fixed(Instant.ofEpochSecond(NOW), ZoneOffset.UTC)); + } + + private static void set(Object target, String name, Object value) throws Exception { + Field field = target.getClass().getDeclaredField(name); + field.setAccessible(true); + field.set(target, value); + } +} diff --git a/core/src/test/java/com/minekube/connect/identity/EndpointTokenStoreTest.java b/core/src/test/java/com/minekube/connect/identity/EndpointTokenStoreTest.java new file mode 100644 index 000000000..a68bf121b --- /dev/null +++ b/core/src/test/java/com/minekube/connect/identity/EndpointTokenStoreTest.java @@ -0,0 +1,104 @@ +package com.minekube.connect.identity; + +import static java.nio.file.attribute.PosixFilePermission.OWNER_READ; +import static java.nio.file.attribute.PosixFilePermission.OWNER_WRITE; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import java.nio.file.FileSystems; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class EndpointTokenStoreTest { + private final EndpointTokenStore store = new EndpointTokenStore(); + + @TempDir Path tempDir; + + @Test + void createsPluginCompatibleTokenJson() throws Exception { + Path file = tempDir.resolve("connect").resolve("token.json"); + + String token = store.loadOrCreate(file, Map.of()); + + assertTrue(token.startsWith("T-")); + assertEquals( + token, + new Gson().fromJson(Files.readString(file), JsonObject.class) + .get("token") + .getAsString()); + if (FileSystems.getDefault().supportedFileAttributeViews().contains("posix")) { + assertEquals(Set.of(OWNER_READ, OWNER_WRITE), Files.getPosixFilePermissions(file)); + } + } + + @Test + void reusesTheSameToken() throws Exception { + Path file = tempDir.resolve("token.json"); + + String first = store.loadOrCreate(file, Map.of()); + String second = store.loadOrCreate(file, Map.of()); + + assertEquals(first, second); + } + + @Test + void connectTokenEnvironmentOverridesDisk() throws Exception { + Path file = tempDir.resolve("token.json"); + store.save(file, "T-disk"); + + assertEquals( + "T-environment", + store.load(file, Map.of(EndpointTokenStore.ENV_TOKEN, "T-environment")) + .orElseThrow()); + assertEquals( + "T-disk", + new Gson().fromJson(Files.readString(file), JsonObject.class) + .get("token") + .getAsString()); + } + + @Test + void rejectsBlankAndNonPrefixedTokens() throws Exception { + Path file = tempDir.resolve("token.json"); + + assertThrows(IllegalArgumentException.class, () -> store.save(file, "")); + assertThrows(IllegalArgumentException.class, () -> store.save(file, "dashboard-token")); + assertThrows( + IllegalArgumentException.class, + () -> store.load(file, Map.of(EndpointTokenStore.ENV_TOKEN, " "))); + + Files.writeString(file, "{\"token\":\"not-connect\"}"); + assertThrows(IllegalArgumentException.class, () -> store.load(file, Map.of())); + } + + @Test + void atomicallyReplacesToken() throws Exception { + Path file = tempDir.resolve("token.json"); + store.save(file, "T-before"); + + store.save(file, "T-after"); + + assertEquals("T-after", store.load(file, Map.of()).orElseThrow()); + try (var files = Files.list(tempDir)) { + assertEquals(Set.of(file), Set.copyOf(files.toList())); + } + } + + @Test + void redactionNeverContainsTheToken() { + String token = "T-this-must-never-appear-in-a-log"; + + String redacted = EndpointTokenStore.redact(token); + + assertFalse(redacted.contains(token)); + assertEquals("", redacted); + } +} diff --git a/core/src/test/java/com/minekube/connect/module/CommonModuleTest.java b/core/src/test/java/com/minekube/connect/module/CommonModuleTest.java index 6c3c2f33c..b2c30a7dd 100644 --- a/core/src/test/java/com/minekube/connect/module/CommonModuleTest.java +++ b/core/src/test/java/com/minekube/connect/module/CommonModuleTest.java @@ -32,7 +32,7 @@ void connectHttpClientSendsPluginVersionHeader() throws Exception { platformUtils, "spigot", new SimpleConnectApi(mock(ConnectLogger.class)), - module.connectToken() + module.connectToken(module.endpointTokenStore()) ); try (MockWebServer server = new MockWebServer()) { @@ -56,10 +56,10 @@ void connectHttpClientSendsPluginVersionHeader() throws Exception { void connectTokenIsPersistedForAllConnectClients() throws Exception { CommonModule module = new CommonModule(tempDir); - String token = module.connectToken(); + String token = module.connectToken(module.endpointTokenStore()); assertTrue(token.startsWith("T-")); - assertEquals(token, module.connectToken()); + assertEquals(token, module.connectToken(module.endpointTokenStore())); assertTrue(java.nio.file.Files.readString(tempDir.resolve("token.json")).contains(token)); } @@ -72,7 +72,7 @@ void watchHttpClientKeepsConnectHeadersAndUsesWebSocketLiveness() throws Excepti platformUtils, "spigot", new SimpleConnectApi(mock(ConnectLogger.class)), - module.connectToken() + module.connectToken(module.endpointTokenStore()) ); OkHttpClient watchClient = module.watchOkHttpClient(connectClient); diff --git a/core/src/test/java/com/minekube/connect/principal/BedrockPrincipalCoreVectorTest.java b/core/src/test/java/com/minekube/connect/principal/BedrockPrincipalCoreVectorTest.java new file mode 100644 index 000000000..a5a4e8402 --- /dev/null +++ b/core/src/test/java/com/minekube/connect/principal/BedrockPrincipalCoreVectorTest.java @@ -0,0 +1,273 @@ +package com.minekube.connect.principal; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.google.gson.Gson; +import com.google.gson.annotations.SerializedName; +import com.minekube.connect.api.player.principal.BedrockPrincipalVerifier; +import com.minekube.connect.api.player.principal.BedrockPrincipalVerifierFactory; +import com.minekube.connect.api.player.principal.PrincipalError; +import com.minekube.connect.api.player.principal.PrincipalVerificationException; +import com.minekube.connect.api.player.principal.SignedPrincipalEnvelope; +import com.minekube.connect.api.player.principal.TrustedProposalContext; +import com.minekube.connect.api.player.principal.VerifierConfiguration; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.Signature; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Arrays; +import java.util.Base64; +import java.util.List; +import java.util.Objects; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; + +class BedrockPrincipalCoreVectorTest { + private static final String VECTOR_SHA256 = + "4f2a442ee71bfd35af2ef1f3944489d17551aa77fed2c08220f2aa77032b6196"; + private static final byte[] TEST_PUBLIC_KEY = Base64.getUrlDecoder() + .decode("diQm8c6MI-Zwn1nie8hq4wqf3mYLuI96uJBC6NHCTDg"); + + @Test + void verifiesAgainstLiteralCoreVectorOutcomes() throws Exception { + byte[] literal = resourceBytes("/bedrock-principal-v2/core-vectors.json"); + assertEquals(VECTOR_SHA256, hex(MessageDigest.getInstance("SHA-256").digest(literal))); + Vector[] vectors = new Gson().fromJson( + new InputStreamReader( + Objects.requireNonNull(getClass().getResourceAsStream( + "/bedrock-principal-v2/core-vectors.json")), + StandardCharsets.UTF_8), + Vector[].class); + assertEquals(6, vectors.length); + + for (Vector vector : vectors) { + BedrockPrincipalVerifier verifier = BedrockPrincipalVerifierFactory.create( + VerifierConfiguration.builder() + .publicKey("connect-v2-test", TEST_PUBLIC_KEY) + .clock(Clock.fixed( + Instant.ofEpochSecond(vector.verificationTimeUnix), + ZoneOffset.UTC)) + .build()); + TrustedProposalContext expected = vector.trustedContext.toContext(); + if (!"OK".equals(vector.expectedError)) { + PrincipalVerificationException error = assertThrows( + PrincipalVerificationException.class, + () -> verifier.verifyAndConsume( + SignedPrincipalEnvelope.of(vector.compactJws), expected), + vector.name); + assertEquals(PrincipalError.valueOf(vector.expectedError), error.error(), vector.name); + continue; + } + + var principal = verifier.verifyAndConsume( + SignedPrincipalEnvelope.of(vector.compactJws), expected); + ExpectedPrincipal literalPrincipal = vector.expectedPrincipal; + assertNotNull(literalPrincipal, vector.name); + assertEquals(literalPrincipal.subjectKind, principal.subjectKind().wireName(), vector.name); + assertEquals(literalPrincipal.canonicalXuid, principal.xuid().value(), vector.name); + assertEquals(UUID.fromString(literalPrincipal.canonicalUnlinkedUuid), + principal.canonicalUnlinkedUuid(), vector.name); + assertEquals(literalPrincipal.bedrockDisplayName, principal.bedrockDisplayName(), vector.name); + assertEquals(UUID.fromString(literalPrincipal.effectiveUuid), + principal.effectiveGameProfile().uuid(), vector.name); + assertEquals(literalPrincipal.effectiveName, + principal.effectiveGameProfile().name(), vector.name); + assertEquals(literalPrincipal.verificationMethod, + principal.verification().verificationMethod(), vector.name); + assertEquals(literalPrincipal.kid, principal.verification().kid(), vector.name); + assertEquals(literalPrincipal.policyRevision, + principal.bindings().policyRevision(), vector.name); + if (literalPrincipal.linkedJava == null) { + assertTrue(principal.linkedJava().isEmpty(), vector.name); + } else { + var linked = principal.linkedJava().orElseThrow(); + assertEquals(UUID.fromString(literalPrincipal.linkedJava.uuid), linked.uuid(), vector.name); + assertEquals(literalPrincipal.linkedJava.name, linked.name(), vector.name); + assertEquals(literalPrincipal.linkedJava.provider, + linked.provenance().provider(), vector.name); + assertEquals(literalPrincipal.linkedJava.recordId, + linked.provenance().recordId(), vector.name); + assertEquals(literalPrincipal.linkedJava.revision, + linked.provenance().revision(), vector.name); + assertEquals(Instant.ofEpochSecond(literalPrincipal.linkedJava.verifiedAtUnix), + linked.provenance().verifiedAt(), vector.name); + } + } + } + + @Test + void consumesReplayExactlyOnce() throws Exception { + Vector vector = Arrays.stream(vectors()) + .filter(candidate -> candidate.name.equals("valid-unlinked")) + .findFirst() + .orElseThrow(); + BedrockPrincipalVerifier verifier = BedrockPrincipalVerifierFactory.create( + VerifierConfiguration.builder() + .publicKey("connect-v2-test", TEST_PUBLIC_KEY) + .clock(Clock.fixed( + Instant.ofEpochSecond(vector.verificationTimeUnix), ZoneOffset.UTC)) + .build()); + SignedPrincipalEnvelope envelope = SignedPrincipalEnvelope.of(vector.compactJws); + assertNotNull(verifier.verifyAndConsume(envelope, vector.trustedContext.toContext())); + PrincipalVerificationException error = assertThrows( + PrincipalVerificationException.class, + () -> verifier.verifyAndConsume(envelope, vector.trustedContext.toContext())); + assertEquals(PrincipalError.REPLAY, error.error()); + } + + @Test + void acceptsNineteenDigitUnsignedXuidAndDerivesLow64Uuid() throws Exception { + Vector vector = Arrays.stream(vectors()) + .filter(candidate -> candidate.name.equals("valid-unlinked")) + .findFirst().orElseThrow(); + String[] parts = vector.compactJws.split("\\."); + var payload = com.google.gson.JsonParser.parseString(new String( + Base64.getUrlDecoder().decode(parts[1]), StandardCharsets.UTF_8)).getAsJsonObject(); + payload.addProperty("canonical_xuid", "9223372036854775808"); + payload.addProperty("canonical_unlinked_uuid", "00000000-0000-0000-8000-000000000000"); + String encodedPayload = Base64.getUrlEncoder().withoutPadding().encodeToString( + new Gson().toJson(payload).getBytes(StandardCharsets.UTF_8)); + + KeyPair keyPair = KeyPairGenerator.getInstance("Ed25519").generateKeyPair(); + Signature signer = Signature.getInstance("Ed25519"); + signer.initSign(keyPair.getPrivate()); + String signingInput = parts[0] + "." + encodedPayload; + signer.update(signingInput.getBytes(StandardCharsets.US_ASCII)); + String compact = signingInput + "." + Base64.getUrlEncoder().withoutPadding() + .encodeToString(signer.sign()); + byte[] encodedKey = keyPair.getPublic().getEncoded(); + byte[] rawKey = Arrays.copyOfRange(encodedKey, encodedKey.length - 32, encodedKey.length); + + var principal = BedrockPrincipalVerifierFactory.create( + VerifierConfiguration.builder().publicKey("connect-v2-test", rawKey) + .clock(Clock.fixed(Instant.ofEpochSecond(vector.verificationTimeUnix), ZoneOffset.UTC)) + .build()) + .verifyAndConsume(SignedPrincipalEnvelope.of(compact), vector.trustedContext.toContext()); + assertEquals("9223372036854775808", principal.xuid().value()); + assertEquals(UUID.fromString("00000000-0000-0000-8000-000000000000"), + principal.canonicalUnlinkedUuid()); + } + + @Test + void concurrentReplayConsumptionHasOneAnonymousWinner() throws Exception { + Vector vector = Arrays.stream(vectors()) + .filter(candidate -> candidate.name.equals("valid-unlinked")) + .findFirst().orElseThrow(); + BedrockPrincipalVerifier verifier = BedrockPrincipalVerifierFactory.create( + VerifierConfiguration.builder().publicKey("connect-v2-test", TEST_PUBLIC_KEY) + .clock(Clock.fixed(Instant.ofEpochSecond( + vector.verificationTimeUnix), ZoneOffset.UTC)).build()); + SignedPrincipalEnvelope envelope = SignedPrincipalEnvelope.of(vector.compactJws); + AtomicInteger successes = new AtomicInteger(); + AtomicInteger replays = new AtomicInteger(); + CountDownLatch start = new CountDownLatch(1); + var executor = Executors.newFixedThreadPool(16); + try { + List> results = new java.util.ArrayList<>(); + for (int index = 0; index < 32; index++) { + results.add(executor.submit(() -> { + start.await(); + try { + verifier.verifyAndConsume(envelope, vector.trustedContext.toContext()); + successes.incrementAndGet(); + } catch (PrincipalVerificationException error) { + if (error.error() != PrincipalError.REPLAY) throw error; + replays.incrementAndGet(); + } + return null; + })); + } + start.countDown(); + for (var result : results) result.get(); + } finally { + executor.shutdownNow(); + } + assertEquals(1, successes.get()); + assertEquals(31, replays.get()); + } + + private Vector[] vectors() { + return new Gson().fromJson( + new InputStreamReader( + Objects.requireNonNull(getClass().getResourceAsStream( + "/bedrock-principal-v2/core-vectors.json")), + StandardCharsets.UTF_8), + Vector[].class); + } + + private static byte[] resourceBytes(String name) throws Exception { + try (var in = Objects.requireNonNull( + BedrockPrincipalCoreVectorTest.class.getResourceAsStream(name))) { + return in.readAllBytes(); + } + } + + private static String hex(byte[] bytes) { + StringBuilder result = new StringBuilder(bytes.length * 2); + for (byte value : bytes) result.append(String.format("%02x", value)); + return result.toString(); + } + + private static final class Vector { + String name; + @SerializedName("compact_jws") String compactJws; + @SerializedName("trusted_context") TrustedContext trustedContext; + @SerializedName("verification_time_unix") long verificationTimeUnix; + @SerializedName("expected_error") String expectedError; + @SerializedName("expected_principal") ExpectedPrincipal expectedPrincipal; + } + + private static final class TrustedContext { + String issuer; + @SerializedName("trust_domain") String trustDomain; + String audience; + @SerializedName("endpoint_id") String endpointId; + @SerializedName("organization_id") String organizationId; + @SerializedName("connect_session_id") String connectSessionId; + @SerializedName("connect_session_nonce") String connectSessionNonce; + @SerializedName("source_protocol") String sourceProtocol; + @SerializedName("source_protocol_version") int sourceProtocolVersion; + @SerializedName("policy_revision") long policyRevision; + + TrustedProposalContext toContext() { + return new TrustedProposalContext( + issuer, trustDomain, audience, endpointId, organizationId, connectSessionId, + Base64.getUrlDecoder().decode(connectSessionNonce), sourceProtocol, + sourceProtocolVersion, policyRevision); + } + } + + private static final class ExpectedPrincipal { + @SerializedName("subject_kind") String subjectKind; + @SerializedName("canonical_xuid") String canonicalXuid; + @SerializedName("canonical_unlinked_uuid") String canonicalUnlinkedUuid; + @SerializedName("bedrock_display_name") String bedrockDisplayName; + @SerializedName("effective_uuid") String effectiveUuid; + @SerializedName("effective_name") String effectiveName; + @SerializedName("verification_method") String verificationMethod; + String kid; + @SerializedName("policy_revision") long policyRevision; + @SerializedName("linked_java") ExpectedLinkedJava linkedJava; + } + + private static final class ExpectedLinkedJava { + String uuid; + String name; + String provider; + @SerializedName("record_id") String recordId; + long revision; + @SerializedName("verified_at_unix") long verifiedAtUnix; + } +} diff --git a/core/src/test/java/com/minekube/connect/principal/BedrockPrincipalWireBoundaryTest.java b/core/src/test/java/com/minekube/connect/principal/BedrockPrincipalWireBoundaryTest.java new file mode 100644 index 000000000..de6929c49 --- /dev/null +++ b/core/src/test/java/com/minekube/connect/principal/BedrockPrincipalWireBoundaryTest.java @@ -0,0 +1,63 @@ +package com.minekube.connect.principal; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.google.protobuf.Descriptors.Descriptor; +import com.google.protobuf.Descriptors.FieldDescriptor; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; +import minekube.connect.v1alpha1.ConnectLibp2P; +import minekube.connect.v1alpha1.WatchServiceOuterClass; +import org.junit.jupiter.api.Test; + +class BedrockPrincipalWireBoundaryTest { + private static final Set FORBIDDEN = Set.of( + "xuid", "bedrock_display_name", "linked_java_uuid", "linked_java_name", + "link_record_id", "jti"); + + @Test + void watchSessionUsesOnlyFrozenOpaqueV2Fields() { + Descriptor session = WatchServiceOuterClass.Session.getDescriptor(); + assertFields(session, List.of( + "id:1", "tunnel_service_addr:2", "player:3", "auth:4", "tunnel_transports:5", + "protocol:6", "endpoint_id:7", "organization_id:8", "connect_session_nonce:9", + "source_protocol_version:10", "policy_revision:11", "signed_bedrock_principal_v2:12")); + assertEquals(List.of("payload"), WatchServiceOuterClass.WatchRequest.getDescriptor() + .getOneofs().stream().map(oneof -> oneof.getName()).toList()); + assertEquals(List.of("payload"), WatchServiceOuterClass.WatchResponse.getDescriptor() + .getOneofs().stream().map(oneof -> oneof.getName()).toList()); + assertNoRawIdentity(WatchServiceOuterClass.getDescriptor().getMessageTypes()); + } + + @Test + void libp2pOfferUsesOnlyFrozenOpaqueV2Fields() { + assertFields(ConnectLibp2P.SessionOffer.getDescriptor(), List.of( + "session_id:1", "endpoint:2", "player:3", "auth:4", "deadline_unix_ms:5", + "endpoint_id:6", "endpoint_org_id:7", "protocol:8", "connect_session_nonce:9", + "source_protocol_version:10", "policy_revision:11", "signed_bedrock_principal_v2:12")); + assertNoRawIdentity(ConnectLibp2P.getDescriptor().getMessageTypes()); + } + + private static void assertFields(Descriptor descriptor, List expected) { + assertEquals(expected, descriptor.getFields().stream() + .map(field -> field.getName() + ":" + field.getNumber()).toList()); + } + + private static void assertNoRawIdentity(List roots) { + for (Descriptor root : roots) assertNoRawIdentity(root); + } + + private static void assertNoRawIdentity(Descriptor descriptor) { + Set names = descriptor.getFields().stream() + .map(FieldDescriptor::getName).collect(Collectors.toSet()); + assertTrue(names.stream().noneMatch(FORBIDDEN::contains), descriptor.getFullName()); + for (FieldDescriptor field : descriptor.getFields()) { + if (field.getJavaType() == FieldDescriptor.JavaType.MESSAGE) { + assertNoRawIdentity(field.getMessageType()); + } + } + } +} diff --git a/core/src/test/java/com/minekube/connect/principal/PrincipalConstructionBoundaryTest.java b/core/src/test/java/com/minekube/connect/principal/PrincipalConstructionBoundaryTest.java new file mode 100644 index 000000000..04a80bd88 --- /dev/null +++ b/core/src/test/java/com/minekube/connect/principal/PrincipalConstructionBoundaryTest.java @@ -0,0 +1,57 @@ +package com.minekube.connect.principal; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.minekube.connect.api.player.principal.VerifiedBedrockPrincipal; +import com.minekube.connect.api.player.principal.VerifiedPrincipal; +import java.lang.reflect.Modifier; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import javax.tools.DiagnosticCollector; +import javax.tools.JavaFileObject; +import javax.tools.StandardJavaFileManager; +import javax.tools.ToolProvider; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class PrincipalConstructionBoundaryTest { + @TempDir Path tempDir; + + @Test + void sealedHierarchyPermitsOnlySdkImplementations() throws Exception { + assertTrue(VerifiedPrincipal.class.isSealed()); + assertTrue(VerifiedBedrockPrincipal.class.isSealed()); + assertEquals(List.of(VerifiedBedrockPrincipal.class), + List.of(VerifiedPrincipal.class.getPermittedSubclasses())); + Class implementation = VerifiedBedrockPrincipal.class.getPermittedSubclasses()[0]; + assertEquals("ImmutableVerifiedBedrockPrincipal", implementation.getSimpleName()); + assertTrue(Modifier.isFinal(implementation.getModifiers())); + assertFalse(Modifier.isPublic(implementation.getModifiers())); + } + + @Test + void hostCannotForgeVerifiedPrincipal() throws Exception { + Path source = tempDir.resolve("Forged.java"); + Files.writeString(source, + "import com.minekube.connect.api.player.principal.*;\n" + + "final class Forged implements VerifiedBedrockPrincipal {}\n", + StandardCharsets.UTF_8); + var compiler = ToolProvider.getSystemJavaCompiler(); + var diagnostics = new DiagnosticCollector(); + try (StandardJavaFileManager files = compiler.getStandardFileManager( + diagnostics, null, StandardCharsets.UTF_8)) { + var units = files.getJavaFileObjects(source.toFile()); + boolean success = compiler.getTask( + null, files, diagnostics, + List.of("-classpath", System.getProperty("java.class.path")), null, units).call(); + assertFalse(success); + } + assertTrue(diagnostics.getDiagnostics().stream() + .map(Object::toString) + .anyMatch(message -> message.contains("sealed"))); + } +} diff --git a/core/src/test/java/com/minekube/connect/principal/PrincipalPrivacyTest.java b/core/src/test/java/com/minekube/connect/principal/PrincipalPrivacyTest.java new file mode 100644 index 000000000..03961e147 --- /dev/null +++ b/core/src/test/java/com/minekube/connect/principal/PrincipalPrivacyTest.java @@ -0,0 +1,103 @@ +package com.minekube.connect.principal; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.google.protobuf.ByteString; +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.minekube.connect.api.player.principal.BedrockPrincipalVerifierFactory; +import com.minekube.connect.api.player.principal.PrincipalError; +import com.minekube.connect.api.player.principal.PrincipalVerificationException; +import com.minekube.connect.api.player.principal.SignedPrincipalEnvelope; +import com.minekube.connect.api.player.principal.TrustedProposalContext; +import com.minekube.connect.api.player.principal.VerifierConfiguration; +import com.minekube.connect.watch.SessionProposal; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Base64; +import java.util.Objects; +import minekube.connect.v1alpha1.WatchServiceOuterClass.Session; +import org.junit.jupiter.api.Test; + +class PrincipalPrivacyTest { + @Test + void principalAndErrorsDoNotSerializeOrLogRawIdentityMaterial() throws Exception { + JsonObject vector = linkedVector(); + JsonObject context = vector.getAsJsonObject("trusted_context"); + String compact = vector.get("compact_jws").getAsString(); + var verifier = BedrockPrincipalVerifierFactory.create(VerifierConfiguration.builder() + .publicKey("connect-v2-test", Base64.getUrlDecoder() + .decode("diQm8c6MI-Zwn1nie8hq4wqf3mYLuI96uJBC6NHCTDg")) + .clock(Clock.fixed(Instant.ofEpochSecond( + vector.get("verification_time_unix").getAsLong()), ZoneOffset.UTC)) + .build()); + TrustedProposalContext trusted = new TrustedProposalContext( + context.get("issuer").getAsString(), + context.get("trust_domain").getAsString(), + context.get("audience").getAsString(), + context.get("endpoint_id").getAsString(), + context.get("organization_id").getAsString(), + context.get("connect_session_id").getAsString(), + Base64.getUrlDecoder().decode(context.get("connect_session_nonce").getAsString()), + context.get("source_protocol").getAsString(), + context.get("source_protocol_version").getAsInt(), + context.get("policy_revision").getAsLong()); + var principal = verifier.verifyAndConsume(SignedPrincipalEnvelope.of(compact), trusted); + + String capture = principal + "\n" + new Gson().toJson(principal) + "\n" + + principal.xuid() + "\n" + principal.linkedJava().orElseThrow(); + for (String forbidden : new String[] { + "BedrockOne", "JavaOne", "record-test-1", "123e4567-e89b-12d3-a456-426614174000", + compact, "AAAAAAAAAAAAAAAAAAAAAA", "AgICAgICAgICAgICAgICAg" + }) { + assertFalse(capture.contains(forbidden), forbidden); + } + + PrincipalVerificationException error = new PrincipalVerificationException(PrincipalError.SIGNATURE); + assertTrue(error.toString().endsWith(PrincipalError.SIGNATURE.name())); + assertNull(error.getCause()); + assertTrue(error.getStackTrace().length == 0); + } + + @Test + void proposalStringCannotExposeEnvelopeNonceOrProfile() { + String envelope = "compact-envelope-sentinel"; + String display = "Bedrock-display-sentinel"; + SessionProposal proposal = new SessionProposal(Session.newBuilder() + .setId("session-correlation") + .setConnectSessionNonce(ByteString.copyFromUtf8("nonce-sentinel-1")) + .setSignedBedrockPrincipalV2(ByteString.copyFromUtf8(envelope)) + .setPlayer(minekube.connect.v1alpha1.WatchServiceOuterClass.Player.newBuilder() + .setProfile(minekube.connect.v1alpha1.WatchServiceOuterClass.GameProfile + .newBuilder().setName(display))) + .build(), ignored -> { }); + + String capture = proposal.toString() + proposal.getSession(); + assertTrue(proposal.hasBedrockPrincipalV2()); + assertFalse(capture.contains(envelope)); + assertFalse(capture.contains("nonce-sentinel-1")); + assertFalse(proposal.toString().contains(display)); + } + + private JsonObject linkedVector() { + try (var reader = new InputStreamReader( + Objects.requireNonNull(getClass().getResourceAsStream( + "/bedrock-principal-v2/core-vectors.json")), StandardCharsets.UTF_8)) { + JsonArray vectors = JsonParser.parseReader(reader).getAsJsonArray(); + for (var value : vectors) { + JsonObject vector = value.getAsJsonObject(); + if ("valid-linked".equals(vector.get("name").getAsString())) return vector; + } + throw new AssertionError("missing valid-linked vector"); + } catch (java.io.IOException error) { + throw new AssertionError(error); + } + } +} diff --git a/core/src/test/java/com/minekube/connect/register/WatcherRegisterTest.java b/core/src/test/java/com/minekube/connect/register/WatcherRegisterTest.java index 463000b4e..2b7f0806e 100644 --- a/core/src/test/java/com/minekube/connect/register/WatcherRegisterTest.java +++ b/core/src/test/java/com/minekube/connect/register/WatcherRegisterTest.java @@ -1,6 +1,9 @@ package com.minekube.connect.register; +import static java.util.concurrent.TimeUnit.SECONDS; +import static org.awaitility.Awaitility.await; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNotSame; @@ -11,11 +14,15 @@ import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.verifyNoMoreInteractions; import static org.mockito.Mockito.when; +import com.google.rpc.Code; +import com.google.rpc.LocalizedMessage; import com.minekube.connect.api.SimpleConnectApi; import com.minekube.connect.api.inject.PlatformInjector; import com.minekube.connect.api.logger.ConnectLogger; @@ -27,6 +34,8 @@ import com.minekube.connect.config.ConnectConfig; import com.minekube.connect.tunnel.p2p.Libp2pEndpoint; import com.minekube.connect.tunnel.Tunneler; +import com.minekube.connect.watch.SessionAdmissionDecision; +import com.minekube.connect.watch.SessionAdmissionGate; import com.minekube.connect.watch.SessionProposal; import com.minekube.connect.watch.WatchBootstrap; import com.minekube.connect.watch.WatchClient; @@ -38,8 +47,10 @@ import java.util.List; import java.util.Map; import java.util.Timer; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; import org.mockito.ArgumentCaptor; import minekube.connect.v1alpha1.WatchServiceOuterClass.GameProfile; import minekube.connect.v1alpha1.WatchServiceOuterClass.GameProfileProperty; @@ -401,8 +412,135 @@ void acceptsLibp2pOnlyProposalWithoutLegacyTunnelServiceAddr() throws Exception watcher.getValue().onProposal(proposal); - verify(fixture.tunneler).prepare(session); - verify(fixture.platformInjector).getServerSocketAddress(); + await().atMost(2, SECONDS).untilAsserted(() -> { + verify(fixture.tunneler).prepare(session); + verify(fixture.platformInjector).getServerSocketAddress(); + }); + } + + @Test + void waitsForAdmissionBeforePreparingTunnel() throws Exception { + CompletableFuture admission = new CompletableFuture<>(); + SessionAdmissionGate gate = mock(SessionAdmissionGate.class); + when(gate.request(any(SessionProposal.class))).thenReturn(admission); + Fixture fixture = newFixture(gate); + register = fixture.register; + register.start(); + ArgumentCaptor watcher = ArgumentCaptor.forClass(Watcher.class); + verify(fixture.watchClient).watch(watcher.capture()); + Session session = validSession("session-pending"); + SessionProposal proposal = new SessionProposal(session, reason -> { + throw new AssertionError("proposal should not be rejected: " + reason); + }); + + watcher.getValue().onProposal(proposal); + + verify(gate).request(proposal); + verifyNoInteractions(fixture.tunneler); + verify(fixture.platformInjector, never()).getServerSocketAddress(); + + admission.complete(SessionAdmissionDecision.allow()); + + await().atMost(2, SECONDS).untilAsserted(() -> { + verify(fixture.tunneler).prepare(session); + verify(fixture.platformInjector).getServerSocketAddress(); + }); + } + + @Test + void deferredAdmissionMayOpenTunnelForLocalLoginApproval() throws Exception { + CompletableFuture admission = new CompletableFuture<>(); + SessionAdmissionGate gate = proposal -> + admission; + Fixture fixture = newFixture(gate); + register = fixture.register; + register.start(); + ArgumentCaptor watcher = ArgumentCaptor.forClass(Watcher.class); + verify(fixture.watchClient).watch(watcher.capture()); + Session session = validSession("session-deferred"); + + watcher.getValue().onProposal(new SessionProposal(session, reason -> { + throw new AssertionError("proposal should not be rejected: " + reason); + })); + admission.complete(SessionAdmissionDecision.deferToLocalLogin()); + + await().atMost(2, SECONDS).untilAsserted(() -> + verify(fixture.tunneler).prepare(session)); + } + + @Test + void deniedOrTimedOutAdmissionRejectsWithoutTunnelWork() throws Exception { + CompletableFuture admission = new CompletableFuture<>(); + Fixture fixture = newFixture(proposal -> admission); + register = fixture.register; + register.start(); + ArgumentCaptor watcher = ArgumentCaptor.forClass(Watcher.class); + verify(fixture.watchClient).watch(watcher.capture()); + AtomicReference rejection = new AtomicReference<>(); + SessionProposal proposal = new SessionProposal( + validSession("session-denied"), + rejection::set); + + watcher.getValue().onProposal(proposal); + admission.complete(SessionAdmissionDecision.deny("Host approval timed out")); + + await().atMost(2, SECONDS).untilAsserted(() -> { + assertNotNull(rejection.get()); + assertEquals(Code.PERMISSION_DENIED_VALUE, rejection.get().getCode()); + assertEquals("Host approval timed out", rejection.get().getMessage()); + assertEquals(1, rejection.get().getDetailsCount()); + LocalizedMessage detail = rejection.get().getDetails(0) + .unpack(LocalizedMessage.class); + assertEquals("en-US", detail.getLocale()); + assertEquals("Host approval timed out", detail.getMessage()); + }); + verifyNoInteractions(fixture.tunneler); + } + + @Test + void exceptionalAdmissionIsSanitizedAndDoesNotOpenTunnel() throws Exception { + CompletableFuture admission = new CompletableFuture<>(); + Fixture fixture = newFixture(proposal -> admission); + register = fixture.register; + register.start(); + ArgumentCaptor watcher = ArgumentCaptor.forClass(Watcher.class); + verify(fixture.watchClient).watch(watcher.capture()); + AtomicReference rejection = new AtomicReference<>(); + SessionProposal proposal = new SessionProposal( + validSession("session-exception"), + rejection::set); + + watcher.getValue().onProposal(proposal); + admission.completeExceptionally(new IllegalStateException("T-secret")); + + await().atMost(2, SECONDS).untilAsserted(() -> { + assertNotNull(rejection.get()); + assertEquals(Code.INTERNAL_VALUE, rejection.get().getCode()); + assertFalse(rejection.get().getMessage().contains("T-secret")); + }); + verifyNoInteractions(fixture.tunneler); + } + + @Test + void stoppingWatcherRejectsPendingAdmissionAndIgnoresLateAllow() throws Exception { + CompletableFuture admission = new CompletableFuture<>(); + Fixture fixture = newFixture(proposal -> admission); + register = fixture.register; + register.start(); + ArgumentCaptor watcher = ArgumentCaptor.forClass(Watcher.class); + verify(fixture.watchClient).watch(watcher.capture()); + AtomicReference rejection = new AtomicReference<>(); + SessionProposal proposal = new SessionProposal( + validSession("session-stopped"), + rejection::set); + watcher.getValue().onProposal(proposal); + + register.stop(); + admission.complete(SessionAdmissionDecision.allow()); + + assertNotNull(rejection.get()); + assertEquals(Code.CANCELLED_VALUE, rejection.get().getCode()); + verifyNoInteractions(fixture.tunneler); } @Test @@ -447,10 +585,23 @@ private static WatchBootstrap emptyBootstrap() { } private static Fixture newFixture() throws Exception { - return newFixture(null); + return newFixture(null, new com.minekube.connect.watch.AllowAllSessionAdmissionGate()); } private static Fixture newFixture(BedrockAdmissionCoordinator admissionCoordinator) throws Exception { + return newFixture( + admissionCoordinator, + new com.minekube.connect.watch.AllowAllSessionAdmissionGate()); + } + + private static Fixture newFixture(SessionAdmissionGate admissionGate) throws Exception { + return newFixture(null, admissionGate); + } + + private static Fixture newFixture( + BedrockAdmissionCoordinator admissionCoordinator, + SessionAdmissionGate admissionGate + ) throws Exception { WatcherRegister register = new WatcherRegister(); WatchClient watchClient = mock(WatchClient.class); when(watchClient.watch(any(Watcher.class))).thenReturn(mock(WebSocket.class)); @@ -461,6 +612,7 @@ private static Fixture newFixture(BedrockAdmissionCoordinator admissionCoordinat inject(register, "logger", mock(ConnectLogger.class)); inject(register, "api", new SimpleConnectApi(mock(ConnectLogger.class))); inject(register, "libp2pEndpoint", mock(Libp2pEndpoint.class)); + inject(register, "sessionAdmissionGate", admissionGate); if (admissionCoordinator != null) { inject(register, "admissionCoordinator", admissionCoordinator); } @@ -471,6 +623,18 @@ private static Fixture newFixture(BedrockAdmissionCoordinator admissionCoordinat (Libp2pEndpoint) getField(register, "libp2pEndpoint")); } + private static Session validSession(String id) { + return Session.newBuilder() + .setId(id) + .setTunnelServiceAddr("wss://tunnel.example") + .setPlayer(Player.newBuilder() + .setAddr("127.0.0.1") + .setProfile(GameProfile.newBuilder() + .setId("00000000-0000-0000-0000-000000000001") + .setName("Player"))) + .build(); + } + private static void inject(WatcherRegister register, String fieldName, Object value) throws Exception { Field field = WatcherRegister.class.getDeclaredField(fieldName); diff --git a/core/src/test/java/com/minekube/connect/startup/PluginGraphStartupTest.java b/core/src/test/java/com/minekube/connect/startup/PluginGraphStartupTest.java index f8955f99c..6865153b6 100644 --- a/core/src/test/java/com/minekube/connect/startup/PluginGraphStartupTest.java +++ b/core/src/test/java/com/minekube/connect/startup/PluginGraphStartupTest.java @@ -26,6 +26,7 @@ package com.minekube.connect.startup; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; @@ -39,7 +40,9 @@ import com.minekube.connect.bedrock.BedrockAdmissionCoordinator; import com.minekube.connect.bedrock.BedrockIdentityEnforcer; import com.minekube.connect.bedrock.BedrockIdentityKeyProvider; +import com.minekube.connect.bedrock.BedrockPrincipalReadiness; import com.minekube.connect.config.ConfigHolder; +import com.minekube.connect.config.ConnectConfig; import com.minekube.connect.inject.CommonPlatformInjector; import com.minekube.connect.module.ServerCommonModule; import com.minekube.connect.platform.util.PlatformUtils; @@ -142,7 +145,13 @@ protected void configure() { }); assertNotNull(injector.getInstance(ConnectApi.class)); - assertNotNull(injector.getInstance(ConfigHolder.class)); + ConfigHolder configHolder = injector.getInstance(ConfigHolder.class); + assertNotNull(configHolder); + configHolder.set(new ConnectConfig()); + BedrockPrincipalReadiness principalReadiness = + injector.getInstance(BedrockPrincipalReadiness.class); + assertNotNull(principalReadiness); + assertSame(principalReadiness, injector.getInstance(BedrockPrincipalReadiness.class)); assertNotNull(injector.getInstance(BedrockAdmissionCoordinator.class)); assertNotNull(injector.getInstance(BedrockIdentityEnforcer.class)); assertNotNull(injector.getInstance(BedrockIdentityKeyProvider.class)); diff --git a/core/src/test/java/com/minekube/connect/tunnel/p2p/DirectP2pNodeTest.java b/core/src/test/java/com/minekube/connect/tunnel/p2p/DirectP2pNodeTest.java new file mode 100644 index 000000000..84cf69edc --- /dev/null +++ b/core/src/test/java/com/minekube/connect/tunnel/p2p/DirectP2pNodeTest.java @@ -0,0 +1,451 @@ +/* + * Copyright (c) 2021-2022 Minekube. https://minekube.com + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package com.minekube.connect.tunnel.p2p; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.ServerSocket; +import java.net.Socket; +import java.security.KeyFactory; +import java.security.Signature; +import java.security.spec.X509EncodedKeySpec; +import java.time.Duration; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class DirectP2pNodeTest { + @TempDir + java.nio.file.Path tempDir; + + private DirectP2pNode host; + private DirectP2pNode guest; + + @AfterEach + void closeNodes() { + if (guest != null) { + guest.close(); + } + if (host != null) { + host.close(); + } + Libp2pRuntime.close(); + } + + @Test + void twoLoopbackNodesExchangeMinecraftShapedBytes() throws Exception { + byte[] minecraftHandshake = new byte[] { + 0x10, 0x00, (byte) 0xff, 0x01, 0x7f, 0x45, 0x00 + }; + AtomicReference session = new AtomicReference<>(); + try (ServerSocket target = new ServerSocket()) { + target.bind(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0)); + CompletableFuture echo = CompletableFuture.runAsync(() -> { + try (Socket accepted = target.accept()) { + byte[] received = new DataInputStream(accepted.getInputStream()) + .readNBytes(minecraftHandshake.length); + new DataOutputStream(accepted.getOutputStream()).write(received); + } catch (Exception e) { + throw new IllegalStateException(e); + } + }); + + host = new DirectP2pNode(); + DirectP2pHostInfo hostInfo = host.startHost( + new DirectP2pHostConfig( + "share-123", + "capability-123456789", + "Robin's World", + false), + directSession -> { + session.set(directSession); + Socket socket = new Socket(); + socket.connect(target.getLocalSocketAddress()); + return socket; + }); + guest = new DirectP2pNode(); + DirectP2pProxy proxy = guest.openProxy( + hostInfo.lanAddresses().get(0), + "share-123", + "capability-123456789", + DirectP2pAuthMode.OFFLINE, + Duration.ofSeconds(3)); + + try (Socket minecraftClient = new Socket()) { + minecraftClient.connect(proxy.localAddress()); + minecraftClient.getOutputStream().write(minecraftHandshake); + assertArrayEquals( + minecraftHandshake, + minecraftClient.getInputStream().readNBytes(minecraftHandshake.length)); + } finally { + proxy.close(); + } + + echo.get(3, TimeUnit.SECONDS); + assertEquals(DirectP2pAuthMode.OFFLINE, session.get().authMode()); + assertEquals(DirectP2pRoute.LAN, session.get().route()); + assertFalse(session.get().peerId().isBlank()); + assertFalse(session.get().connectionId().isBlank()); + } + } + + @Test + void java17PeersTransferAcrossManyMuxerWindows() throws Exception { + byte[] payload = new byte[4 * 1024 * 1024]; + for (int index = 0; index < payload.length; index++) { + payload[index] = (byte) (index * 31); + } + + try (ServerSocket target = new ServerSocket()) { + target.bind(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0)); + CompletableFuture echo = CompletableFuture.runAsync(() -> { + try (Socket accepted = target.accept()) { + accepted.setSoTimeout(15_000); + byte[] received = new DataInputStream(accepted.getInputStream()) + .readNBytes(payload.length); + assertArrayEquals(payload, received); + new DataOutputStream(accepted.getOutputStream()).write(received); + } catch (Exception e) { + throw new IllegalStateException(e); + } + }); + + host = new DirectP2pNode(); + DirectP2pHostInfo hostInfo = host.startHost( + new DirectP2pHostConfig( + "large-share", + "large-capability", + "Large transfer", + false), + ignored -> { + Socket socket = new Socket(); + socket.connect(target.getLocalSocketAddress()); + return socket; + }); + guest = new DirectP2pNode(); + DirectP2pProxy proxy = guest.openProxy( + hostInfo.lanAddresses().get(0), + "large-share", + "large-capability", + DirectP2pAuthMode.OFFLINE, + Duration.ofSeconds(3)); + + try (Socket client = new Socket()) { + client.setSoTimeout(15_000); + client.connect(proxy.localAddress()); + client.getOutputStream().write(payload); + assertArrayEquals( + payload, + new DataInputStream(client.getInputStream()) + .readNBytes(payload.length)); + } finally { + proxy.close(); + } + + echo.get(15, TimeUnit.SECONDS); + } + } + + @Test + void everyHostUsesAnEphemeralPeerIdentityAndSignsWithIt() throws Exception { + host = new DirectP2pNode(); + DirectP2pHostInfo first = host.startHost( + new DirectP2pHostConfig("one", "capability-one", "One", false), + ignored -> new Socket()); + byte[] message = "signed invitation body".getBytes(java.nio.charset.StandardCharsets.UTF_8); + byte[] signature = host.sign(message); + + try (DirectP2pNode other = new DirectP2pNode()) { + DirectP2pHostInfo second = other.startHost( + new DirectP2pHostConfig("two", "capability-two", "Two", false), + ignored -> new Socket()); + + assertNotEquals(first.peerId(), second.peerId()); + } + + Signature verifier = Signature.getInstance("Ed25519"); + verifier.initVerify(KeyFactory.getInstance("Ed25519").generatePublic( + new X509EncodedKeySpec(first.publicKey()))); + verifier.update(message); + assertTrue(verifier.verify(signature)); + } + + @Test + void persistentIdentitySurvivesNodeRestarts() { + java.nio.file.Path identityFile = tempDir.resolve("share-peer.key"); + String firstPeerId; + + host = new DirectP2pNode(identityFile); + firstPeerId = host.startHost( + new DirectP2pHostConfig( + "first-share", + "first-capability", + "First World", + false), + ignored -> new Socket()).peerId(); + host.close(); + host = null; + Libp2pRuntime.close(); + + host = new DirectP2pNode(identityFile); + String restartedPeerId = host.startHost( + new DirectP2pHostConfig( + "second-share", + "second-capability", + "Second World", + false), + ignored -> new Socket()).peerId(); + + assertEquals(firstPeerId, restartedPeerId); + assertTrue(java.nio.file.Files.isRegularFile(identityFile)); + } + + @Test + void persistentPeerIdentityIsAvailableWithoutOpeningAWorld() { + java.nio.file.Path identityFile = tempDir.resolve("friend-peer.key"); + + host = new DirectP2pNode(identityFile); + String firstPeerId = host.peerId(); + host.close(); + host = null; + Libp2pRuntime.close(); + + host = new DirectP2pNode(identityFile); + + assertEquals(firstPeerId, host.peerId()); + assertFalse(firstPeerId.isBlank()); + } + + @Test + void persistentIdentityCanSignAFriendCardWithoutOpeningAWorld() throws Exception { + host = new DirectP2pNode(tempDir.resolve("friend-card-peer.key")); + byte[] message = "friend card".getBytes(java.nio.charset.StandardCharsets.UTF_8); + + byte[] signature = host.sign(message); + + Signature verifier = Signature.getInstance("Ed25519"); + verifier.initVerify(KeyFactory.getInstance("Ed25519").generatePublic( + new X509EncodedKeySpec(host.publicKey()))); + verifier.update(message); + assertTrue(verifier.verify(signature)); + } + + @Test + void publishedHostMetadataCanBeInspectedWithoutAdvertisingItsCapability() { + host = new DirectP2pNode(); + DirectP2pHostInfo hostInfo = host.startHost( + new DirectP2pHostConfig( + "share-inspect", + "capability-inspect", + "Robin's World", + false), + ignored -> new Socket()); + host.publish( + "minekube://share/signed-secret-payload", + "minekube://share/signed-lan-payload"); + + guest = new DirectP2pNode(); + DirectP2pDiscoveredShare discovered = guest.inspect( + hostInfo.lanAddresses().get(0), + Duration.ofSeconds(3)); + + assertEquals("Robin's World", discovered.displayName()); + assertEquals(hostInfo.peerId(), discovered.peerId()); + assertEquals( + "minekube://share/signed-lan-payload", + discovered.invitation()); + assertFalse(discovered.toString().contains("signed-lan-payload")); + assertFalse(discovered.toString().contains(hostInfo.lanAddresses().get(0))); + } + + @Test + void discoveryNodeCanBecomeThePublishedHostWithoutChangingItsPeer() { + host = new DirectP2pNode(); + String peerId = host.peerId(); + host.startDiscovery(ignored -> { }); + + DirectP2pHostInfo hostInfo = host.startHost( + new DirectP2pHostConfig( + "shared-runtime", + "shared-capability-123456789", + "Shared runtime", + false), + ignored -> new Socket()); + host.publish("minekube://share/shared-runtime"); + + guest = new DirectP2pNode(); + DirectP2pDiscoveredShare discovered = guest.inspect( + hostInfo.lanAddresses().get(0), + Duration.ofSeconds(3)); + + assertEquals(peerId, hostInfo.peerId()); + assertEquals(peerId, discovered.peerId()); + assertEquals( + "minekube://share/shared-runtime", + discovered.invitation()); + } + + @Test + void publishedHostCanRefreshItsWorldWithoutChangingItsPeer() { + host = new DirectP2pNode(); + DirectP2pHostInfo first = host.startHost( + new DirectP2pHostConfig( + "stable-share", + "stable-capability-123456789", + "First world", + false), + ignored -> new Socket()); + host.publish("minekube://share/first-world"); + + DirectP2pHostInfo second = host.startHost( + new DirectP2pHostConfig( + "stable-share", + "stable-capability-123456789", + "Second world", + true), + ignored -> new Socket()); + host.publish("minekube://share/second-world"); + + guest = new DirectP2pNode(); + DirectP2pDiscoveredShare discovered = guest.inspect( + second.lanAddresses().get(0), + Duration.ofSeconds(3)); + + assertEquals(first.peerId(), second.peerId()); + assertEquals("Second world", discovered.displayName()); + assertEquals("minekube://share/second-world", discovered.invitation()); + } + + @Test + void mdnsTxtLengthPrefixSupportsModernEd25519PeerIds() { + String peerId = + "12D3KooWEHeJnnq1Rfwt679bTyTxkEdtyTC8peAJWsWCxtAJ4s9y"; + byte[] encodedPeerId = peerId.getBytes(java.nio.charset.StandardCharsets.UTF_8); + byte[] txtRecord = new byte[encodedPeerId.length + 1]; + txtRecord[0] = (byte) encodedPeerId.length; + System.arraycopy(encodedPeerId, 0, txtRecord, 1, encodedPeerId.length); + + assertEquals( + peerId, + DirectP2pNodeRuntime.decodeMdnsPeerId(txtRecord)); + } + + @Test + void mdnsHostNameComesFromPeerIdentityWithoutDnsResolution() { + String peerId = + "12D3KooWEHeJnnq1Rfwt679bTyTxkEdtyTC8peAJWsWCxtAJ4s9y"; + + String hostName = DirectP2pNodeRuntime.mdnsHostName(peerId); + + assertEquals( + "connect-share-12D3KooWEHeJnnq1Rfwt679bTyTxkEdt", + hostName); + assertTrue(hostName.length() <= 63); + } + + @Test + void mdnsDiscoveryRefreshesWhenTheAddressChanges() { + Set seen = new HashSet<>(); + DirectP2pDiscoveredShare first = new DirectP2pDiscoveredShare( + "World", + "12D3KooWHost", + "/ip4/192.168.1.20/tcp/4001/p2p/12D3KooWHost", + "minekube://share/invitation"); + DirectP2pDiscoveredShare moved = new DirectP2pDiscoveredShare( + "World", + "12D3KooWHost", + "/ip4/192.168.1.21/tcp/4001/p2p/12D3KooWHost", + "minekube://share/invitation"); + + assertTrue(DirectP2pNodeRuntime.shouldNotifyDiscovery(seen, first)); + assertFalse(DirectP2pNodeRuntime.shouldNotifyDiscovery(seen, first)); + assertTrue(DirectP2pNodeRuntime.shouldNotifyDiscovery(seen, moved)); + assertFalse(DirectP2pNodeRuntime.shouldNotifyDiscovery(seen, moved)); + } + + @Test + void directNodeNeverAdvertisesOrAcceptsCircuitRelayAddresses() { + host = new DirectP2pNode(); + DirectP2pHostInfo info = host.startHost( + new DirectP2pHostConfig("share", "capability", "World", true), + ignored -> new Socket()); + + assertTrue(info.lanAddresses().stream().noneMatch(it -> it.contains("p2p-circuit"))); + assertTrue(info.internetAddresses().stream().noneMatch(it -> it.contains("p2p-circuit"))); + + guest = new DirectP2pNode(); + assertThrows(IllegalArgumentException.class, () -> guest.openProxy( + "/ip4/203.0.113.2/tcp/4001/p2p/QmRelay/p2p-circuit/p2p/QmHost", + "share", + "capability", + DirectP2pAuthMode.ONLINE, + Duration.ofSeconds(3))); + } + + @Test + void parentBoundaryUsesOnlyJdkTypes() { + List> boundary = List.of( + DirectP2pNode.class, + DirectP2pHostConfig.class, + DirectP2pHostInfo.class, + DirectP2pHostHandler.class, + DirectP2pSession.class, + DirectP2pDiscoveredShare.class, + DirectP2pDiscoveryListener.class, + DirectP2pProxy.class, + DirectP2pRoute.class, + DirectP2pAuthMode.class); + + for (Class type : boundary) { + java.util.stream.Stream.concat( + java.util.Arrays.stream(type.getDeclaredMethods()) + .flatMap(method -> java.util.stream.Stream.concat( + java.util.stream.Stream.of(method.getReturnType()), + java.util.Arrays.stream(method.getParameterTypes()))), + java.util.Arrays.stream(type.getDeclaredFields()) + .map(java.lang.reflect.Field::getType)) + .map(Class::getName) + .forEach(name -> { + assertFalse(name.startsWith("io.libp2p."), name); + assertFalse(name.startsWith("io.netty."), name); + assertFalse(name.startsWith("kotlin."), name); + assertFalse(name.startsWith("kotlinx."), name); + }); + } + } +} diff --git a/core/src/test/java/com/minekube/connect/tunnel/p2p/Libp2pEndpointRuntimeInitTest.java b/core/src/test/java/com/minekube/connect/tunnel/p2p/Libp2pEndpointRuntimeInitTest.java index 11363b0b3..b6faed45d 100644 --- a/core/src/test/java/com/minekube/connect/tunnel/p2p/Libp2pEndpointRuntimeInitTest.java +++ b/core/src/test/java/com/minekube/connect/tunnel/p2p/Libp2pEndpointRuntimeInitTest.java @@ -31,7 +31,9 @@ import com.minekube.connect.api.logger.ConnectLogger; import com.minekube.connect.bedrock.BedrockAdmissionCoordinator; +import com.minekube.connect.bedrock.BedrockPrincipalReadiness; import com.minekube.connect.bedrock.VerifiedBedrockIdentityRegistry; +import com.minekube.connect.config.ConnectConfig; import java.lang.reflect.Field; import java.nio.file.Path; import org.junit.jupiter.api.Test; @@ -51,6 +53,8 @@ class Libp2pEndpointRuntimeInitTest { void initializesRuntimeAcrossIsolatedLoaderBoundary( @TempDir Path dataDirectory) throws Exception { ConnectLogger logger = mock(ConnectLogger.class); + BedrockPrincipalReadiness principalReadiness = + new BedrockPrincipalReadiness(new ConnectConfig()); BedrockAdmissionCoordinator admissionCoordinator = new BedrockAdmissionCoordinator( new VerifiedBedrockIdentityRegistry()); @@ -64,6 +68,7 @@ void initializesRuntimeAcrossIsolatedLoaderBoundary( null, // PlatformInjector null, // SimpleConnectApi null, // BedrockIdentityReadiness + principalReadiness, admissionCoordinator); Field runtimeField = Libp2pEndpoint.class.getDeclaredField("runtime"); @@ -82,6 +87,11 @@ void initializesRuntimeAcrossIsolatedLoaderBoundary( assertNotNull(runtimeAdmissionCoordinator); assertSame(admissionCoordinator, runtimeAdmissionCoordinator); + + Field principalReadinessField = runtime.getClass() + .getDeclaredField("bedrockPrincipalReadiness"); + principalReadinessField.setAccessible(true); + assertSame(principalReadiness, principalReadinessField.get(runtime)); } finally { admissionCoordinator.close(); } diff --git a/core/src/test/java/com/minekube/connect/tunnel/p2p/Libp2pRuntimeLoaderPayloadTest.java b/core/src/test/java/com/minekube/connect/tunnel/p2p/Libp2pRuntimeLoaderPayloadTest.java new file mode 100644 index 000000000..e453e1f87 --- /dev/null +++ b/core/src/test/java/com/minekube/connect/tunnel/p2p/Libp2pRuntimeLoaderPayloadTest.java @@ -0,0 +1,31 @@ +package com.minekube.connect.tunnel.p2p; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import org.junit.jupiter.api.Test; + +class Libp2pRuntimeLoaderPayloadTest { + @Test + void extractsPayloadToContentHashedTemporaryJarAndDeletesIt() throws Exception { + byte[] payload = "isolated-runtime".getBytes(StandardCharsets.UTF_8); + + Path extracted = Libp2pRuntimeLoader.extractRuntimePayload( + new ByteArrayInputStream(payload)); + try { + assertTrue(extracted.getFileName().toString().matches( + "libp2p-runtime-[a-f0-9]{64}\\.jar")); + assertArrayEquals(payload, Files.readAllBytes(extracted)); + } finally { + Libp2pRuntimeLoader.deleteRuntimePayload(extracted); + } + + assertFalse(Files.exists(extracted)); + assertFalse(Files.exists(extracted.getParent())); + } +} diff --git a/core/src/test/java/com/minekube/connect/tunnel/p2p/MdnsAddressSelectorTest.java b/core/src/test/java/com/minekube/connect/tunnel/p2p/MdnsAddressSelectorTest.java new file mode 100644 index 000000000..099bae1b3 --- /dev/null +++ b/core/src/test/java/com/minekube/connect/tunnel/p2p/MdnsAddressSelectorTest.java @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2021-2022 Minekube. https://minekube.com + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package com.minekube.connect.tunnel.p2p; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import java.net.InetAddress; +import java.util.List; +import org.junit.jupiter.api.Test; + +class MdnsAddressSelectorTest { + @Test + void prefersPrivatePhysicalMulticastInterface() throws Exception { + InetAddress selected = MdnsAddressSelector.select(List.of( + candidate("203.0.113.20", true, true, false, false, false, 8), + candidate("192.168.178.100", true, true, false, false, false, 14), + candidate("192.168.64.1", true, true, false, false, true, 21))); + + assertEquals("192.168.178.100", selected.getHostAddress()); + } + + @Test + void ignoresInterfacesThatCannotCarryLanMulticast() throws Exception { + InetAddress selected = MdnsAddressSelector.select(List.of( + candidate("192.168.1.10", false, true, false, false, false, 1), + candidate("192.168.1.11", true, false, false, false, false, 2), + candidate("192.168.1.12", true, true, true, false, false, 3), + candidate("192.168.1.13", true, true, false, true, false, 4), + candidate("127.0.0.1", true, true, false, false, false, 5), + candidate("2001:db8::10", true, true, false, false, false, 6))); + + assertNull(selected); + } + + @Test + void fallsBackToPublicIpv4WhenItIsTheOnlyUsableInterface() throws Exception { + InetAddress selected = MdnsAddressSelector.select(List.of( + candidate("203.0.113.20", true, true, false, false, false, 8))); + + assertEquals("203.0.113.20", selected.getHostAddress()); + } + + private static MdnsAddressSelector.Candidate candidate( + String address, + boolean up, + boolean multicast, + boolean loopback, + boolean pointToPoint, + boolean virtual, + int index) throws Exception { + return new MdnsAddressSelector.Candidate( + InetAddress.getByName(address), + up, + multicast, + loopback, + pointToPoint, + virtual, + index); + } +} diff --git a/core/src/test/java/com/minekube/connect/tunnel/p2p/P2PFrameCodecTest.java b/core/src/test/java/com/minekube/connect/tunnel/p2p/P2PFrameCodecTest.java index 3c502976e..d11211533 100644 --- a/core/src/test/java/com/minekube/connect/tunnel/p2p/P2PFrameCodecTest.java +++ b/core/src/test/java/com/minekube/connect/tunnel/p2p/P2PFrameCodecTest.java @@ -6,11 +6,34 @@ import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; +import minekube.connect.v1alpha1.ConnectLibp2P.PeerRegisterResult; import minekube.connect.v1alpha1.ConnectLibp2P.SessionAck; import minekube.connect.v1alpha1.ConnectLibp2P.SessionResponse; import org.junit.jupiter.api.Test; class P2PFrameCodecTest { + @Test + void roundTripsFrozenKindPrefixedFraming() throws Exception { + PeerRegisterResult message = PeerRegisterResult.newBuilder().setKvRevision(42).build(); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + + P2PFrameCodec.writeKindPrefixed(out, P2PFrameCodec.RENEWAL_RESULT, message); + P2PFrameCodec.KindPrefixedFrame frame = P2PFrameCodec.readKindPrefixed( + new ByteArrayInputStream(out.toByteArray())); + + assertEquals(P2PFrameCodec.RENEWAL_RESULT, frame.kind()); + assertEquals(message, frame.parse(PeerRegisterResult.parser())); + assertEquals(1 + message.getSerializedSize(), out.toByteArray()[0]); + } + + @Test + void kindPrefixedFramingRejectsUnknownAndOversizedFrames() { + assertThrows(IllegalArgumentException.class, () -> P2PFrameCodec.writeKindPrefixed( + new ByteArrayOutputStream(), (byte) 0x05, PeerRegisterResult.getDefaultInstance())); + byte[] oversized = new byte[] {(byte) 0x81, 0x20}; // 4097 + assertThrows(IllegalArgumentException.class, () -> P2PFrameCodec.readKindPrefixed( + new ByteArrayInputStream(oversized))); + } @Test void roundTripsVarintDelimitedProtoFrames() throws Exception { diff --git a/core/src/test/java/com/minekube/connect/tunnel/p2p/PeerRegistrationClientTest.java b/core/src/test/java/com/minekube/connect/tunnel/p2p/PeerRegistrationClientTest.java index 1b6abbc6e..39bd0d42e 100644 --- a/core/src/test/java/com/minekube/connect/tunnel/p2p/PeerRegistrationClientTest.java +++ b/core/src/test/java/com/minekube/connect/tunnel/p2p/PeerRegistrationClientTest.java @@ -1,14 +1,18 @@ package com.minekube.connect.tunnel.p2p; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.clearInvocations; import static org.mockito.Mockito.timeout; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import com.google.protobuf.ByteString; +import com.minekube.connect.bedrock.BedrockPrincipalReadiness; +import com.minekube.connect.config.ConnectConfig; import io.libp2p.core.Stream; import io.netty.buffer.ByteBuf; import io.netty.buffer.ByteBufInputStream; @@ -16,8 +20,11 @@ import io.netty.channel.embedded.EmbeddedChannel; import java.io.ByteArrayOutputStream; import java.nio.file.Path; +import java.lang.reflect.Field; import java.util.Arrays; +import java.util.Base64; import java.util.Collections; +import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; @@ -27,6 +34,10 @@ import minekube.connect.v1alpha1.ConnectLibp2P.PeerRegisterCommit; import minekube.connect.v1alpha1.ConnectLibp2P.PeerRegisterInit; import minekube.connect.v1alpha1.ConnectLibp2P.PeerRegisterResult; +import minekube.connect.v1alpha1.ConnectLibp2P.RegistrationModeResult; +import minekube.connect.v1alpha1.WatchServiceOuterClass.ReadinessAttestation; +import minekube.connect.v1alpha1.WatchServiceOuterClass.ReadinessChallenge; +import minekube.connect.v1alpha1.WatchServiceOuterClass.TunnelTransport; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; import org.mockito.ArgumentCaptor; @@ -258,6 +269,90 @@ void closesRegistrationWhenRenewAckTimesOut() throws Exception { assertTrue(client.closedFuture().isCompletedExceptionally()); } + @Test + void negotiatesFramingBeforeAnsweringReadinessChallenge() throws Exception { + EndpointPeerIdentity identity = EndpointPeerIdentity.loadOrCreate(tempDir.resolve("libp2p-identity.key")); + PeerRegistrationHandshake handshake = new PeerRegistrationHandshake( + identity, "endpoint", "token", "instance", Collections.emptyList(), + OfflineMode.OFFLINE_MODE_ALLOWED, Arrays.asList("session", "status"), + Arrays.asList("session", "status", BedrockPrincipalReadiness.CAPABILITY), + PeerCapacity.newBuilder().setMaxSessions(100).build()); + Stream stream = mock(Stream.class); + when(stream.closeFuture()).thenReturn(new CompletableFuture<>()); + PeerRegistrationClient client = new PeerRegistrationClient(handshake, readyPrincipalConsumer()); + + client.install(stream, Collections.singletonList( + "/ip4/127.0.0.1/tcp/1234/p2p/" + identity.peerId()), 9, 1_000); + ArgumentCaptor handlers = ArgumentCaptor.forClass(ChannelHandler.class); + verify(stream, times(2)).pushHandler(handlers.capture()); + EmbeddedChannel channel = new EmbeddedChannel(handlers.getAllValues().toArray(ChannelHandler[]::new)); + channel.writeInbound(frame(PeerRegisterChallenge.newBuilder() + .setEndpointId("endpoint-id").setEndpointHash("endpoint-hash") + .setPublisherId("publisher").setPublisherPeerId("publisher-peer") + .setRegion("local").setKvTtlMs(10_000).setRenewIntervalMs(1_000) + .setNonce(ByteString.copyFromUtf8("nonce")).build())); + channel.writeInbound(frame(PeerRegisterResult.newBuilder().setKvRevision(1).build())); + + ArgumentCaptor offeredFrame = ArgumentCaptor.forClass(Object.class); + verify(stream, timeout(2_500).times(3)).writeAndFlush(offeredFrame.capture()); + PeerRegisterCommit offered = P2PFrameCodec.read( + new ByteBufInputStream((ByteBuf) offeredFrame.getAllValues().get(2)), + PeerRegisterCommit.parser(), P2PFrameCodec.MAX_CONTROL_FRAME_SIZE); + assertEquals("kind-prefixed-v1", offered.getModeOffer().getFraming()); + assertFalse(offered.getRecord().getCapabilitiesList().contains(BedrockPrincipalReadiness.CAPABILITY)); + + long now = System.currentTimeMillis() / 1_000; + ReadinessChallenge readinessChallenge = ReadinessChallenge.newBuilder() + .setRequestId("request").setNonce(ByteString.copyFrom(new byte[16])) + .setEndpointId("endpoint-id").setOrganizationId("organization-id") + .setConnectorInstanceId("instance").setLeaseId("lease") + .setTransport(TunnelTransport.Type.TYPE_LIBP2P).setPolicyRevision(2) + .setIssuedAtUnix(now).setExpiresAtUnix(now + 30).build(); + clearInvocations(stream); + channel.writeInbound(negotiationAndChallenge( + PeerRegisterResult.newBuilder().setKvRevision(2) + .setModeResult(RegistrationModeResult.newBuilder() + .setVersion(2).setAccepted(true)).build(), + readinessChallenge)); + + ArgumentCaptor answerFrame = ArgumentCaptor.forClass(Object.class); + verify(stream, timeout(500)).writeAndFlush(answerFrame.capture()); + P2PFrameCodec.KindPrefixedFrame answer = P2PFrameCodec.readKindPrefixed( + new ByteBufInputStream((ByteBuf) answerFrame.getValue())); + assertEquals(P2PFrameCodec.READINESS_ATTESTATION, answer.kind()); + assertEquals(ReadinessAttestation.Result.RESULT_READY, + answer.parse(ReadinessAttestation.parser()).getResult()); + + client.close(); + } + + private static BedrockPrincipalReadiness readyPrincipalConsumer() throws Exception { + ConnectConfig config = new ConnectConfig(); + set(config.getBedrockPrincipal(), "configGeneration", 2); + set(config.getBedrockPrincipal(), "mode", "require"); + set(config.getBedrockPrincipal(), "issuer", "minekube-connect"); + set(config.getBedrockPrincipal(), "trustDomain", "urn:minekube:connect:production"); + set(config.getBedrockPrincipal(), "audience", "urn:minekube:connect:bedrock-principal:v2"); + set(config.getBedrockPrincipal(), "metadataOrigin", "https://connect.minekube.com"); + set(config.getBedrockPrincipal(), "publicKeys", Map.of( + "kid", "diQm8c6MI-Zwn1nie8hq4wqf3mYLuI96uJBC6NHCTDg")); + return new BedrockPrincipalReadiness(config); + } + + private static void set(Object target, String name, Object value) throws Exception { + Field field = target.getClass().getDeclaredField(name); + field.setAccessible(true); + field.set(target, value); + } + + private static ByteBuf negotiationAndChallenge( + PeerRegisterResult result, ReadinessChallenge challenge) throws Exception { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + P2PFrameCodec.write(out, result); + P2PFrameCodec.writeKindPrefixed(out, P2PFrameCodec.READINESS_CHALLENGE, challenge); + return io.netty.buffer.Unpooled.wrappedBuffer(out.toByteArray()); + } + private static ByteBuf frame(com.google.protobuf.MessageLite message) throws Exception { ByteArrayOutputStream out = new ByteArrayOutputStream(); P2PFrameCodec.write(out, message); diff --git a/core/src/test/java/com/minekube/connect/tunnel/p2p/PeerRegistrationHandshakeTest.java b/core/src/test/java/com/minekube/connect/tunnel/p2p/PeerRegistrationHandshakeTest.java index c35916b6a..68428321d 100644 --- a/core/src/test/java/com/minekube/connect/tunnel/p2p/PeerRegistrationHandshakeTest.java +++ b/core/src/test/java/com/minekube/connect/tunnel/p2p/PeerRegistrationHandshakeTest.java @@ -64,6 +64,7 @@ void buildsInitAndSignedCommitFromChallenge() throws Exception { .setNonce(ByteString.copyFromUtf8("nonce")) .build(); PeerRegisterCommit commit = handshake.commit(challenge, init.getObservedAddrsList(), 7, 1_000); + assertFalse(commit.hasModeOffer()); EndpointPeerRecord record = commit.getRecord(); assertEquals("endpoint", record.getEndpoint()); @@ -86,6 +87,33 @@ void buildsInitAndSignedCommitFromChallenge() throws Exception { assertTrue(publicKey.verify( PeerRecordSigningPayload.bytes(record), commit.getSignature().toByteArray())); + + PeerRegisterCommit offered = handshake.commit( + challenge, init.getObservedAddrsList(), 8, 2_000, true); + assertEquals(2, offered.getModeOffer().getVersion()); + assertEquals("kind-prefixed-v1", offered.getModeOffer().getFraming()); + } + + @Test + void onlyAdvertisesNegotiatedCapabilitiesAfterFraming() throws Exception { + EndpointPeerIdentity identity = EndpointPeerIdentity.loadOrCreate(tempDir.resolve("libp2p-identity.key")); + PeerRegistrationHandshake handshake = new PeerRegistrationHandshake( + identity, "endpoint", "token", "instance", Collections.emptyList(), + OfflineMode.OFFLINE_MODE_ALLOWED, + Arrays.asList("session", "status"), + Arrays.asList("session", "status", "bedrock-verified-principal-v2"), + PeerCapacity.newBuilder().setMaxSessions(100).build()); + PeerRegisterChallenge challenge = PeerRegisterChallenge.newBuilder() + .setEndpointId("endpoint-id").setEndpointHash("endpoint-hash") + .setPublisherId("publisher").setPublisherPeerId("publisher-peer") + .setRegion("local").setKvTtlMs(45_000).setNonce(ByteString.copyFromUtf8("nonce")) + .build(); + + PeerRegisterCommit offered = handshake.commit(challenge, Collections.emptyList(), 1, 1_000, true); + assertFalse(offered.getRecord().getCapabilitiesList().contains("bedrock-verified-principal-v2")); + PeerRegisterCommit negotiated = handshake.commit( + challenge, Collections.emptyList(), 2, 2_000, false, true); + assertTrue(negotiated.getRecord().getCapabilitiesList().contains("bedrock-verified-principal-v2")); } @Test diff --git a/core/src/test/java/com/minekube/connect/watch/AllowAllSessionAdmissionGateTest.java b/core/src/test/java/com/minekube/connect/watch/AllowAllSessionAdmissionGateTest.java new file mode 100644 index 000000000..4bf84cdba --- /dev/null +++ b/core/src/test/java/com/minekube/connect/watch/AllowAllSessionAdmissionGateTest.java @@ -0,0 +1,19 @@ +package com.minekube.connect.watch; + +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; + +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.Test; + +class AllowAllSessionAdmissionGateTest { + @Test + void defaultGateAllowsImmediately() throws Exception { + SessionAdmissionDecision decision = new AllowAllSessionAdmissionGate() + .request(mock(SessionProposal.class)) + .toCompletableFuture() + .get(1, TimeUnit.SECONDS); + + assertTrue(decision.isAllowed()); + } +} diff --git a/core/src/test/java/com/minekube/connect/watch/WatchClientTest.java b/core/src/test/java/com/minekube/connect/watch/WatchClientTest.java index 547ffeb5b..e4ea81a99 100644 --- a/core/src/test/java/com/minekube/connect/watch/WatchClientTest.java +++ b/core/src/test/java/com/minekube/connect/watch/WatchClientTest.java @@ -2,6 +2,7 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; @@ -10,6 +11,7 @@ import com.minekube.connect.bedrock.BedrockAdmissionCoordinator; import com.minekube.connect.bedrock.BedrockIdentityKeyProvider; import com.minekube.connect.bedrock.BedrockIdentityReadiness; +import com.minekube.connect.bedrock.BedrockPrincipalReadiness; import com.minekube.connect.bedrock.VerifiedBedrockIdentityRegistry; import com.minekube.connect.config.ConnectConfig; import java.util.concurrent.atomic.AtomicReference; @@ -20,6 +22,9 @@ import minekube.connect.v1alpha1.WatchServiceOuterClass.Session; import minekube.connect.v1alpha1.WatchServiceOuterClass.SessionProtocol; import minekube.connect.v1alpha1.WatchServiceOuterClass.WatchResponse; +import minekube.connect.v1alpha1.WatchServiceOuterClass.ReadinessChallenge; +import minekube.connect.v1alpha1.WatchServiceOuterClass.TunnelTransport; +import minekube.connect.v1alpha1.WatchServiceOuterClass.WatchRequest; import okhttp3.OkHttpClient; import okhttp3.Request; import okhttp3.WebSocket; @@ -102,4 +107,43 @@ void defaultDisabledConfigurationDoesNotAdvertiseBedrockIdentity() { assertFalse(request.getValue().headers("Connect-Capabilities") .contains("bedrock-identity-v1")); } + + @Test + void readinessChallengeIsAnsweredAndNeverDeliveredAsSession() { + ConnectConfig config = new ConnectConfig(); + OkHttpClient httpClient = mock(OkHttpClient.class); + WatchClient client = new WatchClient(httpClient, config); + Watcher watcher = mock(Watcher.class); + WebSocket socket = mock(WebSocket.class); + + client.watch(watcher); + ArgumentCaptor listener = ArgumentCaptor.forClass(WebSocketListener.class); + verify(httpClient).newWebSocket(any(Request.class), listener.capture()); + ReadinessChallenge challenge = ReadinessChallenge.newBuilder() + .setRequestId("request") + .setNonce(com.google.protobuf.ByteString.copyFrom(new byte[16])) + .setEndpointId("endpoint") + .setOrganizationId("organization") + .setConnectorInstanceId("instance") + .setLeaseId("lease") + .setTransport(TunnelTransport.Type.TYPE_WEBSOCKET) + .setPolicyRevision(1) + .setIssuedAtUnix(1) + .setExpiresAtUnix(31) + .build(); + + listener.getValue().onMessage(socket, ByteString.of(WatchResponse.newBuilder() + .setReadinessChallenge(challenge).build().toByteArray())); + + ArgumentCaptor response = ArgumentCaptor.forClass(ByteString.class); + verify(socket).send(response.capture()); + WatchRequest request; + try { + request = WatchRequest.parseFrom(response.getValue().toByteArray()); + } catch (com.google.protobuf.InvalidProtocolBufferException error) { + throw new AssertionError(error); + } + assertTrue(request.hasReadinessAttestation()); + verify(watcher, org.mockito.Mockito.never()).onProposal(any()); + } } diff --git a/core/src/test/resources/bedrock-principal-v2/UPSTREAM b/core/src/test/resources/bedrock-principal-v2/UPSTREAM new file mode 100644 index 000000000..aea55bfbb --- /dev/null +++ b/core/src/test/resources/bedrock-principal-v2/UPSTREAM @@ -0,0 +1,4 @@ +repository=https://github.com/minekube/connect +commit=7fcd6f40f326c38b16f94bca37f188e18ae4daa7 +core-vectors.json.sha256=4f2a442ee71bfd35af2ef1f3944489d17551aa77fed2c08220f2aa77032b6196 +v2.schema.json.sha256=648677745c5babd03e0e59c1ff5a98cbb0e3a45ab8311632731ee73dd32a807c diff --git a/core/src/test/resources/bedrock-principal-v2/core-vectors.json b/core/src/test/resources/bedrock-principal-v2/core-vectors.json new file mode 100644 index 000000000..58b45e0b5 --- /dev/null +++ b/core/src/test/resources/bedrock-principal-v2/core-vectors.json @@ -0,0 +1,144 @@ +[ + { + "name": "valid-unlinked", + "compact_jws": "eyJhbGciOiJFZERTQSIsInR5cCI6ImNvbm5lY3QtYmVkcm9jay1wcmluY2lwYWwrandzO3Y9MiIsImtpZCI6ImNvbm5lY3QtdjItdGVzdCJ9.eyJhdWRpZW5jZSI6InVybjptaW5la3ViZTpjb25uZWN0OnRlc3Q6YmVkcm9jay1wcmluY2lwYWw6djIiLCJiZWRyb2NrX2Rpc3BsYXlfbmFtZSI6IkJlZHJvY2tPbmUiLCJjYW5vbmljYWxfdW5saW5rZWRfdXVpZCI6IjAwMDAwMDAwLTAwMDAtMDAwMC0wMDAwLTAwMDAwMDAwMDAwMSIsImNhbm9uaWNhbF94dWlkIjoiMSIsImNvbm5lY3Rfc2Vzc2lvbl9pZCI6InNlc3Npb24tdGVzdCIsImNvbm5lY3Rfc2Vzc2lvbl9ub25jZSI6IkFBQUFBQUFBQUFBQUFBQUFBQUFBQUEiLCJlbmRwb2ludF9pZCI6ImVuZHBvaW50LXRlc3QiLCJleHAiOjE3ODU0OTIwMzAsImlhdCI6MTc4NTQ5MjAwMCwiaXNzdWVyIjoibWluZWt1YmUtY29ubmVjdC10ZXN0IiwianRpIjoiQVFFQkFRRUJBUUVCQVFFQkFRRUJBUSIsIm5iZiI6MTc4NTQ5MjAwMCwib3JnYW5pemF0aW9uX2lkIjoib3JnYW5pemF0aW9uLXRlc3QiLCJwb2xpY3lfcmV2aXNpb24iOjcsInNvdXJjZV9wcm90b2NvbCI6ImJlZHJvY2siLCJzb3VyY2VfcHJvdG9jb2xfdmVyc2lvbiI6Nzc2LCJzdWJqZWN0X2tpbmQiOiJiZWRyb2NrX3h1aWQiLCJ0cnVzdF9kb21haW4iOiJ1cm46bWluZWt1YmU6Y29ubmVjdDp0ZXN0OmNvcnB1cy12MiIsInZlcmlmaWNhdGlvbl9tZXRob2QiOiJtaW5lY3JhZnRfbGVnYWN5X2NoYWluK2NsaWVudF9qd3QrZWNkaF92MSIsInZlcnNpb24iOjJ9.QmT-XuIYP3fbeVvsG1HzGKZXSyETGMBr3F8snZH_vssZoijXE2VsUDESRVm9bcS1aXAGQPqlo5nv8ZsYWAq1DQ", + "trusted_context": { + "issuer": "minekube-connect-test", + "trust_domain": "urn:minekube:connect:test:corpus-v2", + "audience": "urn:minekube:connect:test:bedrock-principal:v2", + "endpoint_id": "endpoint-test", + "organization_id": "organization-test", + "connect_session_id": "session-test", + "connect_session_nonce": "AAAAAAAAAAAAAAAAAAAAAA", + "source_protocol": "bedrock", + "source_protocol_version": 776, + "policy_revision": 7 + }, + "verification_time_unix": 1785492000, + "expected_error": "OK", + "expected_principal": { + "subject_kind": "bedrock_xuid", + "canonical_xuid": "1", + "canonical_unlinked_uuid": "00000000-0000-0000-0000-000000000001", + "bedrock_display_name": "BedrockOne", + "effective_uuid": "00000000-0000-0000-0000-000000000001", + "effective_name": "BedrockOne", + "verification_method": "minecraft_legacy_chain+client_jwt+ecdh_v1", + "kid": "connect-v2-test", + "policy_revision": 7 + } + }, + { + "name": "valid-linked", + "compact_jws": "eyJhbGciOiJFZERTQSIsInR5cCI6ImNvbm5lY3QtYmVkcm9jay1wcmluY2lwYWwrandzO3Y9MiIsImtpZCI6ImNvbm5lY3QtdjItdGVzdCJ9.eyJhdWRpZW5jZSI6InVybjptaW5la3ViZTpjb25uZWN0OnRlc3Q6YmVkcm9jay1wcmluY2lwYWw6djIiLCJiZWRyb2NrX2Rpc3BsYXlfbmFtZSI6IkJlZHJvY2tPbmUiLCJjYW5vbmljYWxfdW5saW5rZWRfdXVpZCI6IjAwMDAwMDAwLTAwMDAtMDAwMC0wMDAwLTAwMDAwMDAwMDAwMSIsImNhbm9uaWNhbF94dWlkIjoiMSIsImNvbm5lY3Rfc2Vzc2lvbl9pZCI6InNlc3Npb24tdGVzdCIsImNvbm5lY3Rfc2Vzc2lvbl9ub25jZSI6IkFBQUFBQUFBQUFBQUFBQUFBQUFBQUEiLCJlbmRwb2ludF9pZCI6ImVuZHBvaW50LXRlc3QiLCJleHAiOjE3ODU0OTIwMzAsImlhdCI6MTc4NTQ5MjAwMCwiaXNzdWVyIjoibWluZWt1YmUtY29ubmVjdC10ZXN0IiwianRpIjoiQWdJQ0FnSUNBZ0lDQWdJQ0FnSUNBZyIsImxpbmtlZF9qYXZhIjp7Im5hbWUiOiJKYXZhT25lIiwicHJvdmVuYW5jZSI6eyJwcm92aWRlciI6Im1veHlfYWNjb3VudF9saW5rX3YxIiwicmVjb3JkX2lkIjoicmVjb3JkLXRlc3QtMSIsInJldmlzaW9uIjozLCJ2ZXJpZmllZF9hdCI6MTc4NTQ5MTk5NX0sInV1aWQiOiIxMjNlNDU2Ny1lODliLTEyZDMtYTQ1Ni00MjY2MTQxNzQwMDAifSwibmJmIjoxNzg1NDkyMDAwLCJvcmdhbml6YXRpb25faWQiOiJvcmdhbml6YXRpb24tdGVzdCIsInBvbGljeV9yZXZpc2lvbiI6Nywic291cmNlX3Byb3RvY29sIjoiYmVkcm9jayIsInNvdXJjZV9wcm90b2NvbF92ZXJzaW9uIjo3NzYsInN1YmplY3Rfa2luZCI6ImJlZHJvY2tfbGlua2VkX2phdmEiLCJ0cnVzdF9kb21haW4iOiJ1cm46bWluZWt1YmU6Y29ubmVjdDp0ZXN0OmNvcnB1cy12MiIsInZlcmlmaWNhdGlvbl9tZXRob2QiOiJtaW5lY3JhZnRfZnVsbF9qd2tzK2NsaWVudF9qd3QrZWNkaF92MSIsInZlcnNpb24iOjJ9.S4OQjj4dNz69jsAbCFQu866Bc3YX6i01wMMdzDQw4PGX3MTfNjIxD1XGX3Jp7BQN21RKFJcHDyGlNsVNc5NeCA", + "trusted_context": { + "issuer": "minekube-connect-test", + "trust_domain": "urn:minekube:connect:test:corpus-v2", + "audience": "urn:minekube:connect:test:bedrock-principal:v2", + "endpoint_id": "endpoint-test", + "organization_id": "organization-test", + "connect_session_id": "session-test", + "connect_session_nonce": "AAAAAAAAAAAAAAAAAAAAAA", + "source_protocol": "bedrock", + "source_protocol_version": 776, + "policy_revision": 7 + }, + "verification_time_unix": 1785492000, + "expected_error": "OK", + "expected_principal": { + "subject_kind": "bedrock_linked_java", + "canonical_xuid": "1", + "canonical_unlinked_uuid": "00000000-0000-0000-0000-000000000001", + "bedrock_display_name": "BedrockOne", + "effective_uuid": "123e4567-e89b-12d3-a456-426614174000", + "effective_name": "JavaOne", + "verification_method": "minecraft_full_jwks+client_jwt+ecdh_v1", + "kid": "connect-v2-test", + "policy_revision": 7, + "linked_java": { + "uuid": "123e4567-e89b-12d3-a456-426614174000", + "name": "JavaOne", + "provider": "moxy_account_link_v1", + "record_id": "record-test-1", + "revision": 3, + "verified_at_unix": 1785491995 + } + } + }, + { + "name": "malformed-jti-tail-bits", + "compact_jws": "eyJhbGciOiJFZERTQSIsInR5cCI6ImNvbm5lY3QtYmVkcm9jay1wcmluY2lwYWwrandzO3Y9MiIsImtpZCI6ImNvbm5lY3QtdjItdGVzdCJ9.eyJhdWRpZW5jZSI6InVybjptaW5la3ViZTpjb25uZWN0OnRlc3Q6YmVkcm9jay1wcmluY2lwYWw6djIiLCJiZWRyb2NrX2Rpc3BsYXlfbmFtZSI6IkJlZHJvY2tPbmUiLCJjYW5vbmljYWxfdW5saW5rZWRfdXVpZCI6IjAwMDAwMDAwLTAwMDAtMDAwMC0wMDAwLTAwMDAwMDAwMDAwMSIsImNhbm9uaWNhbF94dWlkIjoiMSIsImNvbm5lY3Rfc2Vzc2lvbl9pZCI6InNlc3Npb24tdGVzdCIsImNvbm5lY3Rfc2Vzc2lvbl9ub25jZSI6IkFBQUFBQUFBQUFBQUFBQUFBQUFBQUEiLCJlbmRwb2ludF9pZCI6ImVuZHBvaW50LXRlc3QiLCJleHAiOjE3ODU0OTIwMzAsImlhdCI6MTc4NTQ5MjAwMCwiaXNzdWVyIjoibWluZWt1YmUtY29ubmVjdC10ZXN0IiwianRpIjoiQUFBQUFBQUFBQUFBQUFBQUFBQUFBQiIsIm5iZiI6MTc4NTQ5MjAwMCwib3JnYW5pemF0aW9uX2lkIjoib3JnYW5pemF0aW9uLXRlc3QiLCJwb2xpY3lfcmV2aXNpb24iOjcsInNvdXJjZV9wcm90b2NvbCI6ImJlZHJvY2siLCJzb3VyY2VfcHJvdG9jb2xfdmVyc2lvbiI6Nzc2LCJzdWJqZWN0X2tpbmQiOiJiZWRyb2NrX3h1aWQiLCJ0cnVzdF9kb21haW4iOiJ1cm46bWluZWt1YmU6Y29ubmVjdDp0ZXN0OmNvcnB1cy12MiIsInZlcmlmaWNhdGlvbl9tZXRob2QiOiJtaW5lY3JhZnRfbGVnYWN5X2NoYWluK2NsaWVudF9qd3QrZWNkaF92MSIsInZlcnNpb24iOjJ9.ixTwonoVJy6lhy3OOyyvQ3SOBhZRziaCNsIiOQtJjLTLt6sepLVE_d0urHww2aorNhHxa427FNelaIb9YTABDQ", + "trusted_context": { + "issuer": "minekube-connect-test", + "trust_domain": "urn:minekube:connect:test:corpus-v2", + "audience": "urn:minekube:connect:test:bedrock-principal:v2", + "endpoint_id": "endpoint-test", + "organization_id": "organization-test", + "connect_session_id": "session-test", + "connect_session_nonce": "AAAAAAAAAAAAAAAAAAAAAA", + "source_protocol": "bedrock", + "source_protocol_version": 776, + "policy_revision": 7 + }, + "verification_time_unix": 1785492000, + "expected_error": "MALFORMED", + "expected_principal": null + }, + { + "name": "policy-revision-mismatch", + "compact_jws": "eyJhbGciOiJFZERTQSIsInR5cCI6ImNvbm5lY3QtYmVkcm9jay1wcmluY2lwYWwrandzO3Y9MiIsImtpZCI6ImNvbm5lY3QtdjItdGVzdCJ9.eyJhdWRpZW5jZSI6InVybjptaW5la3ViZTpjb25uZWN0OnRlc3Q6YmVkcm9jay1wcmluY2lwYWw6djIiLCJiZWRyb2NrX2Rpc3BsYXlfbmFtZSI6IkJlZHJvY2tPbmUiLCJjYW5vbmljYWxfdW5saW5rZWRfdXVpZCI6IjAwMDAwMDAwLTAwMDAtMDAwMC0wMDAwLTAwMDAwMDAwMDAwMSIsImNhbm9uaWNhbF94dWlkIjoiMSIsImNvbm5lY3Rfc2Vzc2lvbl9pZCI6InNlc3Npb24tdGVzdCIsImNvbm5lY3Rfc2Vzc2lvbl9ub25jZSI6IkFBQUFBQUFBQUFBQUFBQUFBQUFBQUEiLCJlbmRwb2ludF9pZCI6ImVuZHBvaW50LXRlc3QiLCJleHAiOjE3ODU0OTIwMzAsImlhdCI6MTc4NTQ5MjAwMCwiaXNzdWVyIjoibWluZWt1YmUtY29ubmVjdC10ZXN0IiwianRpIjoiQkFRRUJBUUVCQVFFQkFRRUJBUUVCQSIsIm5iZiI6MTc4NTQ5MjAwMCwib3JnYW5pemF0aW9uX2lkIjoib3JnYW5pemF0aW9uLXRlc3QiLCJwb2xpY3lfcmV2aXNpb24iOjcsInNvdXJjZV9wcm90b2NvbCI6ImJlZHJvY2siLCJzb3VyY2VfcHJvdG9jb2xfdmVyc2lvbiI6Nzc2LCJzdWJqZWN0X2tpbmQiOiJiZWRyb2NrX3h1aWQiLCJ0cnVzdF9kb21haW4iOiJ1cm46bWluZWt1YmU6Y29ubmVjdDp0ZXN0OmNvcnB1cy12MiIsInZlcmlmaWNhdGlvbl9tZXRob2QiOiJtaW5lY3JhZnRfbGVnYWN5X2NoYWluK2NsaWVudF9qd3QrZWNkaF92MSIsInZlcnNpb24iOjJ9.3CBE3q1423w7COZr3nOmPPG6p8Q5R-UoKT_5ge4mHfWcGbLWL740phPKItjcul6jQg6wfZVeKDaSdGv0VNGvDA", + "trusted_context": { + "issuer": "minekube-connect-test", + "trust_domain": "urn:minekube:connect:test:corpus-v2", + "audience": "urn:minekube:connect:test:bedrock-principal:v2", + "endpoint_id": "endpoint-test", + "organization_id": "organization-test", + "connect_session_id": "session-test", + "connect_session_nonce": "AAAAAAAAAAAAAAAAAAAAAA", + "source_protocol": "bedrock", + "source_protocol_version": 776, + "policy_revision": 8 + }, + "verification_time_unix": 1785492000, + "expected_error": "BINDING_MISMATCH", + "expected_principal": null + }, + { + "name": "lifetime-thirty-one", + "compact_jws": "eyJhbGciOiJFZERTQSIsInR5cCI6ImNvbm5lY3QtYmVkcm9jay1wcmluY2lwYWwrandzO3Y9MiIsImtpZCI6ImNvbm5lY3QtdjItdGVzdCJ9.eyJhdWRpZW5jZSI6InVybjptaW5la3ViZTpjb25uZWN0OnRlc3Q6YmVkcm9jay1wcmluY2lwYWw6djIiLCJiZWRyb2NrX2Rpc3BsYXlfbmFtZSI6IkJlZHJvY2tPbmUiLCJjYW5vbmljYWxfdW5saW5rZWRfdXVpZCI6IjAwMDAwMDAwLTAwMDAtMDAwMC0wMDAwLTAwMDAwMDAwMDAwMSIsImNhbm9uaWNhbF94dWlkIjoiMSIsImNvbm5lY3Rfc2Vzc2lvbl9pZCI6InNlc3Npb24tdGVzdCIsImNvbm5lY3Rfc2Vzc2lvbl9ub25jZSI6IkFBQUFBQUFBQUFBQUFBQUFBQUFBQUEiLCJlbmRwb2ludF9pZCI6ImVuZHBvaW50LXRlc3QiLCJleHAiOjE3ODU0OTIwMzEsImlhdCI6MTc4NTQ5MjAwMCwiaXNzdWVyIjoibWluZWt1YmUtY29ubmVjdC10ZXN0IiwianRpIjoiQlFVRkJRVUZCUVVGQlFVRkJRVUZCUSIsIm5iZiI6MTc4NTQ5MjAwMCwib3JnYW5pemF0aW9uX2lkIjoib3JnYW5pemF0aW9uLXRlc3QiLCJwb2xpY3lfcmV2aXNpb24iOjcsInNvdXJjZV9wcm90b2NvbCI6ImJlZHJvY2siLCJzb3VyY2VfcHJvdG9jb2xfdmVyc2lvbiI6Nzc2LCJzdWJqZWN0X2tpbmQiOiJiZWRyb2NrX3h1aWQiLCJ0cnVzdF9kb21haW4iOiJ1cm46bWluZWt1YmU6Y29ubmVjdDp0ZXN0OmNvcnB1cy12MiIsInZlcmlmaWNhdGlvbl9tZXRob2QiOiJtaW5lY3JhZnRfbGVnYWN5X2NoYWluK2NsaWVudF9qd3QrZWNkaF92MSIsInZlcnNpb24iOjJ9.neB1VaIdiZm1Nov9WsAAMW4R0AuC8vYOV7O3TyLK-dH262xWy6dsiv33gKq3JfK8aiEtBlotfey4IRLH6cM0Cw", + "trusted_context": { + "issuer": "minekube-connect-test", + "trust_domain": "urn:minekube:connect:test:corpus-v2", + "audience": "urn:minekube:connect:test:bedrock-principal:v2", + "endpoint_id": "endpoint-test", + "organization_id": "organization-test", + "connect_session_id": "session-test", + "connect_session_nonce": "AAAAAAAAAAAAAAAAAAAAAA", + "source_protocol": "bedrock", + "source_protocol_version": 776, + "policy_revision": 7 + }, + "verification_time_unix": 1785492000, + "expected_error": "TIME", + "expected_principal": null + }, + { + "name": "tampered-signature", + "compact_jws": "eyJhbGciOiJFZERTQSIsInR5cCI6ImNvbm5lY3QtYmVkcm9jay1wcmluY2lwYWwrandzO3Y9MiIsImtpZCI6ImNvbm5lY3QtdjItdGVzdCJ9.eyJhdWRpZW5jZSI6InVybjptaW5la3ViZTpjb25uZWN0OnRlc3Q6YmVkcm9jay1wcmluY2lwYWw6djIiLCJiZWRyb2NrX2Rpc3BsYXlfbmFtZSI6IkJlZHJvY2tPbmUiLCJjYW5vbmljYWxfdW5saW5rZWRfdXVpZCI6IjAwMDAwMDAwLTAwMDAtMDAwMC0wMDAwLTAwMDAwMDAwMDAwMSIsImNhbm9uaWNhbF94dWlkIjoiMSIsImNvbm5lY3Rfc2Vzc2lvbl9pZCI6InNlc3Npb24tdGVzdCIsImNvbm5lY3Rfc2Vzc2lvbl9ub25jZSI6IkFBQUFBQUFBQUFBQUFBQUFBQUFBQUEiLCJlbmRwb2ludF9pZCI6ImVuZHBvaW50LXRlc3QiLCJleHAiOjE3ODU0OTIwMzAsImlhdCI6MTc4NTQ5MjAwMCwiaXNzdWVyIjoibWluZWt1YmUtY29ubmVjdC10ZXN0IiwianRpIjoiQmdZR0JnWUdCZ1lHQmdZR0JnWUdCZyIsIm5iZiI6MTc4NTQ5MjAwMCwib3JnYW5pemF0aW9uX2lkIjoib3JnYW5pemF0aW9uLXRlc3QiLCJwb2xpY3lfcmV2aXNpb24iOjcsInNvdXJjZV9wcm90b2NvbCI6ImJlZHJvY2siLCJzb3VyY2VfcHJvdG9jb2xfdmVyc2lvbiI6Nzc2LCJzdWJqZWN0X2tpbmQiOiJiZWRyb2NrX3h1aWQiLCJ0cnVzdF9kb21haW4iOiJ1cm46bWluZWt1YmU6Y29ubmVjdDp0ZXN0OmNvcnB1cy12MiIsInZlcmlmaWNhdGlvbl9tZXRob2QiOiJtaW5lY3JhZnRfbGVnYWN5X2NoYWluK2NsaWVudF9qd3QrZWNkaF92MSIsInZlcnNpb24iOjJ9.B1gHheFVnd1Wa4-6OpCOdi0xLO-N2qdm_jKuPKXoxHqJI701x82b9G0rnoo2SYWuLkayE9vvwk7V9K2CNgnKDA", + "trusted_context": { + "issuer": "minekube-connect-test", + "trust_domain": "urn:minekube:connect:test:corpus-v2", + "audience": "urn:minekube:connect:test:bedrock-principal:v2", + "endpoint_id": "endpoint-test", + "organization_id": "organization-test", + "connect_session_id": "session-test", + "connect_session_nonce": "AAAAAAAAAAAAAAAAAAAAAA", + "source_protocol": "bedrock", + "source_protocol_version": 776, + "policy_revision": 7 + }, + "verification_time_unix": 1785492000, + "expected_error": "SIGNATURE", + "expected_principal": null + } +] diff --git a/core/src/test/resources/bedrock-principal-v2/v2.schema.json b/core/src/test/resources/bedrock-principal-v2/v2.schema.json new file mode 100644 index 000000000..b521c5e17 --- /dev/null +++ b/core/src/test/resources/bedrock-principal-v2/v2.schema.json @@ -0,0 +1,53 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://connect.minekube.com/schema/bedrock-principal/v2", + "type": "object", + "additionalProperties": false, + "required": ["version", "issuer", "trust_domain", "audience", "subject_kind", "canonical_xuid", "canonical_unlinked_uuid", "bedrock_display_name", "endpoint_id", "organization_id", "connect_session_id", "connect_session_nonce", "policy_revision", "source_protocol", "source_protocol_version", "iat", "nbf", "exp", "jti", "verification_method"], + "properties": { + "version": {"const": 2}, + "issuer": {"type": "string", "minLength": 1, "maxLength": 128}, + "trust_domain": {"type": "string", "minLength": 1, "maxLength": 256}, + "audience": {"type": "string", "minLength": 1, "maxLength": 256}, + "subject_kind": {"enum": ["bedrock_xuid", "bedrock_linked_java"]}, + "canonical_xuid": {"type": "string", "pattern": "^[1-9][0-9]{0,18}$", "maxLength": 19}, + "canonical_unlinked_uuid": {"type": "string", "pattern": "^00000000-0000-0000-[0-9a-f]{4}-[0-9a-f]{12}$", "maxLength": 36}, + "linked_java": { + "type": "object", + "additionalProperties": false, + "required": ["uuid", "name", "provenance"], + "properties": { + "uuid": {"type": "string", "format": "uuid", "maxLength": 36}, + "name": {"type": "string", "pattern": "^[A-Za-z0-9_]{1,16}$", "minLength": 1, "maxLength": 16}, + "provenance": { + "type": "object", + "additionalProperties": false, + "required": ["provider", "record_id", "revision", "verified_at"], + "properties": { + "provider": {"const": "moxy_account_link_v1"}, + "record_id": {"type": "string", "minLength": 1, "maxLength": 128}, + "revision": {"type": "integer", "minimum": 1, "maximum": 9223372036854775807}, + "verified_at": {"type": "integer", "minimum": 0, "maximum": 253402300799} + } + } + } + }, + "bedrock_display_name": {"type": "string", "minLength": 1, "maxLength": 64}, + "endpoint_id": {"type": "string", "minLength": 1, "maxLength": 128}, + "organization_id": {"type": "string", "minLength": 1, "maxLength": 128}, + "connect_session_id": {"type": "string", "minLength": 1, "maxLength": 128}, + "connect_session_nonce": {"type": "string", "pattern": "^[A-Za-z0-9_-]{22}$", "minLength": 22, "maxLength": 22}, + "policy_revision": {"type": "integer", "minimum": 1, "maximum": 9223372036854775807}, + "source_protocol": {"const": "bedrock"}, + "source_protocol_version": {"type": "integer", "minimum": 1, "maximum": 2147483647}, + "iat": {"type": "integer", "minimum": 0, "maximum": 253402300799}, + "nbf": {"type": "integer", "minimum": 0, "maximum": 253402300799}, + "exp": {"type": "integer", "minimum": 0, "maximum": 253402300799}, + "jti": {"type": "string", "pattern": "^[A-Za-z0-9_-]{22}$", "minLength": 22, "maxLength": 22}, + "verification_method": {"enum": ["minecraft_legacy_chain+client_jwt+ecdh_v1", "minecraft_full_jwks+client_jwt+ecdh_v1"]} + }, + "allOf": [ + {"if": {"properties": {"subject_kind": {"const": "bedrock_xuid"}}}, "then": {"not": {"required": ["linked_java"]}}}, + {"if": {"properties": {"subject_kind": {"const": "bedrock_linked_java"}}}, "then": {"required": ["linked_java"]}} + ] +} diff --git a/docs/bedrock-identity.md b/docs/bedrock-identity.md index 62ca3690f..61da64caa 100644 --- a/docs/bedrock-identity.md +++ b/docs/bedrock-identity.md @@ -1,19 +1,63 @@ # Bedrock identity defaults +Connect Java supports two additive identity paths. Existing files without a +`bedrock-principal` section retain the original `bedrock-identity` v1 behavior and +`enforcement: warn` unchanged. Merely upgrading the plugin does not rewrite such a file or +advertise generation-2 capability. + +## Signed principal v2 + +New generated configuration includes `bedrock-principal.config-generation: 2` and `mode: require`, +with an empty `public-keys` map until host integration supplies usable pins. The v2 consumer accepts +the compact signed principal only from authenticated Watch `Session` or libp2p `SessionOffer` field +12 (`signed_bedrock_principal_v2`); a game-profile property with the reserved v2 name is rejected. +It verifies the closed schema, Ed25519 signature, proposal bindings, time bounds, identity/link +invariants, and an atomic one-use replay key before applying the verifier-selected effective profile. +A linked Java profile always takes precedence over the derived Bedrock profile. + +The connector advertises `bedrock-verified-principal-v2` only when generation 2 is in `require` +mode and its trust configuration and static Ed25519 pins are usable. Watch readiness challenges +are answered on their authenticated lease. Libp2p first negotiates `kind-prefixed-v1` framing on +a successful legacy renewal, then answers challenges on that same registration stream. Losing +configuration, keys, framing, or the stream fails closed and suppresses operational readiness. + +Static v2 pins use canonical unpadded base64url and contain the raw 32-byte Ed25519 public key: + +```yaml +bedrock-principal: + config-generation: 2 + mode: require + issuer: minekube-connect + trust-domain: urn:minekube:connect:production + audience: urn:minekube:connect:bedrock-principal:v2 + metadata-origin: https://connect.minekube.com + metadata-path: /.well-known/minekube-connect/bedrock-principal-v2.json + public-keys: + "": "" +``` + +Verification failures expose only the bounded `PrincipalError` category. Compact envelopes, +nonces, replay IDs, XUIDs, and link material are excluded from exception messages, stack traces, +principal `toString()` output, and sanitized session proposals. + +## Legacy v1 + Connect Edge authenticates Bedrock players with Microsoft/Xbox and signs a short-lived, -endpoint-scoped identity before forwarding the session. A newly installed Connect Java plugin -trusts that Minekube-signed identity without extra operator configuration: it validates the -metadata URL syntax at registration and advertises `bedrock-identity-v1`. When a Bedrock session +endpoint-scoped identity before forwarding the session. For legacy v1, a newly installed Connect +Java plugin trusts that Minekube-signed identity without extra operator configuration: it validates +the metadata URL syntax at registration and advertises `bedrock-identity-v1`. When a Bedrock session arrives, it fetches the current Ed25519 verifier key over HTTPS from Minekube's authoritative metadata endpoint and verifies the metadata lazily for that session. -This default is appropriate because the Minekube Connect plugin is receiving sessions from the -Minekube Connect edge. The metadata response contains public verifier keys only. The connector -requires HTTPS, rejects URLs containing userinfo or fragments, refuses redirects, and checks that +The legacy v1 `enforcement: warn` default is appropriate because the Minekube Connect plugin is +receiving sessions from the Minekube Connect edge. The metadata response contains public verifier +keys only. The connector requires HTTPS, rejects URLs containing userinfo or fragments, refuses +redirects, and checks that the metadata issuer is exactly `minekube-connect`. -The default enforcement mode is `warn`. It verifies Bedrock identities and logs failures, but it -never rejects a session. Java sessions without the reserved Bedrock identity property return +The default legacy v1 enforcement mode is `warn`. It verifies Bedrock identities and logs +failures, but it never rejects a session. Java sessions without the reserved Bedrock identity +property return through the Java path without fetching identity keys, logging identity warnings, or changing the admission decision. Operators can move to `require` only after confirming their Bedrock traffic verifies successfully. diff --git a/docs/connect-share-adoption-evidence.md b/docs/connect-share-adoption-evidence.md new file mode 100644 index 000000000..1d065657f --- /dev/null +++ b/docs/connect-share-adoption-evidence.md @@ -0,0 +1,265 @@ +# Connect Share Adoption Evidence + +This document tracks acceptance evidence for the first universal-party slice of +[epic #93](https://github.com/minekube/connect-java/issues/93) in +[PR #94](https://github.com/minekube/connect-java/pull/94). It intentionally +distinguishes deterministic proof from product proof on an exact packaged +artifact. No endpoint token, invitation capability, private key, address, raw +peer ID, or account ID belongs in this document. + +Status meanings: + +- **Deterministic proof**: the criterion is implemented and covered by a + focused automated test, but any rendered or real-network claim still needs + exact-head product evidence. +- **Product proof required**: useful implementation and automated coverage + exist, but the acceptance claim depends on a packaged-client or real-network + observation that has not yet been recorded for the current commit. +- **Product proof**: a current packaged artifact has passed the relevant real + client/network evidence gate in addition to deterministic coverage. +- **Gap**: code or focused coverage is incomplete. The issue must remain open. + +## Evidence baseline + +- Original acceptance-audit commit: + `6073f2f6101d86d38c71e517148725fd2c089c82`. +- Latest source head with recorded packaged product probes: + `bd72ea0090a1f4e047ce208d2b73d8fe52b76efd`. +- Deterministic friend/safety command: the focused `:share:common:test` and + `:share:fabric-common:test` selectors listed in the adoption-foundation plan. + Result on 2026-08-02: `BUILD SUCCESSFUL`. +- Packaged adapter command: all `*ArtifactTest*` selectors for Fabric 1.20.1, + 1.21.1, 1.21.11, and 26.2; Forge 1.20.1; and NeoForge 1.21.1. Result on + 2026-08-02: 32 tests, zero skipped, zero failures, and zero errors. +- Gap-fix red/green command: focused `FriendControlWireTest`, + `LoadedCompatibilityProfileFactoryTest`, `ShareScreenPresentationTest`, and + `ShareUiMessageTest`. The red run failed on the absent wire fingerprint, + remote fallback messages, and cancellation presentation; the green run + passed. All four Fabric artifact suites then passed in 1 minute. +- Exact-head direct friend run on 2026-08-02: Fabric 26.2 build, host, and guest + all used SHA-256 + `c2fbd8708247ee9947cd1404bc39c59d460bc436a08baa8c38d08ff5667076c0`. + `PrismFriendJoinE2ETest` passed in 51 seconds with fresh host `Bob joined the + game` and guest `Loaded 2 advancements` evidence. Ask Every Time was restored + and the host was restarted afterward. +- No-mod product probe after `73f306ff`: the rebuilt host/guest artifact hash is + `2c9e413d332475eba1d1540120c671db9b9450ebf36218378a7d74b908a0b4b1`. + A guest with Connect Share removed launched ordinary Direct Connect, and the + public endpoint resolved and accepted TCP. Both offline and authenticated + guests remained at Connecting, while the host showed an active Connect watch + socket, `PersistentConnectState.Available`, and `ShareState.Sharing`, but no + `PendingAdmission` was created. The Connect edge therefore did not deliver a + `SessionProposal`; successful vanilla admission and guest-visible denial + remain external product evidence, not a local completion claim. The guest mod + was restored with the matching hash. +- Exact-head vanilla product run on 2026-08-03: source head `bd72ea00`, host + artifact SHA-256 + `27353ba903e93d785204c8163bbfcece09b7b8d503c6809e228bdecfe2a5460b`, + and a Fabric 26.2 Bob client with zero active Connect Share JARs. Bob launched + ordinary Minecraft Direct Connect against the host's public Connect address. + A temporary uncommitted local driver waited for exactly one real pending + admission and invoked the installed `ShareViewModel`'s normal allow action; + it carried no identity/request data, added no product bypass, and was removed + after the run. Alice recorded `Bob joined the game` and Bob recorded a fresh + advancement load with no Connect Share load or connection-failure marker. + The exact pre-test `ASK_EVERY_TIME` file was restored byte-for-byte, the guest + mod was restored at the same artifact digest, and both fresh runtimes were + verified afterward. +- Production vanilla-denial run on 2026-08-03: Moxy PR #512 was merged and its + candidate completed the guarded production workflow, including disposable + Fly E2E, the complete regional rollout, public Java/Bedrock probes, and the + production Craftless join smoke (`minekube/moxy` workflow + `30844815477`). An ordinary Fabric 26.2 guest with zero active Connect Share + JARs then reached the same release-candidate host. With no approval action, + the guest received the safe host-approval timeout in about 22 seconds; it did + not enter the world, fall back to Browser Hub, or report generic `Timed out`. + The guest JAR was restored at its original digest and loaded on a fresh + runtime. Moxy PR #517 subsequently made the rollout verifier select the last + surviving candidate per region while retaining exact-image, health, and + cross-region uniqueness checks. +- Encrypted-recovery deterministic gate on 2026-08-03: complete + `:share:common:check` and `:share:fabric-common:check` plus all four Fabric + adapter test tasks passed in 1 minute 31 seconds. Rebuilt exact artifacts + each contained 31 recovery classes/entrypoints, one English stop-sharing + safety key, and remained under the 90 MiB artifact gate (approximately + 64.9–65.5 MB). JSON parsing passed for every English and German language + file. No backup content, path, password, identity, capability, or token was + emitted during verification. +- Distribution artifact gate on 2026-08-03: all six supported adapter test + tasks and their tightened 63 MiB size gates passed in 1 minute 10 seconds. + The exact artifacts were 61,823,460–62,575,797 bytes. Fabric 26.2 additionally + started two isolated peers from the final packaged JAR and inspected a + published world. Generic Shadow minimization was rejected after red tests + exposed missing reflective libp2p dependencies; the retained optimization + removes only unused Bouncy Castle post-quantum families and keeps all Kotlin, + networking, conventional cryptography, and cross-platform native support. +- Clean-head direct friend product run on 2026-08-03: source head + `81ac77b0244db0e6b29abc97559f641f2e935710`, clean Fabric 26.2 artifact, + host installation, and guest installation all used SHA-256 + `856a7d6694a562cb4e9e45a9db95d610a9783d4002948c2e6b3fbf23c7a821c9`. + `PrismFriendJoinE2ETest` passed in 40 seconds with fresh host join and guest + advancement evidence after discovery, authenticated activity, and approval. + The test-only automatic admission was removed, the host was restarted, and + `ASK_EVERY_TIME` was verified afterward. +- Final six-adapter release-candidate matrix on 2026-08-03: source commit + `4663efe17631088e831e861d523c463906015b82` passed a clean 133-task build + for Fabric 1.20.1, 1.21.1, 1.21.11, and 26.2; Forge 1.20.1; and NeoForge + 1.21.1. Each exact clean artifact was installed into an isolated host and + guest Prism profile and passed discovery, authenticated friend activity, + one-shot approval, a real host join, and a fresh guest advancement load on + macOS arm64. The tested clean SHA-256 values were + `ebdc93da66bae111c7b58b07317418af4fe46437eccb1a604d47951000e1a838`, + `2b0c22fb2c99b0ff9d2929fd14b2e2c09a62dd49c6e26f1c1f167291cf8e6707`, + `857b2cd0c1fd270b0b0f8a78b3eeb22b0c41ff7b47bd94f1d03bca1f8f975fff`, + `433e58c9190d691ecd4fd3a75079876c941c192808ffc9c132ac9234f30a1cf6`, + `78fd30f3ba1a0d976354831742a0808d65f730b2316fbd2058ac52bfc67a46c3`, + and `08ba9e3e9f92d3a41a94306a0e5fac820e2b96fb01068080f465706fdcfe1e43` + in the adapter order above. + Every pair had zero muxer reference-count failures and zero loader packet + rejections. Forge additionally proved a native modded-server handshake after + red/green coverage for loader-thread affinity and native login negotiation. + Every temporary `AUTO_ACCEPT` permission was restored to `ASK_EVERY_TIME`, + and all twelve Minecraft processes were stopped by exact instance cwd. + +## #95 - one-click presence, request, approval, and join + +| Acceptance criterion | Status | Evidence | Remaining proof | +|---|---|---|---| +| Confirmed friends see online, playing, and joinable state on the title screen and in-game | Product proof required | `FriendPresenceMonitorTest` (`refresh projects online state without exposing saved routes`), `FriendsViewModelTest` (`shared singleplayer world exposes request to join when ready`), and `ShareScreenPresentationTest` (`joinable world is the strongest friend state`) | Record both title-screen and in-game rendering from two exact-head clients | +| Pending relationships receive no presence | Deterministic proof | `FriendsViewModelTest` (`outgoing request never exposes presence as a friend`) and `FriendStore.all()` filtering for `CONFIRMED` | None beyond the full regression gate | +| Request to join is one click and never blocks rendering | Product proof required | `FriendJoinOrchestrator`, off-thread coverage in `FriendPresenceMonitorTest` and `ShareViewModelTest`, plus packaged adapter contracts | Record one-click interaction and render responsiveness on an exact packaged client | +| Host receives an actionable notification anywhere in-game | Product proof required | `NewAdmissionTrackerTest` (`only newly pending requests produce notifications`), `SocialEventTrackerTest`, and adapter toast integration | Observe from menu and active gameplay on the packaged client | +| Accepting creates a one-shot admission and connects the guest automatically | Product proof | deterministic one-shot coverage plus the exact-head Prism run's fresh host join and guest advancements evidence | Repeat on the final release candidate | +| Direct libp2p or Connect fallback is selected silently | Product proof required | `TransportSelectorTest` (`failed direct attempts fall back to Connect exactly once`) and `FabricShareBrowserTest` route tests | Record one direct join and one forced fallback without transport-facing UX | +| Re-entering or switching worlds requires no new link | Product proof required | `SharePreferencesStoreTest` (`share with friends remains enabled across restarts until disabled`), `ShareViewModelTest` (`enabled friend sharing resumes automatically in a new world`), and `EndpointIdentityStoreTest` (`one generated identity survives reload and world changes`) | Switch worlds and rejoin using the same confirmed relationship on exact-head clients | +| Every failure gives an understandable next action | Product proof required | typed safe messages in `FriendJoinAttemptFailure`, `ShareUiMessageTest`, and `ShareJoinDiagnosticsTest` | Exercise unavailable, denied, timed-out, incompatible, and transport-failed screens | + +## #96 - detect modpack mismatch before joining + +| Acceptance criterion | Status | Evidence | Remaining proof | +|---|---|---|---| +| Exchange a privacy-safe compatibility fingerprint before admission | Deterministic proof | `FriendControlWireTest` (`compatibility fingerprint is carried and validated on the wire`) rejects a tampered fingerprint; `FriendJoinOrchestratorTest` proves compatibility runs before approval | None beyond the full regression gate | +| Distinguish Minecraft, loader, missing-mod, and mod-version mismatch | Deterministic proof | `CompatibilityProfileTest` (`minecraft loader missing mod and version differences are distinct`) | None beyond the full regression gate | +| Never report a modpack mismatch as direct or Connect failure | Deterministic proof | `FriendJoinOrchestrator` returns `FriendJoinAttemptFailure.Compatibility` before approval; covered by both mismatch tests in `FriendJoinOrchestratorTest` | None beyond the full regression gate | +| Show a concise list of blocking differences | Product proof required | semantic rows in `ShareScreenPresentation.compatibilityLines`; `ShareScreenPresentationTest` (`compatibility details use localizable semantic lines`) | Inspect the exact packaged compatibility screen | +| Copy or link matching Modrinth or CurseForge pack metadata | Product proof required | `LoadedCompatibilityProfileFactoryTest` covers Modrinth, CurseForge, and rejection of HTTP, credential-bearing, and file URLs; all Fabric mismatch screens copy the safe pack URL | Prove the rendered copy action on an exact packaged client | +| Advanced override supports compatible client-only differences | Product proof required | client-only mods are omitted in `LoadedCompatibilityProfileFactoryTest`; required-mod mismatch uses explicit `allowModMismatch` in `FriendJoinOrchestratorTest` | Exercise the exact packaged Try Anyway flow | +| Never upload a complete mod inventory without explicit consent | Deterministic proof | `LoadedCompatibilityProfileFactoryTest` proves only universal/server gameplay mods enter the peer-to-peer profile; `docs/connect-share.md` states the exchange is not uploaded | None beyond the full regression gate | + +## #97 - broad versions, loaders, and one-click distribution + +| Acceptance criterion | Status | Evidence | Remaining proof | +|---|---|---|---| +| Maintain latest plus the 1.21.1 and 1.20.1 modpack anchors | Deterministic proof | Fabric adapters cover 26.2, 1.21.11, 1.21.1, and 1.20.1; the six-adapter gate passed from the current head | Define and operate the measured latest-version release target after the first public release | +| Provide Fabric, Forge, and NeoForge adapters | Product proof | Fabric 1.20.1/1.21.1/1.21.11/26.2, Forge 1.20.1, and NeoForge 1.21.1 all passed clean packaged builds and exact-artifact two-client joins on macOS arm64 | Extend the same matrix to supported Windows/Linux and x86_64 before claiming broad platform proof | +| Publish verified artifacts on Modrinth, CurseForge, and GitHub Releases | Gap | Artifacts have unambiguous loader/version archive names; `.github/workflows/connect-share-release.yml` fails closed, publishes the six artifacts, creates checksums and GitHub/Sigstore provenance, and verifies release assets/attestations | Marketplace projects, credentials, public metadata, a disposable prerelease proof, and final publication are external release operations and have not occurred from this unmerged PR | +| Modrinth App and Prism install dependencies automatically | Product proof required | Fabric metadata declares Fabric API and Fabric Language Kotlin dependencies; Forge/NeoForge declare Kotlin for Forge as a required install dependency and keep Kotlin out of the shaded artifact | Prove fresh one-click installs through published Modrinth metadata and Prism on all supported loader families | +| Permit modpack inclusion and document dependencies/compatibility | Deterministic proof | `docs/connect-share.md` explicitly permits public/private modpack inclusion under MIT, names every loader/version artifact, and documents automatic and manual Kotlin/loader dependencies | Marketplace copy must reproduce the same contract before publication | +| CI builds every adapter and proves packaged startup | Deterministic proof | CI adapter tasks exist; all six adapter suites passed locally. Fabric 26.2's exact packaged JAR now starts two isolated libp2p peers and inspects a published world | Extend exact packaged peer startup to the release matrix and retain real Minecraft startup/join gates | +| Track and safely reduce artifact size | Deterministic proof | Every adapter now has a 63 MiB build gate; current exact artifacts are 61,823,460–62,575,797 bytes. The shared payload removes only unused Bouncy Castle PQC families, and a real packaged-peer test guards reflective runtime behavior | Continue measuring published download size; do not use generic static minimization on jvm-libp2p | + +## #98 - reliable joining and actionable recovery + +| Acceptance criterion | Status | Evidence | Remaining proof | +|---|---|---|---| +| Direct first and exactly one automatic Connect fallback | Product proof | `TransportSelectorTest` covers the ordering and exactly-once contract; `PrismFriendJoinE2ETest` then authenticated a confirmed friend, closed the live guest direct node after approval while retaining the discovered LAN route, asserted the Connect target, and completed a fresh Fabric 26.2 host/guest login | Repeat on the final release artifact and remaining loader clients | +| UI/control work never blocks rendering | Deterministic proof | `FriendPresenceMonitorTest`, `FriendsViewModelTest`, `ShareViewModelTest`, and `RecoveryViewModelTest` use injected IO dispatchers and test off-thread work/cancellation | Profile the final packaged UI during representative slow/unreachable paths on each supported runtime | +| Requests, cancellation, removal, shutdown, and retry are bounded | Deterministic proof | `FriendRequestClientTest` proves prompt cancellation and acknowledged removal; `AdmissionControllerTest` proves expiry/cancellation/capacity; `ShareCoordinatorTest` proves idempotent exhaustive shutdown; direct/control/login deadlines are explicit | Real suspend/resume and process/network-loss product evidence across OSes | +| Reconnect after IP, LAN, or world changes needs no relinking | Deterministic proof | `FriendStoreTest` persists signed candidates/consent; discovery retains per-peer refreshed routes; `ShareCoordinatorTest` and identity-store tests preserve identity through world replacement | Two-machine IP/LAN/VPN change and world-switch evidence on exact release artifacts | +| Concise stage, actionable failure, and secret-safe diagnostics | Product proof required | `ShareUiMessageTest`, `ShareJoinDiagnosticsTest`, `SecretRedactionTest`, and the plain-language stage/failure models reject transport jargon and secret values | Exercise unavailable, denied, timeout, incompatible, fallback-failed, and resumed states in packaged clients | +| Automated two-client direct, fallback, offline, online, and network-change cases | Partial product proof | Real libp2p direct, forced Connect fallback, and vanilla no-mod Connect joins pass on Fabric 26.2; deterministic selector/auth/network refresh cases pass | Paid online-auth, two-machine network-change, and remaining loader automation still require the external matrix | +| Real-client startup/join gate for every supported release target | Product proof | All six clean release-candidate artifacts passed exact-JAR host/guest startup, authenticated approval, real login, and gameplay load on macOS arm64; Forge also proved native FML negotiation | Add Windows/Linux and x86_64 breadth to the release automation | + +## #99 - let friends join without installing the mod + +| Acceptance criterion | Status | Evidence | Remaining proof | +|---|---|---|---| +| Host copies a short ordinary Minecraft server address | Product proof required | `docs/connect-share.md` and adapter vocabulary cover the action; an exact-head no-mod client completed a real vanilla join through the ordinary public address after the normal host admission action | Inspect the packaged copy action; repeat the successful vanilla join on the final release candidate | +| Stable Connect endpoint token is reused across worlds | Product proof required | `EndpointIdentityStoreTest` (`one generated identity survives reload and world changes`) and `PersistentConnectIngressTest` (`title startup and world leases share one connector until shutdown`) | Record the same redacted endpoint identity fingerprint across two worlds | +| World changes do not create endpoint database spam | Product proof required | the persistent ingress and identity tests above make no create call on world replacement | Verify through a two-world packaged session and, where available, redacted endpoint-count telemetry | +| Address reveals no local or public IP in the UI | Product proof required | `SecretRedactionTest`, `ShareJoinDiagnosticsTest`, and the ordinary Connect hostname presentation | Inspect copy/status UI and diagnostics on the exact artifact | +| Host approval and capacity still apply | Product proof required | `AdmissionControllerTest` covers timeout, capacity, one-shot approval, and identity binding; the exact-head vanilla run proved one real pending admission, the normal allow action, and completed gameplay; the earlier live probe proved bounded timeout/denial | Record packaged capacity exhaustion and repeat approval/denial on the final release candidate | +| Confirmed modded friends retain richer presence and direct-first joining | Product proof required | presence tests plus `TransportSelectorTest` (`same LAN is attempted before internet and Connect`) | Record a modded friend join after restoring the exact artifact | +| Errors distinguish unavailable host from invalid or expired admission | Product proof | `RemoteLoginMessage` and `FabricSessionAdmissionGateTest` provide distinct text and reserve time before vanilla's timeout; Moxy PR #512 is deployed, and a production no-mod run rendered its safe localized host-approval timeout in about 22 seconds without Browser Hub fallback or generic timeout | Repeat unavailable, capacity, explicit decline, and timeout cases across the remaining release adapters | + +## #100 - privacy, permissions, and relationship safety + +| Acceptance criterion | Status | Evidence | Remaining proof | +|---|---|---|---| +| Only confirmed friends receive presence or joinable activity | Deterministic proof | `FriendStore.all()` exposes only confirmed relationships; `FriendsViewModelTest` rejects presence for outgoing requests and raw status | None beyond the full regression gate | +| Display name is never identity | Deterministic proof | `SavedFriend` keys relationships by authenticated peer identity; `AdmissionControllerTest` requires a new approval for a copied offline name, bounds/expires one-shot grants, and `ApprovedJoinTrackerTest` requires the signature-verified invitation peer before automatic friendship | None beyond the full regression gate | +| Requests, reciprocal requests, removals, and blocks converge | Product proof required | `FriendRequestServerTest` covers crossed requests and authenticated idempotent removal; `FriendRemovalSyncTest` covers later acknowledgement; `FriendStoreTest` covers durable blocks | Record reciprocal request, offline removal/reconnect, and block behavior with two clients | +| Per-friend Ask Every Time, Auto-Accept, and Never Allow policies | Product proof required | `FriendStoreTest` (`never allow is durable and distinct from ask every time`) and `FriendRequestServerTest` (`never allow declines join without notifying the host`) | Inspect all three settings and validate exact packaged behavior | +| Online, playing, current-server/world, and joinable state can be hidden independently | Product proof required | `SharePreferencesStoreTest` and the privacy cases in `FriendRequestServerTest`/`FriendsViewModelTest` | Exercise each toggle from the packaged privacy UI | +| Invites and diagnostics reveal no token, key, or local/public IP | Product proof required | `ShareInviteCodecTest` (`signed invitation round trips without leaking its capability`), `SecretRedactionTest`, and `ShareJoinDiagnosticsTest` | Inspect copied diagnostics and all social screens on the exact artifact | +| Removal or block revokes later admission and presence | Product proof required | `AdmissionControllerTest` removal-revocation cases, `ApprovedJoinTrackerTest`, and `FriendStoreTest` block behavior | Record revocation after reconnect with two clients | +| Security and privacy behavior is documented plainly | Deterministic proof | the **Privacy and safety** section of `docs/connect-share.md` | Product-copy review before release | + +## #103 - follow a friend into the next joinable world + +| Acceptance criterion | Status | Evidence | Remaining proof | +|---|---|---|---| +| Follow survives title-screen and menu transitions | Product proof required | `FollowNextSessionController` is installation-scoped through `FriendsViewModel`; packaged adapters poll it from title/menu and gameplay | Record navigation through title/menu before the host becomes joinable | +| At most one request is emitted for a world-presence epoch | Deterministic proof | `FollowNextSessionControllerTest` (`joinable epoch emits one request and duplicate presence cannot storm`) | None beyond the full regression gate | +| Repeated presence cannot create request storms | Deterministic proof | duplicate-epoch test above and `reconnect with a new world epoch can retry without duplicating either epoch` | None beyond the full regression gate | +| Auto-accept requires explicit per-friend policy | Deterministic proof | `FriendPermissions.canJoinAutomatically` requires `AUTO_ACCEPT`; request-server policy tests cover Ask/Never Allow | None beyond the full regression gate | +| Active gameplay is never interrupted automatically | Product proof required | `FollowNextSessionControllerTest` (`active gameplay is never interrupted and receives one join offer`) | Observe Join Now rather than forced connection during active gameplay | +| Both players receive understandable notifications | Product proof required | follower toasts in each Fabric adapter, normal host admission notifications, and `SocialEventTrackerTest` | Observe both sides on exact packaged clients | +| TDD covers expiry, cancellation, reconnect, removal, blocks, duplicates, and simultaneous follow | Deterministic proof | `FollowNextSessionControllerTest` explicitly covers every listed case; `ShareScreenPresentationTest` fixes visible automatic-cancellation copy; every Fabric adapter renders it | Verify the packaged cancellation notification during product proof | + +## #117 - one-click HTTPS invite/install/resume handoff + +| Acceptance criterion | Status | Evidence | Remaining proof | +|---|---|---|---| +| Every invite has a safe HTTPS form led by the human join action | Gap | `docs/connect-share-handoff.md` defines the social copy and fragment-only secret boundary | A real reviewed/deployed handoff page does not exist in this repository; the client deliberately does not emit a dead link | +| Resolve version, loader, OS, launcher, dependencies, and vanilla path without disclosure | Deterministic design | The handoff contract requires a secret-free artifact manifest, allowlisted launcher adapters, required dependencies, and locally verified Connect-hostname fallback | Implement the web application and launcher adapters against published marketplace projects | +| Resume the original invitation exactly once after install/restart | Deterministic design | The contract defines digest-bound expiring state, owner-only local transfer, atomic consume/delete, acknowledgement, and explicit retry | Implement and TDD the signed resume protocol in both web/launcher boundary and mod after the handoff owner/repository is selected | +| Safe expired, revoked, incompatible, malicious, declined, cancelled, and retry states | Deterministic design | Explicit resolution flow and E2E matrix in the handoff contract | Browser/launcher implementation and cross-OS E2E are external/missing | +| Preview and measurement reveal no secrets or graph | Deterministic design | Fragment never reaches HTTP; CSP/referrer/storage/analytics rules and aggregate opt-in boundary are explicit | Independent web privacy review plus log/referrer evidence on the deployed origin | + +## #118 - staged launch, measurement, modpacks, and creators + +| Acceptance criterion | Status | Evidence | Remaining proof | +|---|---|---|---| +| Consistent marketplace promise and sub-30-second demonstration | Gap | `docs/connect-share-launch.md` fixes the promise and exact demonstration story | Marketplace pages, visual assets, video, and publication are external launch work | +| Plain-language modpack, dependency, privacy, security, support, and compatibility material | Deterministic proof | `docs/connect-share.md`, launch contract, threat model, testing guide, and MIT redistribution section cover the source material | Final marketplace/creator copy review and published URLs | +| Creator/modpack kit and staged diverse beta | Gap | Launch contract enumerates approved assets, copy lengths, metadata, checksums, forecast/support form, cohorts, and gates | Produce assets, recruit cohorts, staff support, forecast capacity, and run the beta | +| Localization covers the largest reachable populations | Gap | English/German locale parity is packaged; the launch contract defines the next locale order and safety-copy release gate | Translate, review, and package Brazilian Portuguese, Spanish, French, Russian, Simplified Chinese, Japanese, and evidence-driven additions | +| Privacy-preserving opt-in success/reliability/retention metrics | Deterministic design | Launch contract defines default-off local aggregation, allowed measures, suppression, and a strict forbidden-field list | Reviewed endpoint, consent UI, retention/deletion policy, privacy review, and staged data-quality proof; no telemetry is silently enabled | +| Launch/pause/rollback/graduation criteria precede promotion | Deterministic proof | Four guarded stages, exact graduation/pause conditions, required evidence bundle, and independent rollback are documented | Execute the gates with real product and service data before each stage | + +## #119 - global Connect fallback operations and security review + +| Acceptance criterion | Status | Evidence | Remaining proof | +|---|---|---|---| +| Define regional availability, establishment, and successful-relay SLOs | Deterministic proof | `docs/connect-share-operations.md` defines 99.9% admission availability, 99% eligible relayed join, p95/p99 latency, error budget, and multi-window burn alerts | Instrument and prove the indicators in each production region | +| Load test and capacity-plan realistic sessions, bursts, failover, and degraded upstreams | Deterministic design | Capacity formula, headroom rule, required distributions, evidence bundle, and scenarios are specified | Service repository load generator, staging/production-safe execution, dashboards, and signed results are external/missing | +| Rate limits and abuse controls protect every boundary without content/graph collection | Deterministic design | Admission-scoped authorization, rotating abuse keys, separate budgets, bounded queues, retry-after, and forbidden inspection are specified | Deployment configuration, load tuning, privacy review, and abuse simulation | +| Threat-model all critical assets and obtain independent review | Product proof required | `docs/connect-share-threat-model.md` covers invites, identity, endpoint import, admission, recovery, relay, diagnostics/metrics, HTTPS, and updates with required controls | Independent reviewer, findings/remediation, deployment diagrams, and sign-off are external/missing | +| Privacy-safe observability, alerting, ownership, runbooks, incidents, and postmortems | Deterministic design | Allowlisted signal schema, redaction/retention boundary, alert windows, ownership and required runbooks/communications are explicit | Dashboards, private on-call route, runbook links, exercises, and production evidence | +| Cost budgets, chaos/failover, staged rollout, and rollback | Deterministic design | Cost/session evidence and seven chaos gates preserve direct joins and require bounded blast radius/rollback | Regional service deployment, cost data, failure injection, and executed evidence | +| Signed and verifiable release artifacts | Product proof required | Release workflow now uses `actions/attest@v4`, uploads checksums, and verifies GitHub attestations; workflow syntax passes `actionlint` | Run against a disposable published prerelease and verify every public marketplace digest against the attested files | + +## #120 - encrypted identity and friend recovery + +| Acceptance criterion | Status | Evidence | Remaining proof | +|---|---|---|---| +| Offline export/import keeps recovery plaintext away from Minekube | Deterministic proof | `RecoveryArchiveTest` covers AES-256-GCM round trip, random salt/nonce, PBKDF2-HMAC-SHA256, strict allowlisting, bounds, and redacted values; `RecoveryStoreTest` proves complete offline transfer | Inspect and exercise the exact packaged file-picker flow without recording its path or contents | +| Wrong password, tampering, unsupported versions, and partial writes fail closed | Deterministic proof | `RecoveryArchiveTest` makes wrong passwords and one-byte tampering the same authentication failure; `RecoveryStoreTest` proves wrong-secret no-op, injected rollback, and next-start recovery after simulated process loss | Repeat wrong-password and damaged-file cases with disposable packaged profiles | +| Export and restored files are owner-only and atomically replaced | Deterministic proof | `RecoveryStoreTest` verifies POSIX `0600`, atomic replacement of an existing backup, deterministic staged import, and rollback | Confirm permissions on the final packaged-client backup where POSIX applies | +| Recovery UI is nonblocking, explicit, safe, localized, and distinct from dashboard token import | Product proof required | `RecoveryViewModelTest` covers off-thread work, matching export secrets, authenticated preview, explicit restore confirmation, restart copy, active-share refusal, and password-buffer clearing; all four Fabric adapters compile with English and German recovery strings | Inspect the final screen at minimum and narrow window sizes; verify native save/open dialogs manually | +| Device loss, rotation, revocation, and concurrent restored-copy semantics are honest | Gap | `docs/connect-share.md` defines the offline archive as a single-device transfer and identifies re-verification/removal/blocking; it explicitly warns that copied profiles must not run simultaneously | A future signed identity-rotation protocol is required to revoke a lost active device and deterministically suppress two restored copies without trusting a central social relay | +| Optional account-backed recovery is visible, revocable, and rate limited | Gap | No plaintext or recovery secret is uploaded by the local implementation | Requires an authenticated Minekube recovery service, threat model, enrollment/revocation API, audit trail, and abuse/rate-limit controls; it cannot be truthfully completed inside this client-only PR | + +## Open foundation gaps + +The baseline intentionally leaves #95, #96, #99, #100, and #103 open until the +remaining exact-head product claims are observed. The first audit fixes remain, +the direct, forced Connect-fallback, and vanilla no-mod Prism joins are proven, +and the latest review also bound automatic friendship to the signed direct peer +while making one-shot preapprovals expiring and bounded. The no-mod run proves +Connect session delivery, host admission, and completed gameplay through the +ordinary public address. Moxy PR #512 is now deployed, its guarded production +workflow is green, and an unmodified guest received the intended actionable +terminal denial. Moxy PR #517 also prevents repeated replacement history from +making the final regional-candidate verifier demand a superseded machine. diff --git a/docs/connect-share-handoff.md b/docs/connect-share-handoff.md new file mode 100644 index 000000000..73abdb97a --- /dev/null +++ b/docs/connect-share-handoff.md @@ -0,0 +1,65 @@ +# Connect Share HTTPS invite handoff + +This is the client/web contract for issue #117. The handoff page is not hosted +by this repository, and Connect Share must not copy an HTTPS form by default +until that page is deployed and verified. A broken install link is worse than +the working signed custom URI and ordinary Direct Connect path. + +## Secret boundary + +The canonical shape is: + +`https://connect.minekube.com/share/#` + +The signed invitation is carried only in the URL fragment. Browsers do not send +the fragment in the HTTP request, so the origin, CDN, access log, and normal +server analytics never receive it. The page must use a restrictive CSP, no +third-party scripts, `Referrer-Policy: no-referrer`, no service-worker caching +of invite state, and no fragment-bearing links. It validates the invitation +locally before showing any host-provided text or route. + +The page leads with **Join your friend**. Transport, endpoint, token, peer, and +address terminology is diagnostics-only. + +## Resolution flow + +1. Parse, bound, and verify the signed invitation entirely on the recipient. +2. Show safe expired, revoked, malformed, unsupported, and incompatible states + without echoing the payload. +3. If Connect Share is registered, open the custom URI once and wait for an + explicit local acknowledgement before offering retry. +4. Otherwise resolve Minecraft version, loader, OS, and supported launcher to + an allowlisted artifact/dependency manifest fetched without the invitation. +5. Offer Modrinth App, PrismLauncher, CurseForge, and manual paths only where a + tested adapter exists. Never synthesize shell commands or arbitrary URLs + from invitation fields. +6. If the host enabled no-mod ingress, retain an ordinary Direct Connect option + that reveals only the public Connect hostname after local verification. + +## One-shot install resume + +Before launching an installer, the page creates random one-shot resume state +bound to a digest of the invitation, expected artifact, expiry, and launcher. +The secret invitation remains client-side. A launcher adapter may pass it to the +installed mod through an OS-approved custom-protocol handoff or a short-lived, +owner-only local file. The mod atomically consumes and deletes the state before +opening confirmation. Successful, declined, cancelled, expired, mismatched, +and crashed resumes cannot replay automatically; retry requires an explicit +recipient action. + +Do not use browser local storage, query parameters, server sessions, analytics +events, clipboard history, or launcher logs for the invitation. + +## Verification gate + +Browser/launcher E2E must cover Windows, macOS, and Linux; already installed, +fresh install, dependency install, cancellation, retry, restart, expired, +revoked, incompatible, malicious payload, unavailable launcher, manual +download, and vanilla fallback. Each test verifies that server/CDN/referrer and +launcher logs contain no invitation, capability, token, private address, or +hidden presence. + +Client integration is blocked on a real handoff origin, reviewed web source, +published marketplace project IDs, documented launcher adapters, and a signed +resume protocol. Until then the product keeps the working custom invitation and +ordinary server address; it must not emit a dead HTTPS link. diff --git a/docs/connect-share-known-issues.md b/docs/connect-share-known-issues.md new file mode 100644 index 000000000..951fe85da --- /dev/null +++ b/docs/connect-share-known-issues.md @@ -0,0 +1,18 @@ +# Connect Share known issues + +These are release blockers or limitations for the unmerged Connect Share work +in PR #94. Do not present the mod as generally available until the relevant +item is resolved and its evidence is linked. + +| Area | Current limitation | Safe action | +|---|---|---| +| HTTPS invite | The handoff page and launcher-resume protocol are not deployed | Share the signed in-mod friend invitation; hosts with a proven Connect ingress may separately share the ordinary Minecraft address | +| Marketplace install | Modrinth and CurseForge projects/credentials and a public Share release have not been verified | Use the exact locally built artifact and dependencies from `docs/connect-share.md`; do not redistribute an unreviewed snapshot as a stable release | +| Recovery | Offline backup transfers one identity but cannot revoke a lost active device or safely run the same restored identity concurrently | Close the old profile before restoring; if a device is lost, remove/block the old relationship and re-link a new identity | +| Platform matrix | Every supported loader/version has clean packaged two-client join proof on macOS arm64; Windows, Linux, and x86_64 remain deterministic/build-only | Treat the artifacts as prerelease outside the proven platform until those real-client gates pass | +| Localization | English and German are packaged | Do not claim another locale until its complete safety, recovery, compatibility, and failure journeys are reviewed | + +Support reports should include **Copy safe diagnostics**, exact Minecraft +version, loader, OS family, and artifact SHA-256. Never request or post an +invitation, endpoint token/name, private key, peer ID, address, `friends.json`, +recovery archive/password, username, world name, or complete mod inventory. diff --git a/docs/connect-share-launch.md b/docs/connect-share-launch.md new file mode 100644 index 000000000..c8e7401f2 --- /dev/null +++ b/docs/connect-share-launch.md @@ -0,0 +1,80 @@ +# Connect Share staged launch + +**Promise:** Install once. See your friends. Join whatever they are playing. No +server setup. + +Growth is gated by successful shared play, not download count. Marketplace or +creator promotion may not outrun fallback capacity, security review, support, +or the exact packaged-client evidence matrix. + +## Rollout stages + +| Stage | Cohort | Graduate when | Pause or roll back when | +|---|---|---|---| +| 0 - internal | Maintainers and disposable test pairs | Direct, fallback, no-mod, recovery, compatibility, and all adapter startup gates pass | Any secret leak, unbounded hang, corrupt recovery, or reproducible join regression | +| 1 - closed beta | Diverse invited pairs across regions, offline/online profiles, vanilla-like and major modpacks | ≥95% eligible invite-to-join, p95 request-to-world <10 s, ≥99% crash-free Share sessions, support response <1 business day | Error-budget alert, security finding, generic/unactionable failures >2%, or support backlog >2 business days | +| 2 - marketplace beta | Guarded percentage of published installs | Two weeks within regional fallback SLOs, successful repeat sessions, verified rollback, no unresolved high-severity issue | SLO burn, cost budget breach, launcher dependency failure, or regression concentrated in a version/loader | +| 3 - creator/modpack pilot | Small approved packs and creators with forecast traffic | Capacity headroom survives forecast burst and each cohort has an owner/support channel | Forecast exceeds reserved capacity, abuse spike, or cohort join success misses beta baseline | +| 4 - broad release | Supported marketplaces and packs | Ongoing SLO/error-budget and retention review | Same automated pause gates; rollback client/service independently | + +Each release decision links the exact commit/artifact digests, adapter matrix, +two-client evidence, no-mod result, fallback load/chaos results, current known +issues, privacy review, security review, dashboard, cost budget, rollback, and +incident owner. + +## Opt-in measurement contract + +Metrics are off by default until a reviewed endpoint and consent UI exist. +Consent must be understandable, reversible, and independent of gameplay. The +client aggregates locally and uploads only counts and coarse duration buckets: + +- invite received → already installed / newly installed / vanilla path; +- request → approved / denied / expired / cancelled; +- join stage and safe outcome; +- route class and duration bucket; +- actionable recovery chosen and whether a later attempt succeeded; +- number of locally recognized repeat friend-pair sessions as an aggregate + count, never the peer or relationship key; +- crash-free Share session count and install-source enum. + +No event contains a persistent player/install/social identifier, friend graph, +invitation payload, endpoint credential/name, peer key/ID, IP/address, username, +world/server name, chat, contents, complete inventory, or raw stack trace. +Small cohorts and rare dimension combinations are suppressed. Retention and +deletion windows are documented before collection. Product operation must not +depend on consent. + +## Marketplace and creator kit + +Use the promise above as the lead. Show the human flow - friend becomes joinable, +request, approval, shared world - in under 30 seconds before explaining +networking. The source kit must include: + +- approved icon/banner/screenshots and a silent-captioned demo source; +- 30-, 100-, and 300-word descriptions using the same promise; +- exact supported-version/loader table and required dependencies; +- privacy, security, support, known-issues, and modpack-redistribution links; +- checksummed GitHub Release links, changelog feed, and rollback notice; +- pack metadata examples and a forecast/support form for large cohorts. + +The repository currently provides the product/distribution copy and MIT +redistribution contract in `docs/connect-share.md` plus the ready-to-publish +source copy, metadata, and demo storyboard in +`docs/connect-share-marketplace-kit.md`; final visual assets, +marketplace projects, public demo, creator recruitment, and support staffing are +external launch deliverables. + +## Localization and support + +English and German in-game journeys ship together today. Add locales by +reachable-player coverage and beta demand, beginning with Brazilian Portuguese, +Spanish, French, Russian, Simplified Chinese, and Japanese. Every locale must +cover the friend request/join, approval, privacy, recovery, compatibility, +failure, and install-handoff journeys; untranslated safety copy blocks that +locale's release. + +Publish `docs/connect-share-known-issues.md` with version/loader, symptom, safe workaround, +fixed release, and no secrets. Support requests begin with **Copy safe +diagnostics**; never ask for tokens, invitations, keys, addresses, full friend +files, or recovery archives. Confirmed regressions receive a focused automated +test before the fix and are linked to the staged rollout decision. diff --git a/docs/connect-share-marketplace-kit.md b/docs/connect-share-marketplace-kit.md new file mode 100644 index 000000000..4fa443aa6 --- /dev/null +++ b/docs/connect-share-marketplace-kit.md @@ -0,0 +1,80 @@ +# Connect Share marketplace and creator source kit + +This is the source-of-truth copy and metadata for marketplace pages, modpacks, +and creator pilots. Visual assets and a final video are external launch +deliverables and must follow the storyboard below. + +## Promise and short copy + +**Tagline** + +Install once. See your friends. Join whatever they are playing. No server setup. + +**Short description** + +Link with a friend once, see when their singleplayer world is ready, request to +join, and start playing. Connect Share tries direct peer-to-peer first and uses +Minekube Connect only when needed. + +**Marketplace description** + +Connect Share turns “my friend is playing” into playing together. Link once +with an authenticated friend identity. Later you can see privacy-controlled +online and joinable state, request access, and enter the active singleplayer +world without exchanging another IP or reopening sharing. + +Direct libp2p is tried first, including after networks and IP addresses change. +Minekube Connect is the managed gameplay fallback when a direct route is not +available; nobody needs to run a relay. Ask Every Time is the default, with +per-friend Auto-Accept and Never Allow controls. Pending requests receive no +presence, and display names are never authorization. + +The mod also detects obvious Minecraft, loader, and required-mod differences +before a late Minecraft failure. Offline-mode friends are supported without +silently downgrading an authenticated session. A host may offer an ordinary +Minecraft address to a friend without the mod after the Connect ingress path is +release-proven. + +Connect Share is a focused universal party layer - not a cosmetics, chat, or +server-management suite. + +## Supported release metadata + +| Loader | Minecraft | Required install dependency | +|---|---|---| +| Fabric | 1.20.1, 1.21.1, 1.21.11, 26.2 | Fabric API and Fabric Language Kotlin | +| Forge | 1.20.1 | Kotlin for Forge installable `-all.jar` | +| NeoForge | 1.21.1 | Kotlin for Forge installable `-all.jar` | + +Environment is client required, server optional. Artifact names follow +`connect-share---.jar`. Marketplace relations are +required dependencies, not suggestions. Public/private modpack redistribution +is permitted under MIT when the license notice remains with the binary. + +## Demonstration storyboard (maximum 30 seconds) + +1. **0–4 s:** Two players, title-screen Friends card: “Robin is playing.” +2. **4–8 s:** One click on **Request**; caption: “No address. No server setup.” +3. **8–13 s:** Host receives the in-game request and chooses **Accept**. +4. **13–22 s:** Guest loads into the world; show both players together. +5. **22–27 s:** Privacy panel flashes Ask Every Time / Auto-Accept / Never + Allow and direct-first / managed-fallback copy without network jargon. +6. **27–30 s:** Promise, marketplace badges, and exact supported matrix link. + +Use captions and a silent-safe edit. Do not display endpoint names, invites, +addresses, peer IDs, usernames from real accounts, debug screens, or tokens. + +## Required links and assets + +- player/install/privacy guide: `docs/connect-share.md`; +- known issues: `docs/connect-share-known-issues.md`; +- security model: `docs/connect-share-threat-model.md`; +- source/reproducible build: this repository and the tagged GitHub Release; +- support: Minekube issue/Discord destinations selected for the launch cohort; +- changelog: the matching GitHub Release, never an unversioned download; +- checksums and GitHub artifact provenance from that release. + +Final kit assets: square icon, marketplace banner, title/Friends/request/privacy +screenshots at readable scale, captioned demo source and export, transparent +logo, and light/dark press images. Every asset is reviewed for hidden names, +world data, addresses, or credentials before publication. diff --git a/docs/connect-share-operations.md b/docs/connect-share-operations.md new file mode 100644 index 000000000..99bd20fb2 --- /dev/null +++ b/docs/connect-share-operations.md @@ -0,0 +1,113 @@ +# Connect Share fallback operations + +This is the release contract for the managed Connect fallback used by Connect +Share. It does not assert that production currently meets these targets. A +public rollout may advance only when the named evidence exists for the target +environment and release candidate. + +Direct libp2p success is measured separately. A fallback incident must never +disable same-LAN or otherwise working direct joins. + +## Service levels + +Measure each production region independently over a rolling 30-day window. + +| Indicator | Objective | Eligible population | +|---|---:|---| +| Fallback admission availability | 99.9% | Valid, non-revoked attempts reaching a healthy regional edge; excludes host denial, full worlds, expiry, and incompatibility | +| Successful relayed join | 99.0% | Eligible fallback attempts where both clients remain connected through Minecraft login | +| Connection establishment | p95 ≤ 5 s; p99 ≤ 10 s | Time from direct-route exhaustion to a usable fallback tunnel | +| Control decision delivery | p95 ≤ 2 s | Host approval/denial to guest receipt while both control sessions are connected | + +The 99.9% monthly availability objective permits about 43 minutes 50 seconds of +unavailability per region. Page on both fast burn (14.4× budget for 5 minutes +and 1 hour) and slow burn (6× for 30 minutes and 6 hours). Pause rollout when +either window fires, successful relayed join drops below 99%, or p99 exceeds 10 +seconds for 15 minutes. Roll back when the candidate is correlated with the +regression; otherwise fail over or shed new fallback work while preserving +direct joins. + +## Privacy-safe signals + +The telemetry boundary is an allowlist. Operational events may contain only: + +- coarse timestamp bucket and region; +- client release, Minecraft version, loader, OS family, and CPU family; +- stage enum (`edge_connect`, `admission`, `tunnel`, `minecraft_login`); +- route enum (`direct_lan`, `direct_internet`, `connect_fallback`); +- bounded duration bucket and safe outcome enum; +- retry count bucket, rollout cohort, and aggregate byte bucket. + +Never ingest usernames, display names, friend or relationship identifiers, +peer IDs, invitations, endpoint names/tokens, keys, capabilities, IP or socket +addresses, world/server names, chat, contents, complete mod inventories, raw +exceptions, or diagnostic archives. Edge access logs must redact request paths +and authorization before storage. Source addresses required transiently for +transport are not application telemetry and must not be retained beyond the +shortest security/abuse window approved by the threat model. + +## Capacity and load gate + +Capacity is computed per region from observed, privacy-safe distributions: + +`required concurrent tunnels = peak eligible starts/second × p99 session seconds × failover factor` + +Reserve at least the larger of 30% headroom or one neighboring region's normal +peak before a public cohort can depend on fallback. Model normal sessions, +long sessions, reconnect storms, creator-driven bursts, maintenance drain, one +region lost, control-plane restart, IPv4/IPv6 imbalance, and an upstream DNS or +certificate degradation. Test control requests and bidirectional relay bytes; +connection-only load is insufficient. + +A release evidence bundle records the generator version, anonymized input +histograms, offered/accepted/rejected rates, latency percentiles, resource +saturation, error-budget burn, and estimated cost per successful relay. It +contains no production credentials or per-user traces. + +## Abuse controls + +- Bind relay authorization to a short-lived, single-share admission; expiry, + denial, removal, block, stopping the share, and capacity exhaustion revoke it. +- Rate-limit by privacy-reviewed, rotating edge abuse keys rather than social + identity. Apply separate budgets to endpoint watching, proposals, admission + decisions, tunnel opens, bytes, and repeated failures. +- Use bounded queues and explicit retry-after responses. Never let abuse + protection turn into an unbounded client retry loop. +- Protect hosts from unsolicited proposals and guests from replayed approvals. + Do not inspect Minecraft payload contents or infer a friend graph. +- Escalate suspicious aggregate patterns to a documented review; do not retain + message contents “just in case.” + +Exact limits are deployment configuration, not client constants. They require +load evidence and must be included in the security review. + +## Chaos and failover release gate + +Before expanding a cohort, prove in staging and then a guarded production +slice: + +1. direct success while Connect is unavailable; +2. bounded direct failure followed by one fallback attempt; +3. one regional edge/relay loss and drain to a healthy region; +4. control-plane restart without reused or orphaned admission; +5. expired/revoked credentials, rate limiting, and queue saturation fail safe; +6. recovery after suspend, IP/LAN change, IPv4/IPv6 change, and VPN change; +7. rollback of client and service independently. + +Every injected failure has a stop condition, owner, maximum blast radius, and +verified rollback before execution. + +## Ownership and runbooks + +The Minekube Connect maintainers own the service; each rollout records the +named incident commander and current private on-call route. Public runbooks +must cover regional latency/availability burn, capacity saturation, relay cost +spike, credential abuse, certificate/DNS failure, bad client rollout, and +telemetry privacy incident. Each runbook starts with preserving direct joins, +names a rollback/failover action, defines user communication, and ends with a +postmortem for a material incident. + +Broad promotion is blocked until dashboards, alerts, load evidence, failover +evidence, cost budgets, runbooks, and an independent security review are linked +from the release decision. Local tests and a published JAR cannot satisfy this +gate. diff --git a/docs/connect-share-testing.md b/docs/connect-share-testing.md new file mode 100644 index 000000000..57b7cb2e3 --- /dev/null +++ b/docs/connect-share-testing.md @@ -0,0 +1,210 @@ +# Connect Share acceptance + +Connect Share is built separately for every loader/version in the supported +matrix in [the player guide](connect-share.md), on the matching Java +toolchain. The Minecraft 1.20.1 artifacts target Java 17 and the 1.21.x +artifacts target Java 21. Fabric 26.2 builds on and targets Java 25. Run this +pass against every artifact before calling the singleplayer and direct-sharing +implementation release-ready. + +The mod build does not publish a Connect Java plugin release, rebuild a hub +image, or roll anything out to production. + +## Build the artifacts + +From the repository root: + +```sh +./gradlew :share:fabric-1-20-1:build \ + :share:fabric-1-21-1:build \ + :share:fabric-1-21-11:build \ + :share:fabric-26-2:build \ + :share:forge-1-20-1:build \ + :share:neoforge-1-21-1:build --no-parallel +``` + +Use the unclassified versioned JAR in each module's `build/libs` directory. +Do not install `sources`, `dev`, `unshaded`, or `parent-shadow` artifacts. +Install the loader and dependencies listed in [the player guide](connect-share.md). +That guide also calls out the manual Forge/NeoForge `-all.jar` requirement and +the marketplace dependency metadata. + +## Identity reuse and import + +1. Start a singleplayer world, choose **Share world**, and start sharing. +2. Record the displayed endpoint and a cryptographic digest of + `config/minekube-connect-share/token.json`. Do not copy the token into test + notes or logs. +3. Stop sharing, share the same world again, then share a different world. +4. Confirm the endpoint and token digest remain byte-for-byte identical. No new + endpoint record should appear for either world. +5. Import a dashboard-created endpoint and token. Repeat using a + plugin-compatible `token.json`. +6. Confirm a deliberately invalid endpoint or token is rejected and leaves the + previous endpoint and token files unchanged. +7. Confirm a valid import keeps the dashboard endpoint name. +8. Start once with `CONNECT_ENDPOINT` and `CONNECT_TOKEN`. Confirm both fields + are shown as environment-managed and cannot be edited or reset in the UI. + +## Vanilla guest joins and admission + +For each supported host version: + +1. Start sharing and copy the displayed `*.play.minekube.net` address. +2. Join from an unmodified paid Java client through Connect. +3. Confirm the host sees the guest's name, UUID, and authenticated source before + the tunnel reaches the integrated server. +4. Deny the request and confirm the guest does not enter the world. +5. Reconnect, allow the request, and confirm the guest enters. +6. Reconnect the same authenticated profile during the same share and confirm + the current-share approval is reused. +7. Join from an unmodified non-paid/offline-mode client. +8. Deny once, reconnect, then allow. Confirm an offline approval applies only to + that individual connection and is not silently reused. +9. Fill the configured guest capacity and confirm additional guests receive a + safe full-share rejection. + +## Modded same-LAN direct joins + +Use two machines on the same LAN with the matching Connect Share artifact. +Connect may remain configured, but temporarily block the guest from reaching +the host's `*.play.minekube.net` address so a successful join proves the direct +route works. + +1. Start a host world, choose **Share world**, start sharing, and leave + **Allow faster direct internet connections** disabled. +2. On the guest title screen, choose **Friends**, then **Join Connect Share**. +3. Confirm the host world appears automatically as a nearby share. The host + must not use Minecraft's **Open to LAN** action. +4. Choose the nearby world with the default online identity. Confirm the host + receives an authenticated direct-LAN approval request, can deny it, and can + approve a later attempt. +5. Repeat with **Use an offline identity (unverified)**. Confirm the host sees + an unverified identity and approval is not reused for a later connection. +6. Confirm the guest joins while the Connect hostname remains blocked. +7. Stop sharing and confirm discovery disappears and the old signed invitation + cannot create a usable direct session while the host is stopped. +8. Start sharing again. Confirm the saved libp2p peer identity and access + identity are reused while the persistent Connect endpoint remains unchanged. + +## Invitation, internet-direct, and fallback behavior + +Internet-direct is best-effort and requires an actually reachable public +address from a host network interface. +The mod does not open a public Minecraft listener, configure UPnP, or use a +self-hosted libp2p relay. + +Friend control is separate from gameplay fallback. Its always-on social libp2p +path can carry signed direct candidates even when no world is being shared. +Copying a friend link is an explicit disclosure action. The mDNS advertisement +contains only local discovery metadata and never public candidates, +capabilities, or endpoint tokens. A saved friend tries fresh mDNS first, then +those signed candidates; requests, presence, and removal must never use +Connect. + +1. Copy the signed invitation from the host status screen and paste it into + **Join Connect Share** on a guest outside the LAN. +2. With the host's internet-direct share option disabled, or the guest's + per-friend **Allow direct internet routes for this friend** option disabled, + confirm the guest does not attempt a direct internet route and uses Connect + once. +3. Enable **Allow faster direct internet connections** on the host. On the + guest, open that friend’s **Manage** screen and enable **Allow direct + internet routes for this friend**. Confirm the host's share setup explains + that the path reveals public IP addresses. For a pasted invitation, confirm + the guest's direct-join disclosure appears before the route is attempted. + Restart the guest and confirm the per-friend choice remains enabled without + a new background consent prompt. +4. On a directly reachable network, confirm the direct route succeeds and the + host approval identifies it as internet-direct. +5. Make the advertised direct address unreachable while leaving Connect + available. Confirm the bounded direct attempts are followed by exactly one + Connect attempt and the guest can still join. +6. Repeat without a usable Connect ingress. Confirm same-LAN sharing remains + available, while a relay-required remote guest receives a safe no-route + failure. +7. Modify, truncate, expire, or reuse a signed invitation with a different + libp2p peer address. Confirm it is rejected before Minecraft connects and no + capability, candidate, endpoint token, or signature bytes appear in logs. +8. From two directly reachable networks, send and accept a friend request, + observe presence, and synchronize removal using only the signed direct + candidates. Confirm the route is `direct internet` and no Connect social + ingress is created. +9. Keep a share active through invitation renewal and copy its invitation from + the status screen. Confirm the copied token is the renewed token and remains + valid after the original token expires. + +## Listener and lifecycle safety + +1. While sharing, scan the host from another LAN device. Confirm Minecraft's + chosen TCP port is not reachable on any LAN or wildcard address. +2. Confirm no vanilla LAN multicast advertisement is emitted. +3. Close the status screen without stopping. Confirm the share remains active. +4. Use **Stop sharing** and confirm the public hostname no longer reaches the + world. +5. Leave the world while sharing. Confirm shutdown runs exactly once. +6. Start a different integrated world and confirm the previous share is closed + before the replacement becomes available. +7. Quit Minecraft while sharing and confirm the Connect watcher, local channel, + loopback listener, isolated libp2p loader, and temporary runtime payload all + close. +8. Repeat start/stop twice and compare thread and channel counts. There must be + no accumulating Connect, Netty, watcher, or coroutine resources. +9. Join a direct share, disconnect, and wait for the title screen. Confirm the + guest loopback proxy and discovery node close. Abort a direct connection + before login and confirm the same resources close after the bounded timeout. + +## Artifact inspection + +Inspect the final JARs: + +```sh +for version in 1.20.1 1.21.1 1.21.11 26.2; do + jar tf "share/fabric-$version/build/libs/connect-share-fabric-$version-"*.jar +done +jar tf share/forge-1.20.1/build/libs/connect-share-forge-1.20.1-*.jar +jar tf share/neoforge-1.21.1/build/libs/connect-share-neoforge-1.21.1-*.jar +``` + +Each final artifact must contain its loader metadata, version-specific mixin +configuration, `pack.mcmeta` where the loader expects one, and: + +- English and German translations; +- `LICENSE`; +- `com/minekube/connect/share/` classes; and +- `META-INF/connect/libp2p-runtime.jar`. + +It must not contain top-level `io/libp2p/`, `io/netty/`, or `kotlin/` +packages. Those runtime classes belong only inside the child-loaded payload. +The nested payload must include +`com/minekube/connect/tunnel/p2p/DirectP2pNodeRuntime.class`. + +## Real Prism matrix + +Use the opt-in `PrismFriendJoinE2ETest` harness with the exact packaged +artifact under test, repeating the host/guest run for each of the six artifacts. +The harness is implemented and invoked from `share/fabric-common`; it is +loader-neutral and does not replace launching the loader-specific artifact in +Prism. Run it with `--rerun-tasks`: its live environment variables are +deliberately not Gradle task inputs, so an up-to-date test result is not live +evidence. Keep exactly one host and one guest identity active. Cloned Prism +instances copy `share-libp2p-identity.key`; running two clones with the same key +advertises one peer identity from multiple processes and invalidates discovery +evidence. + +For a manually assembled Prism loader component, include its `cachedRequires` +metadata and allow one online launch to fetch loader libraries before the +offline guest run. A valid pass proves, in order, discovery, authenticated +friend activity, privacy-permitted status when the host exposes its world name, +approval, and a real guest login evidenced by both a new ` joined the +game` host-log line and a new `Loaded ... advancements` guest-log line. When +that name is hidden, the privacy-filtered activity response is the authority +and the raw status probe is intentionally skipped. Startup or control-plane +reachability alone does not pass. + +## Evidence to retain + +Record the host and guest Minecraft versions, Java versions, artifact SHA-256 +digests, endpoint name, admission outcomes, listener scan result, and relevant +redacted log excerpts. Never retain an endpoint token, invitation secret, or +direct-connect candidate in test evidence. diff --git a/docs/connect-share-threat-model.md b/docs/connect-share-threat-model.md new file mode 100644 index 000000000..4e373b449 --- /dev/null +++ b/docs/connect-share-threat-model.md @@ -0,0 +1,53 @@ +# Connect Share threat model + +This model covers the friend/social plane, direct gameplay, Connect fallback, +recovery, diagnostics, and update distribution. It is a living engineering +artifact, not an independent security review. + +## Assets and trust boundaries + +Protected assets are the persistent social private key, ephemeral share key, +Connect endpoint token, relationship graph, approval decisions, presence, +private network addresses, recovery archive/password, Minecraft account +authentication, and release artifacts. + +Trust boundaries exist between two players, the local Minecraft process and +launcher/filesystem, direct libp2p peers, Minekube Connect edge/control/relay, +the dashboard credential export, the future HTTPS handoff page, marketplace +publishers, GitHub Actions, and recovery storage selected by the user. + +Display names are untrusted labels. Authorization uses authenticated peer or +Minecraft identity plus a scoped, expiring admission. + +## Threats and required controls + +| Boundary | Threat | Required control and evidence | +|---|---|---| +| Friend invite | Forgery, tampering, replay, capability disclosure, malicious routes | Signed bounded invitation, authenticated peer binding, expiry, route validation, redacted values, no relay addresses; codec and tamper tests | +| Relationship | Name impersonation, pending-presence leak, crossed requests, removal/block divergence | Identity-keyed records, no pending presence, idempotent reciprocal confirmation, durable revocation and convergence tests | +| Admission | Replay, approval theft, unsolicited join, capacity bypass, online-to-offline downgrade | One-shot share/connection binding, bounded deadline, capacity gate, explicit auth mode, stop/removal/block revocation tests | +| Direct network | Private/public address disclosure, SSRF-like route injection, unbounded dialing | Explicit disclosure/guest opt-in, signed candidates, protocol/address allowlist, no circuit relay, bounded route attempts, secret-safe diagnostics | +| Connect credential | Token theft, confused endpoint, unsafe import, log leakage | Owner-only files, config/token pairing, authenticated import, stable reuse, environment-managed immutability, redaction and rollback tests | +| Connect relay | Unauthorized bandwidth, amplification, host/guest abuse, regional compromise | Short-lived admission authorization, independent rate/byte limits, bounded queues, regional isolation, encrypted transport, load/chaos evidence | +| Recovery | Offline guessing, tampering, partial replace, copied identity concurrency, lost-device compromise | PBKDF2-HMAC-SHA256 at 600,000 iterations, AES-256-GCM, random salt/nonce, fixed allowlist, 0600, authenticated preview, atomic rollback; rotation remains unresolved | +| Diagnostics/metrics | Secret or social-graph exfiltration, raw exception leakage, re-identification | Explicit local copy/opt-in, strict schemas, bounded enums/buckets, no stable social identifier, retention review, redaction tests | +| HTTPS handoff | Invite leakage through server logs/referrers/analytics, hostile install link, repeated resume | Fragment-only secret, local signature validation, restrictive CSP/referrer policy, allowlisted launcher adapters, one-shot state, expiry/revocation E2E | +| Updates | Compromised publisher/CI, artifact substitution, dependency confusion, rollback attack | Protected tag/release, least-privilege publish job, checksums and provenance attestation, marketplace digest verification, staged rollout and rollback | + +## Recovery and device caveat + +The offline archive safely transfers one identity; it does not revoke a lost +still-active device or provide conflict-free concurrent devices. Until a signed +rotation/re-verification protocol exists, a lost device requires removing or +blocking the old relationship and linking a new identity. An account-backed +recovery service additionally needs enrollment authentication, revocation, +rate limits, audit, and a server-blind encryption design. + +## Review gate + +Before broad promotion, an independent reviewer must receive this model, +protocol formats, cryptographic choices, release workflow, recovery tests, +admission tests, relay authorization design, operational data schema, and +deployment diagrams. Findings have owners, severity, target release, and a +public-safe remediation record. Critical/high findings block launch; accepted +risk requires a named maintainer, expiry date, and compensating control. diff --git a/docs/connect-share.md b/docs/connect-share.md new file mode 100644 index 000000000..0ddd1240f --- /dev/null +++ b/docs/connect-share.md @@ -0,0 +1,148 @@ +# Connect Share + +Connect Share is a private friend and party layer for Minecraft Java. Link with +a friend once, then see when they are playing, ask to join a shared world, or +follow them into their next joinable session. Players do not need to exchange +IP addresses or create a new link for every world. + +## The normal flow + +1. Open **Friends** from the title screen and copy your friend link. +2. Send it to the person you know. Adding the link sends a request; it does not + reveal presence or make either player a confirmed friend yet. +3. The other player accepts the request. Reciprocal requests converge into the + same confirmed friendship. +4. When a confirmed friend shares a singleplayer world, choose **Request**. + The host gets an in-game notification and can allow or deny it. +5. Connect Share tries a direct libp2p path first. If that is unavailable, the + approved gameplay connection falls back to Minekube Connect. Friend + requests and presence themselves are authenticated libp2p traffic and never + use Connect as a social relay. + +Friend links carry signed direct candidates from the always-on social libp2p +path when the local host has a usable internet route. This lets the social +plane reach a friend outside the LAN without Connect; copying and sending the +link is the explicit disclosure of that route. The mDNS advertisement contains +only local discovery metadata; it never publishes public candidates, +capabilities, or endpoint tokens. A reciprocal card exchange refreshes saved +candidates when friends reconnect from a new network. No circuit relay is +accepted. + +**Follow next session** waits for one friend for up to 30 minutes. It sends at +most one request for a world session, can be cancelled from the Friends screen, +and never pulls the follower out of active gameplay. Automatic admission still +requires the host to select **Auto-Accept** for that specific friend. + +## Friends without the mod + +While a world is shared, **Copy server address** copies an ordinary +`*.play.minekube.net` address. A vanilla client can paste it into Minecraft's +Direct Connect screen. The host still approves the player and the configured +guest limit still applies. The same endpoint identity and token are reused +across worlds and restarts, so switching worlds does not create endpoint spam. + +The address is unavailable when the host has no working Connect path. An +approval is temporary: denial, timeout, capacity, stopping the share, removal, +or blocking cannot be bypassed with an old attempt. + +## Privacy and safety + +- Only confirmed peer identities receive presence. Display names are labels, + never identity or authorization. +- Online, playing, and joinable state can each be hidden independently under + **Privacy**. **Show the server or world name** hides both multiplayer server + names and singleplayer world names. Raw Minecraft status is not treated as + social presence: a capability-authenticated route can query it only when + online, playing, and current-world visibility are all enabled. The Friends + UI still requires a confirmed, privacy-filtered activity response. +- Each friend can be set to **Ask me every time**, **Let them join + automatically**, or **Never allow joining**. The default is Ask me every + time. +- Removing a friend revokes future presence and admissions and is synchronized + when the peer is reachable. Blocking also prevents the identity from being + added again until explicitly unblocked. +- Internet-direct gameplay remains opt-in on both sides: the host enables + **Allow faster direct internet connections** for the shared world, and the + guest separately enables **Allow direct internet routes for this friend** in + that friend's **Manage** screen. The guest choice is off by default and is + persisted per friend, so background friend activity checks use it without + asking again. A copied friend link may contain signed direct candidates so + the recipient can deliver the friend request without Connect; only send it + to someone you trust. Direct addresses, endpoint tokens, invitation + capabilities, and private keys are never rendered in the social UI. +- **Copy safe diagnostics** is an explicit, local action. Its report contains + version and join-stage outcomes, but no names, addresses, links, tokens, or + keys. + +Compatibility exchange is peer-to-peer and limited to confirmed friends. It +contains Minecraft version, loader, a normalized list of server-relevant mod +identifiers and versions, and an optional HTTPS modpack link configured by the +host. It is not uploaded to Minekube. Client-only differences may be overridden; +Minecraft or loader differences cannot. + +## Backing up friends and identity + +Open **Privacy**, then **Backup & restore**. **Back up friends** creates one +offline file protected by the recovery password you enter twice. It contains +the social and gameplay identities that let existing friends recognize you, +saved relationships, access identity, preferences when present, and the local +Connect endpoint configuration and token when both are present. The file is +encrypted and integrity checked before it is written; neither the file nor its +password is sent to Minekube. + +Keep the backup and its password separately. The password cannot be recovered, +and anyone who has both can act as this Share identity. **Restore backup** first +authenticates the complete file and shows a content-category summary. A second +confirmation then atomically replaces this device's Share data. Stop sharing +before restoring and restart Minecraft afterward. A wrong password, damaged +file, unsupported version, interrupted write, or failed replacement leaves the +current installation unchanged or rolls it back. + +A restored backup is a device transfer, not multi-device synchronization. Do +not run two copied profiles at the same time: they hold the same identity and +can race presence or friend operations. If the old device was lost without a +backup, create a new identity and have friends verify and add it again; removing +or blocking the old relationship remains the revocation mechanism. Automatic +cross-device enrollment, remote revocation, and conflict-free simultaneous +devices require a future recovery protocol and are not provided by the offline +archive. + +The existing **Connect endpoint** token-file import is a separate operation. It +imports credentials downloaded from the Minekube dashboard and does not restore +friends or the Share social identity. Conversely, the recovery screen never +accepts a dashboard token as a recovery password or friend backup. + +## Installation and distribution + +Supported artifacts are named +`connect-share---.jar`. The current matrix is +Fabric 1.20.1, 1.21.1, 1.21.11, and 26.2; Forge 1.20.1; and NeoForge 1.21.1. +Install the artifact matching both the exact Minecraft version and loader. + +Fabric builds require Fabric API and Fabric Language Kotlin. Forge and +NeoForge builds require Kotlin for Forge. For a manual Forge/NeoForge install, +download Kotlin for Forge's installable `-all.jar`; the smaller Maven library +JAR is not a loader mod. Modrinth and CurseForge releases declare these as +required dependencies so their apps and Prism can resolve them automatically. + +The MIT license explicitly permits including Connect Share in public or private +modpacks. Keep its license notice with redistributed binaries. Verified release +artifacts are staged by the manual **Release Connect Share** workflow for +GitHub Releases, Modrinth, and CurseForge only after all six adapter builds, +packaging tests, isolation checks, and artifact-size gates pass. Marketplace +publication additionally requires the repository's project IDs and publisher +credentials; the workflow fails closed when they are absent. + +The release workflow also creates GitHub/Sigstore build-provenance +attestations for every JAR and checksum manifest and verifies them before the +workflow succeeds. Public launch additionally follows the +[operations](connect-share-operations.md), +[threat model](connect-share-threat-model.md), and +[staged launch](connect-share-launch.md) gates. An HTTPS invite is deliberately +not emitted until the separately hosted +[handoff contract](connect-share-handoff.md) is deployed and verified. + +Forge and NeoForge reuse the loader-neutral Kotlin core and version-specific +Minecraft UI/bridge adapters. Use the exact packaged artifact under test for +the real two-client Prism acceptance pass in +[the testing guide](connect-share-testing.md). diff --git a/docs/plans/2026-08-02-connect-share-adoption-foundation.md b/docs/plans/2026-08-02-connect-share-adoption-foundation.md new file mode 100644 index 000000000..a96ce1936 --- /dev/null +++ b/docs/plans/2026-08-02-connect-share-adoption-foundation.md @@ -0,0 +1,194 @@ +# Connect Share Adoption Foundation Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Turn the friend, compatibility, no-mod, safety, and follow behavior already present in PR #94 into acceptance-level evidence, fix every discovered gap TDD-first, and close only the subissues whose complete criteria are proven. + +**Architecture:** Keep the loader-neutral contracts in `share/common`, orchestration and presentation state in `share/fabric-common`, and Minecraft-version rendering/network bridges in their existing adapter modules. Reuse the repository-owned Prism E2E harness for product evidence; add focused regression tests only when an acceptance criterion is not already proved. + +**Tech Stack:** Kotlin/JVM 25, Arrow, kotlinx.coroutines, JUnit 5, Fabric/Forge/NeoForge adapters, Gradle, PrismLauncher, libp2p, Minekube Connect. + +## Global Constraints + +- Work only in `/Users/robin/.treehouse/connect-java-aadf0a/2/connect-java` on `codex/connect-share-mod`. +- Continue in PR #94 and do not merge it. +- Follow `share/AGENTS.md`; use Arrow typed errors/resources and TDD for every behavior change. +- Never print or persist endpoint tokens, invitation capabilities, private keys, IP addresses, or raw identity IDs in evidence. +- Pending relationships receive no presence; Ask Every Time remains the default and final live-test state. +- Friend control traffic remains direct libp2p; Connect is gameplay/no-mod fallback, not a social relay. +- Network work must not block Minecraft's render thread. +- A subissue closes only after every acceptance criterion has code/test or real-client evidence. + +--- + +### Task 1: Acceptance Evidence Matrix + +**Files:** +- Create: `docs/connect-share-adoption-evidence.md` +- Read: `share/common/src/main/kotlin/com/minekube/connect/share/**` +- Read: `share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/**` +- Read: `share/*/src/main/kotlin/com/minekube/connect/share/**` +- Read: `share/*/src/test/kotlin/com/minekube/connect/share/**` + +**Interfaces:** +- Consumes: GitHub acceptance criteria from #95, #96, #99, #100, and #103. +- Produces: A criterion-by-criterion table with `Deterministic proof`, `Product proof required`, or `Gap`, exact source/test paths, exact commands, and no unsubstantiated completion claims. + +- [x] **Step 1: Map each criterion to source and tests** + +Use `rg` to locate the implementation and focused regression for every criterion. Record an exact path and test method; mark missing coverage as `Gap` rather than inferring behavior. A deterministic test does not by itself prove a rendered or real-network product claim. + +- [x] **Step 2: Run the deterministic friend/safety suite** + +Run: + +```bash +./gradlew \ + :share:common:test \ + --tests '*AdmissionControllerTest*' \ + --tests '*CompatibilityProfileTest*' \ + --tests '*FriendControlWireTest*' \ + --tests '*FriendStoreTest*' \ + :share:fabric-common:test \ + --tests '*FabricShareBrowserTest*' \ + --tests '*FriendJoinOrchestratorTest*' \ + --tests '*FriendPresenceMonitorTest*' \ + --tests '*FriendRemovalSyncTest*' \ + --tests '*FriendRequestClientTest*' \ + --tests '*FriendRequestServerTest*' \ + --tests '*FriendsViewModelTest*' \ + --tests '*FollowNextSessionControllerTest*' \ + --tests '*LoadedCompatibilityProfileFactoryTest*' \ + --tests '*SecretRedactionTest*' \ + --tests '*ShareJoinDiagnosticsTest*' \ + --no-parallel +``` + +Expected: `BUILD SUCCESSFUL`. If a failure is product behavior rather than +environment setup, stop this plan and write a focused TDD fix plan naming the +exact failing production and test paths before changing code. + +- [x] **Step 3: Run every packaged adapter contract** + +Run: + +```bash +./gradlew \ + :share:fabric-1-20-1:test --tests '*Fabric1201ArtifactTest*' \ + :share:fabric-1-21-1:test --tests '*Fabric1211ArtifactTest*' \ + :share:fabric-1-21-11:test --tests '*Fabric12111ArtifactTest*' \ + :share:fabric-26-2:test --tests '*Fabric262ArtifactTest*' \ + :share:forge-1-20-1:test --tests '*Forge1201ArtifactTest*' \ + :share:neoforge-1-21-1:test --tests '*NeoForge1211ArtifactTest*' \ + --no-parallel +``` + +Expected: `BUILD SUCCESSFUL`, with each artifact test confirming its embedded UX/protocol vocabulary and runtime isolation. + +- [x] **Step 4: Write the evidence document** + +Create `docs/connect-share-adoption-evidence.md` with one section per subissue and this exact table shape: + +```markdown +| Acceptance criterion | Status | Evidence | Remaining proof | +|---|---|---|---| +| Confirmed friends see privacy-controlled activity | Product proof required | `FriendPresenceMonitorTest` and `FriendsViewModelTest` | Exact-head two-client screenshot/log | +``` + +Do not mark a real-client criterion proven from a unit test. + +- [x] **Step 5: Commit the evidence baseline** + +```bash +git add docs/connect-share-adoption-evidence.md docs/plans/2026-08-02-connect-share-adoption-foundation.md +git commit -m "docs(share): map universal party acceptance evidence" +``` + +--- + +### Task 2: Exact-Head Product Evidence + +**Files:** +- Modify: `docs/connect-share-adoption-evidence.md` +- Modify: `.agents/skills/connect-share-prism-e2e/SKILL.md` only for reusable procedures +- Test: `share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/PrismFriendJoinE2ETest.kt` + +**Interfaces:** +- Consumes: exact unclassified Fabric 26.2 artifact from the current committed head and two isolated Prism profiles. +- Produces: redacted evidence for persistent friend join, compatibility rejection/recovery, no-mod Direct Connect approval/join, relationship safety, and Follow Next Session. + +- [x] **Step 1: Build and hash the exact artifact** + +Run: + +```bash +./gradlew clean :share:fabric-26-2:connectShareJar --no-parallel +shasum -a 256 share/fabric-26.2/build/libs/connect-share-fabric-26.2-*.jar +``` + +Select only the unclassified packaged JAR and install exactly one matching copy in each modded Prism profile. + +- [ ] **Step 2: Prove confirmed-friend join and compatibility UX** + +Use `.agents/skills/connect-share-prism-e2e/SKILL.md`. Require exact artifact hashes, host `joined the game`, guest `Loaded … advancements`, and successful `PrismFriendJoinE2ETest`. Exercise a deliberately mismatched compatibility profile through deterministic tests and inspect the rendered recovery screen without exposing the complete mod inventory. + +- [ ] **Step 3: Prove the no-mod Direct Connect path** + +Temporarily remove Connect Share only from the guest profile, leaving its Minecraft version compatible. Copy the host's ordinary `*.play.minekube.net` address and launch the guest through vanilla Direct Connect. Exercise approval through the noninteractive admission harness when possible; if Minecraft UI interaction is the only remaining proof, record one explicit human checkpoint instead of automating clicks. Require fresh host/guest login evidence, confirm denial and timeout cannot reuse the admission, then restore the guest artifact and verify its hash afterward. + +- [ ] **Step 4: Prove relationship safety and Follow Next Session** + +With two confirmed modded friends, enable one-shot follow while the host is unavailable, start a new joinable world, and require exactly one join request. Verify cancellation, active-gameplay non-interruption, removal/block presence revocation, reciprocal removal convergence after reconnect, and final `ASK_EVERY_TIME` state. + +- [ ] **Step 5: Record only redacted evidence** + +Update the evidence matrix with timestamps, artifact SHA-256, test command/result, and safe log phrases. Never include the friend link, endpoint token, capability, IP, raw peer ID, or account ID. + +- [ ] **Step 6: Commit product evidence and reusable wisdom** + +```bash +git add docs/connect-share-adoption-evidence.md .agents/skills/connect-share-prism-e2e/SKILL.md share/AGENTS.md +git commit -m "test(share): prove universal party foundation" +``` + +Omit unchanged paths from `git add`. + +--- + +### Task 3: Close Proven Foundation Subissues + +**Files:** +- Modify: GitHub issues #95, #96, #99, #100, and #103 +- Modify: PR #94 comment/evidence only; never merge + +**Interfaces:** +- Consumes: the complete evidence matrix and pushed exact-head commits. +- Produces: concise issue completion comments and closed subissues only where every criterion is proven. + +- [ ] **Step 1: Run the focused and broad local gates** + +Run: + +```bash +./gradlew :share:common:test :share:fabric-common:test --no-parallel +./gradlew build --no-parallel +git diff --check +``` + +Expected: both Gradle commands `BUILD SUCCESSFUL`; worktree contains only intentional committed changes. + +- [ ] **Step 2: Run no-mistakes and wait for CI** + +Run the repository gate with intent naming the exact foundation subissues and product evidence. Accept only review/test/document/lint/push/PR/CI completion with no unresolved correctness finding. + +- [ ] **Step 3: Comment and close fully proven issues** + +For each eligible issue, comment with the pushed commit, deterministic test selectors, product evidence, and any deliberately deferred non-goal. Close with reason `completed`. Leave any issue with a missing criterion open and add the exact remaining row instead. + +- [ ] **Step 4: Update epic and PR evidence** + +Comment on #93 with the completed slice and next open dependency. Comment on PR #94 with exact-head evidence, check status, and confirmation that the PR remains unmerged. + +- [ ] **Step 5: Begin the next plan** + +Create the next independently testable plan for #98 and #97 based on the remaining evidence matrix. Do not mix global operations, device recovery, or growth assets into the foundation commit. diff --git a/docs/plans/2026-08-02-connect-share-encrypted-recovery.md b/docs/plans/2026-08-02-connect-share-encrypted-recovery.md new file mode 100644 index 000000000..2d2d1cbb7 --- /dev/null +++ b/docs/plans/2026-08-02-connect-share-encrypted-recovery.md @@ -0,0 +1,66 @@ +# Connect Share Encrypted Recovery Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let a player export and restore the persistent Connect Share social identity, relationships, access identity, preferences, and locally managed Connect endpoint as one passphrase-encrypted, integrity-checked, offline backup without revealing plaintext secrets to Minekube. + +**Architecture:** Add a loader-neutral recovery archive and transactional store in `share/common`, expose typed recovery operations through `share/fabric-common`, and keep Minecraft file-picker/password rendering in version adapters. The archive uses an authenticated binary envelope with a versioned header, PBKDF2-HMAC-SHA256, AES-256-GCM, a strict filename allowlist, bounded sizes, owner-only permissions where supported, and atomic replace/rollback semantics. Import validates and decrypts the complete bundle before touching live files. + +**Tech Stack:** Kotlin/JVM 17+, Arrow `Either`/`Raise`, JCA PBKDF2/AES-GCM/SecureRandom, Gson, JUnit 5, Minecraft Fabric adapters. + +## Constraints + +- Use only the isolated `codex/connect-share-mod` worktree and PR #94; do not merge. +- Never log, render, or commit archive plaintext, passphrases, private keys, endpoint tokens, friend capabilities, peer IDs, or account IDs. +- Accept passphrases as `CharArray`, clear derived password/key material where JCA permits, and never persist a recovery secret. +- Export only the explicit recovery allowlist; reject traversal, symlinks, oversized files, duplicates, unknown entries, and unsupported versions. +- Keep dashboard endpoint-token import separate from social recovery in names, screens, and docs. +- Import must fail closed and leave the current installation byte-for-byte unchanged on wrong secret, tampering, interruption, or partial-write failure. + +### Task 1: Authenticated Recovery Archive + +**Files:** +- Create: `share/common/src/main/kotlin/com/minekube/connect/share/recovery/RecoveryArchive.kt` +- Test: `share/common/src/test/kotlin/com/minekube/connect/share/recovery/RecoveryArchiveTest.kt` + +- [x] Write failing tests for round trip, wrong secret, one-byte tampering, unsupported version, oversized archive, missing required identity, unknown/duplicate filename, and empty/weak passphrase validation. +- [x] Implement a bounded version-1 envelope with fixed magic, KDF/cipher identifiers, iteration count, random salt/nonce, authenticated header, and AES-GCM ciphertext. +- [x] Encode a versioned JSON manifest containing only filename, byte length, and Base64 content; validate the entire manifest before returning plaintext entries. +- [x] Run `./gradlew :share:common:test --tests '*RecoveryArchiveTest*' --no-parallel` and require the intentional red run followed by green. + +### Task 2: Atomic Export, Import, and Rollback + +**Files:** +- Create: `share/common/src/main/kotlin/com/minekube/connect/share/recovery/RecoveryStore.kt` +- Test: `share/common/src/test/kotlin/com/minekube/connect/share/recovery/RecoveryStoreTest.kt` + +- [x] Write failing tests proving the allowlist, offline round trip, identity rotation rollback, wrong-secret no-op, atomic export replacement, import rollback after an injected replacement failure, recovery from an interrupted transaction, and owner-only output permissions where POSIX is available. +- [x] Export required social identity, gameplay identity, access identity, and friends plus optional preferences and locally stored endpoint config/token; omit absent optional entries and reject missing required entries. +- [x] Stage every import, durably back up existing allowlisted files, write a transaction marker, replace in deterministic order, fsync, mark committed, and clean up; recover a leftover uncommitted marker before any new operation. +- [x] Return an Arrow-typed summary that reveals counts/entry categories but never names, IDs, addresses, or secret material. +- [x] Run `./gradlew :share:common:test --tests '*RecoveryStoreTest*' --no-parallel` and the complete `:share:common:test` suite. + +### Task 3: Recovery UX and Relationship Semantics + +**Files:** +- Create: `share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/recovery/RecoveryViewModel.kt` +- Test: `share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/recovery/RecoveryViewModelTest.kt` +- Modify: each supported Fabric settings/friends adapter and `en_us.json`/`de_de.json` +- Modify: `docs/connect-share.md` + +- [x] Write failing pure-view-model tests for export confirmation, import preview, wrong-secret/tamper messages, busy-state nonblocking behavior, restart-required success, password clearing, and dashboard-import wording separation. +- [x] Add **Back up friends** and **Restore backup** flows with persistent labels, passphrase confirmation on export, explicit overwrite/restart confirmation on import, clear content/loss warnings, and no secret in mutable state after completion. +- [x] Run file/crypto work on IO dispatchers; render only typed summaries and safe localizable errors. +- [x] Document offline backup, loss of recovery secret, device-copy risks, identity rotation/re-verification, concurrent-device single-active-device semantics, revocation, and the separate dashboard endpoint import. +- [x] Add deterministic tests for simultaneous-device duplicate suppression and rotation invalidating the prior identity, or record the exact remaining protocol gap rather than claiming it. + +### Task 4: Evidence and Delivery + +**Files:** +- Modify: `docs/connect-share-adoption-evidence.md` +- Modify: `.agents/skills/connect-share-prism-e2e/SKILL.md` and `share/AGENTS.md` only for reusable discoveries + +- [x] Build all supported artifacts and assert recovery strings/entrypoints are packaged where applicable. +- [ ] Export from one isolated Prism profile, rotate its local files, import into a stopped second profile, and prove the restored friend identity/relationship offline without exposing archive contents. +- [ ] Verify wrong-secret and tampered archives do not change either profile, then leave both profiles in safe Ask Every Time state with matching intended artifacts. +- [ ] Commit and push incremental reviewed commits to PR #94; comment on #120 with deterministic and product evidence, leaving any account-backed or external-device service work precisely open. diff --git a/docs/plans/2026-08-03-connect-share-release-operations.md b/docs/plans/2026-08-03-connect-share-release-operations.md new file mode 100644 index 000000000..36bdaf6f5 --- /dev/null +++ b/docs/plans/2026-08-03-connect-share-release-operations.md @@ -0,0 +1,54 @@ +# Connect Share Release and Operations Plan + +**Goal:** Make every repository-owned release, operations, security, launch, and +HTTPS-handoff requirement in epic #93 explicit and enforceable without claiming +that external services or reviews already exist. + +**Architecture:** Keep the Minecraft client free of a telemetry or web-service +dependency until those services have reviewed schemas and real endpoints. Put +release gates in GitHub Actions, stable human contracts in `docs/`, and map each +external dependency to an owner, verification artifact, and issue criterion. + +## Task 1: Distribution and provenance + +- [x] Verify the six-adapter release workflow fails closed before publication. +- [x] Add artifact provenance/signing only through a supported GitHub primitive. +- [ ] Verify the resulting attestations against a disposable prerelease. This + is an external credentialed product gate and remains recorded in evidence. +- [x] Preserve exact loader/version names, dependency metadata, checksums, and + the 63 MiB release budget. + +## Task 2: Global fallback operations and security + +- [x] Define regional SLOs, error budgets, allowed observability fields, load + distributions, capacity math, rate-limit principles, chaos gates, runbooks, + rollback, incident communication, and cost protection. +- [x] Threat-model invitations, imported endpoint credentials, admission, + social identity, recovery, diagnostics, relays, and update distribution. +- [x] Identify infrastructure tests and independent review as external gates; + never convert a document into a production-readiness claim. + +## Task 3: Staged launch and measurement + +- [x] Define launch/pause/rollback/graduation gates and beta cohorts. +- [x] Define a strict opt-in aggregate metrics schema with no stable social + identity, graph, invitation, token, key, address, username, world, chat, or + complete inventory fields. +- [x] Provide marketplace and creator-kit source copy, localization process, + support loop, and known-issues contract. + +## Task 4: HTTPS handoff boundary + +- [x] Specify a fragment-only invite transport, local verification, one-shot + resume state, safe launcher adapters, vanilla fallback, CSP/referrer policy, + expiry/revocation handling, and browser/launcher E2E matrix. +- [x] Do not emit a default HTTPS invite until a deployed handoff page is + independently verified at the configured origin. + +## Task 5: Evidence and handoff + +- [x] Extend the acceptance matrix for #117, #118, and #119. +- [x] Run Markdown/link checks available in the repository, workflow syntax + checks, targeted tests, the broader build, and `git diff --check`. +- [x] Push reviewed commits to PR #94; comment on each issue with completed + repository work and exact external gates. Keep the PR unmerged. diff --git a/docs/superpowers/plans/2026-07-06-bedrock-identity-enforcement.md b/docs/superpowers/plans/2026-07-06-bedrock-identity-enforcement.md index 83366309a..881958a65 100644 --- a/docs/superpowers/plans/2026-07-06-bedrock-identity-enforcement.md +++ b/docs/superpowers/plans/2026-07-06-bedrock-identity-enforcement.md @@ -1,6 +1,7 @@ # Superseded Bedrock identity implementation plan This historical plan covered the initial static-key, opt-in enforcement phase. It is not the -current configuration contract: generated installs now use Minekube metadata in non-rejecting -`warn` mode by default. See [the current Bedrock identity documentation](../../bedrock-identity.md) -for defaults, trust boundaries, and operator pinning instructions. +current configuration contract. The original v1 path used `bedrock-identity.enforcement: warn` +by default. See +[the current Bedrock identity documentation](../../bedrock-identity.md) for the current +configuration, trust boundaries, and operator pinning instructions. diff --git a/docs/superpowers/plans/2026-07-30-connect-share-direct-p2p.md b/docs/superpowers/plans/2026-07-30-connect-share-direct-p2p.md new file mode 100644 index 000000000..985db3445 --- /dev/null +++ b/docs/superpowers/plans/2026-07-30-connect-share-direct-p2p.md @@ -0,0 +1,84 @@ +# Connect Share Direct P2P Implementation Plan + +**Goal:** Complete the approved Connect Share scope with automatic same-LAN +mod-to-mod joins, explicitly opted-in internet-direct attempts, signed +invitations, and exactly-once Connect fallback. + +**Architecture:** Keep the existing Minecraft listener bound to loopback. An +isolated child-loaded jvm-libp2p node advertises and discovers active shares +with mDNS, validates a signed versioned invitation/preface, and proxies the +resulting byte stream to the loopback Minecraft listener. The guest creates a +loopback-only proxy so vanilla Minecraft's client protocol remains unchanged. +Only JDK types and small immutable boundary records cross the reflective +classloader boundary. + +**Policy invariants:** + +- Same-LAN discovery and direct dialing are automatic when both players have + the mod. +- Internet candidates are gathered and used only after explicit opt-in on both + peers. +- No circuit-relay address is accepted or advertised by the direct runtime. +- Connect is the sole relay and the only fallback after a failed direct dial. +- Direct online authentication never downgrades to offline. Offline identity is + visibly unverified and approved per connection. +- Friend peer identities and access capabilities persist so confirmed friends + can reconnect across worlds. Active direct sessions and approvals are scoped + to a share; invitations remain time-limited and approval-bound. The Connect + endpoint token remains persistent as well. + +## Task 1: Common invitation and route policy + +- Add tests for signed invitation round-trip, tampering, expiry, version + rejection, relay-address rejection, redaction, LAN-first ordering, dual + internet opt-in, and exactly-once Connect fallback. +- Add Arrow-based invitation validation and transport selection models in + `share/common`. +- Extend share options and state with direct-path status without exposing + candidates or capabilities in `toString`. + +## Task 2: Isolated libp2p host, discovery, and guest proxy + +- Add failing Core tests for two loopback hosts exchanging a + Minecraft-shaped stream, mDNS metadata resolution, persistent identities, + signed invitation validation, and classloader boundary safety. +- Add parent-first JDK-only direct boundary types and a reflective + `DirectP2pNode` facade. +- Implement the child-loaded runtime with Noise, Yamux, TCP, mDNS, + versioned control frames, signed invitations, bounded timeouts, and no relay + transport. +- Implement a host stream-to-loopback socket proxy and a guest loopback-only + socket-to-stream proxy. + +## Task 3: Host lifecycle and admission + +- Add coordinator tests proving direct survives Connect failure, Connect + survives direct failure, both are cleaned up, and no ingress yields `FAILED`. +- Add a `DirectShareIngress` resource to `ShareCoordinator` and report Connect, + LAN, and internet statuses independently. +- Tag proxied direct sockets before Minecraft initializes login. +- Gate direct login after profile resolution. Reject an online request when + Mojang authentication did not complete; treat explicit offline mode as + unverified and approve it per connection. + +## Task 4: Guest discovery, invitation join, and fallback + +- Add a shared browser/join service with bounded LAN and internet timeouts. +- Start discovery when the **Friends**/**Join Connect Share** UI is open and remove it on + close. +- Add native Minecraft **Join Connect Share** UI to both Fabric versions, including paste + handling, path status, internet IP-disclosure confirmation, and actionable + no-route errors. +- Route the successful local proxy address through each version's normal + Minecraft connection screen. + +## Task 5: Packaging, documentation, and verification + +- Assert direct runtime classes remain inside the isolated payload and all + public parent signatures reject isolated libp2p, Netty, Kotlin, and kotlinx + types. +- Build and boot both exact Fabric targets. +- Update manual acceptance documentation and Epic #83 with implemented scope + and the real-network checks still requiring two machines/live Connect. +- Run targeted tests, both mod builds, the broader Gradle build, artifact + inspection, and a final diff/review pass. diff --git a/docs/superpowers/plans/2026-07-30-connect-share-pasted-lan-invite.md b/docs/superpowers/plans/2026-07-30-connect-share-pasted-lan-invite.md new file mode 100644 index 000000000..0a12909b0 --- /dev/null +++ b/docs/superpowers/plans/2026-07-30-connect-share-pasted-lan-invite.md @@ -0,0 +1,303 @@ +# Connect Share Pasted LAN Invitation Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make a pasted Connect Share invitation prefer its already-validated +same-LAN mDNS discovery before falling back to Minekube Connect. + +**Architecture:** Keep reconciliation inside `FabricShareBrowser`, which owns +both parsed invitations and the validated discovery snapshot. Derive one +effective LAN address from the explicit UI selection or a discovery with the +same signed `shareId` and `peerId`, then pass that address through the existing +route planner and fallback loop. + +**Tech Stack:** Kotlin 2.4.10, Arrow 2.2.3, kotlinx.coroutines, Fabric, +JUnit Platform through Kotlin Test, Gradle. + +## Global Constraints + +- LAN addresses remain outside copied invitations and logs. +- A discovery match requires both `shareId` and `peerId`. +- The pasted invitation remains the source of the tunnel capability. +- Missing or failed LAN discovery preserves internet-direct and Connect + fallback. +- The behavior is implemented once in `share/fabric-common` for Minecraft + 1.21.11 and 26.2. +- Use Arrow where it supplies an appropriate abstraction, following + `share/AGENTS.md`; keep the existing nullable Fabric interop parameter. + +--- + +### Task 1: Reconcile Pasted Invitations With Validated LAN Discovery + +**Files:** +- Modify: `share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FabricShareBrowserTest.kt` +- Modify: `share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FabricShareBrowser.kt` + +**Interfaces:** +- Consumes: `FabricShareBrowser.discovered`, `SignedShareInvite.payload`, + `DiscoveredLanShare.lanAddress`, and the existing nullable + `FabricShareBrowser.join(..., lanAddress: String?, ...)` parameter. +- Produces: private + `matchingLanAddress(invitation: SignedShareInvite): String?` and an effective + LAN address used by `TransportSelector.plan` and `openDirect`. + +- [ ] **Step 1: Write the failing regression and identity-match tests** + +Add tests that start discovery, inject signed nearby shares, and call `join` +as the paste path does with `lanAddress = null`: + +```kotlin +@Test +fun `pasted invitation uses its matching discovered LAN address`() = runTest { + val node = FakeGuestNode() + val browser = browser(node) + browser.start() + val invitation = invitation() + node.discover( + DirectP2pDiscoveredShare( + "Robin's World", + PEER_ID, + LAN_ADDRESS, + invitation, + ), + ) + + val result = browser.join( + invitationUri = invitation, + lanAddress = null, + internetOptIn = false, + authMode = DirectP2pAuthMode.OFFLINE, + ) + + val target = assertIs>(result).value + assertEquals(ShareRoute.DIRECT_LAN, target.route) + assertEquals(listOf(LAN_ADDRESS), node.openedAddresses) + target.close() + browser.close() +} + +@Test +fun `pasted invitation ignores discovery with a different peer`() = runTest { + val node = FakeGuestNode() + val browser = browser(node) + browser.start() + val otherPeer = "12D3KooWOther" + node.discover( + DirectP2pDiscoveredShare( + "Other World", + otherPeer, + lanAddress(otherPeer), + invitation(peerId = otherPeer), + ), + ) + + val result = browser.join( + invitationUri = invitation(), + lanAddress = null, + internetOptIn = false, + authMode = DirectP2pAuthMode.OFFLINE, + ) + + assertIs>(result) + assertTrue(node.openedAddresses.isEmpty()) + browser.close() +} + +@Test +fun `pasted invitation ignores discovery with a different share`() = runTest { + val node = FakeGuestNode() + val browser = browser(node) + browser.start() + val otherShare = UUID.fromString("72a5d404-0ef9-48bc-882b-a2ec896afbe5") + node.discover( + DirectP2pDiscoveredShare( + "Other World", + PEER_ID, + LAN_ADDRESS, + invitation(shareId = otherShare), + ), + ) + + val result = browser.join( + invitationUri = invitation(), + lanAddress = null, + internetOptIn = false, + authMode = DirectP2pAuthMode.OFFLINE, + ) + + assertIs>(result) + assertTrue(node.openedAddresses.isEmpty()) + browser.close() +} +``` + +Make the invitation fixture accept identity parameters and generate matching +direct candidates: + +```kotlin +private fun invitation( + shareId: UUID = SHARE_ID, + peerId: String = PEER_ID, +): String { + val pair = KeyPairGenerator.getInstance("Ed25519").generateKeyPair() + val payload = ShareInvitePayload( + wireVersion = ShareInviteCodec.WIRE_VERSION, + shareId = shareId, + expiresAtEpochMillis = NOW + 60_000, + connectAddress = "amber-fox.play.minekube.net", + peerId = peerId, + internetDirectEnabled = true, + directCandidates = listOf(internetAddress(peerId)), + capability = CAPABILITY, + ) + val unsigned = ShareInviteCodec.unsignedBytes(payload, pair.public.encoded) + val signature = Signature.getInstance("Ed25519").run { + initSign(pair.private) + update(unsigned) + sign() + } + return ShareInviteCodec.encode( + SignedShareInvite(payload, pair.public.encoded, signature), + ) +} + +private fun lanAddress(peerId: String) = + "/ip4/192.168.1.20/tcp/4001/p2p/$peerId" + +private fun internetAddress(peerId: String) = + "/ip6/2001:db8::20/tcp/4001/p2p/$peerId" +``` + +- [ ] **Step 2: Run the focused test and verify RED** + +Run: + +```sh +./gradlew :share:fabric-common:test \ + --tests com.minekube.connect.share.fabric.FabricShareBrowserTest +``` + +Expected: FAIL in +`pasted invitation uses its matching discovered LAN address` because the +result is `GuestJoinTarget.Connect`, while the two mismatch tests pass. + +- [ ] **Step 3: Implement the minimal common browser fix** + +After parsing the invitation, derive and use the effective address: + +```kotlin +val payload = invitation.payload +val effectiveLanAddress = + lanAddress ?: matchingLanAddress(invitation) +val routes = TransportSelector.plan( + sameLan = effectiveLanAddress != null, + hostInternetOptIn = payload.internetDirectEnabled, + guestInternetOptIn = internetOptIn, + connectAddress = payload.connectAddress, +) +``` + +Use `effectiveLanAddress` in the `DIRECT_LAN` branch and add: + +```kotlin +private fun matchingLanAddress( + invitation: SignedShareInvite, +): String? { + val payload = invitation.payload + return mutableDiscovered.value.firstOrNull { + val discovered = it.invitation.payload + discovered.shareId == payload.shareId && + discovered.peerId == payload.peerId + }?.lanAddress +} +``` + +- [ ] **Step 4: Run focused tests and verify GREEN** + +Run: + +```sh +./gradlew :share:fabric-common:test \ + --tests com.minekube.connect.share.fabric.FabricShareBrowserTest +``` + +Expected: all `FabricShareBrowserTest` cases PASS. + +- [ ] **Step 5: Run the common-module suite** + +Run: + +```sh +./gradlew :share:fabric-common:test +``` + +Expected: `BUILD SUCCESSFUL`. + +- [ ] **Step 6: Commit the tested fix** + +```sh +git add \ + share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FabricShareBrowser.kt \ + share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FabricShareBrowserTest.kt +git commit -m "fix(share): prefer discovered LAN route for pasted invites" +``` + +### Task 2: Build, Install, and Live-Verify Both Fabric Versions + +**Files:** +- Verify: `share/fabric-26.2/build/libs/connect-share-fabric-26.2-0.13.3-SNAPSHOT.jar` +- Verify: `share/fabric-1.21.11/build/libs/connect-share-fabric-1.21.11-0.13.3-SNAPSHOT.jar` +- Install: PrismLauncher `26.2`, `26.2 two`, and `1.21.11` instance `mods` directories. + +**Interfaces:** +- Consumes: the committed common browser behavior from Task 1. +- Produces: clean Fabric artifacts installed in all configured test instances + and evidence that a pasted invitation selects the loopback direct proxy. + +- [ ] **Step 1: Run repository-wide verification** + +Run: + +```sh +./gradlew build +``` + +Expected: `BUILD SUCCESSFUL`, including artifact isolation tests for both +Fabric versions. + +- [ ] **Step 2: Install the clean artifacts** + +Copy the exact non-dirty JARs into: + +```text +/Users/robin/Library/Application Support/PrismLauncher/instances/26.2/minecraft/mods/ +/Users/robin/Library/Application Support/PrismLauncher/instances/26.2 two/minecraft/mods/ +/Users/robin/Library/Application Support/PrismLauncher/instances/1.21.11/minecraft/mods/ +``` + +Remove only obsolete `connect-share-fabric-*.jar` files from those three +`mods` directories, preserving Fabric API, Fabric Language Kotlin, and all +unrelated mods. Verify each installed artifact's SHA-256 against its matching +build output. + +- [ ] **Step 3: Restart both 26.2 test clients and verify mod loading** + +Gracefully stop only the two running 26.2 Minecraft processes. Relaunch +PrismLauncher instances `26.2` and `26.2 two`, using the existing offline +`ConnectGuest` profile where configured. Check both `latest.log` files for the +Connect Share mod version and absence of mixin, class-loading, or linkage +errors. + +- [ ] **Step 4: Verify a real pasted-invitation direct LAN join** + +Start sharing on one 26.2 client, wait until the other client discovers the +same signed share over mDNS, paste the invitation into the join screen, and +join without internet-direct opt-in. + +Expected evidence: + +- the guest log connects to `127.0.0.1:`, not a + `*.play.minekube.net` hostname; +- the host accepts the session through `DIRECT_LAN`; and +- the guest reaches the world without a Connect relay connection. diff --git a/docs/superpowers/plans/2026-07-30-connect-share-singleplayer.md b/docs/superpowers/plans/2026-07-30-connect-share-singleplayer.md new file mode 100644 index 000000000..da14712d4 --- /dev/null +++ b/docs/superpowers/plans/2026-07-30-connect-share-singleplayer.md @@ -0,0 +1,1369 @@ +# Connect Share Singleplayer Ingress Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build the first working Connect Share vertical slice: a Kotlin Fabric client mod for Minecraft 1.21.11 and 26.2 that privately publishes the current singleplayer world, reuses or imports one persistent Connect endpoint identity, accepts paid and non-paid vanilla Java guests through Connect, and asks the host to approve each guest. + +**Architecture:** Pure Kotlin domain logic lives in `share/common`; reusable Fabric lifecycle and presentation logic lives in `share/fabric-common`; the two Fabric modules contain only their exact Minecraft adapter, mixins, resources, and packaging rules. A small Java extension to Connect Core adds asynchronous session admission and reusable credential primitives. The integrated server binds vanilla only to loopback, while Connect tunnels enter through `LocalServerChannelWrapper` using the captured vanilla child initializer. + +**Tech Stack:** Gradle 9.5.1, Fabric Loom 1.17.17, Fabric Loader 0.19.3, Fabric API 0.141.6+1.21.11 and 0.156.0+26.2, Fabric Language Kotlin 1.13.13+kotlin.2.4.10, Kotlin 2.4.10, Java 21 and 25 toolchains, JUnit 5, MockWebServer, Netty Local transport, Connect WatchService, jvm-libp2p 1.3.5-RELEASE. + +## Global Constraints + +- Work only in the Treehouse worktree on `codex/connect-share-mod`; preserve the root worktree and all user changes. +- Keep plugin artifacts, mod artifacts, and production rollout as separate gates. +- Support exactly Minecraft `1.21.11` on Java 21 and Minecraft `26.2` on Java 25 in this plan. +- Use `net.fabricmc.fabric-loom-remap` for 1.21.11 and `net.fabricmc.fabric-loom` for 26.2. +- Product logic is Kotlin. Java is permitted only for mixins/accessors and the existing Java Core extension. +- Never bind a Minecraft listener to a wildcard, LAN, or WAN address. The vanilla TCP listener must bind `InetAddress.getLoopbackAddress()`. +- Persist one endpoint name and token per installation. A world or share ID must never generate endpoint credentials. +- Accept `CONNECT_ENDPOINT` and `CONNECT_TOKEN` with per-field precedence over disk. +- Dashboard imports require endpoint name plus token, validate before persistence, and leave the previous identity intact on every failure. +- Do not automatically rotate an endpoint name or token after authentication failure. +- Connect is the only relay. This plan does not add a second relay service. +- Accept paid and non-paid Connect sessions. Managed non-passthrough profiles are Connect-authenticated; locally accepted offline profiles are unverified and connection-scoped. +- Host approval expires after 30 seconds. At most 16 approvals may be pending and at most 16 guests may be configured. +- Non-passthrough Connect profiles are approved before tunnel creation. Passthrough profiles are approved after Minecraft resolves local authentication but before world entry. +- Preserve the reflective libp2p boundary. Parent-facing signatures must not expose `io.libp2p`, isolated `io.netty`, or isolated `kotlin` types. +- Every implementation task is test-first and ends in a focused Conventional Commit. + +## Delivery Split + +This plan is the independently testable singleplayer-through-Connect slice. It ends with two installable Fabric JARs and real Connect ingress. The already approved direct-P2P scope follows in a second plan after this slice is green: signed invitations, automatic LAN libp2p, opt-in internet direct attempts, and Connect fallback. + +## File Map + +### Build and automation + +- `gradle/wrapper/gradle-wrapper.properties` - Gradle 9.5.1 wrapper. +- `settings.gradle.kts` - Fabric repositories/plugins and four Share projects. +- `build.gradle.kts` - keeps Java-11 plugin conventions away from Fabric projects. +- `build-logic/src/main/kotlin/Versions.kt` - pins Loom/Fabric/Kotlin/Arrow/libp2p versions. +- `share/AGENTS.md` - requires appropriate Arrow abstractions throughout the Kotlin mod. +- `.github/workflows/pullrequest.yml` - plugin matrix plus isolated Java-21/25 mod jobs. + +### Connect Core extension + +- `core/src/main/java/com/minekube/connect/identity/EndpointTokenStore.java` - plugin-compatible token loading, generation, owner-only atomic persistence, and redaction. +- `core/src/main/java/com/minekube/connect/watch/SessionAdmissionGate.java` - asynchronous pre-tunnel admission port. +- `core/src/main/java/com/minekube/connect/watch/SessionAdmissionDecision.java` - allow/defer/deny result with safe guest message. +- `core/src/main/java/com/minekube/connect/watch/AllowAllSessionAdmissionGate.java` - preserves plugin behavior. +- `core/src/main/java/com/minekube/connect/register/WatcherRegister.java` - invokes the gate before `Tunneler.prepare` or `LocalSession.connect`. +- `core/src/main/java/com/minekube/connect/ConnectPlatform.java` - accepts a prebuilt `ConnectConfig` for embedded clients. +- `core/src/main/java/com/minekube/connect/config/ConnectConfig.java` - explicit embedded configuration factory. +- `core/src/main/java/com/minekube/connect/module/CommonModule.java` - uses `EndpointTokenStore`. + +### Loader-neutral Kotlin domain + +- `share/common/src/main/kotlin/com/minekube/connect/share/identity/EndpointIdentity.kt` - endpoint/token value and source. +- `share/common/src/main/kotlin/com/minekube/connect/share/identity/EndpointIdentityStore.kt` - persistent generated/imported/environment identity. +- `share/common/src/main/kotlin/com/minekube/connect/share/identity/EndpointCredentialValidator.kt` - validation port. +- `share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/RandomEndpointNameSource.kt` - normal Connect random-name service with bounded fallback. +- `share/common/src/main/kotlin/com/minekube/connect/share/admission/AdmissionIdentity.kt` - Connect-, Mojang-, and locally-unverified identity types. +- `share/common/src/main/kotlin/com/minekube/connect/share/admission/AdmissionController.kt` - pending/approved decisions and limits. +- `share/common/src/main/kotlin/com/minekube/connect/share/ShareOptions.kt` - game mode, cheats, and guest capacity. +- `share/common/src/main/kotlin/com/minekube/connect/share/ShareState.kt` - state model. +- `share/common/src/main/kotlin/com/minekube/connect/share/ShareCoordinator.kt` - ordered start/stop and cleanup. +- `share/common/src/main/kotlin/com/minekube/connect/share/MinecraftShareBridge.kt` - local bridge port. +- `share/common/src/main/kotlin/com/minekube/connect/share/ConnectShareIngress.kt` - Connect ingress port. + +### Shared Fabric runtime + +- `share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/ConnectShareClient.kt` - singleton client lifecycle. +- `share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/ConnectShareRuntime.kt` - constructs Core/Fabric adapters. +- `share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FabricSessionAdmissionGate.kt` - maps Core proposals to `AdmissionController`. +- `share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FabricConnectIngress.kt` - starts/stops the embedded Connect graph. +- `share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/ui/ShareViewModel.kt` - screen state and user actions. + +### Per-version Fabric adapters + +- `share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/Minecraft12111Bridge.kt` +- `share/fabric-1.21.11/src/main/java/com/minekube/connect/share/fabric/v1_21_11/mixin/ServerConnectionListenerMixin.java` +- `share/fabric-1.21.11/src/main/java/com/minekube/connect/share/fabric/v1_21_11/mixin/IntegratedServerAccessor.java` +- `share/fabric-1.21.11/src/main/java/com/minekube/connect/share/fabric/v1_21_11/mixin/ServerConnectionListenerAccessor.java` +- `share/fabric-1.21.11/src/main/java/com/minekube/connect/share/fabric/v1_21_11/mixin/ConnectionAccessor.java` +- `share/fabric-1.21.11/src/main/java/com/minekube/connect/share/fabric/v1_21_11/mixin/PauseScreenMixin.java` +- `share/fabric-1.21.11/src/main/java/com/minekube/connect/share/fabric/v1_21_11/mixin/ServerLoginPacketListenerMixin.java` +- `share/fabric-26.2/src/main/kotlin/com/minekube/connect/share/fabric/v26_2/Minecraft262Bridge.kt` +- `share/fabric-26.2/src/main/java/com/minekube/connect/share/fabric/v26_2/mixin/ServerConnectionListenerMixin.java` +- `share/fabric-26.2/src/main/java/com/minekube/connect/share/fabric/v26_2/mixin/IntegratedServerAccessor.java` +- `share/fabric-26.2/src/main/java/com/minekube/connect/share/fabric/v26_2/mixin/ServerConnectionListenerAccessor.java` +- `share/fabric-26.2/src/main/java/com/minekube/connect/share/fabric/v26_2/mixin/ConnectionAccessor.java` +- `share/fabric-26.2/src/main/java/com/minekube/connect/share/fabric/v26_2/mixin/PauseScreenMixin.java` +- `share/fabric-26.2/src/main/java/com/minekube/connect/share/fabric/v26_2/mixin/ServerLoginPacketListenerMixin.java` +- Each version module owns `fabric.mod.json`, its mixin JSON, translations, icon, and artifact verification test. + +--- + +### Task 1: Add the isolated multi-version Fabric build + +**Files:** +- Modify: `gradle/wrapper/gradle-wrapper.properties` +- Modify: `gradle.properties` +- Modify: `settings.gradle.kts` +- Modify: `build.gradle.kts` +- Modify: `build-logic/src/main/kotlin/Versions.kt` +- Create: `share/common/build.gradle.kts` +- Create: `share/fabric-common/build.gradle.kts` +- Create: `share/fabric-1.21.11/build.gradle.kts` +- Create: `share/fabric-26.2/build.gradle.kts` +- Create: `share/common/src/main/kotlin/com/minekube/connect/share/ShareBuild.kt` +- Create: `share/common/src/test/kotlin/com/minekube/connect/share/BuildPinsTest.kt` + +**Interfaces:** +- Consumes: Existing root versioning through `gitVersion()` and existing `:api`/`:core` projects. +- Produces: Gradle projects `:share:common`, `:share:fabric-common`, `:share:fabric-1-21-11`, and `:share:fabric-26-2`; constants `Versions.fabricLoaderVersion`, `fabricApi12111Version`, `fabricApi262Version`, `fabricLanguageKotlinVersion`, `kotlinVersion`, `coroutinesVersion`, `arrowVersion`, and `loomVersion`. + +- [x] **Step 1: Write the failing build-pin test** + +```kotlin +package com.minekube.connect.share + +import kotlin.test.Test +import kotlin.test.assertEquals + +class BuildPinsTest { + @Test + fun wireProtocolStartsAtOne() { + assertEquals(1, ShareBuild.WIRE_PROTOCOL) + assertEquals("connect-share", ShareBuild.MOD_ID) + } +} +``` + +Create the production type referenced by the test only after observing the failure: + +```kotlin +package com.minekube.connect.share + +object ShareBuild { + const val MOD_ID = "connect-share" + const val WIRE_PROTOCOL = 1 +} +``` + +- [x] **Step 2: Add the exact Gradle pins and project includes** + +Add these constants to `Versions.kt`: + +```kotlin +const val loomVersion = "1.17.17" +const val fabricLoaderVersion = "0.19.3" +const val fabricApi12111Version = "0.141.6+1.21.11" +const val fabricApi262Version = "0.156.0+26.2" +const val fabricLanguageKotlinVersion = "1.13.13+kotlin.2.4.10" +const val kotlinVersion = "2.4.10" +const val coroutinesVersion = "1.11.0" +const val arrowVersion = "2.2.3" +const val jvmLibp2pVersion = "1.3.5-RELEASE" +``` + +Add `maven("https://maven.fabricmc.net/")` to dependency and plugin repositories. Register both Loom plugin IDs at `1.17.17` and Kotlin JVM at `2.4.10`. Include: + +```kotlin +include(":share:common") +include(":share:fabric-common") +include(":share:fabric-1-21-11") +include(":share:fabric-26-2") +``` + +Set the wrapper URL exactly: + +```properties +distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip +``` + +Give the combined Loom-remap and plugin-shadow build enough heap: + +```properties +org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=768m +``` + +- [x] **Step 3: Keep plugin and Fabric conventions separate** + +In root `build.gradle.kts`, use Gradle-safe project paths (the directory names +retain dots while Gradle project names use hyphens): + +```kotlin +val shareProjectPaths = setOf( + ":share", + ":share:common", + ":share:fabric-common", + ":share:fabric-1-21-11", + ":share:fabric-26-2", +) +``` + +Apply the existing Java-11/Lombok/Shadow conventions only when +`path !in shareProjectPaths`. The common modules apply Kotlin JVM and target +Java 21. The 1.21.11 module applies `net.fabricmc.fabric-loom-remap` and Java +21. The 26.2 module applies `net.fabricmc.fabric-loom` and Java 25. Declare the +Kotlin plugin once on the root with `apply false` so Gradle shares one plugin +classloader across the modules. + +The `share/common` dependencies are: + +```kotlin +implementation(projects.core) +implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:${Versions.coroutinesVersion}") +api(platform("io.arrow-kt:arrow-stack:${Versions.arrowVersion}")) +api("io.arrow-kt:arrow-core") +implementation("io.arrow-kt:arrow-fx-coroutines") +testImplementation(kotlin("test")) +testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:${Versions.coroutinesVersion}") +testRuntimeOnly("org.junit.platform:junit-platform-launcher") +``` + +The `share/fabric-common` dependencies are: + +```kotlin +implementation(projects.core) +implementation(projects.share.common) +implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:${Versions.coroutinesVersion}") +implementation(platform("io.arrow-kt:arrow-stack:${Versions.arrowVersion}")) +implementation("io.arrow-kt:arrow-core") +implementation("io.arrow-kt:arrow-fx-coroutines") +implementation("com.squareup.okhttp3:okhttp:4.9.3") +testImplementation(kotlin("test")) +testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:${Versions.coroutinesVersion}") +testImplementation("com.squareup.okhttp3:mockwebserver:4.9.3") +testRuntimeOnly("org.junit.platform:junit-platform-launcher") +``` + +Both common modules configure `tasks.test { useJUnitPlatform() }`. + +The 1.21.11 dependency block must contain: + +```kotlin +minecraft("com.mojang:minecraft:1.21.11") +mappings(loom.officialMojangMappings()) +modImplementation("net.fabricmc:fabric-loader:${Versions.fabricLoaderVersion}") +modImplementation("net.fabricmc.fabric-api:fabric-api:${Versions.fabricApi12111Version}") +modImplementation("net.fabricmc:fabric-language-kotlin:${Versions.fabricLanguageKotlinVersion}") +implementation(projects.core) +implementation(projects.share.common) +implementation(projects.share.fabricCommon) +``` + +The 26.2 block uses `minecraft("com.mojang:minecraft:26.2")`, no mappings +dependency, and ordinary `implementation` dependencies for Fabric Loader, +Fabric API at `Versions.fabricApi262Version`, and Fabric Language Kotlin. The +non-remapping Loom plugin intentionally does not create `modImplementation`. + +Loom owns project-local repositories for remapped artifacts, so repository mode +must allow project repositories. Declare Connect Core's non-central runtime +sources (OpenCollab releases and snapshots, jvm-libp2p Cloudsmith, ConsenSys, +and the group-filtered JitPack source) in both Fabric projects so Loom +resolution does not hide the settings repositories. + +- [x] **Step 4: Run the new test and both empty mod builds** + +Run: + +```bash +./gradlew :share:common:test :share:fabric-1-21-11:build :share:fabric-26-2:build +``` + +Expected: `BuildPinsTest` passes and both Fabric projects produce JAR tasks without changing plugin artifact names. + +- [x] **Step 5: Run the existing plugin build** + +Run: + +```bash +./gradlew build +``` + +Expected: all existing plugin tests pass under Gradle 9.5.1. Fix only concrete Gradle-9 API errors encountered; retain Java-11 bytecode for `api`, `core`, `spigot`, `velocity`, and `bungee`. + +- [x] **Step 6: Commit** + +```bash +git add gradle/wrapper/gradle-wrapper.properties settings.gradle.kts build.gradle.kts build-logic/src/main/kotlin/Versions.kt share +git commit -m "build: add multi-version Fabric Share modules" +``` + +### Task 2: Extract plugin-compatible endpoint token persistence + +**Files:** +- Create: `core/src/main/java/com/minekube/connect/identity/EndpointTokenStore.java` +- Create: `core/src/test/java/com/minekube/connect/identity/EndpointTokenStoreTest.java` +- Modify: `core/src/main/java/com/minekube/connect/module/CommonModule.java` +- Modify: `core/src/test/java/com/minekube/connect/module/CommonModuleTest.java` + +**Interfaces:** +- Consumes: `Utils.randomSecureString(20)` and Gson. +- Produces: `EndpointTokenStore.load(Path, Map)`, `loadOrCreate(Path, Map)`, `save(Path,String)`, `generate()`, and `redact(String)`. + +- [x] **Step 1: Write failing token-store tests** + +Cover these exact cases: + +```java +@Test void createsPluginCompatibleTokenJson() +@Test void reusesTheSameToken() +@Test void connectTokenEnvironmentOverridesDisk() +@Test void rejectsBlankAndNonPrefixedTokens() +@Test void atomicallyReplacesToken() +@Test void redactionNeverContainsTheToken() +``` + +The core assertions are: + +```java +assertTrue(token.startsWith("T-")); +assertEquals(token, new Gson().fromJson(Files.readString(file), JsonObject.class).get("token").getAsString()); +assertFalse(EndpointTokenStore.redact(token).contains(token)); +``` + +- [x] **Step 2: Run the focused test and observe failure** + +Run: + +```bash +./gradlew :core:test --tests com.minekube.connect.identity.EndpointTokenStoreTest +``` + +Expected: compilation fails because `EndpointTokenStore` does not exist. + +- [x] **Step 3: Implement the store** + +`EndpointTokenStore` must: + +```java +public final class EndpointTokenStore { + public static final String ENV_TOKEN = "CONNECT_TOKEN"; + + public Optional load(Path tokenFile, Map environment) throws IOException; + public String loadOrCreate(Path tokenFile, Map environment) throws IOException; + public void save(Path tokenFile, String token) throws IOException; + public String generate(); + public static String redact(String token); +} +``` + +`save` writes `{"token":"T-AAAAAAAAAAAAAAAAAAAA"}` to a sibling temporary file, applies owner read/write permissions when POSIX permissions are supported, then moves with `ATOMIC_MOVE` and `REPLACE_EXISTING`, falling back to `REPLACE_EXISTING` only when atomic moves are unsupported. `load` validates the environment or disk value before returning it. + +- [x] **Step 4: Make CommonModule use the shared store** + +Replace the private `CommonModule.Token` class with an injected/provider-created `EndpointTokenStore` and: + +```java +return endpointTokenStore.loadOrCreate( + dataDirectory.resolve("token.json"), + System.getenv()); +``` + +Keep the existing `CommonModuleTest.connectTokenIsPersistedForAllConnectClients` green. + +- [x] **Step 5: Run token and core tests** + +Run: + +```bash +./gradlew :core:test --tests com.minekube.connect.identity.EndpointTokenStoreTest --tests com.minekube.connect.module.CommonModuleTest +``` + +Expected: all focused tests pass. + +- [x] **Step 6: Commit** + +```bash +git add core/src/main/java/com/minekube/connect/identity core/src/test/java/com/minekube/connect/identity core/src/main/java/com/minekube/connect/module/CommonModule.java core/src/test/java/com/minekube/connect/module/CommonModuleTest.java +git commit -m "refactor: share endpoint token persistence" +``` + +### Task 3: Persist, import, validate, and roll back endpoint identities + +**Files:** +- Create: `share/common/src/main/kotlin/com/minekube/connect/share/identity/EndpointIdentity.kt` +- Create: `share/common/src/main/kotlin/com/minekube/connect/share/identity/EndpointIdentityStore.kt` +- Create: `share/common/src/main/kotlin/com/minekube/connect/share/identity/EndpointCredentialValidator.kt` +- Create: `share/common/src/test/kotlin/com/minekube/connect/share/identity/EndpointIdentityStoreTest.kt` +- Create: `share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/WatchEndpointCredentialValidator.kt` +- Create: `share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/RandomEndpointNameSource.kt` +- Create: `share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/WatchEndpointCredentialValidatorTest.kt` +- Create: `share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/RandomEndpointNameSourceTest.kt` + +**Interfaces:** +- Consumes: `EndpointTokenStore`, OkHttp `WebSocket`, and the existing watch endpoint contract. +- Produces: + +```kotlin +enum class CredentialSource { GENERATED, IMPORTED, ENVIRONMENT } +data class EndpointIdentity( + val endpoint: String, + val token: String, + val endpointSource: CredentialSource, + val tokenSource: CredentialSource, +) +fun interface EndpointNameSource { + suspend fun create(): String +} +fun interface EndpointCredentialValidator { + suspend fun validate( + identity: EndpointIdentity, + ): Either +} +sealed interface CredentialValidationError { + val safeMessage: String + data class InvalidInput(override val safeMessage: String) : CredentialValidationError + data class Rejected(override val safeMessage: String) : CredentialValidationError + data class Network(override val safeMessage: String) : CredentialValidationError + data class ManagedByEnvironment( + val fields: NonEmptyList, + override val safeMessage: String, + ) : CredentialValidationError +} +``` + +- [x] **Step 1: Write the identity-store tests** + +Tests must prove: + +```kotlin +@Test fun `one generated identity survives reload and world changes`() +@Test fun `environment overrides are resolved per field`() +@Test fun `dashboard import commits endpoint and token only after validation`() +@Test fun `bad token leaves prior identity byte-for-byte intact`() +@Test fun `cancelled and failed validation leave prior identity intact`() +@Test fun `plugin token json can be imported`() +@Test fun `reset is explicit and creates one replacement identity`() +@Test fun `logs and toString never contain token`() +@Test fun `second file failure restores the prior identity`() +@Test fun `interrupted transaction rolls back on next load`() +``` + +Use a deterministic `EndpointNameSource { "amber-fox" }` and token source returning `T-AAAAAAAAAAAAAAAAAAAA`. + +- [x] **Step 2: Run and observe the missing-type failure** + +Run: + +```bash +./gradlew :share:common:test --tests com.minekube.connect.share.identity.EndpointIdentityStoreTest +``` + +Expected: compilation fails on `EndpointIdentityStore`. + +- [x] **Step 3: Implement exact persistence semantics** + +`EndpointIdentityStore` has this constructor and public API: + +```kotlin +class EndpointIdentityStore( + private val directory: Path, + private val environment: Map, + private val endpointNames: EndpointNameSource, + private val tokenStore: EndpointTokenStore, +) { + suspend fun currentOrCreate(): EndpointIdentity + suspend fun import( + endpoint: String, + token: String, + validator: EndpointCredentialValidator, + ): Either + suspend fun importTokenFile( + endpoint: String, + tokenFile: Path, + validator: EndpointCredentialValidator, + ): Either + suspend fun resetConfirmed(): Either +} +``` + +Use `config.json` with: + +```json +{"endpoint":"amber-fox","endpointSource":"IMPORTED","tokenSource":"IMPORTED"} +``` + +Validate endpoint names with `^[a-z0-9][a-z0-9-]{2,62}$`. Treat tokens as secrets and override `EndpointIdentity.toString()` to print `token=`. Import writes neither file until validation returns `Either.Right(Unit)`, then atomically replaces token first and config second while retaining backups until both moves succeed. Restore both backups when the second move fails. + +Use Arrow `either`, `ensure`, `ensureNotNull`, `bind`, and `NonEmptyList` for +the validation workflow and its typed failures. Environment management is +tracked independently for endpoint and token so a field supplied by +`CONNECT_ENDPOINT` or `CONNECT_TOKEN` is never silently overwritten. + +Before either move, write `identity-transaction.json` containing the old and +new endpoint names plus both backup and staged file names. `currentOrCreate()` +calls `recoverInterruptedTransaction()` before reading identity files. When +the journal exists without a committed marker, restore both backups, or remove +both partially created files when no prior identity existed, then delete the +journal. When the committed marker is durable, retain the new pair and only +clean staged and backup files. Delete backups and the journal only after both +final files are durable. + +- [x] **Step 4: Write validator tests against MockWebServer** + +Assert that a validation request sends: + +```text +Authorization: Bearer T-AAAAAAAAAAAAAAAAAAAA +Connect-Endpoint: amber-fox +Connect-Platform: Fabric +``` + +The WebSocket listener must close immediately after HTTP 101 and reject any +binary `WatchResponse` proposal without creating a local tunnel. HTTP 401 +returns a sanitized `CredentialValidationError.Rejected`; transport failure +and timeout return `CredentialValidationError.Network`. Caller cancellation +must remain cancellation. + +- [x] **Step 5: Implement the Watch validator** + +Expose: + +```kotlin +class WatchEndpointCredentialValidator( + private val client: OkHttpClient, + private val watchUrl: HttpUrl, + private val timeout: Duration = 10.seconds, +) : EndpointCredentialValidator +``` + +The coroutine resumes exactly once using an atomic completion guard, cancels the WebSocket on coroutine cancellation, and never includes endpoint tokens in exceptions. + +Implement `RandomEndpointNameSource` with a five-second OkHttp timeout against +`https://randomname.minekube.net`. Accept only the endpoint-name pattern from +Step 3. On timeout, non-200, empty body, or invalid body, return five lowercase +letters from `SecureRandom`; do not fail identity creation and do not include +network response bodies in logs. + +- [x] **Step 6: Run focused tests** + +Run: + +```bash +./gradlew :share:common:test :share:fabric-common:test --tests '*EndpointIdentityStoreTest' --tests '*WatchEndpointCredentialValidatorTest' --tests '*RandomEndpointNameSourceTest' +``` + +Expected: identity and validation tests pass. + +- [x] **Step 7: Commit** + +```bash +git add share/common/src/main/kotlin/com/minekube/connect/share/identity share/common/src/test/kotlin/com/minekube/connect/share/identity share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/WatchEndpointCredentialValidator.kt share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/RandomEndpointNameSource.kt share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/WatchEndpointCredentialValidatorTest.kt share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/RandomEndpointNameSourceTest.kt +git commit -m "feat: persist and import Share endpoint identities" +``` + +### Task 4: Add host admission for Connect, Mojang, and offline identities + +**Files:** +- Create: `share/common/src/main/kotlin/com/minekube/connect/share/admission/AdmissionIdentity.kt` +- Create: `share/common/src/main/kotlin/com/minekube/connect/share/admission/AdmissionController.kt` +- Create: `share/common/src/test/kotlin/com/minekube/connect/share/admission/AdmissionControllerTest.kt` + +**Interfaces:** +- Produces: + +```kotlin +sealed interface AdmissionIdentity { + val name: String + val uuid: UUID + + data class Authenticated( + override val name: String, + override val uuid: UUID, + val source: AuthSource, + ) : AdmissionIdentity + + data class UnverifiedOffline( + override val name: String, + override val uuid: UUID, + val connectionId: String, + val ingress: Ingress, + ) : AdmissionIdentity +} + +enum class AuthSource { CONNECT, MOJANG } +enum class Ingress { CONNECT, DIRECT_LAN, DIRECT_INTERNET } +enum class AdmissionAnswer { ALLOW, DENY, TIMEOUT, STOPPED, CAPACITY } +``` + +- [x] **Step 1: Write failing admission tests** + +Cover: + +```kotlin +@Test fun `authenticated UUID approval is reused only during current share`() +@Test fun `offline reconnect with copied name requires a new approval`() +@Test fun `duplicate live requests share one decision`() +@Test fun `seventeenth pending request is rejected`() +@Test fun `request expires after thirty seconds`() +@Test fun `stop resolves all pending requests and clears approvals`() +@Test fun `capacity rejects before adding a pending card`() +``` + +Use `kotlinx.coroutines.test.runTest` and a test scheduler for the 30-second timeout. + +- [x] **Step 2: Run and observe failure** + +Run: + +```bash +./gradlew :share:common:test --tests com.minekube.connect.share.admission.AdmissionControllerTest +``` + +Expected: missing admission types. + +- [x] **Step 3: Implement AdmissionController** + +Expose: + +```kotlin +class AdmissionController( + private val scope: CoroutineScope, + private val timeout: Duration = 30.seconds, + private val maxPending: Int = 16, + private val connectedCount: () -> Int, + private val maxGuests: () -> Int, +) { + val pending: StateFlow> + suspend fun request(identity: AdmissionIdentity): AdmissionAnswer + fun answer(requestId: UUID, allow: Boolean) + fun resetShare() +} +``` + +Key authenticated approvals by UUID. Key unverified requests by `connectionId`. Never key offline approval by name or deterministic offline UUID. Complete deferred results outside the controller mutex. `resetShare()` returns `STOPPED` to pending callers and clears remembered authenticated UUIDs. + +- [x] **Step 4: Run tests** + +Run: + +```bash +./gradlew :share:common:test --tests com.minekube.connect.share.admission.AdmissionControllerTest +``` + +Expected: all seven cases pass. + +- [x] **Step 5: Commit** + +```bash +git add share/common/src/main/kotlin/com/minekube/connect/share/admission share/common/src/test/kotlin/com/minekube/connect/share/admission +git commit -m "feat: add Share host admission policy" +``` + +### Task 5: Gate Connect proposals before opening tunnels + +**Files:** +- Create: `core/src/main/java/com/minekube/connect/watch/SessionAdmissionGate.java` +- Create: `core/src/main/java/com/minekube/connect/watch/SessionAdmissionDecision.java` +- Create: `core/src/main/java/com/minekube/connect/watch/AllowAllSessionAdmissionGate.java` +- Modify: `core/src/main/java/com/minekube/connect/register/WatcherRegister.java` +- Modify: `core/src/main/java/com/minekube/connect/module/CommonModule.java` +- Modify: `core/src/test/java/com/minekube/connect/register/WatcherRegisterTest.java` +- Create: `core/src/test/java/com/minekube/connect/watch/AllowAllSessionAdmissionGateTest.java` + +**Interfaces:** +- Consumes: `SessionProposal`. +- Produces: + +```java +public interface SessionAdmissionGate { + CompletionStage request(SessionProposal proposal); +} + +public final class SessionAdmissionDecision { + public static SessionAdmissionDecision allow(); + public static SessionAdmissionDecision deferToLocalLogin(); + public static SessionAdmissionDecision deny(String safeMessage); + public boolean isAllowed(); + public boolean isDeferredToLocalLogin(); + public String getSafeMessage(); +} +``` + +- [x] **Step 1: Add failing WatcherRegister tests** + +Add tests that hold a `CompletableFuture` and assert: + +```java +verifyNoInteractions(tunneler); +assertEquals(0, localSessionConnections.get()); +``` + +before completion. On `allow()`, assert one `prepare` and one local connection. On deny, timeout, exceptional completion, or watcher stop, assert proposal rejection and zero tunnel work. + +- [x] **Step 2: Run and observe failure** + +Run: + +```bash +./gradlew :core:test --tests com.minekube.connect.register.WatcherRegisterTest +``` + +Expected: compilation fails because the gate does not exist. + +- [x] **Step 3: Implement the default gate and WatcherRegister sequencing** + +Use Guice `OptionalBinder` in `CommonModule`: set +`AllowAllSessionAdmissionGate` as the default `SessionAdmissionGate`, and let +the Fabric platform module set the actual binding without a duplicate-binding +error. In `WatcherRegister.WatcherImpl.onProposal`, call the gate after +structural validation and before `tunneler.prepare`. Continue on the existing +watcher executor only when: + +```java +started.get() + && proposal.getState() == State.ACCEPTED + && (decision.isAllowed() || decision.isDeferredToLocalLogin()) +``` + +Treat `deferToLocalLogin()` as permission to open the bounded tunnel without marking the player admitted; the Fabric login hook owns the later decision. Map deny/exception to a `PERMISSION_DENIED` or `INTERNAL` `google.rpc.Status` with only the safe message. Never throw asynchronous gate failures on OkHttp's callback thread. + +- [x] **Step 4: Run Core tests** + +Run: + +```bash +./gradlew :core:test --tests com.minekube.connect.register.WatcherRegisterTest --tests com.minekube.connect.watch.AllowAllSessionAdmissionGateTest +``` + +Expected: focused tests pass and existing plugin behavior remains immediate-allow. + +- [x] **Step 5: Commit** + +```bash +git add core/src/main/java/com/minekube/connect/watch core/src/main/java/com/minekube/connect/register/WatcherRegister.java core/src/main/java/com/minekube/connect/module/CommonModule.java core/src/test/java/com/minekube/connect/watch core/src/test/java/com/minekube/connect/register/WatcherRegisterTest.java +git commit -m "feat: gate Connect sessions before tunneling" +``` + +### Task 6: Implement the Share state machine and cleanup contract + +**Files:** +- Create: `share/common/src/main/kotlin/com/minekube/connect/share/ShareOptions.kt` +- Create: `share/common/src/main/kotlin/com/minekube/connect/share/ShareState.kt` +- Create: `share/common/src/main/kotlin/com/minekube/connect/share/MinecraftShareBridge.kt` +- Create: `share/common/src/main/kotlin/com/minekube/connect/share/ConnectShareIngress.kt` +- Create: `share/common/src/main/kotlin/com/minekube/connect/share/ShareCoordinator.kt` +- Create: `share/common/src/test/kotlin/com/minekube/connect/share/ShareCoordinatorTest.kt` + +**Interfaces:** +- Produces: + +```kotlin +data class ShareOptions( + val gameMode: ShareGameMode, + val allowCheats: Boolean, + val maxGuests: Int = 8, +) + +data class LocalShareTarget( + val address: SocketAddress, + val close: suspend () -> Unit, +) + +interface MinecraftShareBridge { + suspend fun open(options: ShareOptions): LocalShareTarget +} + +interface ConnectShareIngress { + suspend fun start(identity: EndpointIdentity, target: SocketAddress): ConnectShareHandle +} + +data class ConnectShareHandle( + val endpoint: String, + val publicAddress: String, + val close: suspend () -> Unit, +) +``` + +- [x] **Step 1: Write state and cleanup tests** + +Prove: + +```kotlin +@Test fun `start orders bridge before ingress`() +@Test fun `connect failure closes bridge and enters failed`() +@Test fun `stop closes ingress then bridge and clears admission`() +@Test fun `stop is idempotent`() +@Test fun `world replacement stops active share`() +@Test fun `capacity outside one through sixteen is rejected`() +@Test fun `start cancellation releases the bridge and remains cancellation`() +``` + +- [x] **Step 2: Run and observe missing production types** + +Run: + +```bash +./gradlew :share:common:test --tests com.minekube.connect.share.ShareCoordinatorTest +``` + +Expected: compilation failure. + +- [x] **Step 3: Implement the coordinator** + +`ShareState` is: + +```kotlin +sealed interface ShareState { + data object Idle : ShareState + data object Starting : ShareState + data class Sharing(val endpoint: String, val address: String) : ShareState + data object Stopping : ShareState + data class Failed(val safeMessage: String) : ShareState +} +``` + +`ShareCoordinator.start` runs under a mutex, creates the bridge, loads identity, +starts Connect, and publishes `Sharing`. It returns +`Either`. Model the bridge and ingress +as one Arrow `Resource`; the coordinator's carefully bounded `allocate` +interop keeps that resource alive across UI events while explicitly releasing +partially acquired resources on every failed or cancelled start. + +`stop` snapshots the release handle under the mutex, publishes `Stopping`, +releases the Arrow resource (ingress then bridge), resets admission, then +publishes `Idle`. Arrow runs every finalizer and combines cleanup failures. +Return a typed `StopFailed`, report only a fixed safe summary, and never convert +coroutine cancellation into a domain failure. + +- [x] **Step 4: Run tests and commit** + +Run: + +```bash +./gradlew :share:common:test +``` + +Expected: all common tests pass. + +Commit: + +```bash +git add share/common +git commit -m "feat: add Connect Share lifecycle" +``` + +### Task 7: Create an embedded Connect runtime for Fabric + +**Files:** +- Modify: `core/src/main/java/com/minekube/connect/config/ConnectConfig.java` +- Modify: `core/src/main/java/com/minekube/connect/ConnectPlatform.java` +- Create: `core/src/test/java/com/minekube/connect/EmbeddedConnectPlatformTest.java` +- Create: `share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FabricSessionAdmissionGate.kt` +- Create: `share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FabricLocalLoginAdmission.kt` +- Create: `share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FabricConnectIngress.kt` +- Create: `share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FabricPlatformUtils.kt` +- Create: `share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FabricSessionAdmissionGateTest.kt` +- Create: `share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FabricConnectIngressTest.kt` + +**Interfaces:** +- Consumes: `EndpointIdentity`, `AdmissionController`, `PlatformInjector`, and `ConnectPlatform`. +- Produces: `ConnectConfig.embedded(String endpoint, boolean allowOfflineModePlayers)`, `ConnectPlatform.initEmbedded(Path dataDirectory, ConnectConfig config, ConfigHolder configHolder, PacketHandlers packetHandlers)`, `FabricSessionAdmissionGate`, `FabricLocalLoginAdmission`, and `FabricConnectIngress`. + +- [x] **Step 1: Write failing embedded-platform tests** + +Assert: + +```java +ConnectConfig config = ConnectConfig.embedded("amber-fox", true); +assertEquals("amber-fox", config.getEndpoint()); +assertEquals(Boolean.TRUE, config.getAllowOfflineModePlayers()); +``` + +Create a fake `PlatformInjector` and assert `initEmbedded` never creates `config.yml`, starts Watch only after injector success, and closes Watch, libp2p, tunnels, and local channel once. + +- [x] **Step 2: Add the embedded Core entry point** + +Add: + +```java +public static ConnectConfig embedded(String endpoint, boolean allowOfflineModePlayers) +``` + +and: + +```java +public void initEmbedded( + Path dataDirectory, + ConnectConfig config, + ConfigHolder configHolder, + PacketHandlers packetHandlers) +``` + +Share the common initialization tail with the existing `init`; do not change plugin config loading. + +- [x] **Step 3: Implement the Kotlin admission adapter** + +`FabricSessionAdmissionGate.request` maps: + +- non-passthrough Connect profile → `AdmissionIdentity.Authenticated(name, uuid, AuthSource.CONNECT)`; +- passthrough Connect proposal → `SessionAdmissionDecision.deferToLocalLogin()`. + +Map `ALLOW` to `SessionAdmissionDecision.allow()` and every other non-deferred answer to a safe denial. The returned `CompletionStage` is cancelled when the share stops. + +`FabricLocalLoginAdmission` exposes: + +```kotlin +suspend fun request( + name: String, + uuid: UUID, + connectionId: String, + minecraftAuthenticated: Boolean, +): AdmissionAnswer +``` + +It maps an authenticated profile to +`AdmissionIdentity.Authenticated(name, uuid, AuthSource.MOJANG)` and a locally +offline profile to +`AdmissionIdentity.UnverifiedOffline(name, uuid, connectionId, +Ingress.CONNECT)`. It completes before vanilla moves the connection into +configuration/play state. + +- [x] **Step 4: Implement FabricConnectIngress** + +Build a private Guice injector from `ServerCommonModule`, a Fabric platform module providing logger/platform metadata/injector/gate, `ConfigLoadedModule(config)`, `Libp2pEndpointModule`, and `WatcherModule`. Set: + +```text +platformName = Fabric +serverImplementationName = Minecraft integrated server +authType = OFFLINE +allowOfflineModePlayers = true +``` + +Use the already persisted `token.json`; do not generate or write credentials +inside `start`. Read and compare the effective stored/environment token with +the already resolved `EndpointIdentity` before constructing the runtime. +Return the `ConnectShareHandle` defined in Task 6: + +```kotlin +ConnectShareHandle( + endpoint = identity.endpoint, + publicAddress = "${identity.endpoint}.play.minekube.net", + close = { platform.disable() }, +) +``` + +where `publicAddress` is `.play.minekube.net`. + +- [x] **Step 5: Run focused and Core regression tests** + +Run: + +```bash +./gradlew :core:test --tests com.minekube.connect.EmbeddedConnectPlatformTest :share:fabric-common:test +``` + +Expected: embedded lifecycle and admission mapping pass. + +- [x] **Step 6: Commit** + +```bash +git add core/src/main/java/com/minekube/connect/config/ConnectConfig.java core/src/main/java/com/minekube/connect/ConnectPlatform.java core/src/test/java/com/minekube/connect/EmbeddedConnectPlatformTest.java share/fabric-common +git commit -m "feat: add embedded Fabric Connect ingress" +``` + +### Task 8: Implement the 1.21.11 private integrated-server bridge + +**Files:** +- Create: `share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/Minecraft12111Bridge.kt` +- Create: `share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/CapturedServerTransport.kt` +- Create: `share/fabric-1.21.11/src/main/java/com/minekube/connect/share/fabric/v1_21_11/mixin/ServerConnectionListenerMixin.java` +- Create: `share/fabric-1.21.11/src/main/java/com/minekube/connect/share/fabric/v1_21_11/mixin/IntegratedServerAccessor.java` +- Create: `share/fabric-1.21.11/src/main/java/com/minekube/connect/share/fabric/v1_21_11/mixin/ServerConnectionListenerAccessor.java` +- Create: `share/fabric-1.21.11/src/main/java/com/minekube/connect/share/fabric/v1_21_11/mixin/ConnectionAccessor.java` +- Create: `share/fabric-1.21.11/src/main/java/com/minekube/connect/share/fabric/v1_21_11/mixin/ServerLoginPacketListenerMixin.java` +- Create: `share/fabric-1.21.11/src/test/kotlin/com/minekube/connect/share/fabric/v1_21_11/Minecraft12111BridgeTest.kt` + +**Interfaces:** +- Consumes: `IntegratedServer.publishServer`, `ServerConnectionListener.startTcpServerListener`, `LocalServerChannelWrapper`, and Connect channel attributes. +- Produces: `Minecraft12111Bridge : MinecraftShareBridge`. + +- [x] **Step 1: Generate and inspect exact 1.21.11 sources** + +Run: + +```bash +./gradlew :share:fabric-1-21-11:genSources +``` + +Confirm the official mapped members used by this task exist: + +```text +IntegratedServer.publishServer(GameType, boolean, int) +IntegratedServer.publishedPort +MinecraftServer.getConnection() +ServerConnectionListener.startTcpServerListener(InetAddress, int) +ServerConnectionListener.channels +``` + +If Loom reports a different official member name, update only the adapter and record the exact resolved name in the mixin JSON; do not use broad reflection. + +- [x] **Step 2: Write the bridge test before mixins** + +Use a fake captured transport and assert: + +```kotlin +assertTrue(boundAddress.address.isLoopbackAddress) +assertTrue(localAddress is LocalAddress) +assertEquals(-1, publishedPortAfterClose) +assertEquals(0, capturedListenerCountAfterClose) +``` + +Opening twice after close must succeed; opening while active must fail without adding a second listener. + +- [x] **Step 3: Capture vanilla's child initializer and force loopback** + +`ServerConnectionListenerMixin` uses `@ModifyArg` on `ServerBootstrap.childHandler` and `ServerBootstrap.group` to capture the exact initializer/group, and a second `@ModifyArg`/method argument modification so the active Share publish calls: + +```java +InetAddress.getLoopbackAddress() +``` + +It must leave ordinary vanilla publishing unchanged unless `CapturedServerTransport.isShareStartArmed()` is true. + +`ServerConnectionListenerAccessor` exposes the listener +`List`. `IntegratedServerAccessor` exposes mutable +`publishedPort`. `ConnectionAccessor` exposes the exact Netty `Channel` held by +Minecraft's `Connection` so the login mixin can read Connect's channel +attribute without reflection. + +- [x] **Step 4: Bind the local channel and implement stop** + +After `publishServer`, identify exactly one newly added loopback `ChannelFuture`. Bind: + +```kotlin +ServerBootstrap() + .channel(LocalServerChannelWrapper::class.java) + .childHandler(captured.childInitializer) + .group(DefaultEventLoopGroup(0, DefaultThreadFactory("Connect Share local"))) + .localAddress(LocalAddress.ANY) + .bind() + .syncUninterruptibly() +``` + +On close, stop Connect first through the coordinator, close/remove the local future, close/remove the captured loopback future, set `publishedPort = -1`, and shut down the dedicated local event loop gracefully. + +- [x] **Step 5: Inject Connect-authenticated login profiles** + +`ServerLoginPacketListenerMixin` reads `ConnectAttributes.CONNECT_PLAYER` from the connection channel. For non-passthrough sessions it converts the Connect profile to Mojang `GameProfile`, preserves signed properties, bypasses a second Mojang encryption/authentication round trip, and enters vanilla's verified-login continuation. + +For passthrough Connect sessions it lets vanilla resolve online/offline login, then pauses before configuration/play state, calls `FabricLocalLoginAdmission`, and continues only on `ALLOW`. Deny, timeout, disconnect, or share stop closes the connection. Ordinary LAN channels execute untouched vanilla code. + +- [x] **Step 6: Run adapter tests and a headless launch smoke** + +Run: + +```bash +./gradlew :share:fabric-1-21-11:test :share:fabric-1-21-11:runServer --args='nogui' +``` + +Expected: unit tests pass; the dev server reaches startup with every mixin applied. Terminate the smoke after the ready log and confirm no mixin application error. + +- [ ] **Step 7: Commit** + +```bash +git add share/fabric-1.21.11 +git commit -m "feat: bridge Connect into 1.21.11 singleplayer" +``` + +### Task 9: Implement the 26.2 adapter and assert cross-version parity + +**Files:** +- Create: matching `v26_2` bridge and mixin files under `share/fabric-26.2/src/main` +- Create: `share/fabric-26.2/src/test/kotlin/com/minekube/connect/share/fabric/v26_2/Minecraft262BridgeTest.kt` +- Create: `share/common/src/test/kotlin/com/minekube/connect/share/AdapterContractTest.kt` + +**Interfaces:** +- Consumes: the same `MinecraftShareBridge` contract and unobfuscated 26.2 Minecraft classes. +- Produces: `Minecraft262Bridge : MinecraftShareBridge` with behavior identical to Task 8. + +- [x] **Step 1: Generate 26.2 sources and verify names** + +Run: + +```bash +./gradlew :share:fabric-26-2:genSources +``` + +Use the unobfuscated 26.2 member names reported by Loom. Keep all changed names inside `v26_2`; do not add Minecraft types to `share/common` or `share/fabric-common`. + +- [x] **Step 2: Write parity tests** + +Run the same contract fixture against both fake adapters: + +```kotlin +fun bridgeContract(factory: () -> MinecraftShareBridgeHarness) { + val first = factory().openAndClose() + val second = factory().openAndClose() + assertTrue(first.boundAddress.address.isLoopbackAddress) + assertTrue(second.boundAddress.address.isLoopbackAddress) + assertEquals(-1, second.publishedPortAfterClose) +} +``` + +- [x] **Step 3: Implement the 26.2 bridge and mixins** + +Repeat the explicit loopback, captured initializer, `LocalServerChannelWrapper`, login profile injection, and exact close semantics with 26.2 official names. The behavioral code remains Kotlin; Java mixins only expose/capture Minecraft internals. + +- [x] **Step 4: Build and smoke both versions** + +Run: + +```bash +./gradlew :share:fabric-1-21-11:test :share:fabric-26-2:test :share:fabric-1-21-11:build :share:fabric-26-2:build +``` + +Expected: both artifacts compile and parity tests pass. + +- [x] **Step 5: Commit** + +```bash +git add share/fabric-26.2 share/common/src/test/kotlin/com/minekube/connect/share/AdapterContractTest.kt +git commit -m "feat: bridge Connect into 26.2 singleplayer" +``` + +### Task 10: Add the pause-menu sharing and approval UI + +**Files:** +- Create: `share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/ConnectShareClient.kt` +- Create: `share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/ConnectShareRuntime.kt` +- Create: `share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/ui/ShareViewModel.kt` +- Create: `share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/ui/ShareViewModelTest.kt` +- Create: per-version `PauseScreenMixin.java`, `ShareSetupScreen.kt`, `ShareStatusScreen.kt`, and `EndpointIdentityScreen.kt` +- Create: per-version `assets/connect-share/lang/en_us.json` +- Create: per-version `assets/connect-share/lang/de_de.json` +- Create: per-version `fabric.mod.json` and mixin JSON + +**Interfaces:** +- Consumes: `ShareCoordinator.state`, `AdmissionController.pending`, and `EndpointIdentityStore`. +- Produces: the host's complete start/stop/copy/import/approve/deny experience. + +- [ ] **Step 1: Write view-model tests** + +Prove: + +```kotlin +@Test fun `start is disabled without a world or while starting`() +@Test fun `capacity is clamped to one through sixteen`() +@Test fun `token is cleared from mutable UI state after successful import`() +@Test fun `environment managed fields cannot be edited`() +@Test fun `allow and deny target the exact pending request`() +@Test fun `leaving a world invokes stop exactly once`() +``` + +- [ ] **Step 2: Implement ConnectShareClient lifecycle** + +Register the Fabric client initializer, create one runtime under: + +```text +FabricLoader.getInstance().configDir/minekube-connect-share +``` + +Listen for client disconnect/game shutdown/integrated-server replacement and call `ShareCoordinator.stop()`. Never stop merely because a screen closes. + +- [ ] **Step 3: Implement exact screens** + +The pause menu button is **Share with friends** when idle and **Sharing with friends** when active. + +The setup screen contains game mode, cheats, max guests default 8, the direct +internet option, and **Share with friends**. + +The status screen contains: + +- stable `.play.minekube.net` with copy button; +- state line; +- pending cards showing name, UUID, **Connect authenticated**, **Verified online**, or **Unverified offline**; +- **Allow**, **Deny**, and **Stop sharing with friends**; +- **Advanced settings…** link. + +The identity screen contains: + +- endpoint name; +- masked credential source; +- **Import token.json…**; +- endpoint field plus masked token field; +- `token.json` chooser; +- **Validate and save**; +- warned **Reset endpoint identity…**. + +Never render or retain a successful token value. + +- [ ] **Step 4: Add metadata and translations** + +Each `fabric.mod.json` declares client environment, Kotlin entrypoint, exact Minecraft version, Java floor, Fabric Loader, Fabric API, and Fabric Language Kotlin. Use the mod ID `connect-share`. + +- [ ] **Step 5: Run tests and compile UI** + +Run: + +```bash +./gradlew :share:fabric-common:test :share:fabric-1-21-11:build :share:fabric-26-2:build +``` + +Expected: view-model tests pass and both UI adapters compile. + +- [ ] **Step 6: Commit** + +```bash +git add share/fabric-common share/fabric-1.21.11 share/fabric-26.2 +git commit -m "feat: add Connect Share host UI" +``` + +### Task 11: Harden packaged runtime isolation and artifact contents + +**Files:** +- Modify: `core/src/main/java/com/minekube/connect/tunnel/p2p/Libp2pRuntimeLoader.java` +- Modify: `build-logic/src/main/kotlin/connect.shadow-conventions.gradle.kts` +- Modify: both Fabric build scripts +- Create: `share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/SecretRedactionTest.kt` +- Create: `share/fabric-1.21.11/src/test/kotlin/com/minekube/connect/share/fabric/v1_21_11/Fabric12111ArtifactTest.kt` +- Create: `share/fabric-26.2/src/test/kotlin/com/minekube/connect/share/fabric/v26_2/Fabric262ArtifactTest.kt` + +**Interfaces:** +- Consumes: existing reflective `Libp2pRuntimeLoader`. +- Produces: self-contained Fabric JARs with no parent-facing duplicate Netty/Kotlin/libp2p classes and a child-only isolated runtime payload. + +- [ ] **Step 1: Write failing artifact tests** + +Open the remapped JARs and assert: + +```text +fabric.mod.json exists +LICENSE exists +connect-share mixin JSON exists +com/minekube/connect/share classes exist +io/libp2p/ does not exist at top level +io/netty/ does not exist at top level +kotlin/ does not exist at top level +META-INF/connect/libp2p-runtime.jar exists +``` + +Reflect over parent-facing Share/Core types and reject fields, parameters, or return types beginning `io.libp2p.`, isolated `io.netty.`, or isolated `kotlin.`. + +- [ ] **Step 2: Package the runtime as a child-only payload** + +Build `META-INF/connect/libp2p-runtime.jar` from jvm-libp2p 1.3.5 and its runtime dependencies. Update `Libp2pRuntimeLoader` to extract that resource to a content-hashed temporary file, add it only to `ChildFirstRuntimeClassLoader`, close extracted resources on shutdown, and preserve plugin classpath fallback for development tests. + +Merge `:api`, `:core`, `:share:common`, and `:share:fabric-common` into each mod artifact while excluding top-level libp2p/Netty/Kotlin runtime dependencies. Fabric Language Kotlin supplies the parent Kotlin runtime. + +- [ ] **Step 3: Add secret scans** + +Construct failures containing endpoint tokens, invitations, and direct candidates. Assert captured logs and screen models contain `` and do not contain the raw values. + +- [ ] **Step 4: Run artifact and isolation verification** + +Run: + +```bash +./gradlew :core:test --tests '*Libp2pRuntime*' :share:fabric-1-21-11:build :share:fabric-26-2:build :share:fabric-1-21-11:test --tests '*ArtifactTest' :share:fabric-26-2:test --tests '*ArtifactTest' +``` + +Expected: all isolation and artifact assertions pass. + +- [ ] **Step 5: Commit** + +```bash +git add core/src/main/java/com/minekube/connect/tunnel/p2p/Libp2pRuntimeLoader.java build-logic/src/main/kotlin/connect.shadow-conventions.gradle.kts share +git commit -m "build: isolate Connect Share networking runtime" +``` + +### Task 12: Add CI gates and complete the singleplayer acceptance pass + +**Files:** +- Modify: `.github/workflows/pullrequest.yml` +- Create: `docs/connect-share-testing.md` +- Modify: `README.md` + +**Interfaces:** +- Consumes: both remapped Fabric artifacts and all verification tasks. +- Produces: PR CI proof for plugin Java 17/21 plus mod Java 21/25; operator-facing test guide. + +- [ ] **Step 1: Add isolated CI jobs** + +Keep the existing plugin matrix. Add: + +```yaml +share-1-21-11: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "21" + cache: gradle + - uses: gradle/actions/setup-gradle@v4 + - run: ./gradlew :share:fabric-1-21-11:build + +share-26-2: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "25" + cache: gradle + - uses: gradle/actions/setup-gradle@v4 + - run: ./gradlew :share:fabric-26-2:build +``` + +Archive each remapped mod JAR under a distinct artifact name. Do not add mod files to the plugin release workflow in this plan. + +- [ ] **Step 2: Write the manual acceptance guide** + +Document exact checks: + +1. Create an automatic identity and share twice; endpoint and token remain byte-for-byte identical. +2. Import a dashboard endpoint/token; bad import rolls back, good import preserves its endpoint name. +3. Join 1.21.11 and 26.2 from an unmodified paid Java client through Connect. +4. Join through Connect from a non-paid/offline-mode client. +5. Deny and allow requests; reconnect behavior matches authentication trust. +6. Stop sharing; hostname no longer reaches the world. +7. Start a different world; same endpoint works and no new endpoint record appears. +8. From another LAN device, verify the chosen TCP port is unreachable. +9. Repeat start/stop twice and inspect thread/channel counts for leaks. + +- [ ] **Step 3: Run the complete local verification** + +Run: + +```bash +./gradlew :core:test :share:common:test :share:fabric-common:test :share:fabric-1-21-11:build :share:fabric-26-2:build +./gradlew build +git diff --check +``` + +Expected: every command exits 0. + +- [ ] **Step 4: Inspect artifacts** + +Run: + +```bash +jar tf share/fabric-1.21.11/build/libs/connect-share-fabric-1.21.11.jar +jar tf share/fabric-26.2/build/libs/connect-share-fabric-26.2.jar +``` + +Expected: the required metadata, translations, license, Share classes, and isolated runtime payload are present; no top-level duplicate Netty/libp2p/Kotlin packages are present. + +- [ ] **Step 5: Commit** + +```bash +git add .github/workflows/pullrequest.yml docs/connect-share-testing.md README.md +git commit -m "ci: verify Connect Share Fabric artifacts" +``` + +## Phase Completion Gate + +Before starting the direct-P2P plan: + +- Both Fabric JARs build on their required JDK. +- Existing `./gradlew build` remains green. +- One endpoint identity is reused across worlds. +- Dashboard credential import is validated and atomic. +- Paid and non-paid vanilla Java clients reach the world through Connect. +- Non-passthrough host admission happens before tunnel creation; passthrough admission happens before world entry. +- Stop/world-exit/game-exit cleanup is idempotent. +- No wildcard/LAN/WAN Minecraft listener is reachable. +- The mod package preserves Core's networking/runtime isolation. +- Epic #83 is updated with the singleplayer slice result and remaining direct-P2P work. diff --git a/docs/superpowers/specs/2026-07-06-bedrock-identity-enforcement-design.md b/docs/superpowers/specs/2026-07-06-bedrock-identity-enforcement-design.md index 62a3d3fe1..400c12ba1 100644 --- a/docs/superpowers/specs/2026-07-06-bedrock-identity-enforcement-design.md +++ b/docs/superpowers/specs/2026-07-06-bedrock-identity-enforcement-design.md @@ -1,6 +1,7 @@ # Superseded Bedrock identity design This historical design covered the initial static-key, opt-in enforcement phase. It is not the -current configuration contract: generated installs now use Minekube metadata in non-rejecting -`warn` mode by default. See [the current Bedrock identity documentation](../../bedrock-identity.md) -for defaults, trust boundaries, and operator pinning instructions. +current configuration contract. The original v1 path used `bedrock-identity.enforcement: warn` +by default. See +[the current Bedrock identity documentation](../../bedrock-identity.md) for the current +configuration, trust boundaries, and operator pinning instructions. diff --git a/docs/superpowers/specs/2026-07-30-connect-share-mod-design.md b/docs/superpowers/specs/2026-07-30-connect-share-mod-design.md new file mode 100644 index 000000000..fa40efc16 --- /dev/null +++ b/docs/superpowers/specs/2026-07-30-connect-share-mod-design.md @@ -0,0 +1,534 @@ +# Connect Share Mod Design + +**Date:** 2026-07-30 +**Status:** Approved for implementation +**Epic:** [minekube/connect-java#83](https://github.com/minekube/connect-java/issues/83) + +> Historical scope note: this document records the initial implementation +> slice. The delivered feature expanded beyond it; current supported targets, +> behavior, and acceptance requirements live in [Connect Share](../../connect-share.md) +> and [Connect Share acceptance](../../connect-share-testing.md). + +## Summary + +Connect Share is a client-side Minecraft mod that lets a player share the +singleplayer world they are currently playing. The host installs the mod. +Vanilla guests can join through the host's Minekube Connect endpoint while the +share is active. Guests with the mod can additionally use a direct libp2p +connection when both sides permit it. + +Connect is the private default and the only relay fallback. The mod does not +operate, recommend, or configure an independent public relay. Same-LAN direct +connections are automatic. Internet direct connections are attempted only when +both host and guest explicitly opt in because that path reveals their public IP +addresses to each other. + +The first release supports Fabric on Minecraft 1.21.11 and 26.2. Application +logic is written in Kotlin and shared across both versions. + +## Product Decisions + +- The host starts sharing from a dedicated **Share with friends** pause-menu + action; they do not press Minecraft's Open to LAN button. +- No listener is exposed on a LAN or WAN interface. +- The mod creates one Connect endpoint identity per Minecraft installation and + persists its endpoint name and token like the Connect plugin. Every world + reuses that identity, so repeated shares do not create control-plane endpoint + records. +- A player who already created or imported an endpoint in the Minekube + Dashboard can import that exact endpoint name and dashboard-issued token + instead of creating another endpoint. +- Stopping the share or leaving the world makes the stable endpoint + unreachable until the host explicitly starts another share. +- Connect supplies the vanilla guest's profile and authentication type at the + edge. The mod preserves that session context when it injects the connection + locally. +- Online and offline-mode Java accounts are supported, matching Connect. A + profile authenticated by the managed Connect edge or by Mojang may be + remembered for the current share. A locally accepted offline profile is + visibly labeled unverified and approved per connection so a copied username + cannot inherit an earlier approval. +- Same-LAN mod-to-mod traffic is attempted automatically through direct + libp2p discovery and dialing. +- Internet P2P is disabled by default. Both peers must enable it for the + current connection attempt. +- Connect is the only fallback when direct connectivity fails. Without + Connect, same-LAN and otherwise directly reachable peers can still connect; + NAT combinations that require a relay fail with an actionable message. +- Fabric builds are published for Minecraft 1.21.11 and 26.2. NeoForge is a + later adapter, not part of this implementation. + +## Goals + +1. Let a host share an integrated singleplayer server without port forwarding + or a publicly bound LAN listener. +2. Let an unmodified Java client join through the host's Connect hostname + while sharing is active. +3. Reuse Connect's session identity, authentication-type, and tunnel semantics + instead of creating a parallel public ingress service. +4. Let two modded clients connect directly on the same LAN without Connect. +5. Let two modded clients optionally attempt a direct internet connection, + falling back to Connect when available. +6. Keep the Minecraft-version hooks small and keep lifecycle, admission, + invitation, and transport selection independently testable. +7. Let an endpoint owner reuse a dashboard-managed endpoint, token, and public + hostname without creating a duplicate endpoint. +8. Accept both online and offline-mode Java guests while presenting whether + identity was authenticated by Connect, Mojang, or neither. + +## Non-goals + +- Dedicated-server or current-multiplayer-server sharing +- World synchronization or host migration +- A friend graph, social network, or persistent invitations +- UPnP-based public Minecraft TCP listeners +- An independent libp2p relay network +- Bedrock guest support in the first mod release +- Voice chat tunneling +- NeoForge, Forge, or Quilt artifacts in the first release + +## Build and Module Structure + +The existing plugin build remains intact. Mod releases and plugin releases are +separate products and separate workflows. + +The mod is organized into focused modules: + +```text +share/ +├── common/ Kotlin state, policy, invitations, and transport selection +├── fabric-common/ Fabric entrypoint and loader integration shared by both versions +├── fabric-1.21.11/ Java 21 Minecraft adapter and mixins +└── fabric-26.2/ Java 25 Minecraft adapter and mixins +``` + +`share/common` contains no version-specific Minecraft classes. It owns public +interfaces such as `ShareCoordinator`, `AdmissionController`, +`TransportSelector`, `ShareInviteCodec`, and `ShareState`. + +`share/fabric-common` owns screens, translations, Fabric lifecycle wiring, and +the adapter-neutral glue between Minecraft and `share/common`. + +Each version module implements `MinecraftShareBridge`, which is the only +component allowed to depend on version-specific integrated-server and login +classes. Mixins and accessors stay in these modules. Handwritten application +logic is Kotlin. A minimal Java mixin or accessor shim is permitted only when +Mixin's generated bytecode or annotation processing requires a stable Java +signature; such a shim contains no product logic. + +Kotlin domain and runtime code uses Arrow as its default functional toolkit. +Expected failures are typed with `Raise`/`Either`; independent validation errors +are accumulated; managed tunnel/channel/runtime lifetimes use Arrow resource +scopes; and Arrow Fx/Resilience/Optics/STM capabilities replace local +equivalents when their use case exists. Fabric, Minecraft, and Java Core +boundaries keep their native signatures and adapt into Arrow at the edge. The +scoped rules and exceptions live in `share/AGENTS.md`. + +The build pins: + +- Fabric Loader `0.19.3` +- Fabric API `0.141.6+1.21.11` for Minecraft 1.21.11 +- Fabric API `0.156.0+26.2` for Minecraft 26.2 +- Fabric Language Kotlin `1.13.13+kotlin.2.4.10` +- Arrow `2.2.3` +- jvm-libp2p `1.3.5` +- Java toolchain 21 for Minecraft 1.21.11 +- Java toolchain 25 for Minecraft 26.2 + +The wire protocol has its own integer version and does not use the mod artifact +version as a compatibility signal. + +## Component Boundaries + +### ShareCoordinator + +Owns the single active share and its state machine: + +```text +IDLE -> STARTING -> SHARING -> STOPPING -> IDLE + \-> DEGRADED + \--------------------> FAILED +``` + +It starts and stops the Minecraft bridge, Connect ingress, and direct P2P +service in a fixed order. Stop is idempotent and always attempts every cleanup +step. A world change, disconnect, game shutdown, or integrated-server halt +stops the share. + +`DEGRADED` means at least one usable ingress remains. For example, Connect may +be unavailable while same-LAN direct sharing continues. `FAILED` means no +ingress is usable and the local bridge has been closed. + +### MinecraftShareBridge + +Publishes the integrated server for remote sessions without exposing it on a +network interface. + +The adapter invokes Minecraft's integrated-server publishing lifecycle with a +loopback-only TCP listener so vanilla initializes its normal connection +pipeline. It captures the resulting child `ChannelInitializer` and event loop, +then binds a Connect `LocalServerChannelWrapper` using that initializer. +Connect's `LocalChannelWithSessionContext` carries the profile, +authentication type, and other Connect session data into the accepted local +channel. + +The loopback listener is an implementation detail and is never advertised. +External sessions use the in-memory local channel. This is deliberately safer +than manually reconstructing a Minecraft `Connection` and less invasive than +trying to bypass the publishing lifecycle completely. + +The bridge also provides the version-specific hook that pauses login until +`AdmissionController` accepts or rejects it. It can accept a profile already +authenticated by Connect, run normal Mojang authentication, or initialize the +vanilla-compatible offline profile without changing unrelated local play. + +### ConnectShareIngress + +Loads or creates one persistent Connect identity for the Minecraft +installation: + +```text +config/minekube-connect-share/config.json +config/minekube-connect-share/token.json +``` + +`config.json` stores the endpoint name and credential-source metadata. +`token.json` stores the endpoint token using the same `{"token":"T-..."}` +shape as the Connect plugin. The token is created once, written with +owner-only permissions where the operating system supports them, and redacted +from logs and UI. The standard `CONNECT_ENDPOINT` and `CONNECT_TOKEN` +environment variables override the files for compatibility with existing +Connect deployments and managed launchers. + +Environment overrides are resolved per field, matching the existing plugin: +`CONNECT_ENDPOINT` overrides the stored endpoint name and `CONNECT_TOKEN` +overrides `token.json`. While either override is active, the corresponding +field is marked **Managed by environment** and cannot be changed or reset from +the in-game UI. + +Every world share starts the existing Connect watch/libp2p connector runtime +with this identity and stops it with the share. No share or world identifier +is used as an endpoint name. The database therefore contains at most one +endpoint per mod installation unless the user explicitly resets their +identity. + +An endpoint-token mismatch never triggers automatic endpoint or token +rotation. The UI explains the mismatch and lets the user restore the token or +explicitly choose **Reset endpoint identity…**. Resetting warns that it creates +a new endpoint and invalidates the old local identity. + +The identity setup screen offers: + +1. **Create a Connect endpoint**, which generates and persists one local + endpoint identity using the normal connector behavior; and +2. **Use an existing dashboard endpoint**, which accepts an endpoint name and + masked dashboard-issued token. The user may paste the token or select an + existing plugin-compatible `token.json`. + +Because a token is opaque and authorized for one endpoint in one Minekube +organization, importing a token always requires its endpoint name. The mod +stages the imported pair in memory, opens an authenticated Connect validation +session that rejects every player proposal, and atomically replaces the +persisted identity only after validation succeeds. This path also updates a +stored token after the owner resets that same endpoint's token in the +Dashboard. A mismatch, wrong organization, malformed token file, network +failure, cancellation, or game crash leaves the previously working identity +unchanged. Imported credentials are never regenerated by the mod. + +The import screen warns that an endpoint should not simultaneously route from +another server or connector. If Connect reports a conflicting active +connector, sharing fails closed instead of allowing ambiguous routing. + +For a non-passthrough Connect session, the proposal remains pending while the +host approves the Connect-authenticated profile; denial happens before a local +tunnel is opened. A passthrough session must open a bounded local tunnel so +Minecraft can perform online or offline login. That login is paused after its +profile is resolved and before the player enters the world, then presented for +host approval with its resulting trust level. The connector advertises support +for offline-mode players, as the Connect plugin can. Denial, timeout, world +shutdown, and capacity exhaustion fail closed at the earliest stage where the +session's identity is available. + +### DirectP2pIngress + +Reuses Connect Java's isolated jvm-libp2p runtime. The reflective classloader +boundary remains authoritative: `io.libp2p.*`, its Netty version, and its +Kotlin runtime never leak into Minecraft- or parent-loaded public signatures. + +The installation persists one libp2p identity for friend relationships and +direct authentication, so confirmed friends can reconnect across worlds. Each +active share publishes a signed, time-limited invitation. The direct service +supports: + +- mDNS discovery and direct dialing on the same LAN; +- directly dialable IPv6 or explicitly mapped candidates; +- coordinated QUIC hole punching when candidate exchange is available; +- no circuit-relay candidates outside the managed Connect path. + +The direct stream carries a small versioned preface followed by ordinary +Minecraft login bytes into the same local Minecraft initializer. The preface +declares the guest's requested authentication mode: + +- online guests complete normal Mojang/Microsoft authentication before + admission; and +- offline guests receive Minecraft's deterministic offline profile and are + marked unverified before per-connection admission. + +The direct protocol never silently downgrades a failed online login to offline +mode. The guest must already be operating in offline mode and explicitly +declares it in the mod-to-mod preface. + +### ShareInviteCodec + +A copied invitation is a versioned URI: + +```text +minekube://share/{base64url-cbor-payload} +``` + +The signed payload contains: + +- wire protocol version; +- share ID; +- expiry; +- persistent Connect hostname when Connect is available; +- installation-scoped host peer ID; +- direct candidates only when the host enabled internet P2P; +- an unguessable persisted access capability; +- the host peer signature over every preceding field. + +The capability authorizes requesting admission; it never bypasses host +approval and never changes the guest's displayed authentication status. +Same-LAN discovery advertises the share ID, protocol version, peer ID, and a +short display name, but not the internet capability or public candidates. + +An unmodified guest receives only the Connect hostname. A modded guest can +paste the URI into the **Join Connect Share** screen. Pasting the URI into Minecraft's +Direct Connection field is detected by the mod and routed through the same +parser. + +### AdmissionController + +Admission uses an explicit identity type: + +```text +AuthenticatedProfile(uuid, name, authSource = CONNECT | MOJANG) +UnverifiedOffline(offlineUuid, claimedName, connectionId, ingress) +``` + +For a new identity, the controller: + +1. creates one pending request; +2. shows the host the name, UUID, authentication badge, and ingress type; +3. offers **Allow** and **Deny** actions; +4. expires the request after 30 seconds; +5. remembers an allowed authenticated UUID until this share stops; or +6. applies an unverified approval only to that connection. + +Duplicate requests for the same authenticated UUID, or the same live offline +connection ID, share one decision. An offline reconnect creates a new request +even when its claimed name and deterministic offline UUID match. At most 16 +requests may be pending, with bounded attempts per Connect session or direct +peer. Excess requests are rejected. Denial and timeout are visible to the +guest without exposing internal errors. + +### TransportSelector + +The modded guest applies this order: + +1. If the discovered host is on the same LAN, try direct libp2p for 3 seconds. +2. If both peers enabled internet P2P, try direct candidates and coordinated + QUIC punching for 5 seconds. +3. If a Connect hostname exists, join through Connect. +4. Otherwise report that no direct route was available and Connect was not + enabled. + +Internet candidate gathering and publication do not start until the host opts +in. The guest confirms the same privacy warning before an internet-direct +attempt. Failure falls back silently to Connect except for a concise status +indicator; it does not spam chat. + +## User Experience + +### Host + +The pause menu contains **Share with friends**. The setup screen shows: + +- game mode; +- allow-cheats option; +- maximum guests, default 8 and range 1–16; +- **Allow faster direct internet connections**, off by default, with an IP-disclosure + warning; +- **Share with friends**. + +While active, the screen shows: + +- Connect address and copy button; +- copyable full mod invitation; +- Connect, LAN direct, and internet direct status separately; +- connected and approved players; +- pending approval cards; +- **Stop sharing with friends**. + +Connect identity settings show the endpoint name, credential source +(generated, imported, or environment), and a masked token status. They provide +**Advanced settings…** opens the endpoint identity screen, which provides +**Import token.json…**, **Validate and save**, and the separately warned +**Reset endpoint identity…** action. The token value is never displayed again +after a successful import. + +The host receives a toast and chat action when an approval is pending. Closing +the screen does not stop sharing. + +### Guest + +Vanilla guests add or directly connect to the host's Connect hostname. Modded +guests can open **Friends**, then **Join Connect Share**, or paste a +`minekube://share/` invitation. The +hostname is stable and is not treated as a secret; the displayed +authentication level and host approval remain the authorization boundary. + +The guest sees which path won: **Direct LAN**, **Direct internet**, or +**Minekube Connect**. Internet-direct confirmation explains that both peers +will learn each other's IP address. Approval requests and the connected-player +list show **Connect authenticated**, **Verified online**, or **Unverified +offline**; the UI never presents a locally derived offline UUID or username as +authenticated. + +## Security and Privacy + +- Connect identity is accepted only from a session context produced by the + managed Connect ingress. +- Profiles delivered by a non-passthrough managed Connect session are trusted + as Connect-authenticated whether the player uses a paid or non-paid account. +- Direct online and Connect-passthrough online sessions complete normal + Mojang/Microsoft authentication before admission. +- Locally accepted offline sessions are supported but explicitly marked + unverified. Their approval is bound to one connection and cannot be reused by + another client claiming the same username or deterministic offline UUID. +- Every ingress requires host approval under the admission identity rules. +- Active direct sessions and approvals end with the share. Invitations remain + time-limited and approval-bound, while the libp2p identity, access + capability, and Connect endpoint name and token persist across shares. +- The persistent endpoint token is stored separately from ordinary settings, + never included in invitations, and redacted from logs and UI. +- Secrets and direct candidate addresses are redacted from normal logs. +- Internet P2P is opt-in on both peers and never inferred from merely having + the mod installed. +- Direct P2P does not accept or advertise circuit-relay addresses. +- The host limits the share to 16 guests, 16 pending approvals, and one active + share. Admission attempts are additionally bounded per Connect session or + active direct peer. +- Malformed, expired, unsupported-version, incorrectly signed, or + capability-mismatched invitations are rejected before dialing. + +## Failure Handling + +- If local bridge creation fails, sharing fails without starting any ingress. +- If Connect fails but a direct ingress is usable, the share enters + `DEGRADED` and clearly says it is available only to modded direct peers. +- If direct setup fails, Connect sharing remains active. +- A failed direct guest attempt falls back to Connect when the invitation + contains a Connect hostname. +- If Connect authentication rejects the endpoint identity, the UI shows the + sanitized watch-service reason and offers token recovery or an explicit, + warned identity reset. It never creates another endpoint automatically. +- All partial startup paths run the same idempotent stop sequence. +- Minecraft-version hook drift fails at startup with the affected version and + mixin/accessor name; it never exposes a partially initialized share. + +## Testing Strategy + +### Common unit tests + +- state-machine transitions and idempotent cleanup; +- persistent endpoint creation, reload, environment override, redaction, + cross-world reuse, and explicit-only reset; +- dashboard credential paste and `token.json` import, staged validation, + atomic replacement, rollback on every failure, and credential-source + precedence; +- Connect-authenticated, Mojang-authenticated, and unverified admission allow, + deny, duplicate, reconnect, impersonated-name, timeout, capacity, rate-limit, + and share reset; +- invitation round-trip, signature, expiry, version, capability, and redaction; +- transport order, privacy opt-in, timeouts, and Connect fallback. + +### Networking tests + +- local Connect channel preserves `ConnectPlayer` session context; +- Connect ingress preserves passthrough/offloaded authentication semantics and + accepts both paid and non-paid account modes; +- direct stream reaches the vanilla child initializer without a public bind; +- direct online authentication never downgrades to offline after failure; +- direct offline login creates an unverified profile and requires a fresh + approval after reconnect; +- two loopback libp2p hosts exchange a Minecraft-shaped byte stream; +- direct configuration contains no circuit-relay candidate; +- failed direct dial selects Connect exactly once; +- runtime-isolation tests reject libp2p, Netty, or Kotlin types crossing the + reflective parent boundary. + +### Version tests + +Both Fabric artifacts must: + +- compile against their exact Minecraft and Fabric API versions; +- apply every mixin in a headless integrated-server startup smoke test; +- create and stop the local bridge twice in one process; +- package the correct `fabric.mod.json`, mixin config, translations, and + dependency constraints; +- expose the same wire protocol fixtures. + +### Build and CI + +- Existing plugin verification remains `./gradlew build`. +- Mod verification builds on Java 21 and Java 25 as appropriate. +- CI verifies both remapped Fabric JARs and rejects duplicate or leaked + unisolated networking classes. +- Release automation publishes mod artifacts separately from + `connect-spigot.jar`, `connect-velocity.jar`, and `connect-bungee.jar`. + +### Manual acceptance + +Before calling the feature complete: + +1. Share a 1.21.11 world and join from an unmodified client through Connect. +2. Repeat on 26.2. +3. Deny then approve an authenticated UUID and verify approval resets after + restart. +4. Join from a vanilla offline-mode client through Connect, verify the host + sees **Connect authenticated**, and verify the connection succeeds. +5. Join directly from a modded offline-mode client and verify the same + per-connection approval rule. +6. Join automatically between two modded clients on one LAN with Connect + unavailable. +7. Verify internet direct is never attempted without confirmation on both + peers. +8. Verify successful internet direct where NAT permits it. +9. Verify a failed internet-direct attempt falls back to Connect. +10. Stop sharing and prove the hostname no longer reaches the world. +11. Start a different world and prove the same endpoint name, token, libp2p + identity, and access identity are reused; an old invitation must not bypass + host approval. +12. Import a dashboard-created endpoint and token, then prove its hostname is + used without creating another endpoint. +13. Reject a bad imported token and prove the prior working identity remains + intact. +14. Confirm no LAN/WAN Minecraft listener is reachable from another machine. + +## Delivery Sequence + +Implementation proceeds in independently testable slices without reducing the +final scope: + +1. Kotlin/Fabric multi-version build, share state, admission, invitations, and + version adapters. +2. Integrated-server local bridge and persistent Connect ingress for vanilla + guests. +3. Same-LAN direct libp2p. +4. Opt-in internet direct attempts and Connect fallback. +5. Host/guest UI, packaging, release automation, and real-network acceptance. + +Each slice follows test-first development and leaves both Fabric targets +buildable. Plugin release, mod release, and any production rollout remain +separate operations. diff --git a/docs/superpowers/specs/2026-07-30-connect-share-pasted-lan-invite-design.md b/docs/superpowers/specs/2026-07-30-connect-share-pasted-lan-invite-design.md new file mode 100644 index 000000000..3b4542f8a --- /dev/null +++ b/docs/superpowers/specs/2026-07-30-connect-share-pasted-lan-invite-design.md @@ -0,0 +1,105 @@ +# Connect Share Pasted LAN Invitation Design + +**Date:** 2026-07-30 +**Status:** Approved for implementation +**Parent design:** `2026-07-30-connect-share-mod-design.md` + +> Historical scope note: this design covers the initial 1.21.11 and 26.2 +> implementation slice. See [Connect Share](../../connect-share.md) for the +> current supported targets and behavior. + +## Problem + +Connect Share advertises active modded hosts on the local network through +mDNS. The guest validates the signed invitation returned by the discovered +libp2p peer and stores its LAN multiaddress in `FabricShareBrowser`. + +Selecting a nearby share in the join screen passes that multiaddress to +`FabricShareBrowser.join`, so the route planner tries direct LAN before +Connect. Pasting the same invitation clears the screen's selected LAN address. +The browser then plans with `sameLan = false`, skips direct LAN, and connects +through the public Connect endpoint even when the matching host is already +discovered nearby. + +Live diagnosis confirmed that the host's mDNS advertisement, LAN TCP listener, +libp2p peer identity, and metadata protocol were reachable. The guest still +selected the public Connect hostname because the pasted-invitation path did +not associate the invitation with the matching discovery. + +## Decision + +`FabricShareBrowser` will reconcile a parsed invitation with its current +validated mDNS discoveries before planning routes. + +When `join` receives no explicit LAN address, it will search the current +discovery snapshot for an entry whose signed invitation has both the same +`shareId` and the same `peerId` as the invitation being joined. A match +supplies the effective LAN address. Route planning then treats the peers as +same-LAN and preserves the existing order: + +1. direct LAN; +2. direct internet, only when both peers opted in; +3. Minekube Connect. + +An explicit LAN address from selecting a nearby-share button remains +authoritative. This keeps the current UI behavior while making paste, keyboard +paste, and programmatic join paths equally capable. + +## Security and Privacy + +LAN addresses remain outside copied invitations. They are local, transient, +and may reveal network topology if shared beyond the LAN. + +Only validated discoveries are eligible for reconciliation. The existing +discovery path: + +- dials the advertised libp2p peer; +- retrieves the invitation over the metadata protocol; +- verifies the invitation signature and expiry; and +- requires the invitation's `peerId` to equal the connected peer. + +The additional `shareId` and `peerId` match prevents an unrelated nearby share +from influencing routing. The pasted invitation continues to supply the +capability used for tunnel authentication; no capability, endpoint token, +invitation URI, or LAN address is added to logs or error messages. + +## Failure Behavior + +Discovery is opportunistic. If the matching advertisement has not arrived, +has expired, or is unavailable, behavior remains unchanged: route planning +uses internet-direct candidates only when both peers opted in, then falls back +to Connect when a Connect address exists. + +If the matched LAN address cannot be dialed, the existing direct failure +handling continues to the next planned route. The fix does not make mDNS or +direct P2P mandatory and does not weaken Connect fallback. + +## Scope + +The behavior belongs in `share/fabric-common` so Minecraft 1.21.11 and 26.2 +receive the same fix without version-specific screen changes. + +The implementation will modify: + +- `FabricShareBrowser.join` to derive one effective LAN address from the + explicit selection or a matching validated discovery; and +- `FabricShareBrowserTest` to cover pasted matching invitations and unrelated + discoveries. + +No invitation wire-format, mDNS protocol, libp2p protocol, Connect endpoint, +or Minecraft-version adapter changes are required. + +## Acceptance Criteria + +- Pasting an active same-LAN host's signed invitation while its matching mDNS + discovery is present attempts `DIRECT_LAN` before Connect. +- The match requires both `shareId` and `peerId`. +- Selecting a nearby share explicitly continues to attempt `DIRECT_LAN`. +- An unrelated discovery never supplies a LAN address. +- Missing or failed LAN discovery preserves internet-direct and Connect + fallback behavior. +- Tests pass for `share:fabric-common`, followed by the repository-wide + `./gradlew build`. +- The rebuilt Fabric 26.2 mod is installed in both PrismLauncher test + instances and a live join shows the guest connecting to a loopback proxy + while the host records the session as direct LAN. diff --git a/docs/superpowers/specs/2026-07-31-connect-share-prism-skill-design.md b/docs/superpowers/specs/2026-07-31-connect-share-prism-skill-design.md new file mode 100644 index 000000000..25788fb9a --- /dev/null +++ b/docs/superpowers/specs/2026-07-31-connect-share-prism-skill-design.md @@ -0,0 +1,60 @@ +# Connect Share Prism E2E Skill Design + +## Purpose + +Preserve the non-obvious procedure for testing Connect Share with two real +Prism Launcher clients so future agents can reproduce friend presence, join +approval, direct transport, Connect fallback, and Minecraft login failures. + +## Location and discovery + +Create the versioned repository skill at: + +```text +.agents/skills/connect-share-prism-e2e/ +├── SKILL.md +└── agents/openai.yaml +``` + +The skill description will trigger for Connect Share live testing, Prism +installation and launch, two-client friend joining, direct-versus-Connect route +diagnosis, Minecraft login diagnosis, and updating the reusable E2E procedure. + +## Contents + +Keep `SKILL.md` concise and procedural. It will require agents to: + +1. Work from the isolated Connect Share worktree and read `share/AGENTS.md`. +2. Run only one Gradle invocation in a worktree at a time. +3. Build and install the exact same 26.2 artifact in both Prism instances. +4. Launch distinct host and guest identities with Prism's `--profile`, + `--offline`, `--world`, and `--server` arguments. +5. Prove discovery, confirmed-friend activity, privacy-permitted Minecraft + status, join request, approval, and a real `joined the game` log line as + separate gates. +6. Use a fresh direct target for status and gameplay when status is permitted, + because the current proxy is one-shot. +7. Diagnose readiness and pipeline failures with logs, `dns-sd`, and `jcmd`. +8. Preserve the offline-versus-online authentication invariant. +9. Restore temporary friend auto-approval and leave both test profiles in a + safe state. +10. Promote genuinely reusable discoveries back into the skill and + `share/AGENTS.md`, without recording machine-specific paths or secrets. + +The skill will point to `PrismFriendJoinE2ETest` as the executable harness. It +will not duplicate that test or add another shell script. + +## Validation + +- Generate `agents/openai.yaml` with the skill-creator helper. +- Run `quick_validate.py` against the completed skill directory. +- Confirm the skill contains no endpoint tokens, friend capabilities, account + credentials, or absolute user-specific paths. +- Commit the skill separately so it remains auditable. + +## Non-goals + +- Do not install the skill globally; the repository owns this knowledge. +- Do not automate Minecraft UI clicks. +- Do not replace deterministic unit and integration tests with the live E2E. +- Do not encode the current Prism instance names as universal defaults. diff --git a/gradle.properties b/gradle.properties index 01a84aa2e..3eff474f8 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,5 @@ org.gradle.configureondemand=true org.gradle.caching=true org.gradle.parallel=true -version=2.2.3-SNAPSHOT \ No newline at end of file +org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=768m +version=2.2.3-SNAPSHOT diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index a59520664..9937dae91 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/settings.gradle.kts b/settings.gradle.kts index af17065cb..ee34a5e0e 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -2,8 +2,13 @@ enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") dependencyResolutionManagement { - repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + // Loom must publish and resolve remapped Minecraft/mod artifacts through + // project-local cache repositories that it owns. + repositoriesMode.set(RepositoriesMode.PREFER_PROJECT) repositories { + maven("https://maven.fabricmc.net/") { + name = "Fabric" + } // Geyser, Cumulus etc. TODO remove maven("https://repo.opencollab.dev/maven-releases") { mavenContent { releasesOnly() } @@ -64,16 +69,27 @@ dependencyResolutionManagement { pluginManagement { repositories { + maven("https://maven.fabricmc.net/") { + name = "Fabric" + } + maven("https://maven.neoforged.net/releases") { + name = "NeoForged" + } gradlePluginPortal() } repositories { maven("https://plugins.gradle.org/m2/") } plugins { - id("net.kyori.blossom") version "1.2.0" + id("net.kyori.blossom") version "2.2.0" id("net.kyori.indra") id("net.kyori.indra.git") - id("com.google.protobuf") version "0.8.18" + id("com.google.protobuf") version "0.10.0" + id("net.fabricmc.fabric-loom") version "1.17.17" + id("net.fabricmc.fabric-loom-remap") version "1.17.17" + id("net.neoforged.moddev.legacyforge") version "2.0.143" + id("net.neoforged.moddev") version "2.0.143" + id("org.jetbrains.kotlin.jvm") version "2.4.10" } includeBuild("build-logic") } @@ -85,3 +101,23 @@ include(":core") include(":bungee") include(":spigot") include(":velocity") + +// Fabric Loom has a newer JVM floor than the legacy Connect modules. The +// Java 17/21 root CI build skips these modules; dedicated Share jobs use their +// normal project paths with their matching JDK. +if (!gradle.startParameter.projectProperties.containsKey("skip-share")) { + include(":share:common") + include(":share:fabric-common") + include(":share:fabric-1-21-11") + project(":share:fabric-1-21-11").projectDir = file("share/fabric-1.21.11") + include(":share:fabric-1-21-1") + project(":share:fabric-1-21-1").projectDir = file("share/fabric-1.21.1") + include(":share:fabric-1-20-1") + project(":share:fabric-1-20-1").projectDir = file("share/fabric-1.20.1") + include(":share:fabric-26-2") + project(":share:fabric-26-2").projectDir = file("share/fabric-26.2") + include(":share:forge-1-20-1") + project(":share:forge-1-20-1").projectDir = file("share/forge-1.20.1") + include(":share:neoforge-1-21-1") + project(":share:neoforge-1-21-1").projectDir = file("share/neoforge-1.21.1") +} diff --git a/share/AGENTS.md b/share/AGENTS.md new file mode 100644 index 000000000..289d60c4d --- /dev/null +++ b/share/AGENTS.md @@ -0,0 +1,157 @@ +# Connect Share Kotlin Agent Instructions + +These instructions apply to every file under `share/`. + +## Arrow Is the Default Kotlin Toolkit + +Connect Share uses [Arrow](https://github.com/arrow-kt/arrow) as the preferred +toolkit for functional domain modeling, typed errors, validation, concurrency, +resource safety, resilience, and immutable data transformations. Before writing +a custom abstraction in one of those areas, check Arrow's +[library reference](https://arrow-kt.io/learn/quickstart/libs/) and use the +Arrow equivalent when it fits. + +Do not recreate capabilities Arrow already provides: + +- Model expected domain failures with `Raise` inside cohesive workflows and + `Either` at module or asynchronous boundaries. Reserve exceptions for + defects, cancellation, and genuinely exceptional infrastructure failures. +- Use `ensure`, `ensureNotNull`, `zipOrAccumulate`, `mapOrAccumulate`, and + `NonEmptyList` for parsing and validation instead of hand-written error + collectors or fail-fast exception chains. +- Use `Option` when absence is part of the domain and must be explicit. Keep + nullable values at Fabric, Minecraft, Java, JSON, or other interop edges, then + convert them at the boundary. +- Use `resourceScope`, `Resource`, or Arrow AutoClose utilities for acquired + tunnels, channels, embedded Connect runtimes, and other lifetimes that require + ordered cleanup. Cancellation must never skip release. +- Use Arrow Fx Coroutines operators such as `parZip`, `parMap`, and race + operators when they express intended structured concurrency more directly + than custom coroutine orchestration. +- Use Arrow Resilience schedules, retry policies, and circuit breakers when the + feature needs those behaviors; do not grow custom retry loops. +- Use Arrow Optics for repeated or deeply nested immutable updates instead of + copy-chain helpers. Add the Optics/KSP dependency only once such updates exist. +- Use Arrow STM only when several pieces of concurrent state must change as one + invariant-preserving transaction. Do not substitute it for a simple atomic or + immutable state flow. +- Prefer Arrow's non-empty collections, combinators, and function utilities + over equivalent local wrappers. + +This is a preference for the appropriate Arrow abstraction, not a requirement +to wrap every Kotlin expression. Plain data classes, sealed interfaces, +collections, `when`, and structured coroutines remain idiomatic. Minecraft and +Fabric callback signatures stay native at their boundaries, and no Arrow type +may cross the Java Connect Core public API unless that API is deliberately +redesigned for Kotlin. + +## Dependency Discipline + +- Pin the stable Arrow stack version once in `Versions.arrowVersion` and import + the `arrow-stack` BOM. Do not put independent Arrow versions in module builds. +- `share:common` exposes `arrow-core` because its typed outcomes are part of the + Kotlin domain API. Runtime-specific modules keep additional Arrow libraries + as implementation dependencies unless their types are intentionally public. +- Add an Arrow module when the code uses its capability. Do not add the entire + Arrow ecosystem speculatively. +- Preserve coroutine cancellation. Never catch `CancellationException` as a + typed domain error. + +## Tests + +- Assert both sides of typed outcomes and every accumulated validation error. +- For managed resources, test release on success, typed failure, exception, and + cancellation. +- For retries or parallel operators, use deterministic virtual-time tests; no + real sleeps. + +## Prism Two-Client E2E + +The E2E procedure has two owners; do not retell it here: + +- The [connect-share-prism-e2e skill](../.agents/skills/connect-share-prism-e2e/SKILL.md) + owns launching Prism identities, installing artifacts, running the opt-in + `PrismFriendJoinE2ETest` harness (including its `LIVE_*` environment + variables), keyboard-only visual QA and focus-order capture, and gate-by-gate + failure diagnosis. +- [The testing guide](../docs/connect-share-testing.md) owns the acceptance + matrix, evidence gates, `--rerun-tasks` rationale, identity-key cloning + hazard, and manual Prism loader-component setup. + +When a live run reveals a new stable rule, update the skill or guide and keep +only the operative invariant below. + +## Share Invariants (pinned) + +- `ShareConnectionGateway` installs Minecraft's captured Netty initializer + after its accepted channel is already active; newly installed handlers must + receive the active lifecycle before the first Minecraft bytes. Every path + that installs the captured initializer runs on the loader's logical-server + thread group - Forge derives packet side from the thread group, so a generic + executor rejects login/play custom payloads as client-side. A directly bound + local listener borrows Minecraft's captured `EventLoopGroup` and closes only + its channel; the always-on Forge gateway owns loader-classified event loops + supplied by the adapter. Pinned by + `share/common/.../ShareConnectionGatewayTest`. +- Authenticated friend activity is the authority for visible online, playing, + world-name, and joinable state. The gateway rejects raw Minecraft status + whenever online, playing, or the current server/world name is hidden, and a + capability route answering status must never promote an unknown or pending + identity into social presence. Login remains independently admissible. + Pinned by `ShareConnectionGatewayTest`. +- An integrated server object exists before its local player connection is + ready. Publish only after both exist, and advertise `HOSTING_WORLD` only + from an actual `ShareState.Sharing`. +- Admission resumes through a loader continuation: Fabric can call + `handleAcceptedLogin` immediately, but Forge must enter its native + `NEGOTIATING` state so FML login queries finish before play; skipping it + makes Forge clients misclassify each other as vanilla. +- A direct session negotiated as `OFFLINE` must create Minecraft's standard + offline profile in `handleHello`, before vanilla starts Mojang session + authentication; `ONLINE` direct sessions must never silently downgrade. +- A `DirectP2pProxy` target is one-shot: a status probe consumes it, so open a + separate target for gameplay and keep it alive until login finishes. Never + reuse the friend-control target for a status probe or login. +- Keep the direct runtime on jvm-libp2p's tested Mplex default until another + muxer passes both the Java 17 multi-window regression + (`core/.../tunnel/p2p/DirectP2pNodeTest.java17PeersTransferAcrossManyMuxerWindows`) + and every real-client adapter; Yamux on Netty 4.2 double-releases buffered + window data on Java 17 after server login. +- The persistent social control peer and the active-world peer intentionally + advertise the same stable share ID with different peer IDs. Discovery must + retain one entry per `(shareId, peerId)` and refresh it when the same peer + advertises a changed address; deduplicating by share ID alone can evict the + saved friend's control route right after authenticated activity. +- Persistent friend cards retain signed direct candidates, tried after mDNS, + never via Connect as a social relay. Copying a friend link is the disclosure + boundary for those routes; removal revokes both admission grants and + reciprocal-card proofs. Every copy action resolves the current handle + invitation - renewing only the mDNS token leaves copies stale. +- `approveNextJoin` grants are one-shot admission capabilities, not durable + friend state: expire within the admission timeout, deduplicate, bound by + `maxPending`, and evict the oldest when full. A vanilla no-mod Connect join + carries no signed direct-peer proof and may require ordinary pending + admission even against a same-named auto-accept friend; never weaken + UUID/peer matching. Automatic friend-card exchange requires a proof carrying + a direct peer ID plus a signature-verified invitation naming that same peer; + fail closed otherwise. Pinned by + `share/common/.../admission/AdmissionControllerTest`. +- No-mod session admission must finish before vanilla's own connection + timeout: keep a deadline buffer, cancel the pending host request on expiry, + and encode an intentional denial as `PermissionDenied` with the safe copy + repeated in a `google.rpc.LocalizedMessage` detail - Moxy never shows a + connector-controlled raw status message, and a generic `Timed out` is a + failed UX result. +- Recovery export/import runs only against the fixed Share allowlist and only + while sharing is stopped; a backup target must never resolve to a live + identity, friend, preference, endpoint, or transaction path. Validate and + decrypt the entire archive before replacement, keep rollback material until + a committed marker is durable, and never print archive paths, contents, + passwords, identities, or tokens as evidence. +- Do not apply Shadow's generic `minimize()` to the isolated libp2p payload: + jvm-libp2p reaches Kotlin, cryptography, protobuf, Noise, Guava, and Netty + classes reflectively. Any payload-size reduction must keep cross-platform + natives and be proved between two peers loaded from the exact packaged + artifact. Legacy Forge's final reobfuscated JAR must contain its generated + Mixin refmap named from the loader-specific mixin config, and Forge/NeoForge + client resources need a compatible `pack.mcmeta`. diff --git a/share/common/build.gradle.kts b/share/common/build.gradle.kts new file mode 100644 index 000000000..d2ee1ae00 --- /dev/null +++ b/share/common/build.gradle.kts @@ -0,0 +1,35 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + `java-library` + id("org.jetbrains.kotlin.jvm") +} + +java { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } +} + +kotlin { + jvmToolchain(21) + compilerOptions.jvmTarget.set(JvmTarget.JVM_17) +} + +dependencies { + implementation(projects.core) + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:${Versions.coroutinesVersion}") + api(platform("io.arrow-kt:arrow-stack:${Versions.arrowVersion}")) + api("io.arrow-kt:arrow-core") + implementation("io.arrow-kt:arrow-fx-coroutines") + + testImplementation(kotlin("test")) + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:${Versions.coroutinesVersion}") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") +} + +tasks.test { + useJUnitPlatform() +} diff --git a/share/common/src/main/kotlin/com/minekube/connect/share/CapturedServerTransport.kt b/share/common/src/main/kotlin/com/minekube/connect/share/CapturedServerTransport.kt new file mode 100644 index 000000000..be28ccc49 --- /dev/null +++ b/share/common/src/main/kotlin/com/minekube/connect/share/CapturedServerTransport.kt @@ -0,0 +1,118 @@ +package com.minekube.connect.share + +import arrow.core.Either +import arrow.core.left +import arrow.core.right +import com.minekube.connect.share.direct.DirectSessionAttributes +import com.minekube.connect.share.direct.DirectSessionRegistry +import io.netty.channel.Channel +import io.netty.channel.ChannelInitializer +import io.netty.channel.EventLoopGroup + +object CapturedServerTransport { + private val captureLock = Any() + + @Volatile + private var armed: ArmedCapture? = null + + @JvmStatic + fun arm(): CaptureLease = synchronized(captureLock) { + check(armed == null) { "A Minecraft transport capture is already active" } + val capture = ArmedCapture(Thread.currentThread()) + armed = capture + CaptureLease(capture) + } + + @JvmStatic + fun isShareStartArmed(): Boolean = + armed?.owner === Thread.currentThread() + + @JvmStatic + fun captureChildInitializer( + initializer: ChannelInitializer, + ): ChannelInitializer { + val wrapped = object : ChannelInitializer() { + override fun initChannel(channel: Channel) { + DirectSessionRegistry.claim(channel.remoteAddress())?.let { + channel.attr(DirectSessionAttributes.SESSION).set(it) + } + channel.pipeline().addLast(initializer) + } + } + synchronized(captureLock) { + armed + ?.takeIf { it.owner === Thread.currentThread() } + ?.childInitializer = wrapped + } + return wrapped + } + + @JvmStatic + fun captureEventLoopGroup(group: EventLoopGroup): EventLoopGroup { + synchronized(captureLock) { + armed + ?.takeIf { it.owner === Thread.currentThread() } + ?.eventLoopGroup = group + } + return group + } + + internal fun complete( + expected: ArmedCapture, + ): Either = synchronized(captureLock) { + val current = armed + if (current !== expected || current.owner !== Thread.currentThread()) { + return@synchronized CaptureFailure.Incomplete.left() + } + armed = null + val initializer = current.childInitializer + val group = current.eventLoopGroup + if (initializer == null || group == null) { + CaptureFailure.Incomplete.left() + } else { + CapturedTransport(initializer, group).right() + } + } + + internal fun cancel(expected: ArmedCapture) { + synchronized(captureLock) { + if (armed === expected) { + armed = null + } + } + } + + internal class ArmedCapture( + val owner: Thread, + var childInitializer: ChannelInitializer? = null, + var eventLoopGroup: EventLoopGroup? = null, + ) +} + +class CaptureLease internal constructor( + private val capture: CapturedServerTransport.ArmedCapture, +) : AutoCloseable { + private var completed = false + + fun complete(): Either { + check(!completed) { "Minecraft transport capture is already complete" } + completed = true + return CapturedServerTransport.complete(capture) + } + + override fun close() { + if (!completed) { + completed = true + CapturedServerTransport.cancel(capture) + } + } +} + +data class CapturedTransport( + val childInitializer: ChannelInitializer, + val eventLoopGroup: EventLoopGroup, +) + +sealed interface CaptureFailure { + data object Incomplete : CaptureFailure +} diff --git a/share/common/src/main/kotlin/com/minekube/connect/share/ConnectShareIngress.kt b/share/common/src/main/kotlin/com/minekube/connect/share/ConnectShareIngress.kt new file mode 100644 index 000000000..e5a5c076a --- /dev/null +++ b/share/common/src/main/kotlin/com/minekube/connect/share/ConnectShareIngress.kt @@ -0,0 +1,17 @@ +package com.minekube.connect.share + +import com.minekube.connect.share.identity.EndpointIdentity +import java.net.SocketAddress + +data class ConnectShareHandle( + val endpoint: String, + val publicAddress: String, + val close: suspend () -> Unit, +) + +fun interface ConnectShareIngress { + suspend fun start( + identity: EndpointIdentity, + target: SocketAddress, + ): ConnectShareHandle +} diff --git a/share/common/src/main/kotlin/com/minekube/connect/share/DirectShareIngress.kt b/share/common/src/main/kotlin/com/minekube/connect/share/DirectShareIngress.kt new file mode 100644 index 000000000..81e3f8d9b --- /dev/null +++ b/share/common/src/main/kotlin/com/minekube/connect/share/DirectShareIngress.kt @@ -0,0 +1,51 @@ +package com.minekube.connect.share + +import java.net.SocketAddress + +class DirectShareHandle( + private val invitationProvider: () -> String, + val lanAvailable: Boolean, + val internetAvailable: Boolean, + val close: suspend () -> Unit, +) { + constructor( + invitation: String, + lanAvailable: Boolean, + internetAvailable: Boolean, + close: suspend () -> Unit, + ) : this( + invitationProvider = { invitation }, + lanAvailable = lanAvailable, + internetAvailable = internetAvailable, + close = close, + ) + + val invitation: String + get() = invitationProvider() + + fun copy( + invitation: String? = null, + lanAvailable: Boolean = this.lanAvailable, + internetAvailable: Boolean = this.internetAvailable, + close: suspend () -> Unit = this.close, + ) = DirectShareHandle( + invitationProvider = invitation?.let { value -> { value } } + ?: invitationProvider, + lanAvailable = lanAvailable, + internetAvailable = internetAvailable, + close = close, + ) + + override fun toString(): String = + "DirectShareHandle(invitation=, " + + "lanAvailable=$lanAvailable, " + + "internetAvailable=$internetAvailable)" +} + +fun interface DirectShareIngress { + suspend fun start( + options: ShareOptions, + target: SocketAddress, + connectAddress: String?, + ): DirectShareHandle +} diff --git a/share/common/src/main/kotlin/com/minekube/connect/share/MinecraftShareBridge.kt b/share/common/src/main/kotlin/com/minekube/connect/share/MinecraftShareBridge.kt new file mode 100644 index 000000000..91e29ae4f --- /dev/null +++ b/share/common/src/main/kotlin/com/minekube/connect/share/MinecraftShareBridge.kt @@ -0,0 +1,13 @@ +package com.minekube.connect.share + +import java.net.SocketAddress + +data class LocalShareTarget( + val address: SocketAddress, + val directAddress: SocketAddress = address, + val close: suspend () -> Unit, +) + +fun interface MinecraftShareBridge { + suspend fun open(options: ShareOptions): LocalShareTarget +} diff --git a/share/common/src/main/kotlin/com/minekube/connect/share/ShareBuild.kt b/share/common/src/main/kotlin/com/minekube/connect/share/ShareBuild.kt new file mode 100644 index 000000000..2d2105ef3 --- /dev/null +++ b/share/common/src/main/kotlin/com/minekube/connect/share/ShareBuild.kt @@ -0,0 +1,6 @@ +package com.minekube.connect.share + +object ShareBuild { + const val MOD_ID = "connect-share" + const val WIRE_PROTOCOL = 1 +} diff --git a/share/common/src/main/kotlin/com/minekube/connect/share/ShareConnectionGateway.kt b/share/common/src/main/kotlin/com/minekube/connect/share/ShareConnectionGateway.kt new file mode 100644 index 000000000..ed94ba739 --- /dev/null +++ b/share/common/src/main/kotlin/com/minekube/connect/share/ShareConnectionGateway.kt @@ -0,0 +1,345 @@ +package com.minekube.connect.share + +import com.minekube.connect.inject.CommonPlatformInjector +import com.minekube.connect.network.netty.LocalServerChannelWrapper +import com.minekube.connect.share.direct.DirectSessionAttributes +import com.minekube.connect.share.direct.DirectSessionRegistry +import com.minekube.connect.share.friend.FriendControlChannelHandler +import com.minekube.connect.share.friend.FriendControlServer +import com.minekube.connect.share.friend.friendControlContext +import io.netty.buffer.ByteBuf +import io.netty.buffer.Unpooled +import io.netty.bootstrap.ServerBootstrap +import io.netty.channel.Channel +import io.netty.channel.ChannelFuture +import io.netty.channel.ChannelHandlerContext +import io.netty.channel.ChannelInboundHandlerAdapter +import io.netty.channel.ChannelInitializer +import io.netty.channel.DefaultEventLoopGroup +import io.netty.channel.EventLoopGroup +import io.netty.channel.local.LocalAddress +import io.netty.channel.nio.NioEventLoopGroup +import io.netty.channel.socket.nio.NioServerSocketChannel +import io.netty.util.ReferenceCountUtil +import io.netty.util.concurrent.DefaultThreadFactory +import java.net.InetAddress +import java.net.InetSocketAddress +import java.io.ByteArrayOutputStream +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicReference +import java.util.concurrent.ThreadFactory + +class ShareConnectionGateway private constructor( + private val friendServer: FriendControlServer, + minecraftThreadFactory: ThreadFactory?, +) : CommonPlatformInjector(), AutoCloseable { + private val activeMinecraft = + AtomicReference?>(null) + private val closed = AtomicBoolean() + private val localEventLoop: EventLoopGroup = DefaultEventLoopGroup( + 1, + minecraftThreadFactory + ?: DefaultThreadFactory("Connect Share gateway local"), + ) + private val directEventLoop: EventLoopGroup = NioEventLoopGroup( + 1, + minecraftThreadFactory + ?: DefaultThreadFactory("Connect Share gateway direct"), + ) + private val directChannel: ChannelFuture + + val directAddress: InetSocketAddress + get() = directChannel.channel().localAddress() as InetSocketAddress + + val isClosed: Boolean + get() = closed.get() + + init { + try { + localChannel = bindLocal() + serverSocketAddress = localChannel.channel().localAddress() + directChannel = bindDirect() + } catch (failure: Throwable) { + closeAfterFailedBind() + throw failure + } + } + + fun activateMinecraft( + initializer: ChannelInitializer, + ): AutoCloseable { + check(!closed.get()) { "Connect Share gateway is closed" } + check(activeMinecraft.compareAndSet(null, initializer)) { + "A Minecraft world is already active" + } + return AutoCloseable { + activeMinecraft.compareAndSet(initializer, null) + } + } + + override fun inject(): Boolean = !closed.get() + + override fun isInjected(): Boolean = + !closed.get() && + localChannel?.channel()?.isOpen == true && + directChannel.channel().isOpen + + override fun shutdown() { + // The embedded Connect runtime borrows this injector. The gateway owns + // both listeners and releases them from close(), after every borrower. + } + + override fun close() { + if (!closed.compareAndSet(false, true)) { + return + } + activeMinecraft.set(null) + closeChannel(directChannel) + closeChannel(localChannel) + localChannel = null + shutdownEventLoop(directEventLoop) + shutdownEventLoop(localEventLoop) + } + + private fun bindLocal(): ChannelFuture = + ServerBootstrap() + .channel(LocalServerChannelWrapper::class.java) + .childHandler(gatewayInitializer()) + .group(localEventLoop) + .localAddress(LocalAddress.ANY) + .bind() + .syncUninterruptibly() + + private fun bindDirect(): ChannelFuture = + ServerBootstrap() + .channel(NioServerSocketChannel::class.java) + .childHandler(gatewayInitializer()) + .group(directEventLoop) + .localAddress( + InetSocketAddress(InetAddress.getLoopbackAddress(), 0), + ) + .bind() + .syncUninterruptibly() + + private fun gatewayInitializer() = + object : ChannelInitializer() { + override fun initChannel(channel: Channel) { + DirectSessionRegistry.claim(channel.remoteAddress())?.let { + channel.attr(DirectSessionAttributes.SESSION).set(it) + } + channel.pipeline().addLast( + FRIEND_CONTROL_HANDLER, + FriendControlChannelHandler(friendServer), + ) + channel.pipeline().addLast( + MINECRAFT_STATUS_PRIVACY_HANDLER, + MinecraftStatusPrivacyHandler(friendServer), + ) + channel.pipeline().addLast( + MINECRAFT_DISPATCH_HANDLER, + MinecraftDispatchHandler(activeMinecraft), + ) + } + } + + private fun closeAfterFailedBind() { + runCatching { closeChannel(localChannel) } + localChannel = null + shutdownEventLoop(directEventLoop) + shutdownEventLoop(localEventLoop) + } + + private class MinecraftStatusPrivacyHandler( + private val friendServer: FriendControlServer, + ) : ChannelInboundHandlerAdapter() { + private val buffered = ByteArrayOutputStream() + + override fun channelRead( + context: ChannelHandlerContext, + message: Any, + ) { + if (message !is ByteBuf) { + context.fireChannelRead(message) + return + } + try { + val bytes = ByteArray(message.readableBytes()) + message.readBytes(bytes) + buffered.write(bytes) + } finally { + ReferenceCountUtil.release(message) + } + if (buffered.size() > MAX_MINECRAFT_HANDSHAKE_BYTES) { + context.close() + return + } + val bytes = buffered.toByteArray() + when (val decoded = MinecraftHandshake.decode(bytes)) { + MinecraftHandshakeDecode.Incomplete -> Unit + MinecraftHandshakeDecode.Invalid -> context.close() + is MinecraftHandshakeDecode.Decoded -> { + if ( + decoded.intent == MinecraftHandshakeIntent.STATUS && + !friendServer.allowsMinecraftStatus( + context.channel().friendControlContext(), + ) + ) { + context.close() + } else { + context.pipeline().remove(this) + context.fireChannelRead(Unpooled.wrappedBuffer(bytes)) + } + } + } + } + } + + private enum class MinecraftHandshakeIntent { + STATUS, + LOGIN, + } + + private sealed interface MinecraftHandshakeDecode { + data object Incomplete : MinecraftHandshakeDecode + data object Invalid : MinecraftHandshakeDecode + data class Decoded( + val intent: MinecraftHandshakeIntent, + ) : MinecraftHandshakeDecode + } + + private object MinecraftHandshake { + fun decode(bytes: ByteArray): MinecraftHandshakeDecode { + val frameLength = readVarInt(bytes, 0) + ?: return MinecraftHandshakeDecode.Incomplete + if (frameLength.value < 0 || frameLength.value > MAX_MINECRAFT_HANDSHAKE_BYTES) { + return MinecraftHandshakeDecode.Invalid + } + val frameEnd = frameLength.next + frameLength.value + if (frameEnd > bytes.size) { + return MinecraftHandshakeDecode.Incomplete + } + var cursor = frameLength.next + val packetId = readVarInt(bytes, cursor) + ?: return MinecraftHandshakeDecode.Invalid + if (packetId.value != 0) return MinecraftHandshakeDecode.Invalid + cursor = packetId.next + val protocol = readVarInt(bytes, cursor) + ?: return MinecraftHandshakeDecode.Invalid + cursor = protocol.next + val addressLength = readVarInt(bytes, cursor) + ?: return MinecraftHandshakeDecode.Invalid + if (addressLength.value !in 0..MAX_SERVER_ADDRESS_BYTES) { + return MinecraftHandshakeDecode.Invalid + } + cursor = addressLength.next + addressLength.value + if (cursor + PORT_BYTES > frameEnd) { + return MinecraftHandshakeDecode.Invalid + } + cursor += PORT_BYTES + val intent = readVarInt(bytes, cursor) + ?: return MinecraftHandshakeDecode.Invalid + if (intent.next != frameEnd) return MinecraftHandshakeDecode.Invalid + return when (intent.value) { + 1 -> MinecraftHandshakeDecode.Decoded( + MinecraftHandshakeIntent.STATUS, + ) + 2, 3 -> MinecraftHandshakeDecode.Decoded( + MinecraftHandshakeIntent.LOGIN, + ) + else -> MinecraftHandshakeDecode.Invalid + } + } + + private fun readVarInt( + bytes: ByteArray, + start: Int, + ): DecodedVarInt? { + var value = 0 + var position = 0 + var cursor = start + while (position < MAX_VAR_INT_BITS) { + if (cursor >= bytes.size) return null + val current = bytes[cursor].toInt() and 0xff + value = value or ((current and 0x7f) shl position) + cursor++ + if (current and 0x80 == 0) { + return DecodedVarInt(value, cursor) + } + position += 7 + } + return null + } + + private data class DecodedVarInt( + val value: Int, + val next: Int, + ) + } + + private class MinecraftDispatchHandler( + private val active: + AtomicReference?>, + ) : ChannelInboundHandlerAdapter() { + override fun channelRead( + context: ChannelHandlerContext, + message: Any, + ) { + val initializer = active.get() + if (initializer == null) { + ReferenceCountUtil.release(message) + context.close() + return + } + val pipeline = context.pipeline() + pipeline.remove(this) + pipeline.addLast( + MINECRAFT_LIFECYCLE_REPLAY, + ChannelInboundHandlerAdapter(), + ) + pipeline.addLast(MINECRAFT_INITIALIZER, initializer) + checkNotNull( + pipeline.context(MINECRAFT_LIFECYCLE_REPLAY), + ).fireChannelActive() + pipeline.remove(MINECRAFT_LIFECYCLE_REPLAY) + pipeline.fireChannelRead(message) + } + } + + companion object { + fun bind(friendServer: FriendControlServer): ShareConnectionGateway = + ShareConnectionGateway(friendServer, null) + + fun bind( + minecraftThreadFactory: ThreadFactory?, + friendServer: FriendControlServer, + ): + ShareConnectionGateway = + ShareConnectionGateway(friendServer, minecraftThreadFactory) + + private fun closeChannel(future: ChannelFuture?) { + val channel = future?.channel() ?: return + if (channel.isOpen) { + channel.close().syncUninterruptibly() + } + } + + private fun shutdownEventLoop(group: EventLoopGroup) { + group.shutdownGracefully().syncUninterruptibly() + } + + private const val FRIEND_CONTROL_HANDLER = + "connect-share-friend-control" + private const val MINECRAFT_DISPATCH_HANDLER = + "connect-share-minecraft-dispatch" + private const val MINECRAFT_STATUS_PRIVACY_HANDLER = + "connect-share-minecraft-status-privacy" + private const val MINECRAFT_INITIALIZER = + "connect-share-minecraft-initializer" + private const val MINECRAFT_LIFECYCLE_REPLAY = + "connect-share-minecraft-lifecycle-replay" + private const val MAX_MINECRAFT_HANDSHAKE_BYTES = 8_192 + private const val MAX_SERVER_ADDRESS_BYTES = 255 + private const val PORT_BYTES = 2 + private const val MAX_VAR_INT_BITS = 35 + } +} diff --git a/share/common/src/main/kotlin/com/minekube/connect/share/ShareCoordinator.kt b/share/common/src/main/kotlin/com/minekube/connect/share/ShareCoordinator.kt new file mode 100644 index 000000000..4c358ec58 --- /dev/null +++ b/share/common/src/main/kotlin/com/minekube/connect/share/ShareCoordinator.kt @@ -0,0 +1,217 @@ +package com.minekube.connect.share + +import arrow.core.Either +import arrow.fx.coroutines.ExitCase +import arrow.fx.coroutines.ExitCase.Companion.ExitCase +import arrow.fx.coroutines.Resource +import arrow.fx.coroutines.ResourceScope +import arrow.fx.coroutines.allocate +import arrow.fx.coroutines.resource +import com.minekube.connect.share.admission.AdmissionController +import com.minekube.connect.share.identity.EndpointIdentity +import java.util.concurrent.CancellationException +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext + +class ShareCoordinator( + private val bridge: MinecraftShareBridge, + private val ingress: ConnectShareIngress, + private val identityProvider: suspend () -> EndpointIdentity, + private val admission: AdmissionController, + private val directIngress: DirectShareIngress? = null, + private val failureReporter: (String) -> Unit = {}, +) { + private val lifecycleMutex = Mutex() + private val mutableState = MutableStateFlow(ShareState.Idle) + @Volatile + private var active: ActiveShare? = null + + val state: StateFlow = mutableState.asStateFlow() + + suspend fun start( + options: ShareOptions, + ): Either = lifecycleMutex.withLock { + if ( + active != null || + mutableState.value == ShareState.Starting || + mutableState.value == ShareState.Stopping + ) { + return@withLock Either.Left(ShareLifecycleError.AlreadyActive) + } + mutableState.value = ShareState.Starting + + try { + val managedShare = resource { + val target = install( + acquire = { bridge.open(options) }, + release = { acquired, _ -> acquired.close() }, + ) + var connectFailed = false + val connect = try { + val identity = identityProvider() + install( + acquire = { ingress.start(identity, target.address) }, + release = { acquired, _ -> acquired.close() }, + ) + } catch (cancellation: CancellationException) { + throw cancellation + } catch (_: Exception) { + connectFailed = true + null + } + var directFailed = false + val direct = try { + directIngress?.let { + install( + acquire = { + it.start( + options = options, + target = target.directAddress, + connectAddress = connect?.publicAddress, + ) + }, + release = { acquired, _ -> acquired.close() }, + ) + } + } catch (cancellation: CancellationException) { + throw cancellation + } catch (_: Exception) { + directFailed = true + null + } + check(connect != null || direct != null) { + "Connect Share has no usable ingress" + } + when { + connectFailed -> reportFailure(CONNECT_DEGRADED_REPORT) + directFailed -> reportFailure(DIRECT_DEGRADED_REPORT) + } + AcquiredShare(target, connect, direct) + } + val (acquired, release) = managedShare.allocateSafely() + val sharing = ShareState.Sharing( + endpoint = acquired.connect?.endpoint, + address = acquired.connect?.publicAddress, + invitation = acquired.direct?.invitation, + connectAvailable = acquired.connect != null, + lanDirectAvailable = acquired.direct?.lanAvailable == true, + internetDirectAvailable = + acquired.direct?.internetAvailable == true, + ) + active = ActiveShare(release, acquired.direct) + mutableState.value = sharing + Either.Right(sharing) + } catch (cancellation: CancellationException) { + mutableState.value = ShareState.Idle + throw cancellation + } catch (_: Exception) { + mutableState.value = ShareState.Failed( + ShareLifecycleError.StartFailed.safeMessage, + ) + reportFailure(START_FAILURE_REPORT) + Either.Left(ShareLifecycleError.StartFailed) + } + } + + suspend fun stop(): Either { + val share = lifecycleMutex.withLock { + when { + active != null -> { + mutableState.value = ShareState.Stopping + active.also { active = null } + } + + mutableState.value == ShareState.Stopping -> return Either.Right(Unit) + + else -> { + admission.resetShare() + mutableState.value = ShareState.Idle + return Either.Right(Unit) + } + } + } ?: return Either.Right(Unit) + + var cleanupFailure: Throwable? = null + var cancellation: CancellationException? = null + withContext(NonCancellable) { + try { + share.release(ExitCase.Completed) + } catch (failure: CancellationException) { + cancellation = failure + } catch (failure: Exception) { + cleanupFailure = failure + } finally { + admission.resetShare() + lifecycleMutex.withLock { + mutableState.value = ShareState.Idle + } + } + } + cancellation?.let { throw it } + return if (cleanupFailure == null) { + Either.Right(Unit) + } else { + reportFailure(STOP_FAILURE_REPORT) + Either.Left(ShareLifecycleError.StopFailed) + } + } + + suspend fun worldReplaced(): Either = stop() + + fun currentInvitation(): String? = active?.direct?.invitation + + private data class AcquiredShare( + val target: LocalShareTarget, + val connect: ConnectShareHandle?, + val direct: DirectShareHandle?, + ) + + private data class ActiveShare( + val release: suspend (ExitCase) -> Unit, + val direct: DirectShareHandle?, + ) + + @OptIn(kotlinx.coroutines.DelicateCoroutinesApi::class) + private suspend fun Resource.allocateSafely(): Pair Unit> { + val scopeResource: Resource = resource { this } + val (scope, releaseAll) = scopeResource.allocate() + return try { + with(scope) { + this@allocateSafely.bind() + } to releaseAll + } catch (failure: Throwable) { + try { + releaseAll(ExitCase(failure)) + } catch (releaseFailure: Throwable) { + if (releaseFailure !== failure) { + failure.addSuppressed(releaseFailure) + } + } + throw failure + } + } + + private fun reportFailure(safeMessage: String) { + try { + failureReporter(safeMessage) + } catch (cancellation: CancellationException) { + throw cancellation + } catch (_: RuntimeException) { + // Reporting must not leave lifecycle state half-transitioned. + } + } + + private companion object { + const val START_FAILURE_REPORT = "Connect Share start failed" + const val STOP_FAILURE_REPORT = "Connect Share cleanup failed" + const val CONNECT_DEGRADED_REPORT = + "Connect Share started without Minekube Connect ingress" + const val DIRECT_DEGRADED_REPORT = + "Connect Share started without direct P2P ingress" + } +} diff --git a/share/common/src/main/kotlin/com/minekube/connect/share/ShareOptions.kt b/share/common/src/main/kotlin/com/minekube/connect/share/ShareOptions.kt new file mode 100644 index 000000000..a11383a78 --- /dev/null +++ b/share/common/src/main/kotlin/com/minekube/connect/share/ShareOptions.kt @@ -0,0 +1,26 @@ +package com.minekube.connect.share + +data class ShareOptions( + val gameMode: ShareGameMode, + val allowCheats: Boolean, + val maxGuests: Int = 8, + val allowInternetDirect: Boolean = false, +) { + init { + require(maxGuests in MIN_GUESTS..MAX_GUESTS) { + "Share capacity must be between $MIN_GUESTS and $MAX_GUESTS" + } + } + + companion object { + const val MIN_GUESTS = 1 + const val MAX_GUESTS = 16 + } +} + +enum class ShareGameMode { + SURVIVAL, + CREATIVE, + ADVENTURE, + SPECTATOR, +} diff --git a/share/common/src/main/kotlin/com/minekube/connect/share/ShareState.kt b/share/common/src/main/kotlin/com/minekube/connect/share/ShareState.kt new file mode 100644 index 000000000..18fdcf153 --- /dev/null +++ b/share/common/src/main/kotlin/com/minekube/connect/share/ShareState.kt @@ -0,0 +1,44 @@ +package com.minekube.connect.share + +sealed interface ShareState { + data object Idle : ShareState + data object Starting : ShareState + + data class Sharing( + val endpoint: String?, + val address: String?, + val invitation: String? = null, + val connectAvailable: Boolean = true, + val lanDirectAvailable: Boolean = false, + val internetDirectAvailable: Boolean = false, + ) : ShareState { + override fun toString(): String = + "Sharing(endpoint=$endpoint, address=$address, " + + "invitation=, " + + "connectAvailable=$connectAvailable, " + + "lanDirectAvailable=$lanDirectAvailable, " + + "internetDirectAvailable=$internetDirectAvailable)" + } + + data object Stopping : ShareState + + data class Failed( + val safeMessage: String, + ) : ShareState +} + +sealed interface ShareLifecycleError { + val safeMessage: String + + data object AlreadyActive : ShareLifecycleError { + override val safeMessage: String = "A Connect Share operation is already active" + } + + data object StartFailed : ShareLifecycleError { + override val safeMessage: String = "Could not start Connect Share" + } + + data object StopFailed : ShareLifecycleError { + override val safeMessage: String = "Connect Share stopped with cleanup errors" + } +} diff --git a/share/common/src/main/kotlin/com/minekube/connect/share/VersionedMinecraftBridge.kt b/share/common/src/main/kotlin/com/minekube/connect/share/VersionedMinecraftBridge.kt new file mode 100644 index 000000000..5701ce23b --- /dev/null +++ b/share/common/src/main/kotlin/com/minekube/connect/share/VersionedMinecraftBridge.kt @@ -0,0 +1,334 @@ +package com.minekube.connect.share + +import arrow.core.Either +import arrow.core.raise.either +import arrow.core.raise.ensure +import com.minekube.connect.inject.CommonPlatformInjector +import com.minekube.connect.network.netty.LocalServerChannelWrapper +import io.netty.bootstrap.ServerBootstrap +import io.netty.channel.Channel +import io.netty.channel.ChannelFuture +import io.netty.channel.ChannelInitializer +import io.netty.channel.EventLoopGroup +import io.netty.channel.local.LocalAddress +import java.net.InetSocketAddress +import java.net.SocketAddress + +open class VersionedMinecraftBridge private constructor( + private val transport: MinecraftVersionTransport, + private val localBinder: LocalShareChannelBinder?, + private val gateway: ShareConnectionGateway?, + private val loginAdmissionAcquire: (() -> AutoCloseable)? = null, +) : CommonPlatformInjector(), MinecraftShareBridge { + constructor( + transport: MinecraftVersionTransport, + localBinder: LocalShareChannelBinder, + loginAdmissionAcquire: (() -> AutoCloseable)? = null, + ) : this( + transport = transport, + localBinder = localBinder, + gateway = null, + loginAdmissionAcquire = loginAdmissionAcquire, + ) + + constructor( + transport: MinecraftVersionTransport, + gateway: ShareConnectionGateway, + loginAdmissionAcquire: (() -> AutoCloseable)? = null, + ) : this( + transport = transport, + localBinder = null, + gateway = gateway, + loginAdmissionAcquire = loginAdmissionAcquire, + ) + + private val lifecycleLock = Any() + private var active: ActiveTransport? = null + + override suspend fun open(options: ShareOptions): LocalShareTarget = synchronized(lifecycleLock) { + check(active == null) { "Connect Share is already active" } + + val published = transport.publish(options) + var local: LocalShareChannel? = null + var localAdded = false + var gatewayLease: AutoCloseable? = null + var admission: AutoCloseable? = null + try { + validatePublished(published).fold( + ifLeft = { failure -> throw IllegalStateException(failure.safeMessage) }, + ifRight = {}, + ) + val connectAddress: SocketAddress + val directAddress: InetSocketAddress + if (gateway != null) { + connectAddress = gateway.serverSocketAddress + directAddress = gateway.directAddress + validateGateway(connectAddress, directAddress).fold( + ifLeft = { + throw IllegalStateException(it.safeMessage) + }, + ifRight = {}, + ) + gatewayLease = gateway.activateMinecraft( + published.childInitializer, + ) + } else { + local = checkNotNull(localBinder) + .bind( + published.childInitializer, + published.eventLoopGroup, + ) + validateLocal(local).fold( + ifLeft = { + throw IllegalStateException(it.safeMessage) + }, + ifRight = {}, + ) + published.addLocalListener(local) + localAdded = true + connectAddress = local.address + directAddress = published.address + } + admission = loginAdmissionAcquire?.invoke() + val acquired = ActiveTransport( + published = published, + local = local, + localAdded = localAdded, + gatewayLease = gatewayLease, + admission = admission, + ) + active = acquired + serverSocketAddress = connectAddress + LocalShareTarget( + address = connectAddress, + directAddress = directAddress, + ) { + close(acquired) + } + } catch (failure: Throwable) { + var cleanup: Throwable? = failure + cleanup = releaseAfter(cleanup) { + admission?.close() + } + cleanup = releaseAfter(cleanup) { + gatewayLease?.close() + } + if (localAdded) { + cleanup = releaseAfter(cleanup) { + published.removeLocalListener(checkNotNull(local)) + } + } + cleanup = releaseAfter(cleanup) { + local?.close() + } + releaseAfter(cleanup) { + published.close() + } + throw failure + } + } + + override fun inject(): Boolean = isInjected + + override fun isInjected(): Boolean = synchronized(lifecycleLock) { + active != null + } + + override fun shutdown() { + synchronized(lifecycleLock) { + val acquired = active ?: return + var failure = acquired.stopAdmission(null) + failure = acquired.closeLocal(failure) + failure?.let { throw it } + } + } + + private fun close(acquired: ActiveTransport) { + synchronized(lifecycleLock) { + if (active !== acquired) { + return + } + active = null + acquired.close() + serverSocketAddress = null + } + } + + private fun validatePublished( + published: PublishedMinecraftTransport, + ): Either = either { + ensure(published.address.address.isLoopbackAddress) { + BridgeValidationError.PublicListener + } + } + + private fun validateLocal( + local: LocalShareChannel, + ): Either = either { + ensure(local.address is LocalAddress) { + BridgeValidationError.NonLocalConnectTarget + } + } + + private fun validateGateway( + connectAddress: SocketAddress, + directAddress: InetSocketAddress, + ): Either = either { + ensure(connectAddress is LocalAddress) { + BridgeValidationError.NonLocalConnectTarget + } + ensure(directAddress.address.isLoopbackAddress) { + BridgeValidationError.PublicListener + } + } + + private class ActiveTransport( + private val published: PublishedMinecraftTransport, + private val local: LocalShareChannel?, + private val localAdded: Boolean, + private val gatewayLease: AutoCloseable?, + private val admission: AutoCloseable?, + ) { + private var admissionStopped = false + private var routeClosed = false + private var publishedClosed = false + + fun stopAdmission(primary: Throwable?): Throwable? { + if (admissionStopped) { + return primary + } + admissionStopped = true + return releaseAfter(primary) { + admission?.close() + } + } + + fun closeLocal(primary: Throwable?): Throwable? { + if (routeClosed) { + return primary + } + routeClosed = true + var failure = releaseAfter(primary) { + gatewayLease?.close() + } + if (!localAdded || local == null) { + return failure + } + failure = releaseAfter(failure) { + published.removeLocalListener(local) + } + failure = releaseAfter(failure) { + local.close() + } + return failure + } + + fun close() { + var failure = stopAdmission(null) + failure = closeLocal(failure) + if (!publishedClosed) { + publishedClosed = true + failure = releaseAfter(failure) { + published.close() + } + } + failure?.let { throw it } + } + } +} + +private inline fun releaseAfter( + primary: Throwable?, + release: () -> Unit, +): Throwable? = try { + release() + primary +} catch (releaseFailure: Throwable) { + if (primary == null) { + releaseFailure + } else { + if (releaseFailure !== primary) { + primary.addSuppressed(releaseFailure) + } + primary + } +} + +fun interface MinecraftVersionTransport { + fun publish(options: ShareOptions): PublishedMinecraftTransport +} + +interface PublishedMinecraftTransport { + val address: InetSocketAddress + val childInitializer: ChannelInitializer + val eventLoopGroup: EventLoopGroup + + fun addLocalListener(listener: LocalShareChannel) + + fun removeLocalListener(listener: LocalShareChannel) + + fun close() +} + +fun interface LocalShareChannelBinder { + fun bind( + childInitializer: ChannelInitializer, + eventLoopGroup: EventLoopGroup, + ): LocalShareChannel +} + +interface LocalShareChannel { + val address: SocketAddress + val future: ChannelFuture? + get() = null + + fun close() +} + +class NettyLocalShareChannelBinder : LocalShareChannelBinder { + override fun bind( + childInitializer: ChannelInitializer, + eventLoopGroup: EventLoopGroup, + ): LocalShareChannel { + try { + val future = ServerBootstrap() + .channel(LocalServerChannelWrapper::class.java) + .childHandler(childInitializer) + .group(eventLoopGroup) + .localAddress(LocalAddress.ANY) + .bind() + .syncUninterruptibly() + return NettyLocalShareChannel(future) + } catch (failure: Throwable) { + throw failure + } + } +} + +private class NettyLocalShareChannel( + override val future: ChannelFuture, +) : LocalShareChannel { + override val address = future.channel().localAddress() + + override fun close() { + future.closeChannel() + } +} + +private fun ChannelFuture.closeChannel() { + if (channel().isOpen) { + channel().close().syncUninterruptibly() + } +} + +private sealed interface BridgeValidationError { + val safeMessage: String + + data object PublicListener : BridgeValidationError { + override val safeMessage = "Minecraft tried to open Connect Share beyond loopback" + } + + data object NonLocalConnectTarget : BridgeValidationError { + override val safeMessage = "Connect Share requires an in-process Minecraft target" + } +} diff --git a/share/common/src/main/kotlin/com/minekube/connect/share/admission/AdmissionController.kt b/share/common/src/main/kotlin/com/minekube/connect/share/admission/AdmissionController.kt new file mode 100644 index 000000000..7cc6a8f5a --- /dev/null +++ b/share/common/src/main/kotlin/com/minekube/connect/share/admission/AdmissionController.kt @@ -0,0 +1,346 @@ +package com.minekube.connect.share.admission + +import java.util.UUID +import java.util.concurrent.atomic.AtomicReference +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +class AdmissionController( + private val scope: CoroutineScope, + private val timeout: Duration = 30.seconds, + private val maxPending: Int = 16, + private val connectedCount: () -> Int, + private val maxGuests: () -> Int, + private val autoApprove: (AdmissionIdentity) -> Boolean = { false }, + private val nowMillis: () -> Long = System::currentTimeMillis, +) { + private val lock = Any() + private val requests = linkedMapOf() + private val authenticatedApprovals = mutableSetOf() + private val preapprovedJoins = linkedMapOf() + private val mutablePending = MutableStateFlow>(emptyList()) + + val pending: StateFlow> = mutablePending.asStateFlow() + + init { + require(timeout.isPositive()) { "Admission timeout must be positive" } + require(maxPending > 0) { "Maximum pending admissions must be positive" } + } + + suspend fun request( + identity: AdmissionIdentity, + purpose: AdmissionPurpose = AdmissionPurpose.JOIN, + ): AdmissionAnswer { + val lookup = synchronized(lock) { + val key = identity.admissionKey(purpose) + requests[key]?.let { + it.waiters++ + return@synchronized RequestLookup.Await(it, startTimeout = false) + } + if ( + purpose == AdmissionPurpose.JOIN && + connectedCount() >= maxGuests() + ) { + return@synchronized RequestLookup.Immediate(AdmissionAnswer.CAPACITY) + } + if (purpose == AdmissionPurpose.JOIN) { + removeExpiredPreapprovals(nowMillis()) + val preapproved = preapprovedJoins.keys.firstOrNull { + it.matches(identity) + } + if (preapproved != null) { + preapprovedJoins.remove(preapproved) + return@synchronized RequestLookup.Immediate( + AdmissionAnswer.ALLOW, + ) + } + } + if ( + purpose == AdmissionPurpose.JOIN && + autoApprove(identity) + ) { + return@synchronized RequestLookup.Immediate(AdmissionAnswer.ALLOW) + } + if ( + purpose == AdmissionPurpose.JOIN && + identity is AdmissionIdentity.Authenticated && + authenticatedApprovals.any { it.matches(identity) } + ) { + return@synchronized RequestLookup.Immediate(AdmissionAnswer.ALLOW) + } + if (requests.size >= maxPending) { + return@synchronized RequestLookup.Immediate(AdmissionAnswer.CAPACITY) + } + + val request = PendingRequest( + key = key, + pending = PendingAdmission( + requestId = UUID.randomUUID(), + identity = identity, + purpose = purpose, + ), + ) + requests[key] = request + publishPending() + RequestLookup.Await(request, startTimeout = true) + } + + return when (lookup) { + is RequestLookup.Immediate -> lookup.answer + is RequestLookup.Await -> { + if (lookup.startTimeout) { + startTimeout(lookup.request) + } + try { + lookup.request.answer.await() + } finally { + releaseWaiter(lookup.request) + } + } + } + } + + fun answer(requestId: UUID, allow: Boolean) { + val answer = if (allow) AdmissionAnswer.ALLOW else AdmissionAnswer.DENY + val completed = synchronized(lock) { + val entry = requests.entries.firstOrNull { + it.value.pending.requestId == requestId + } ?: return + requests.remove(entry.key) + if ( + allow && + entry.value.pending.purpose == AdmissionPurpose.JOIN + ) { + val identity = entry.value.pending.identity + if (identity is AdmissionIdentity.Authenticated) { + authenticatedApprovals += AuthenticatedApproval( + uuid = identity.uuid, + directPeerId = identity.directPeerId, + ingress = identity.ingress, + ) + } + } + publishPending() + entry.value + } + complete(completed, answer) + } + + fun denyDirectPeer( + peerId: String, + purpose: AdmissionPurpose, + ): Int { + val denied = synchronized(lock) { + preapprovedJoins.keys.removeIf { it.directPeerId == peerId } + val matches = requests.entries.filter { entry -> + entry.value.pending.purpose == purpose && + entry.value.pending.identity.directPeerId == peerId + } + matches.forEach { requests.remove(it.key) } + if (matches.isNotEmpty()) publishPending() + matches.map { it.value } + } + denied.forEach { complete(it, AdmissionAnswer.DENY) } + return denied.size + } + + fun revokeDirectPeer( + peerId: String, + minecraftUuid: UUID? = null, + ): Int { + val revoked = synchronized(lock) { + preapprovedJoins.keys.removeIf { it.directPeerId == peerId } + authenticatedApprovals.removeIf { + it.directPeerId == peerId || + ( + it.directPeerId == null && + minecraftUuid != null && + it.uuid == minecraftUuid + ) + } + val matches = requests.entries.filter { entry -> + val identity = entry.value.pending.identity + identity.directPeerId == peerId || + ( + identity.directPeerId == null && + minecraftUuid != null && + identity.uuid == minecraftUuid + ) + } + matches.forEach { requests.remove(it.key) } + if (matches.isNotEmpty()) publishPending() + matches.map { it.value } + } + revoked.forEach { complete(it, AdmissionAnswer.DENY) } + return revoked.size + } + + fun resetShare() { + val stopped = synchronized(lock) { + val current = requests.values.toList() + requests.clear() + authenticatedApprovals.clear() + preapprovedJoins.clear() + publishPending() + current + } + stopped.forEach { + complete(it, AdmissionAnswer.STOPPED) + } + } + + fun approveNextJoin(identity: AdmissionIdentity) { + synchronized(lock) { + val now = nowMillis() + removeExpiredPreapprovals(now) + val grant = PreapprovedJoin( + directPeerId = identity.directPeerId, + minecraftUuid = identity.uuid, + ) + preapprovedJoins.remove(grant) + while (preapprovedJoins.size >= maxPending) { + preapprovedJoins.remove(preapprovedJoins.keys.first()) + } + preapprovedJoins[grant] = now + } + } + + private fun removeExpiredPreapprovals(now: Long) { + val lifetimeMillis = timeout.inWholeMilliseconds + preapprovedJoins.entries.removeIf { (_, approvedAt) -> + now >= approvedAt && now - approvedAt >= lifetimeMillis + } + } + + private fun startTimeout(request: PendingRequest) { + val timeoutJob = scope.launch { + delay(timeout) + expire(request) + } + if (!request.timeoutJob.compareAndSet(null, timeoutJob)) { + timeoutJob.cancel() + } else if (request.answer.isCompleted) { + timeoutJob.cancel() + } + } + + private fun expire(request: PendingRequest) { + val expired = synchronized(lock) { + if (requests[request.key] !== request) { + return + } + requests.remove(request.key) + publishPending() + request + } + expired.answer.complete(AdmissionAnswer.TIMEOUT) + } + + private fun complete(request: PendingRequest, answer: AdmissionAnswer) { + request.timeoutJob.get()?.cancel() + request.answer.complete(answer) + } + + private fun releaseWaiter(request: PendingRequest) { + val abandoned = synchronized(lock) { + request.waiters-- + check(request.waiters >= 0) { + "Admission request waiter count became negative" + } + if ( + request.waiters == 0 && + !request.answer.isCompleted && + requests[request.key] === request + ) { + requests.remove(request.key) + publishPending() + request + } else { + null + } + } + abandoned?.timeoutJob?.get()?.cancel() + } + + private fun publishPending() { + mutablePending.value = requests.values.map(PendingRequest::pending) + } + + private fun AdmissionIdentity.admissionKey( + purpose: AdmissionPurpose, + ): AdmissionKey = when (this) { + is AdmissionIdentity.Authenticated -> + AdmissionKey.Authenticated(uuid, purpose) + + is AdmissionIdentity.UnverifiedOffline -> + AdmissionKey.Unverified(connectionId, purpose) + } + + private sealed interface AdmissionKey { + data class Authenticated( + val uuid: UUID, + val purpose: AdmissionPurpose, + ) : AdmissionKey + + data class Unverified( + val connectionId: String, + val purpose: AdmissionPurpose, + ) : AdmissionKey + } + + private data class PreapprovedJoin( + val directPeerId: String?, + val minecraftUuid: UUID, + ) { + fun matches(identity: AdmissionIdentity): Boolean = + minecraftUuid == identity.uuid && + ( + directPeerId == identity.directPeerId || + ( + directPeerId != null && + identity.directPeerId == null && + when (identity) { + is AdmissionIdentity.Authenticated -> + identity.ingress == Ingress.CONNECT + is AdmissionIdentity.UnverifiedOffline -> + identity.ingress == Ingress.CONNECT + } + ) + ) + } + + private data class AuthenticatedApproval( + val uuid: UUID, + val directPeerId: String?, + val ingress: Ingress, + ) { + fun matches(identity: AdmissionIdentity.Authenticated): Boolean = + uuid == identity.uuid && + (directPeerId == null || directPeerId == identity.directPeerId) && + (directPeerId != null || ingress == identity.ingress) + } + + private class PendingRequest( + val key: AdmissionKey, + val pending: PendingAdmission, + val answer: CompletableDeferred = CompletableDeferred(), + val timeoutJob: AtomicReference = AtomicReference(), + var waiters: Int = 1, + ) + + private sealed interface RequestLookup { + data class Immediate(val answer: AdmissionAnswer) : RequestLookup + data class Await( + val request: PendingRequest, + val startTimeout: Boolean, + ) : RequestLookup + } +} diff --git a/share/common/src/main/kotlin/com/minekube/connect/share/admission/AdmissionIdentity.kt b/share/common/src/main/kotlin/com/minekube/connect/share/admission/AdmissionIdentity.kt new file mode 100644 index 000000000..60aadc558 --- /dev/null +++ b/share/common/src/main/kotlin/com/minekube/connect/share/admission/AdmissionIdentity.kt @@ -0,0 +1,55 @@ +package com.minekube.connect.share.admission + +import java.util.UUID + +sealed interface AdmissionIdentity { + val name: String + val uuid: UUID + val directPeerId: String? + + data class Authenticated( + override val name: String, + override val uuid: UUID, + val source: AuthSource, + val ingress: Ingress = Ingress.CONNECT, + override val directPeerId: String? = null, + ) : AdmissionIdentity + + data class UnverifiedOffline( + override val name: String, + override val uuid: UUID, + val connectionId: String, + val ingress: Ingress, + override val directPeerId: String? = null, + ) : AdmissionIdentity +} + +enum class AuthSource { + CONNECT, + MOJANG, +} + +enum class Ingress { + CONNECT, + DIRECT_LAN, + DIRECT_INTERNET, +} + +enum class AdmissionAnswer { + ALLOW, + DENY, + TIMEOUT, + STOPPED, + CAPACITY, +} + +enum class AdmissionPurpose { + JOIN, + FRIEND, +} + +data class PendingAdmission( + val requestId: UUID, + val identity: AdmissionIdentity, + val purpose: AdmissionPurpose = AdmissionPurpose.JOIN, +) diff --git a/share/common/src/main/kotlin/com/minekube/connect/share/admission/NewAdmissionTracker.kt b/share/common/src/main/kotlin/com/minekube/connect/share/admission/NewAdmissionTracker.kt new file mode 100644 index 000000000..98f985822 --- /dev/null +++ b/share/common/src/main/kotlin/com/minekube/connect/share/admission/NewAdmissionTracker.kt @@ -0,0 +1,17 @@ +package com.minekube.connect.share.admission + +import java.util.UUID + +class NewAdmissionTracker { + private var currentIds: Set = emptySet() + + fun update(pending: List): List { + val newRequests = pending.filterNot { + it.requestId in currentIds + } + currentIds = pending.mapTo(mutableSetOf()) { + it.requestId + } + return newRequests + } +} diff --git a/share/common/src/main/kotlin/com/minekube/connect/share/direct/DirectSessionRegistry.kt b/share/common/src/main/kotlin/com/minekube/connect/share/direct/DirectSessionRegistry.kt new file mode 100644 index 000000000..ccc2edb47 --- /dev/null +++ b/share/common/src/main/kotlin/com/minekube/connect/share/direct/DirectSessionRegistry.kt @@ -0,0 +1,65 @@ +package com.minekube.connect.share.direct + +import com.minekube.connect.tunnel.p2p.DirectP2pSession +import io.netty.util.AttributeKey +import java.net.InetSocketAddress +import java.net.SocketAddress +import java.util.concurrent.ConcurrentHashMap + +object DirectSessionAttributes { + @JvmField + val SESSION: AttributeKey = + AttributeKey.valueOf("connect-share:direct-session") +} + +object DirectSessionRegistry { + private val pending = ConcurrentHashMap() + + fun register( + sourcePort: Int, + session: DirectP2pSession, + nowNanos: Long = System.nanoTime(), + ): AutoCloseable { + require(sourcePort in 1..65_535) { "Direct source port is invalid" } + purgeExpired(nowNanos) + val registered = PendingSession( + session = session, + expiresAtNanos = nowNanos + REGISTRATION_TTL_NANOS, + ) + check(pending.putIfAbsent(sourcePort, registered) == null) { + "A direct session is already registered for this source port" + } + return AutoCloseable { + pending.remove(sourcePort, registered) + } + } + + fun claim( + remoteAddress: SocketAddress?, + nowNanos: Long = System.nanoTime(), + ): DirectP2pSession? { + val address = remoteAddress as? InetSocketAddress ?: return null + if (!address.address.isLoopbackAddress) { + return null + } + purgeExpired(nowNanos) + return pending.remove(address.port) + ?.takeIf { it.expiresAtNanos >= nowNanos } + ?.session + } + + internal fun clear() { + pending.clear() + } + + private fun purgeExpired(nowNanos: Long) { + pending.entries.removeIf { it.value.expiresAtNanos < nowNanos } + } + + private data class PendingSession( + val session: DirectP2pSession, + val expiresAtNanos: Long, + ) + + private const val REGISTRATION_TTL_NANOS = 10_000_000_000L +} diff --git a/share/common/src/main/kotlin/com/minekube/connect/share/direct/ShareInviteCodec.kt b/share/common/src/main/kotlin/com/minekube/connect/share/direct/ShareInviteCodec.kt new file mode 100644 index 000000000..06e47794d --- /dev/null +++ b/share/common/src/main/kotlin/com/minekube/connect/share/direct/ShareInviteCodec.kt @@ -0,0 +1,424 @@ +package com.minekube.connect.share.direct + +import arrow.core.Either +import arrow.core.raise.either +import arrow.core.raise.ensure +import java.io.ByteArrayOutputStream +import java.security.KeyFactory +import java.security.Signature +import java.security.spec.X509EncodedKeySpec +import java.time.Instant +import java.util.Base64 +import java.util.UUID + +class ShareInvitePayload( + val wireVersion: Int, + val shareId: UUID, + val expiresAtEpochMillis: Long, + val connectAddress: String?, + val peerId: String, + val internetDirectEnabled: Boolean, + val directCandidates: List, + val capability: String, + val displayName: String? = null, +) { + override fun equals(other: Any?): Boolean = + other is ShareInvitePayload && + wireVersion == other.wireVersion && + shareId == other.shareId && + expiresAtEpochMillis == other.expiresAtEpochMillis && + connectAddress == other.connectAddress && + peerId == other.peerId && + internetDirectEnabled == other.internetDirectEnabled && + directCandidates == other.directCandidates && + capability == other.capability && + displayName == other.displayName + + override fun hashCode(): Int { + var result = wireVersion + result = 31 * result + shareId.hashCode() + result = 31 * result + expiresAtEpochMillis.hashCode() + result = 31 * result + (connectAddress?.hashCode() ?: 0) + result = 31 * result + peerId.hashCode() + result = 31 * result + internetDirectEnabled.hashCode() + result = 31 * result + directCandidates.hashCode() + result = 31 * result + capability.hashCode() + result = 31 * result + (displayName?.hashCode() ?: 0) + return result + } + + override fun toString(): String = + "ShareInvitePayload(wireVersion=$wireVersion, shareId=$shareId, " + + "expiresAtEpochMillis=$expiresAtEpochMillis, " + + "connectAddress=$connectAddress, peerId=$peerId, " + + "internetDirectEnabled=$internetDirectEnabled, " + + "directCandidates=, capability=, " + + "displayName=$displayName)" +} + +class SignedShareInvite( + val payload: ShareInvitePayload, + publicKey: ByteArray, + signature: ByteArray, +) { + val publicKey: ByteArray = publicKey.copyOf() + val signature: ByteArray = signature.copyOf() + + override fun equals(other: Any?): Boolean = + other is SignedShareInvite && + payload == other.payload && + publicKey.contentEquals(other.publicKey) && + signature.contentEquals(other.signature) + + override fun hashCode(): Int = + 31 * (31 * payload.hashCode() + publicKey.contentHashCode()) + + signature.contentHashCode() + + override fun toString(): String = + "SignedShareInvite(payload=$payload, publicKey=, " + + "signature=)" +} + +sealed interface ShareInviteError { + val safeMessage: String + + data object Malformed : ShareInviteError { + override val safeMessage = "This Connect Share invitation is invalid" + } + + data class UnsupportedVersion( + val version: Int, + ) : ShareInviteError { + override val safeMessage = "This Connect Share invitation uses an unsupported version" + } + + data object Expired : ShareInviteError { + override val safeMessage = "This Connect Share invitation has expired" + } + + data object InvalidSignature : ShareInviteError { + override val safeMessage = "This Connect Share invitation has an invalid signature" + } + + data object RelayCandidateForbidden : ShareInviteError { + override val safeMessage = "Direct Connect Share invitations cannot use a relay" + } + + data object PeerMismatch : ShareInviteError { + override val safeMessage = + "A direct Connect Share route does not match the signed host" + } +} + +object ShareInviteCodec { + const val WIRE_VERSION = 2 + private const val URI_PREFIX = "minekube://share/" + private const val MAX_URI_LENGTH = 32_768 + private const val MAX_TEXT_LENGTH = 8_192 + private const val LEGACY_WIRE_VERSION = 1 + private const val LEGACY_FIELD_COUNT = 10 + private const val FIELD_COUNT = 11 + private const val LEGACY_UNSIGNED_FIELD_COUNT = 9 + private const val UNSIGNED_FIELD_COUNT = 10 + private const val MAX_DISPLAY_NAME_LENGTH = 64 + private const val MAX_CANDIDATE_COUNT = 256 + + fun encode(invite: SignedShareInvite): String { + require( + invite.payload.wireVersion != LEGACY_WIRE_VERSION || + invite.payload.displayName == null, + ) { + "Legacy invitations cannot contain a display name" + } + val writer = CborWriter() + writer.array(fieldCount(invite.payload.wireVersion)) + writer.invitePayload(invite.payload) + writer.bytes(invite.publicKey) + writer.bytes(invite.signature) + return URI_PREFIX + Base64.getUrlEncoder() + .withoutPadding() + .encodeToString(writer.toByteArray()) + } + + fun unsignedBytes( + payload: ShareInvitePayload, + publicKey: ByteArray, + ): ByteArray = CborWriter().apply { + require( + payload.wireVersion != LEGACY_WIRE_VERSION || + payload.displayName == null, + ) { + "Legacy invitations cannot contain a display name" + } + array(unsignedFieldCount(payload.wireVersion)) + invitePayload(payload) + bytes(publicKey) + }.toByteArray() + + fun decode( + uri: String, + now: Instant = Instant.now(), + ): Either { + if (!uri.startsWith(URI_PREFIX) || uri.length > MAX_URI_LENGTH) { + return Either.Left(ShareInviteError.Malformed) + } + val parsed = try { + val bytes = Base64.getUrlDecoder().decode(uri.removePrefix(URI_PREFIX)) + CborReader(bytes).readInvite() + } catch (_: RuntimeException) { + return Either.Left(ShareInviteError.Malformed) + } + return either { + ensure(verify(parsed)) { ShareInviteError.InvalidSignature } + ensure( + parsed.payload.wireVersion == LEGACY_WIRE_VERSION || + parsed.payload.wireVersion == WIRE_VERSION, + ) { + ShareInviteError.UnsupportedVersion(parsed.payload.wireVersion) + } + ensure(parsed.payload.expiresAtEpochMillis >= now.toEpochMilli()) { + ShareInviteError.Expired + } + ensure(parsed.payload.directCandidates.none(::isRelayAddress)) { + ShareInviteError.RelayCandidateForbidden + } + ensure( + parsed.payload.directCandidates.all { + candidatePeerId(it) == parsed.payload.peerId + }, + ) { + ShareInviteError.PeerMismatch + } + ensure( + parsed.payload.displayName?.let { displayName -> + displayName == displayName.trim() && + displayName.length in 1..MAX_DISPLAY_NAME_LENGTH + } != false, + ) { + ShareInviteError.Malformed + } + parsed + } + } + + private fun verify(invite: SignedShareInvite): Boolean = try { + val publicKey = KeyFactory.getInstance("Ed25519").generatePublic( + X509EncodedKeySpec(invite.publicKey), + ) + Signature.getInstance("Ed25519").run { + initVerify(publicKey) + update(unsignedBytes(invite.payload, invite.publicKey)) + verify(invite.signature) + } + } catch (_: Exception) { + false + } + + private fun isRelayAddress(candidate: String): Boolean = + candidate.contains("/p2p-circuit") || + candidate.contains("/circuit/") + + private fun candidatePeerId(candidate: String): String? { + val segments = candidate.split('/') + val marker = segments.indexOfLast { it == "p2p" } + if (marker < 0) return null + return segments.getOrNull(marker + 1)?.takeIf(String::isNotBlank) + } + + private fun CborWriter.invitePayload(payload: ShareInvitePayload) { + unsigned(payload.wireVersion.toLong()) + text(payload.shareId.toString()) + unsigned(payload.expiresAtEpochMillis) + nullableText(payload.connectAddress) + text(payload.peerId) + bool(payload.internetDirectEnabled) + array(payload.directCandidates.size) + payload.directCandidates.forEach(::text) + text(payload.capability) + if (payload.wireVersion != LEGACY_WIRE_VERSION) { + nullableText(payload.displayName) + } + } + + private fun fieldCount(wireVersion: Int): Int = + if (wireVersion == LEGACY_WIRE_VERSION) { + LEGACY_FIELD_COUNT + } else { + FIELD_COUNT + } + + private fun unsignedFieldCount(wireVersion: Int): Int = + if (wireVersion == LEGACY_WIRE_VERSION) { + LEGACY_UNSIGNED_FIELD_COUNT + } else { + UNSIGNED_FIELD_COUNT + } + + private class CborWriter { + private val out = ByteArrayOutputStream() + + fun array(size: Int) = head(4, size.toLong()) + + fun unsigned(value: Long) { + require(value >= 0) { "CBOR value must be unsigned" } + head(0, value) + } + + fun text(value: String) { + val bytes = value.toByteArray(Charsets.UTF_8) + require(bytes.size <= MAX_TEXT_LENGTH) { "CBOR text is too long" } + head(3, bytes.size.toLong()) + out.write(bytes) + } + + fun nullableText(value: String?) { + if (value == null) { + out.write(0xf6) + } else { + text(value) + } + } + + fun bytes(value: ByteArray) { + head(2, value.size.toLong()) + out.write(value) + } + + fun bool(value: Boolean) { + out.write(if (value) 0xf5 else 0xf4) + } + + fun toByteArray(): ByteArray = out.toByteArray() + + private fun head(major: Int, value: Long) { + when { + value < 24 -> out.write((major shl 5) or value.toInt()) + value <= 0xff -> { + out.write((major shl 5) or 24) + out.write(value.toInt()) + } + + value <= 0xffff -> { + out.write((major shl 5) or 25) + writeLong(value, 2) + } + + value <= 0xffff_ffffL -> { + out.write((major shl 5) or 26) + writeLong(value, 4) + } + + else -> { + out.write((major shl 5) or 27) + writeLong(value, 8) + } + } + } + + private fun writeLong(value: Long, bytes: Int) { + for (shift in (bytes - 1) * 8 downTo 0 step 8) { + out.write((value ushr shift).toInt() and 0xff) + } + } + } + + private class CborReader( + private val bytes: ByteArray, + ) { + private var offset = 0 + + fun readInvite(): SignedShareInvite { + val fields = readLength(4) + val wireVersion = unsigned().toInt() + require(fields == fieldCount(wireVersion)) + val payload = ShareInvitePayload( + wireVersion = wireVersion, + shareId = UUID.fromString(text()), + expiresAtEpochMillis = unsigned(), + connectAddress = nullableText(), + peerId = text(), + internetDirectEnabled = bool(), + directCandidates = List( + readLength(4).also { + require(it <= MAX_CANDIDATE_COUNT) + }, + ) { text() }, + capability = text(), + displayName = if (wireVersion == LEGACY_WIRE_VERSION) { + null + } else { + nullableText() + }, + ) + val publicKey = byteString() + val signature = byteString() + require(offset == bytes.size) + return SignedShareInvite(payload, publicKey, signature) + } + + private fun unsigned(): Long = readValue(0) + + private fun text(): String { + val length = readLength(3) + require(length <= MAX_TEXT_LENGTH) + return String(readBytes(length), Charsets.UTF_8) + } + + private fun nullableText(): String? { + if (peek() == 0xf6) { + offset++ + return null + } + return text() + } + + private fun byteString(): ByteArray = readBytes(readLength(2)) + + private fun bool(): Boolean = when (readByte()) { + 0xf4 -> false + 0xf5 -> true + else -> error("Expected CBOR boolean") + } + + private fun readLength(expectedMajor: Int): Int { + val value = readValue(expectedMajor) + require(value <= Int.MAX_VALUE) + return value.toInt() + } + + private fun readValue(expectedMajor: Int): Long { + val first = readByte() + require(first ushr 5 == expectedMajor) + return when (val additional = first and 0x1f) { + in 0..23 -> additional.toLong() + 24 -> readLong(1) + 25 -> readLong(2) + 26 -> readLong(4) + 27 -> readLong(8) + else -> error("Indefinite CBOR values are forbidden") + } + } + + private fun readLong(count: Int): Long { + var value = 0L + repeat(count) { + value = (value shl 8) or readByte().toLong() + } + return value + } + + private fun readBytes(count: Int): ByteArray { + require(count >= 0 && offset + count <= bytes.size) + return bytes.copyOfRange(offset, offset + count).also { + offset += count + } + } + + private fun peek(): Int { + require(offset < bytes.size) + return bytes[offset].toInt() and 0xff + } + + private fun readByte(): Int = peek().also { offset++ } + } +} diff --git a/share/common/src/main/kotlin/com/minekube/connect/share/direct/TransportSelector.kt b/share/common/src/main/kotlin/com/minekube/connect/share/direct/TransportSelector.kt new file mode 100644 index 000000000..65c3f980a --- /dev/null +++ b/share/common/src/main/kotlin/com/minekube/connect/share/direct/TransportSelector.kt @@ -0,0 +1,60 @@ +package com.minekube.connect.share.direct + +import arrow.core.Either +import arrow.core.left +import arrow.core.right + +enum class ShareRoute { + DIRECT_LAN, + DIRECT_INTERNET, + CONNECT, +} + +object TransportSelector { + fun plan( + sameLan: Boolean, + hostInternetOptIn: Boolean, + guestInternetOptIn: Boolean, + connectAddress: String?, + ): List = buildList { + if (sameLan) { + add(ShareRoute.DIRECT_LAN) + } + if (hostInternetOptIn && guestInternetOptIn) { + add(ShareRoute.DIRECT_INTERNET) + } + if (!connectAddress.isNullOrBlank()) { + add(ShareRoute.CONNECT) + } + } +} + +sealed interface ShareJoinError { + val safeMessage: String + + data object RouteUnavailable : ShareJoinError { + override val safeMessage = "This Connect Share route is unavailable" + } + + data object NoRoute : ShareJoinError { + override val safeMessage = + "No direct route was available and Minekube Connect is not enabled" + } +} + +class ShareJoinCoordinator( + private val attempt: + suspend (ShareRoute) -> Either, +) { + suspend fun join( + routes: List, + ): Either { + for (route in routes.distinct()) { + when (attempt(route)) { + is Either.Left -> Unit + is Either.Right -> return route.right() + } + } + return ShareJoinError.NoRoute.left() + } +} diff --git a/share/common/src/main/kotlin/com/minekube/connect/share/friend/CompatibilityProfile.kt b/share/common/src/main/kotlin/com/minekube/connect/share/friend/CompatibilityProfile.kt new file mode 100644 index 000000000..3e60d201d --- /dev/null +++ b/share/common/src/main/kotlin/com/minekube/connect/share/friend/CompatibilityProfile.kt @@ -0,0 +1,177 @@ +package com.minekube.connect.share.friend + +import java.nio.charset.StandardCharsets +import java.security.MessageDigest + +enum class ModLoader { + FABRIC, + NEOFORGE, + FORGE, +} + +data class RequiredMod( + val id: String, + val version: String, +) { + init { + require(id.isNotBlank()) { "Mod id cannot be blank" } + require(version.isNotBlank()) { "Mod version cannot be blank" } + } +} + +enum class PackPlatform { + MODRINTH, + CURSEFORGE, + OTHER, +} + +data class PackReference( + val platform: PackPlatform, + val projectId: String, + val versionId: String, + val url: String, +) + +data class CompatibilityProfile( + val minecraftVersion: String, + val loader: ModLoader, + val requiredMods: List, + val pack: PackReference? = null, +) { + init { + require(minecraftVersion.isNotBlank()) { + "Minecraft version cannot be blank" + } + } + + fun fingerprint(): String = MessageDigest + .getInstance("SHA-256") + .digest(canonical().toByteArray(StandardCharsets.UTF_8)) + .joinToString(separator = "") { byte -> "%02x".format(byte) } + + fun compareTo(remote: CompatibilityProfile): CompatibilityReport { + val differences = buildList { + if (minecraftVersion != remote.minecraftVersion) { + add( + CompatibilityDifference.MinecraftVersion( + local = minecraftVersion, + remote = remote.minecraftVersion, + ), + ) + } + if (loader != remote.loader) { + add( + CompatibilityDifference.Loader( + local = loader, + remote = remote.loader, + ), + ) + } + + val localMods = normalizedMods() + val remoteMods = remote.normalizedMods() + (remoteMods.keys - localMods.keys).sorted().forEach { modId -> + add( + CompatibilityDifference.MissingLocal( + modId, + remoteMods.getValue(modId), + ), + ) + } + (localMods.keys - remoteMods.keys).sorted().forEach { modId -> + add( + CompatibilityDifference.MissingRemote( + modId, + localMods.getValue(modId), + ), + ) + } + (localMods.keys intersect remoteMods.keys).sorted().forEach { modId -> + val localVersion = localMods.getValue(modId) + val remoteVersion = remoteMods.getValue(modId) + if (localVersion != remoteVersion) { + add( + CompatibilityDifference.ModVersion( + modId = modId, + local = localVersion, + remote = remoteVersion, + ), + ) + } + } + } + return if (differences.isEmpty()) { + CompatibilityReport.Compatible + } else { + CompatibilityReport.Mismatch(differences, remote.pack) + } + } + + private fun canonical(): String = buildString { + append(minecraftVersion.trim()) + append('\n') + append(loader.name) + normalizedMods().forEach { (id, version) -> + append('\n') + append(id) + append('=') + append(version) + } + } + + private fun normalizedMods(): Map = requiredMods + .associate { mod -> + mod.id.trim().lowercase() to mod.version.trim() + } + .toSortedMap() +} + +sealed interface CompatibilityReport { + data object Compatible : CompatibilityReport + + data class Mismatch( + val differences: List, + val pack: PackReference? = null, + ) : CompatibilityReport { + val hasHardBlock: Boolean = differences.any { + it is CompatibilityDifference.MinecraftVersion || + it is CompatibilityDifference.Loader + } + + val safeMessage: String = when { + differences.any { it is CompatibilityDifference.MinecraftVersion } -> + "Your Minecraft versions do not match." + differences.any { it is CompatibilityDifference.Loader } -> + "Your mod loaders do not match." + else -> "Your required mods do not match." + } + } +} + +sealed interface CompatibilityDifference { + data class MinecraftVersion( + val local: String, + val remote: String, + ) : CompatibilityDifference + + data class Loader( + val local: ModLoader, + val remote: ModLoader, + ) : CompatibilityDifference + + data class MissingLocal( + val modId: String, + val remoteVersion: String, + ) : CompatibilityDifference + + data class MissingRemote( + val modId: String, + val localVersion: String, + ) : CompatibilityDifference + + data class ModVersion( + val modId: String, + val local: String, + val remote: String, + ) : CompatibilityDifference +} diff --git a/share/common/src/main/kotlin/com/minekube/connect/share/friend/FriendControlChannelHandler.kt b/share/common/src/main/kotlin/com/minekube/connect/share/friend/FriendControlChannelHandler.kt new file mode 100644 index 000000000..bbbd7b4aa --- /dev/null +++ b/share/common/src/main/kotlin/com/minekube/connect/share/friend/FriendControlChannelHandler.kt @@ -0,0 +1,322 @@ +package com.minekube.connect.share.friend + +import com.minekube.connect.share.admission.Ingress +import com.minekube.connect.share.direct.DirectSessionAttributes +import com.minekube.connect.tunnel.p2p.DirectP2pRoute +import io.netty.buffer.ByteBuf +import io.netty.buffer.Unpooled +import io.netty.channel.ChannelFutureListener +import io.netty.channel.Channel +import io.netty.channel.ChannelHandlerContext +import io.netty.channel.ChannelInboundHandlerAdapter +import io.netty.util.ReferenceCountUtil +import java.io.ByteArrayOutputStream +import java.util.concurrent.CompletionStage +import java.util.concurrent.atomic.AtomicReference + +data class FriendControlContext( + val ingress: Ingress, + val directPeerId: String?, +) + +fun interface FriendControlServer { + fun handle( + context: FriendControlContext, + request: FriendControlRequest, + ): CompletionStage + + fun handleRemoval( + context: FriendControlContext, + request: FriendRemovalRequest, + ): CompletionStage = + java.util.concurrent.CompletableFuture.completedFuture( + FriendControlResponse.Invalid, + ) + + fun handleActivity( + context: FriendControlContext, + request: FriendActivityRequest, + ): CompletionStage = + java.util.concurrent.CompletableFuture.completedFuture( + FriendControlResponse.Invalid, + ) + + fun handleJoin( + context: FriendControlContext, + request: FriendJoinRequest, + ): CompletionStage = + java.util.concurrent.CompletableFuture.completedFuture( + FriendControlResponse.Invalid, + ) + + /** + * Decides whether an authenticated route may query Minecraft's public + * status protocol. Login remains a separate admission decision. + */ + fun allowsMinecraftStatus(context: FriendControlContext): Boolean = true +} + +internal fun Channel.friendControlContext(): FriendControlContext { + val direct = attr(DirectSessionAttributes.SESSION).get() + val ingress = when (direct?.route()) { + DirectP2pRoute.LAN -> Ingress.DIRECT_LAN + DirectP2pRoute.INTERNET -> Ingress.DIRECT_INTERNET + null -> Ingress.CONNECT + } + return FriendControlContext( + ingress = ingress, + directPeerId = direct?.peerId(), + ) +} + +class FriendControlChannelHandler( + private val server: FriendControlServer, +) : ChannelInboundHandlerAdapter() { + private val buffered = ByteArrayOutputStream() + private val response = + AtomicReference?>(null) + private var controlHandshake = false + private var passedThrough = false + + override fun channelRead( + context: ChannelHandlerContext, + message: Any, + ) { + if (passedThrough || message !is ByteBuf) { + context.fireChannelRead(message) + return + } + try { + val bytes = ByteArray(message.readableBytes()) + message.readBytes(bytes) + buffered.write(bytes) + } finally { + ReferenceCountUtil.release(message) + } + if (buffered.size() > FriendControlWire.MAX_REQUEST_BYTES) { + context.close() + return + } + + val accumulated = buffered.toByteArray() + if (!controlHandshake) { + when ( + val inspected = + FriendControlWire.inspectControlMessage(accumulated) + ) { + FriendControlDecode.Incomplete -> return + FriendControlDecode.Invalid -> { + context.close() + return + } + + is FriendControlDecode.Decoded -> { + if (inspected.value == FriendControlMessageKind.OTHER) { + passThrough(context, accumulated) + return + } + controlHandshake = true + } + } + } + + when (val inspected = + FriendControlWire.inspectControlMessage(accumulated) + ) { + is FriendControlDecode.Decoded -> when (inspected.value) { + FriendControlMessageKind.PAIRING -> + decodePairing(context, accumulated) + + FriendControlMessageKind.REMOVAL -> + decodeRemoval(context, accumulated) + + FriendControlMessageKind.ACTIVITY -> + decodeActivity(context, accumulated) + + FriendControlMessageKind.JOIN -> + decodeJoin(context, accumulated) + + FriendControlMessageKind.OTHER -> Unit + } + + FriendControlDecode.Incomplete -> Unit + FriendControlDecode.Invalid -> context.close() + } + } + + private fun decodePairing( + context: ChannelHandlerContext, + accumulated: ByteArray, + ) = when (val decoded = FriendControlWire.decodeRequest(accumulated)) { + FriendControlDecode.Incomplete -> Unit + FriendControlDecode.Invalid -> context.close() + is FriendControlDecode.Decoded -> { + if (decoded.consumedBytes != accumulated.size) { + context.close() + } else { + beginRequest(context, decoded.value) + } + } + } + + private fun decodeRemoval( + context: ChannelHandlerContext, + accumulated: ByteArray, + ) = when (val decoded = FriendControlWire.decodeRemoval(accumulated)) { + FriendControlDecode.Incomplete -> Unit + FriendControlDecode.Invalid -> context.close() + is FriendControlDecode.Decoded -> { + if (decoded.consumedBytes != accumulated.size) { + context.close() + } else { + beginRemoval(context, decoded.value) + } + } + } + + private fun decodeActivity( + context: ChannelHandlerContext, + accumulated: ByteArray, + ) = when ( + val decoded = FriendControlWire.decodeActivityRequest(accumulated) + ) { + FriendControlDecode.Incomplete -> Unit + FriendControlDecode.Invalid -> context.close() + is FriendControlDecode.Decoded -> { + if (decoded.consumedBytes != accumulated.size) context.close() + else beginActivity(context, decoded.value) + } + } + + private fun decodeJoin( + context: ChannelHandlerContext, + accumulated: ByteArray, + ) = when ( + val decoded = FriendControlWire.decodeJoinRequest(accumulated) + ) { + FriendControlDecode.Incomplete -> Unit + FriendControlDecode.Invalid -> context.close() + is FriendControlDecode.Decoded -> { + if (decoded.consumedBytes != accumulated.size) context.close() + else beginJoin(context, decoded.value) + } + } + + override fun channelInactive(context: ChannelHandlerContext) { + response.getAndSet(null)?.toCompletableFuture()?.cancel(true) + context.fireChannelInactive() + } + + override fun exceptionCaught( + context: ChannelHandlerContext, + cause: Throwable, + ) { + context.close() + } + + private fun beginRequest( + context: ChannelHandlerContext, + request: FriendControlRequest, + ) { + if (response.get() != null) { + context.close() + return + } + writeResponse(context, FriendControlResponse.Received) + beginResponse( + context, + server.handle(context.controlContext(), request), + ) + } + + private fun beginRemoval( + context: ChannelHandlerContext, + request: FriendRemovalRequest, + ) { + if (response.get() != null) { + context.close() + return + } + writeResponse(context, FriendControlResponse.Received) + beginResponse( + context, + server.handleRemoval(context.controlContext(), request), + ) + } + + private fun beginActivity( + context: ChannelHandlerContext, + request: FriendActivityRequest, + ) = beginControl(context) { + server.handleActivity(context.controlContext(), request) + } + + private fun beginJoin( + context: ChannelHandlerContext, + request: FriendJoinRequest, + ) = beginControl(context) { + server.handleJoin(context.controlContext(), request) + } + + private inline fun beginControl( + context: ChannelHandlerContext, + operation: () -> CompletionStage, + ) { + if (response.get() != null) { + context.close() + return + } + writeResponse(context, FriendControlResponse.Received) + beginResponse(context, operation()) + } + + private fun beginResponse( + context: ChannelHandlerContext, + pending: CompletionStage, + ) { + if (!response.compareAndSet(null, pending)) { + pending.toCompletableFuture().cancel(true) + context.close() + return + } + pending.whenComplete { answer, failure -> + context.executor().execute { + if (!context.channel().isOpen) { + return@execute + } + val safeAnswer = if (failure == null && answer != null) { + answer + } else { + FriendControlResponse.Invalid + } + val bytes = FriendControlWire.encodeResponse(safeAnswer) + context.writeAndFlush(Unpooled.wrappedBuffer(bytes)) + .addListener(ChannelFutureListener.CLOSE) + } + } + } + + private fun passThrough( + context: ChannelHandlerContext, + bytes: ByteArray, + ) { + passedThrough = true + context.pipeline().remove(this) + context.fireChannelRead(Unpooled.wrappedBuffer(bytes)) + } + + private fun writeResponse( + context: ChannelHandlerContext, + value: FriendControlResponse, + ) { + context.writeAndFlush( + Unpooled.wrappedBuffer( + FriendControlWire.encodeResponse(value), + ), + ) + } + + private fun ChannelHandlerContext.controlContext(): FriendControlContext { + return channel().friendControlContext() + } +} diff --git a/share/common/src/main/kotlin/com/minekube/connect/share/friend/FriendControlWire.kt b/share/common/src/main/kotlin/com/minekube/connect/share/friend/FriendControlWire.kt new file mode 100644 index 000000000..7807b1d6e --- /dev/null +++ b/share/common/src/main/kotlin/com/minekube/connect/share/friend/FriendControlWire.kt @@ -0,0 +1,663 @@ +package com.minekube.connect.share.friend + +import java.io.ByteArrayOutputStream +import java.nio.ByteBuffer +import java.nio.charset.StandardCharsets +import java.util.UUID + +data class FriendControlRequest( + val requestId: UUID, + val relationshipId: UUID = requestId, + val displayName: String, + val invitation: String, +) + +data class FriendRemovalRequest( + val operationId: UUID, + val relationshipId: UUID = operationId, +) + +data class FriendActivityRequest(val requestId: UUID) + +data class FriendJoinRequest( + val requestId: UUID, + val playerName: String, + val playerUuid: UUID, +) + +enum class FriendActivityKind { + ONLINE, + HOSTING_WORLD, + PLAYING_SERVER, +} + +data class FriendActivity( + val kind: FriendActivityKind, + val description: String? = null, + val joinable: Boolean = kind != FriendActivityKind.ONLINE, + val sessionEpoch: String? = null, + val compatibility: CompatibilityProfile? = null, +) + +enum class FriendControlMessageKind { + PAIRING, + REMOVAL, + ACTIVITY, + JOIN, + OTHER, +} + +sealed interface FriendControlResponse { + data object Received : FriendControlResponse + + data class Accepted( + val invitation: String, + ) : FriendControlResponse + + data object Declined : FriendControlResponse + + data object TimedOut : FriendControlResponse + + data object Invalid : FriendControlResponse + + data object Removed : FriendControlResponse + + data class Activity(val activity: FriendActivity) : FriendControlResponse + + data class JoinAccepted(val address: String) : FriendControlResponse + + data object SharedWorldJoinAccepted : FriendControlResponse +} + +sealed interface FriendControlDecode { + data class Decoded( + val value: A, + val consumedBytes: Int, + ) : FriendControlDecode + + data object Incomplete : FriendControlDecode + + data object Invalid : FriendControlDecode +} + +object FriendControlWire { + const val MAX_REQUEST_BYTES = 65_536 + + private const val STATUS_INTENTION = 1 + private const val HANDSHAKE_PACKET_ID = 0 + private const val CONTROL_REQUEST_PACKET_ID = 0x43F1 + private const val CONTROL_RESPONSE_PACKET_ID = 0x43F2 + private const val CONTROL_REMOVAL_PACKET_ID = 0x43F3 + private const val CONTROL_ACTIVITY_PACKET_ID = 0x43F4 + private const val CONTROL_JOIN_PACKET_ID = 0x43F5 + private const val MAX_ADDRESS_BYTES = 255 + private const val MAX_DISPLAY_NAME_BYTES = 256 + private const val MAX_INVITATION_BYTES = 32_768 + private const val MAX_ACTIVITY_BYTES = 512 + private const val MAX_SESSION_EPOCH_BYTES = 128 + private const val MAX_SERVER_ADDRESS_BYTES = 1_024 + private const val MAX_PLAYER_NAME_BYTES = 64 + private const val MAX_VERSION_BYTES = 128 + private const val COMPATIBILITY_FINGERPRINT_BYTES = 64 + private const val MAX_MOD_ID_BYTES = 256 + private const val MAX_REQUIRED_MODS = 512 + private const val MAX_PACK_FIELD_BYTES = 2_048 + + fun encodeRequest( + request: FriendControlRequest, + ): ByteArray { + require( + request.displayName.trim().isNotEmpty() && + request.displayName.toByteArray(StandardCharsets.UTF_8).size <= + MAX_DISPLAY_NAME_BYTES, + ) { + "Friend display name is invalid" + } + require( + request.invitation.toByteArray(StandardCharsets.UTF_8).size <= + MAX_INVITATION_BYTES, + ) { + "Friend invitation is too large" + } + + val output = ByteArrayOutputStream() + output.writePacket { + writeVarInt(CONTROL_REQUEST_PACKET_ID) + writeLong(request.requestId.mostSignificantBits) + writeLong(request.requestId.leastSignificantBits) + writeLong(request.relationshipId.mostSignificantBits) + writeLong(request.relationshipId.leastSignificantBits) + writeString(request.displayName.trim()) + writeString(request.invitation) + } + return output.toByteArray().also { + require(it.size <= MAX_REQUEST_BYTES) { + "Friend request is too large" + } + } + } + + fun decodeRequest( + bytes: ByteArray, + ): FriendControlDecode { + if (bytes.size > MAX_REQUEST_BYTES) { + return FriendControlDecode.Invalid + } + val current = decode(bytes) { + val control = readPacket() + ensure(control.readVarInt() == CONTROL_REQUEST_PACKET_ID) + val requestId = UUID( + control.readLong(), + control.readLong(), + ) + val relationshipId = UUID( + control.readLong(), + control.readLong(), + ) + val displayName = control + .readString(MAX_DISPLAY_NAME_BYTES) + .trim() + ensure(displayName.isNotEmpty()) + val invitation = control.readString(MAX_INVITATION_BYTES) + ensure(invitation.isNotEmpty()) + control.ensureFinished() + FriendControlRequest( + requestId = requestId, + relationshipId = relationshipId, + displayName = displayName, + invitation = invitation, + ) + } + if (current is FriendControlDecode.Decoded) { + return current + } + val legacy = decode(bytes) { + val control = readPacket() + ensure(control.readVarInt() == CONTROL_REQUEST_PACKET_ID) + val requestId = UUID( + control.readLong(), + control.readLong(), + ) + val displayName = control + .readString(MAX_DISPLAY_NAME_BYTES) + .trim() + ensure(displayName.isNotEmpty()) + val invitation = control.readString(MAX_INVITATION_BYTES) + ensure(invitation.isNotEmpty()) + control.ensureFinished() + FriendControlRequest( + requestId = requestId, + relationshipId = requestId, + displayName = displayName, + invitation = invitation, + ) + } + return when { + legacy is FriendControlDecode.Decoded -> legacy + current is FriendControlDecode.Incomplete || + legacy is FriendControlDecode.Incomplete -> + FriendControlDecode.Incomplete + else -> FriendControlDecode.Invalid + } + } + + fun encodeRemoval(request: FriendRemovalRequest): ByteArray { + val output = ByteArrayOutputStream() + output.writePacket { + writeVarInt(CONTROL_REMOVAL_PACKET_ID) + writeLong(request.operationId.mostSignificantBits) + writeLong(request.operationId.leastSignificantBits) + writeLong(request.relationshipId.mostSignificantBits) + writeLong(request.relationshipId.leastSignificantBits) + } + return output.toByteArray() + } + + fun decodeRemoval( + bytes: ByteArray, + ): FriendControlDecode { + if (bytes.size > MAX_REQUEST_BYTES) { + return FriendControlDecode.Invalid + } + val current = decode(bytes) { + val control = readPacket() + ensure(control.readVarInt() == CONTROL_REMOVAL_PACKET_ID) + val request = FriendRemovalRequest( + UUID(control.readLong(), control.readLong()), + UUID(control.readLong(), control.readLong()), + ) + control.ensureFinished() + request + } + if (current is FriendControlDecode.Decoded) { + return current + } + val legacy = decode(bytes) { + val control = readPacket() + ensure(control.readVarInt() == CONTROL_REMOVAL_PACKET_ID) + val operationId = UUID(control.readLong(), control.readLong()) + control.ensureFinished() + FriendRemovalRequest( + operationId = operationId, + relationshipId = operationId, + ) + } + return when { + legacy is FriendControlDecode.Decoded -> legacy + current is FriendControlDecode.Incomplete || + legacy is FriendControlDecode.Incomplete -> + FriendControlDecode.Incomplete + else -> FriendControlDecode.Invalid + } + } + + fun encodeActivityRequest(request: FriendActivityRequest): ByteArray = + encodeIdRequest(CONTROL_ACTIVITY_PACKET_ID, request.requestId) + + fun decodeActivityRequest( + bytes: ByteArray, + ): FriendControlDecode = + decodeIdRequest(bytes, CONTROL_ACTIVITY_PACKET_ID) { + FriendActivityRequest(it) + } + + fun encodeJoinRequest(request: FriendJoinRequest): ByteArray { + val playerName = request.playerName.trim() + require( + playerName.isNotEmpty() && + playerName.toByteArray(StandardCharsets.UTF_8).size <= + MAX_PLAYER_NAME_BYTES, + ) { "Player name is invalid" } + val output = ByteArrayOutputStream() + output.writePacket { + writeVarInt(CONTROL_JOIN_PACKET_ID) + writeLong(request.requestId.mostSignificantBits) + writeLong(request.requestId.leastSignificantBits) + writeString(playerName) + writeLong(request.playerUuid.mostSignificantBits) + writeLong(request.playerUuid.leastSignificantBits) + } + return output.toByteArray() + } + + fun decodeJoinRequest( + bytes: ByteArray, + ): FriendControlDecode { + if (bytes.size > MAX_REQUEST_BYTES) return FriendControlDecode.Invalid + return decode(bytes) { + val control = readPacket() + ensure(control.readVarInt() == CONTROL_JOIN_PACKET_ID) + val request = FriendJoinRequest( + requestId = UUID(control.readLong(), control.readLong()), + playerName = control.readString(MAX_PLAYER_NAME_BYTES), + playerUuid = UUID(control.readLong(), control.readLong()), + ) + control.ensureFinished() + request + } + } + + private fun encodeIdRequest(packetId: Int, id: UUID): ByteArray { + val output = ByteArrayOutputStream() + output.writePacket { + writeVarInt(packetId) + writeLong(id.mostSignificantBits) + writeLong(id.leastSignificantBits) + } + return output.toByteArray() + } + + private fun decodeIdRequest( + bytes: ByteArray, + packetId: Int, + create: (UUID) -> A, + ): FriendControlDecode { + if (bytes.size > MAX_REQUEST_BYTES) return FriendControlDecode.Invalid + return decode(bytes) { + val control = readPacket() + ensure(control.readVarInt() == packetId) + val value = create(UUID(control.readLong(), control.readLong())) + control.ensureFinished() + value + } + } + + fun isStatusHandshake(bytes: ByteArray): Boolean = try { + val reader = Reader(bytes) + val handshake = reader.readPacket() + handshake.readVarInt() == HANDSHAKE_PACKET_ID && + handshake.run { + readVarInt() + readString(MAX_ADDRESS_BYTES) + readUnsignedShort() + readVarInt() == STATUS_INTENTION + } + } catch (_: DecodeFailure) { + false + } + + fun inspectControlRequest( + bytes: ByteArray, + ): FriendControlDecode = decode(bytes) { + val firstPacket = readPacket() + firstPacket.readVarInt() == CONTROL_REQUEST_PACKET_ID + } + + fun inspectControlMessage( + bytes: ByteArray, + ): FriendControlDecode = decode(bytes) { + val packet = readPacket() + when (packet.readVarInt()) { + CONTROL_REQUEST_PACKET_ID -> FriendControlMessageKind.PAIRING + CONTROL_REMOVAL_PACKET_ID -> FriendControlMessageKind.REMOVAL + CONTROL_ACTIVITY_PACKET_ID -> FriendControlMessageKind.ACTIVITY + CONTROL_JOIN_PACKET_ID -> FriendControlMessageKind.JOIN + else -> FriendControlMessageKind.OTHER + } + } + + fun encodeResponse(response: FriendControlResponse): ByteArray { + val output = ByteArrayOutputStream() + output.writePacket { + writeVarInt(CONTROL_RESPONSE_PACKET_ID) + when (response) { + FriendControlResponse.Received -> write(0) + is FriendControlResponse.Accepted -> { + write(1) + writeString(response.invitation) + } + + FriendControlResponse.Declined -> write(2) + FriendControlResponse.TimedOut -> write(3) + FriendControlResponse.Invalid -> write(4) + FriendControlResponse.Removed -> write(5) + is FriendControlResponse.Activity -> { + write(6) + write(response.activity.kind.ordinal) + write(if (response.activity.joinable) 1 else 0) + writeString(response.activity.sessionEpoch.orEmpty()) + writeString(response.activity.description.orEmpty()) + val compatibility = response.activity.compatibility + write(if (compatibility == null) 0 else 1) + if (compatibility != null) { + writeCompatibilityProfile(compatibility) + } + } + is FriendControlResponse.JoinAccepted -> { + write(7) + writeString(response.address) + } + FriendControlResponse.SharedWorldJoinAccepted -> write(8) + } + } + return output.toByteArray().also { + require(it.size <= MAX_REQUEST_BYTES) { + "Friend response is too large" + } + } + } + + fun decodeResponse( + bytes: ByteArray, + ): FriendControlDecode = decode(bytes) { + val response = readPacket() + ensure(response.readVarInt() == CONTROL_RESPONSE_PACKET_ID) + val decoded = when (response.readByte()) { + 0 -> FriendControlResponse.Received + 1 -> FriendControlResponse.Accepted( + response.readString(MAX_INVITATION_BYTES), + ) + + 2 -> FriendControlResponse.Declined + 3 -> FriendControlResponse.TimedOut + 4 -> FriendControlResponse.Invalid + 5 -> FriendControlResponse.Removed + 6 -> { + val kind = FriendActivityKind.entries.getOrNull( + response.readByte(), + ) ?: invalid() + FriendControlResponse.Activity( + FriendActivity( + kind = kind, + joinable = response.readByte() != 0, + sessionEpoch = response + .readString(MAX_SESSION_EPOCH_BYTES) + .takeIf(String::isNotEmpty), + description = response.readString(MAX_ACTIVITY_BYTES) + .takeIf(String::isNotEmpty), + compatibility = when (response.readByte()) { + 0 -> null + 1 -> response.readCompatibilityProfile() + else -> invalid() + }, + ), + ) + } + 7 -> FriendControlResponse.JoinAccepted( + response.readString(MAX_SERVER_ADDRESS_BYTES), + ) + 8 -> FriendControlResponse.SharedWorldJoinAccepted + else -> invalid() + } + response.ensureFinished() + decoded + } + + private inline fun decode( + bytes: ByteArray, + block: Reader.() -> A, + ): FriendControlDecode = try { + val reader = Reader(bytes) + val value = reader.block() + FriendControlDecode.Decoded(value, reader.position) + } catch (_: IncompleteFailure) { + FriendControlDecode.Incomplete + } catch (_: InvalidFailure) { + FriendControlDecode.Invalid + } + + private fun ByteArrayOutputStream.writePacket( + payload: ByteArrayOutputStream.() -> Unit, + ) { + val packet = ByteArrayOutputStream().apply(payload).toByteArray() + writeVarInt(packet.size) + write(packet) + } + + private fun ByteArrayOutputStream.writeString(value: String) { + val encoded = value.toByteArray(StandardCharsets.UTF_8) + writeVarInt(encoded.size) + write(encoded) + } + + private fun ByteArrayOutputStream.writeCompatibilityProfile( + profile: CompatibilityProfile, + ) { + require(profile.requiredMods.size <= MAX_REQUIRED_MODS) { + "Compatibility profile has too many required mods" + } + writeString(profile.fingerprint()) + writeString(profile.minecraftVersion) + write(profile.loader.ordinal) + writeVarInt(profile.requiredMods.size) + profile.requiredMods.forEach { mod -> + require( + mod.id.toByteArray(StandardCharsets.UTF_8).size <= + MAX_MOD_ID_BYTES && + mod.version.toByteArray(StandardCharsets.UTF_8).size <= + MAX_VERSION_BYTES, + ) { "Compatibility mod entry is too large" } + writeString(mod.id) + writeString(mod.version) + } + profile.pack?.let { pack -> + require( + listOf(pack.projectId, pack.versionId, pack.url).all { + it.toByteArray(StandardCharsets.UTF_8).size <= + MAX_PACK_FIELD_BYTES + }, + ) { "Pack reference is too large" } + write(1) + write(pack.platform.ordinal) + writeString(pack.projectId) + writeString(pack.versionId) + writeString(pack.url) + } ?: write(0) + } + + private fun ByteArrayOutputStream.writeLong(value: Long) { + write(ByteBuffer.allocate(Long.SIZE_BYTES).putLong(value).array()) + } + + private fun ByteArrayOutputStream.writeVarInt(value: Int) { + var remaining = value + do { + var byte = remaining and 0x7f + remaining = remaining ushr 7 + if (remaining != 0) { + byte = byte or 0x80 + } + write(byte) + } while (remaining != 0) + } + + private open class DecodeFailure : RuntimeException() + + private class IncompleteFailure : DecodeFailure() + + private class InvalidFailure : DecodeFailure() + + private fun invalid(): Nothing = throw InvalidFailure() + + private class Reader( + private val bytes: ByteArray, + private val end: Int = bytes.size, + var position: Int = 0, + ) { + fun readPacket(): Reader { + val length = readVarInt() + if (length < 0 || length > MAX_REQUEST_BYTES) { + invalid() + } + val packetEnd = position + length + if (packetEnd < position || packetEnd > end) { + throw IncompleteFailure() + } + val packet = Reader(bytes, packetEnd, position) + position = packetEnd + return packet + } + + fun readVarInt(): Int { + var result = 0 + var shift = 0 + while (shift < 35) { + val byte = readByte() + result = result or ((byte and 0x7f) shl shift) + if (byte and 0x80 == 0) { + return result + } + shift += 7 + } + invalid() + } + + fun readUnsignedShort(): Int = + (readByte() shl 8) or readByte() + + fun readLong(): Long { + requireAvailable(Long.SIZE_BYTES) + return ByteBuffer.wrap( + bytes, + position, + Long.SIZE_BYTES, + ).long.also { + position += Long.SIZE_BYTES + } + } + + fun readString(maxBytes: Int): String { + val length = readVarInt() + if (length < 0 || length > maxBytes) { + invalid() + } + requireAvailable(length) + return String( + bytes, + position, + length, + StandardCharsets.UTF_8, + ).also { + position += length + } + } + + fun readByte(): Int { + requireAvailable(1) + return bytes[position++].toInt() and 0xff + } + + fun readCompatibilityProfile(): CompatibilityProfile { + val expectedFingerprint = + readString(COMPATIBILITY_FINGERPRINT_BYTES) + ensure( + expectedFingerprint.length == + COMPATIBILITY_FINGERPRINT_BYTES && + expectedFingerprint.all { + it in '0'..'9' || it in 'a'..'f' + }, + ) + val minecraftVersion = readString(MAX_VERSION_BYTES) + ensure(minecraftVersion.isNotBlank()) + val loader = ModLoader.entries.getOrNull(readByte()) ?: invalid() + val modCount = readVarInt() + ensure(modCount in 0..MAX_REQUIRED_MODS) + val mods = buildList { + repeat(modCount) { + val id = readString(MAX_MOD_ID_BYTES) + val version = readString(MAX_VERSION_BYTES) + ensure(id.isNotBlank() && version.isNotBlank()) + add(RequiredMod(id, version)) + } + } + val pack = when (readByte()) { + 0 -> null + 1 -> PackReference( + platform = PackPlatform.entries.getOrNull(readByte()) + ?: invalid(), + projectId = readString(MAX_PACK_FIELD_BYTES), + versionId = readString(MAX_PACK_FIELD_BYTES), + url = readString(MAX_PACK_FIELD_BYTES), + ) + else -> invalid() + } + val profile = CompatibilityProfile( + minecraftVersion = minecraftVersion, + loader = loader, + requiredMods = mods, + pack = pack, + ) + ensure(profile.fingerprint() == expectedFingerprint) + return profile + } + + fun ensure(condition: Boolean) { + if (!condition) { + invalid() + } + } + + fun ensureFinished() { + ensure(position == end) + } + + private fun requireAvailable(count: Int) { + if (count < 0 || position + count < position) { + invalid() + } + if (position + count > end) { + throw IncompleteFailure() + } + } + } +} diff --git a/share/common/src/main/kotlin/com/minekube/connect/share/friend/FriendStore.kt b/share/common/src/main/kotlin/com/minekube/connect/share/friend/FriendStore.kt new file mode 100644 index 000000000..1f40b5ca9 --- /dev/null +++ b/share/common/src/main/kotlin/com/minekube/connect/share/friend/FriendStore.kt @@ -0,0 +1,814 @@ +package com.minekube.connect.share.friend + +import arrow.core.Either +import arrow.core.Option +import arrow.core.raise.either +import arrow.core.raise.ensure +import arrow.core.toOption +import com.google.gson.Gson +import com.google.gson.JsonArray +import com.google.gson.JsonObject +import com.google.gson.JsonParseException +import com.minekube.connect.share.direct.ShareInviteCodec +import com.minekube.connect.share.direct.ShareInviteError +import java.io.IOException +import java.nio.ByteBuffer +import java.nio.channels.FileChannel +import java.nio.charset.StandardCharsets +import java.nio.file.AtomicMoveNotSupportedException +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.StandardCopyOption.ATOMIC_MOVE +import java.nio.file.StandardCopyOption.REPLACE_EXISTING +import java.nio.file.StandardOpenOption.TRUNCATE_EXISTING +import java.nio.file.StandardOpenOption.WRITE +import java.nio.file.attribute.PosixFilePermission +import java.time.Instant +import java.util.Base64 +import java.util.EnumSet +import java.util.UUID + +enum class FriendAccessPolicy { + ASK_EVERY_TIME, + AUTO_ACCEPT, + NEVER_ALLOW, +} + +data class FriendPermissions( + val notifyWhenOnline: Boolean = true, + val canSeeMyWorlds: Boolean = true, + val accessPolicy: FriendAccessPolicy = FriendAccessPolicy.ASK_EVERY_TIME, +) { + val canJoinAutomatically: Boolean + get() = accessPolicy == FriendAccessPolicy.AUTO_ACCEPT + + constructor( + notifyWhenOnline: Boolean = true, + canSeeMyWorlds: Boolean = true, + canJoinAutomatically: Boolean, + ) : this( + notifyWhenOnline = notifyWhenOnline, + canSeeMyWorlds = canSeeMyWorlds, + accessPolicy = if (canJoinAutomatically) { + FriendAccessPolicy.AUTO_ACCEPT + } else { + FriendAccessPolicy.ASK_EVERY_TIME + }, + ) +} + +enum class FriendRelationshipStatus { + PENDING_OUTGOING, + CONFIRMED, +} + +data class SavedFriend( + val peerId: String, + val publicKeyBase64: String, + val shareId: UUID, + val capability: String, + val relationshipId: UUID = UUID.randomUUID(), + val relationshipIdKnown: Boolean = true, + val connectAddress: String?, + val internetDirectEnabled: Boolean = false, + val directCandidates: List = emptyList(), + val internetDirectGuestOptIn: Boolean = false, + val displayName: String, + val minecraftUuid: UUID? = null, + val permissions: FriendPermissions = FriendPermissions(), + val relationshipStatus: FriendRelationshipStatus = + FriendRelationshipStatus.CONFIRMED, +) { + override fun toString(): String = + "SavedFriend(peerId=$peerId, publicKey=, " + + "shareId=$shareId, capability=, " + + "connectAddress=$connectAddress, directCandidates=, " + + "displayName=$displayName, " + + "minecraftUuid=$minecraftUuid, permissions=$permissions, " + + "relationshipStatus=$relationshipStatus)" +} + +data class PendingFriendRemoval( + val operationId: UUID, + val friend: SavedFriend, + val removedAt: Instant, +) + +data class BlockedFriend( + val peerId: String, + val publicKeyBase64: String, + val displayName: String, + val blockedAt: Instant, +) { + override fun toString(): String = + "BlockedFriend(peerId=$peerId, publicKey=, " + + "displayName=$displayName, blockedAt=$blockedAt)" +} + +sealed interface FriendStoreError { + val safeMessage: String + + data class InvalidInvitation( + val reason: ShareInviteError, + ) : FriendStoreError { + override val safeMessage: String = reason.safeMessage + } + + data object InvalidDisplayName : FriendStoreError { + override val safeMessage = "Friend name must be between 1 and 64 characters" + } + + data object IdentityConflict : FriendStoreError { + override val safeMessage = + "This friend identity does not match the previously saved key" + } + + data object NotFound : FriendStoreError { + override val safeMessage = "This friend is no longer saved" + } + + data object Blocked : FriendStoreError { + override val safeMessage = + "This identity is blocked. Unblock it before adding it again" + } +} + +class FriendStore( + private val directory: Path, +) { + private var cached: StoreData? = null + + @Synchronized + fun all(): List = + read().filter { + it.relationshipStatus == FriendRelationshipStatus.CONFIRMED + } + + @Synchronized + fun outgoingRequests(): List = + read().filter { + it.relationshipStatus == + FriendRelationshipStatus.PENDING_OUTGOING + } + + @Synchronized + fun relationship(peerId: String): Option = + read().firstOrNull { it.peerId == peerId }.toOption() + + @Synchronized + fun pendingRemovals(): List = + data().removals + + @Synchronized + fun blocked(): List = data().blocked + + @Synchronized + fun isBlocked(peerId: String): Boolean = + data().blocked.any { it.peerId == peerId } + + @Synchronized + fun accept( + invitationUri: String, + displayName: String, + now: Instant = Instant.now(), + relationshipId: UUID? = null, + ): Either = + storeInvitation( + invitationUri = invitationUri, + displayName = displayName, + relationshipStatus = FriendRelationshipStatus.CONFIRMED, + now = now, + relationshipId = relationshipId, + ) + + @Synchronized + fun acceptAndAllowJoin( + invitationUri: String, + displayName: String, + now: Instant = Instant.now(), + relationshipId: UUID? = null, + ): Either = + storeInvitation( + invitationUri = invitationUri, + displayName = displayName, + relationshipStatus = FriendRelationshipStatus.CONFIRMED, + allowAutomaticJoin = true, + now = now, + relationshipId = relationshipId, + ) + + @Synchronized + fun sendRequest( + invitationUri: String, + displayName: String, + now: Instant = Instant.now(), + relationshipId: UUID = UUID.randomUUID(), + internetDirectGuestOptIn: Boolean = false, + ): Either = + storeInvitation( + invitationUri = invitationUri, + displayName = displayName, + relationshipStatus = + FriendRelationshipStatus.PENDING_OUTGOING, + now = now, + relationshipId = relationshipId, + internetDirectGuestOptIn = internetDirectGuestOptIn, + ) + + @Synchronized + fun confirmOutgoing( + peerId: String, + ): Either = update(peerId) { friend -> + friend.copy( + relationshipStatus = FriendRelationshipStatus.CONFIRMED, + ) + } + + private fun storeInvitation( + invitationUri: String, + displayName: String, + relationshipStatus: FriendRelationshipStatus, + allowAutomaticJoin: Boolean = false, + internetDirectGuestOptIn: Boolean = false, + now: Instant, + relationshipId: UUID?, + ): Either = either { + val invite = ShareInviteCodec.decode(invitationUri.trim(), now) + .mapLeft(FriendStoreError::InvalidInvitation) + .bind() + val normalizedName = displayName.trim() + ensure(normalizedName.length in 1..MAX_DISPLAY_NAME_LENGTH) { + FriendStoreError.InvalidDisplayName + } + + val current = read() + val publicKey = Base64.getEncoder().encodeToString(invite.publicKey) + ensure(data().blocked.none { it.peerId == invite.payload.peerId }) { + FriendStoreError.Blocked + } + val existing = current.firstOrNull { + it.peerId == invite.payload.peerId + } + ensure(existing == null || existing.publicKeyBase64 == publicKey) { + FriendStoreError.IdentityConflict + } + val effectiveRelationshipStatus = when { + existing?.relationshipStatus == + FriendRelationshipStatus.CONFIRMED -> + FriendRelationshipStatus.CONFIRMED + + else -> relationshipStatus + } + val friend = SavedFriend( + peerId = invite.payload.peerId, + publicKeyBase64 = publicKey, + shareId = invite.payload.shareId, + capability = invite.payload.capability, + relationshipId = when { + existing == null -> relationshipId ?: UUID.randomUUID() + relationshipStatus == FriendRelationshipStatus.CONFIRMED && + relationshipId != null -> relationshipId + else -> existing.relationshipId + }, + relationshipIdKnown = when { + existing == null -> true + relationshipStatus == FriendRelationshipStatus.CONFIRMED && + relationshipId != null -> true + else -> existing.relationshipIdKnown + }, + connectAddress = invite.payload.connectAddress, + internetDirectEnabled = invite.payload.internetDirectEnabled, + directCandidates = invite.payload.directCandidates, + internetDirectGuestOptIn = internetDirectGuestOptIn || + existing?.internetDirectGuestOptIn == true, + displayName = existing?.displayName ?: normalizedName, + minecraftUuid = existing?.minecraftUuid, + permissions = (existing?.permissions ?: FriendPermissions()) + .let { permissions -> + if (allowAutomaticJoin) { + permissions.copy( + accessPolicy = FriendAccessPolicy.AUTO_ACCEPT, + ) + } else { + permissions + } + }, + relationshipStatus = effectiveRelationshipStatus, + ) + write( + data().copy( + friends = current.filterNot { + it.peerId == friend.peerId + } + friend, + removals = data().removals.filterNot { + it.friend.peerId == friend.peerId + }, + ), + ) + friend + } + + @Synchronized + fun rename( + peerId: String, + displayName: String, + ): Either = update(peerId) { friend -> + val normalized = displayName.trim() + ensure(normalized.length in 1..MAX_DISPLAY_NAME_LENGTH) { + FriendStoreError.InvalidDisplayName + } + friend.copy(displayName = normalized) + } + + @Synchronized + fun updatePermissions( + peerId: String, + permissions: FriendPermissions, + ): Either = update(peerId) { friend -> + friend.copy(permissions = permissions) + } + + @Synchronized + fun setInternetDirectGuestOptIn( + peerId: String, + enabled: Boolean, + ): Either = update(peerId) { friend -> + friend.copy(internetDirectGuestOptIn = enabled) + } + + @Synchronized + fun linkMinecraftIdentity( + peerId: String, + minecraftUuid: UUID, + ): Either = update(peerId) { friend -> + friend.copy(minecraftUuid = minecraftUuid) + } + + @Synchronized + fun remove( + peerId: String, + now: Instant = Instant.now(), + ): Boolean { + val current = read() + val removed = current.firstOrNull { it.peerId == peerId } + ?: return false + val remaining = current.filterNot { it.peerId == peerId } + val removals = data().removals.filterNot { + it.friend.peerId == peerId + } + PendingFriendRemoval( + operationId = UUID.randomUUID(), + friend = removed, + removedAt = now, + ) + write( + data().copy( + friends = remaining, + removals = removals, + ), + ) + return true + } + + @Synchronized + fun block( + peerId: String, + now: Instant = Instant.now(), + ): Boolean { + val current = read() + val blockedFriend = current.firstOrNull { it.peerId == peerId } + ?: return false + val removal = PendingFriendRemoval( + operationId = UUID.randomUUID(), + friend = blockedFriend, + removedAt = now, + ) + val blocked = BlockedFriend( + peerId = blockedFriend.peerId, + publicKeyBase64 = blockedFriend.publicKeyBase64, + displayName = blockedFriend.displayName, + blockedAt = now, + ) + write( + data().copy( + friends = current.filterNot { it.peerId == peerId }, + removals = data().removals.filterNot { + it.friend.peerId == peerId + } + removal, + blocked = data().blocked.filterNot { + it.peerId == peerId + } + blocked, + ), + ) + return true + } + + @Synchronized + fun unblock(peerId: String): Boolean { + val current = data() + val remaining = current.blocked.filterNot { it.peerId == peerId } + if (remaining.size == current.blocked.size) return false + write(current.copy(blocked = remaining)) + return true + } + + @Synchronized + fun applyRemoteRemoval( + peerId: String, + relationshipId: UUID, + ): SavedFriend? { + val current = read() + val removed = current.firstOrNull { it.peerId == peerId } + ?.takeIf { + it.relationshipIdKnown && it.relationshipId == relationshipId + } + ?: return null + write(data().copy(friends = current.filterNot { it.peerId == peerId })) + return removed + } + + @Synchronized + fun acknowledgeRemoval(operationId: UUID): Boolean { + val current = data() + val remaining = current.removals.filterNot { + it.operationId == operationId + } + if (remaining.size == current.removals.size) { + return false + } + write(current.copy(removals = remaining)) + return true + } + + private fun update( + peerId: String, + transform: + arrow.core.raise.Raise.(SavedFriend) -> SavedFriend, + ): Either = either { + val current = read() + val existing = current.firstOrNull { it.peerId == peerId } + ensure(existing != null) { FriendStoreError.NotFound } + val updated = transform(existing) + write(current.map { if (it.peerId == peerId) updated else it }) + updated + } + + private fun read(): List = data().friends + + private fun data(): StoreData = + cached ?: load().also { cached = it } + + private fun load(): StoreData { + Files.createDirectories(directory) + if (!Files.exists(friendsFile)) { + return StoreData() + } + try { + val root = GSON.fromJson( + Files.readString(friendsFile), + JsonObject::class.java, + ) ?: throw IOException("Friends file is empty") + val version = root.requiredInt("version") + if (version !in MIN_WIRE_VERSION..WIRE_VERSION) { + throw IOException("Friends file version is unsupported") + } + val entries = root.getAsJsonArray("friends") + ?: throw IOException("Friends file is missing friends") + val friends = entries.map { element -> + parseFriend(element.asJsonObject, version) + } + if (friends.size > MAX_FRIENDS) { + throw IOException("Friends file contains too many entries") + } + if (friends.map(SavedFriend::peerId).distinct().size != friends.size) { + throw IOException("Friends file contains duplicate identities") + } + val removals = if (version >= 2) { + root.getAsJsonArray("pendingRemovals") + ?.map { element -> parseRemoval(element.asJsonObject, version) } + ?: emptyList() + } else { + emptyList() + } + if (removals.size > MAX_FRIENDS) { + throw IOException("Friends file contains too many removals") + } + val blocked = if (version >= 4) { + root.getAsJsonArray("blocked") + ?.map { element -> parseBlocked(element.asJsonObject) } + ?: emptyList() + } else { + emptyList() + } + if (blocked.size > MAX_FRIENDS) { + throw IOException("Friends file contains too many blocks") + } + return StoreData(friends, removals, blocked) + } catch (exception: JsonParseException) { + throw IOException("Friends file is invalid JSON", exception) + } catch (exception: IllegalStateException) { + throw IOException("Friends file is invalid", exception) + } catch (exception: IllegalArgumentException) { + throw IOException("Friends file contains invalid data", exception) + } + } + + private fun parseRemoval( + json: JsonObject, + version: Int, + ): PendingFriendRemoval = + PendingFriendRemoval( + operationId = UUID.fromString(json.requiredString("operationId")), + friend = json.getAsJsonObject("friend")?.let { + parseFriend(it, version) + } ?: throw IOException("Removal is missing friend"), + removedAt = Instant.ofEpochMilli( + json.get("removedAtEpochMillis")?.asLong + ?: throw IOException("Removal is missing time"), + ), + ) + + private fun parseBlocked(json: JsonObject): BlockedFriend = + BlockedFriend( + peerId = json.requiredString("peerId"), + publicKeyBase64 = json.requiredString("publicKey").also { + Base64.getDecoder().decode(it) + }, + displayName = json.requiredString("displayName"), + blockedAt = Instant.ofEpochMilli( + json.get("blockedAtEpochMillis")?.asLong + ?: throw IOException("Block is missing time"), + ), + ) + + private fun parseFriend( + json: JsonObject, + version: Int, + ): SavedFriend { + val peerId = json.requiredString("peerId") + val publicKey = json.requiredString("publicKey") + val shareId = UUID.fromString(json.requiredString("shareId")) + val capability = json.requiredString("capability") + val relationshipId = json.optionalString("relationshipId") + ?.let(UUID::fromString) + ?: legacyRelationshipId(peerId, shareId, capability) + val relationshipIdKnown = json.optionalBoolean("relationshipIdKnown") + ?: (version >= 6 && json.has("relationshipId")) + val connectAddress = json.optionalString("connectAddress") + val internetDirectEnabled = + json.optionalBoolean("internetDirectEnabled") ?: false + val directCandidates = json.getAsJsonArray("directCandidates") + ?.map { it.asString } + ?: emptyList() + val internetDirectGuestOptIn = + json.optionalBoolean("internetDirectGuestOptIn") ?: false + val displayName = json.requiredString("displayName") + val minecraftUuid = json.optionalString("minecraftUuid") + ?.let(UUID::fromString) + if ( + peerId.isBlank() || + publicKey.isBlank() || + !isValidCapability(capability) || + directCandidates.size > MAX_DIRECT_CANDIDATES || + (!internetDirectEnabled && directCandidates.isNotEmpty()) || + directCandidates.any { + it.isBlank() || + it.length > MAX_DIRECT_CANDIDATE_LENGTH || + it.contains("/p2p-circuit") || + it.contains("/circuit/") || + it.substringAfterLast("/p2p/", "") != peerId + } || + displayName.trim().length !in 1..MAX_DISPLAY_NAME_LENGTH + ) { + throw IOException("Friends file contains an invalid friend") + } + Base64.getDecoder().decode(publicKey) + val permissions = json.getAsJsonObject("permissions") + ?: throw IOException("Friends file is missing permissions") + val parsedPermissions = FriendPermissions( + notifyWhenOnline = + permissions.requiredBoolean("notifyWhenOnline"), + canSeeMyWorlds = + permissions.requiredBoolean("canSeeMyWorlds"), + accessPolicy = permissions.optionalString("accessPolicy") + ?.let(FriendAccessPolicy::valueOf) + ?: if (permissions.requiredBoolean("canJoinAutomatically")) { + FriendAccessPolicy.AUTO_ACCEPT + } else { + FriendAccessPolicy.ASK_EVERY_TIME + }, + ) + val relationshipStatus = json + .optionalString("relationshipStatus") + ?.let(::parseRelationshipStatus) + ?: legacyRelationshipStatus( + minecraftUuid = minecraftUuid, + permissions = parsedPermissions, + ) + return SavedFriend( + peerId = peerId, + publicKeyBase64 = publicKey, + shareId = shareId, + capability = capability, + relationshipId = relationshipId, + relationshipIdKnown = relationshipIdKnown, + connectAddress = connectAddress, + internetDirectEnabled = internetDirectEnabled, + directCandidates = directCandidates, + internetDirectGuestOptIn = internetDirectGuestOptIn, + displayName = displayName, + minecraftUuid = minecraftUuid, + permissions = parsedPermissions, + relationshipStatus = relationshipStatus, + ) + } + + private fun write(friends: List) { + write(data().copy(friends = friends)) + } + + private fun write(data: StoreData) { + require(data.friends.size <= MAX_FRIENDS) { + "Connect Share supports at most $MAX_FRIENDS saved friends" + } + require(data.removals.size <= MAX_FRIENDS) { + "Connect Share supports at most $MAX_FRIENDS pending removals" + } + require(data.blocked.size <= MAX_FRIENDS) { + "Connect Share supports at most $MAX_FRIENDS blocked identities" + } + Files.createDirectories(directory) + val entries = JsonArray() + data.friends.sortedBy { it.displayName.lowercase() }.forEach { friend -> + entries.add(friend.toJson()) + } + val removals = JsonArray() + data.removals.sortedBy { it.removedAt }.forEach { removal -> + removals.add(JsonObject().apply { + addProperty("operationId", removal.operationId.toString()) + addProperty( + "removedAtEpochMillis", + removal.removedAt.toEpochMilli(), + ) + add("friend", removal.friend.toJson()) + }) + } + val root = JsonObject().apply { + addProperty("version", WIRE_VERSION) + add("friends", entries) + add("pendingRemovals", removals) + add("blocked", JsonArray().apply { + data.blocked.sortedBy { it.blockedAt }.forEach { blocked -> + add(JsonObject().apply { + addProperty("peerId", blocked.peerId) + addProperty("publicKey", blocked.publicKeyBase64) + addProperty("displayName", blocked.displayName) + addProperty( + "blockedAtEpochMillis", + blocked.blockedAt.toEpochMilli(), + ) + }) + } + }) + } + writeAtomic(GSON.toJson(root)) + cached = data.copy( + friends = data.friends.toList(), + removals = data.removals.toList(), + ) + } + + private fun SavedFriend.toJson(): JsonObject = JsonObject().apply { + addProperty("peerId", peerId) + addProperty("publicKey", publicKeyBase64) + addProperty("shareId", shareId.toString()) + addProperty("relationshipId", relationshipId.toString()) + addProperty("relationshipIdKnown", relationshipIdKnown) + addProperty("capability", capability) + connectAddress?.let { addProperty("connectAddress", it) } + addProperty("internetDirectEnabled", internetDirectEnabled) + add("directCandidates", JsonArray().apply { + directCandidates.forEach(::add) + }) + addProperty("internetDirectGuestOptIn", internetDirectGuestOptIn) + addProperty("displayName", displayName) + minecraftUuid?.let { addProperty("minecraftUuid", it.toString()) } + addProperty("relationshipStatus", relationshipStatus.name) + add( + "permissions", + JsonObject().apply { + addProperty("notifyWhenOnline", permissions.notifyWhenOnline) + addProperty("canSeeMyWorlds", permissions.canSeeMyWorlds) + addProperty("accessPolicy", permissions.accessPolicy.name) + }, + ) + } + + private fun writeAtomic(content: String) { + val temporary = Files.createTempFile(directory, "$FILE_NAME.", ".tmp") + try { + setOwnerOnlyPermissions(temporary) + val bytes = content.toByteArray(StandardCharsets.UTF_8) + FileChannel.open(temporary, WRITE, TRUNCATE_EXISTING).use { channel -> + val remaining = ByteBuffer.wrap(bytes) + while (remaining.hasRemaining()) { + channel.write(remaining) + } + channel.force(true) + } + try { + Files.move(temporary, friendsFile, ATOMIC_MOVE, REPLACE_EXISTING) + } catch (_: AtomicMoveNotSupportedException) { + Files.move(temporary, friendsFile, REPLACE_EXISTING) + } + setOwnerOnlyPermissions(friendsFile) + } finally { + Files.deleteIfExists(temporary) + } + } + + private fun setOwnerOnlyPermissions(file: Path) { + try { + Files.setPosixFilePermissions( + file, + EnumSet.of( + PosixFilePermission.OWNER_READ, + PosixFilePermission.OWNER_WRITE, + ), + ) + } catch (_: UnsupportedOperationException) { + // Non-POSIX filesystems do not expose Unix file modes. + } + } + + private fun JsonObject.requiredString(name: String): String = + get(name)?.takeUnless { it.isJsonNull }?.asString + ?: throw IOException("Friends file is missing $name") + + private fun JsonObject.optionalString(name: String): String? = + get(name)?.takeUnless { it.isJsonNull }?.asString + + private fun JsonObject.requiredInt(name: String): Int = + get(name)?.takeUnless { it.isJsonNull }?.asInt + ?: throw IOException("Friends file is missing $name") + + private fun JsonObject.requiredBoolean(name: String): Boolean = + get(name)?.takeUnless { it.isJsonNull }?.asBoolean + ?: throw IOException("Friends file is missing $name") + + private fun JsonObject.optionalBoolean(name: String): Boolean? = + get(name)?.takeUnless { it.isJsonNull }?.asBoolean + + private val friendsFile: Path + get() = directory.resolve(FILE_NAME) + + companion object { + const val FILE_NAME = "friends.json" + private const val MIN_WIRE_VERSION = 1 + private const val WIRE_VERSION = 7 + private const val MAX_FRIENDS = 256 + private const val MAX_DIRECT_CANDIDATES = 4 + private const val MAX_DIRECT_CANDIDATE_LENGTH = 8_192 + private const val MAX_DISPLAY_NAME_LENGTH = 64 + private val GSON = Gson() + + private fun legacyRelationshipStatus( + minecraftUuid: UUID?, + permissions: FriendPermissions, + ): FriendRelationshipStatus = + if ( + minecraftUuid != null || + permissions.canJoinAutomatically + ) { + FriendRelationshipStatus.CONFIRMED + } else { + FriendRelationshipStatus.PENDING_OUTGOING + } + + private fun parseRelationshipStatus( + value: String, + ): FriendRelationshipStatus = + if (value == "PENDING_INCOMING") { + FriendRelationshipStatus.PENDING_OUTGOING + } else { + FriendRelationshipStatus.valueOf(value) + } + + private fun isValidCapability(value: String): Boolean = + value.length in 16..512 && + value.none(Char::isWhitespace) + + private fun legacyRelationshipId( + peerId: String, + shareId: UUID, + capability: String, + ): UUID = UUID.nameUUIDFromBytes( + "$peerId|$shareId|$capability".toByteArray(StandardCharsets.UTF_8), + ) + } + + private data class StoreData( + val friends: List = emptyList(), + val removals: List = emptyList(), + val blocked: List = emptyList(), + ) +} diff --git a/share/common/src/main/kotlin/com/minekube/connect/share/friend/ShareAccessIdentityStore.kt b/share/common/src/main/kotlin/com/minekube/connect/share/friend/ShareAccessIdentityStore.kt new file mode 100644 index 000000000..a7ffb20c6 --- /dev/null +++ b/share/common/src/main/kotlin/com/minekube/connect/share/friend/ShareAccessIdentityStore.kt @@ -0,0 +1,177 @@ +package com.minekube.connect.share.friend + +import com.google.gson.Gson +import com.google.gson.JsonObject +import com.google.gson.JsonParseException +import java.io.IOException +import java.nio.ByteBuffer +import java.nio.channels.FileChannel +import java.nio.charset.StandardCharsets +import java.nio.file.AtomicMoveNotSupportedException +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.StandardCopyOption.ATOMIC_MOVE +import java.nio.file.StandardCopyOption.REPLACE_EXISTING +import java.nio.file.StandardOpenOption.TRUNCATE_EXISTING +import java.nio.file.StandardOpenOption.WRITE +import java.nio.file.attribute.PosixFilePermission +import java.security.SecureRandom +import java.util.Base64 +import java.util.EnumSet +import java.util.UUID + +data class ShareAccessIdentity( + val shareId: UUID, + val capability: String, +) { + override fun toString(): String = + "ShareAccessIdentity(shareId=$shareId, capability=)" +} + +class ShareAccessIdentityStore private constructor( + private val directory: Path, + private val generateShareId: () -> UUID, + private val generateCapability: () -> String, +) { + constructor(directory: Path) : this( + directory = directory, + generateShareId = UUID::randomUUID, + generateCapability = ::newCapability, + ) + + @Synchronized + fun currentOrCreate(): ShareAccessIdentity { + Files.createDirectories(directory) + return if (Files.exists(identityFile)) { + read() + } else { + create().also(::write) + } + } + + @Synchronized + fun rotate(): ShareAccessIdentity { + Files.createDirectories(directory) + return create().also(::write) + } + + private fun create(): ShareAccessIdentity = ShareAccessIdentity( + shareId = generateShareId(), + capability = generateCapability().also { + require(isValidCapability(it)) { + "Generated friend capability is invalid" + } + }, + ) + + private fun read(): ShareAccessIdentity { + try { + val json = GSON.fromJson( + Files.readString(identityFile), + JsonObject::class.java, + ) ?: throw IOException("Share access identity is empty") + val version = json.requiredInt("version") + if (version != WIRE_VERSION) { + throw IOException("Share access identity version is unsupported") + } + val shareId = try { + UUID.fromString(json.requiredString("shareId")) + } catch (exception: IllegalArgumentException) { + throw IOException("Share access identity has an invalid ID", exception) + } + val capability = json.requiredString("capability") + if (!isValidCapability(capability)) { + throw IOException("Share access identity has an invalid capability") + } + return ShareAccessIdentity(shareId, capability) + } catch (exception: JsonParseException) { + throw IOException("Share access identity is invalid JSON", exception) + } catch (exception: IllegalStateException) { + throw IOException("Share access identity is invalid", exception) + } + } + + private fun write(identity: ShareAccessIdentity) { + val json = JsonObject().apply { + addProperty("version", WIRE_VERSION) + addProperty("shareId", identity.shareId.toString()) + addProperty("capability", identity.capability) + } + val temporary = Files.createTempFile( + directory, + "$FILE_NAME.", + ".tmp", + ) + try { + setOwnerOnlyPermissions(temporary) + val bytes = GSON.toJson(json).toByteArray(StandardCharsets.UTF_8) + FileChannel.open(temporary, WRITE, TRUNCATE_EXISTING).use { channel -> + val remaining = ByteBuffer.wrap(bytes) + while (remaining.hasRemaining()) { + channel.write(remaining) + } + channel.force(true) + } + try { + Files.move(temporary, identityFile, ATOMIC_MOVE, REPLACE_EXISTING) + } catch (_: AtomicMoveNotSupportedException) { + Files.move(temporary, identityFile, REPLACE_EXISTING) + } + setOwnerOnlyPermissions(identityFile) + } finally { + Files.deleteIfExists(temporary) + } + } + + private fun setOwnerOnlyPermissions(file: Path) { + try { + Files.setPosixFilePermissions( + file, + EnumSet.of( + PosixFilePermission.OWNER_READ, + PosixFilePermission.OWNER_WRITE, + ), + ) + } catch (_: UnsupportedOperationException) { + // Windows and other non-POSIX filesystems do not expose Unix modes. + } + } + + private fun JsonObject.requiredString(name: String): String = + get(name)?.takeUnless { it.isJsonNull }?.asString + ?: throw IOException("Share access identity is missing $name") + + private fun JsonObject.requiredInt(name: String): Int = + get(name)?.takeUnless { it.isJsonNull }?.asInt + ?: throw IOException("Share access identity is missing $name") + + private val identityFile: Path + get() = directory.resolve(FILE_NAME) + + companion object { + const val FILE_NAME = "share-access-identity.json" + private const val WIRE_VERSION = 1 + private const val CAPABILITY_BYTES = 32 + private val GSON = Gson() + + internal fun testing( + directory: Path, + generateShareId: () -> UUID, + generateCapability: () -> String, + ) = ShareAccessIdentityStore( + directory = directory, + generateShareId = generateShareId, + generateCapability = generateCapability, + ) + + private fun newCapability(): String = + ByteArray(CAPABILITY_BYTES) + .also(SecureRandom()::nextBytes) + .let(Base64.getUrlEncoder().withoutPadding()::encodeToString) + + private fun isValidCapability(value: String): Boolean = + value.length >= 16 && + value.length <= 512 && + value.none(Char::isWhitespace) + } +} diff --git a/share/common/src/main/kotlin/com/minekube/connect/share/friend/SharePreferencesStore.kt b/share/common/src/main/kotlin/com/minekube/connect/share/friend/SharePreferencesStore.kt new file mode 100644 index 000000000..79144b29f --- /dev/null +++ b/share/common/src/main/kotlin/com/minekube/connect/share/friend/SharePreferencesStore.kt @@ -0,0 +1,136 @@ +package com.minekube.connect.share.friend + +import com.google.gson.Gson +import com.google.gson.JsonObject +import com.google.gson.JsonParseException +import java.io.IOException +import java.nio.ByteBuffer +import java.nio.channels.FileChannel +import java.nio.charset.StandardCharsets +import java.nio.file.AtomicMoveNotSupportedException +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.StandardCopyOption.ATOMIC_MOVE +import java.nio.file.StandardCopyOption.REPLACE_EXISTING +import java.nio.file.StandardOpenOption.TRUNCATE_EXISTING +import java.nio.file.StandardOpenOption.WRITE + +data class PresencePrivacy( + val showOnline: Boolean = true, + val showPlaying: Boolean = true, + val showCurrentServer: Boolean = true, + val showJoinable: Boolean = true, +) + +data class SharePreferences( + val shareWithFriends: Boolean = false, + val presence: PresencePrivacy = PresencePrivacy(), +) + +class SharePreferencesStore( + private val directory: Path, +) { + @Synchronized + fun load(): SharePreferences { + Files.createDirectories(directory) + if (!Files.exists(preferencesFile)) { + return SharePreferences() + } + try { + val json = GSON.fromJson( + Files.readString(preferencesFile), + JsonObject::class.java, + ) ?: throw IOException("Share preferences are empty") + val version = json.requiredInt("version") + if (version !in MIN_WIRE_VERSION..WIRE_VERSION) { + throw IOException("Share preferences version is unsupported") + } + return SharePreferences( + shareWithFriends = json.requiredBoolean("shareWithFriends"), + presence = if (version >= 2) { + val presence = json.getAsJsonObject("presence") + ?: throw IOException( + "Share preferences are missing presence privacy", + ) + PresencePrivacy( + showOnline = presence.requiredBoolean("showOnline"), + showPlaying = presence.requiredBoolean("showPlaying"), + showCurrentServer = presence.requiredBoolean( + "showCurrentServer", + ), + showJoinable = presence.requiredBoolean("showJoinable"), + ) + } else { + PresencePrivacy() + }, + ) + } catch (exception: JsonParseException) { + throw IOException("Share preferences are invalid JSON", exception) + } catch (exception: IllegalStateException) { + throw IOException("Share preferences are invalid", exception) + } + } + + @Synchronized + fun save(preferences: SharePreferences) { + Files.createDirectories(directory) + val json = JsonObject().apply { + addProperty("version", WIRE_VERSION) + addProperty("shareWithFriends", preferences.shareWithFriends) + add("presence", JsonObject().apply { + addProperty("showOnline", preferences.presence.showOnline) + addProperty("showPlaying", preferences.presence.showPlaying) + addProperty( + "showCurrentServer", + preferences.presence.showCurrentServer, + ) + addProperty("showJoinable", preferences.presence.showJoinable) + }) + } + val temporary = Files.createTempFile( + directory, + "$FILE_NAME.", + ".tmp", + ) + try { + val bytes = GSON.toJson(json).toByteArray(StandardCharsets.UTF_8) + FileChannel.open(temporary, WRITE, TRUNCATE_EXISTING).use { channel -> + val remaining = ByteBuffer.wrap(bytes) + while (remaining.hasRemaining()) { + channel.write(remaining) + } + channel.force(true) + } + try { + Files.move( + temporary, + preferencesFile, + ATOMIC_MOVE, + REPLACE_EXISTING, + ) + } catch (_: AtomicMoveNotSupportedException) { + Files.move(temporary, preferencesFile, REPLACE_EXISTING) + } + } finally { + Files.deleteIfExists(temporary) + } + } + + private fun JsonObject.requiredInt(name: String): Int = + get(name)?.takeUnless { it.isJsonNull }?.asInt + ?: throw IOException("Share preferences are missing $name") + + private fun JsonObject.requiredBoolean(name: String): Boolean = + get(name)?.takeUnless { it.isJsonNull }?.asBoolean + ?: throw IOException("Share preferences are missing $name") + + private val preferencesFile: Path + get() = directory.resolve(FILE_NAME) + + companion object { + const val FILE_NAME = "share-preferences.json" + private const val MIN_WIRE_VERSION = 1 + private const val WIRE_VERSION = 2 + private val GSON = Gson() + } +} diff --git a/share/common/src/main/kotlin/com/minekube/connect/share/identity/EndpointIdentity.kt b/share/common/src/main/kotlin/com/minekube/connect/share/identity/EndpointIdentity.kt new file mode 100644 index 000000000..d66858121 --- /dev/null +++ b/share/common/src/main/kotlin/com/minekube/connect/share/identity/EndpointIdentity.kt @@ -0,0 +1,52 @@ +package com.minekube.connect.share.identity + +import arrow.core.Either +import arrow.core.NonEmptyList + +enum class CredentialSource { + GENERATED, + IMPORTED, + ENVIRONMENT, +} + +data class EndpointIdentity( + val endpoint: String, + val token: String, + val endpointSource: CredentialSource, + val tokenSource: CredentialSource, +) { + override fun toString(): String = + "EndpointIdentity(endpoint=$endpoint, token=, " + + "endpointSource=$endpointSource, tokenSource=$tokenSource)" +} + +fun interface EndpointNameSource { + suspend fun create(): String +} + +fun interface EndpointCredentialValidator { + suspend fun validate( + identity: EndpointIdentity, + ): Either +} + +sealed interface CredentialValidationError { + val safeMessage: String + + data class InvalidInput( + override val safeMessage: String, + ) : CredentialValidationError + + data class Rejected( + override val safeMessage: String, + ) : CredentialValidationError + + data class Network( + override val safeMessage: String, + ) : CredentialValidationError + + data class ManagedByEnvironment( + val fields: NonEmptyList, + override val safeMessage: String = "Connect credentials are managed by the environment", + ) : CredentialValidationError +} diff --git a/share/common/src/main/kotlin/com/minekube/connect/share/identity/EndpointIdentityStore.kt b/share/common/src/main/kotlin/com/minekube/connect/share/identity/EndpointIdentityStore.kt new file mode 100644 index 000000000..49a979410 --- /dev/null +++ b/share/common/src/main/kotlin/com/minekube/connect/share/identity/EndpointIdentityStore.kt @@ -0,0 +1,505 @@ +package com.minekube.connect.share.identity + +import arrow.core.Either +import arrow.core.nonEmptyListOf +import arrow.core.raise.either +import arrow.core.raise.ensure +import arrow.core.raise.ensureNotNull +import com.google.gson.Gson +import com.google.gson.JsonObject +import com.google.gson.JsonParseException +import com.minekube.connect.identity.EndpointTokenStore +import java.io.IOException +import java.nio.ByteBuffer +import java.nio.channels.FileChannel +import java.nio.charset.StandardCharsets +import java.nio.file.AtomicMoveNotSupportedException +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.StandardCopyOption.ATOMIC_MOVE +import java.nio.file.StandardCopyOption.COPY_ATTRIBUTES +import java.nio.file.StandardCopyOption.REPLACE_EXISTING +import java.nio.file.StandardOpenOption.TRUNCATE_EXISTING +import java.nio.file.StandardOpenOption.WRITE +import java.util.UUID + +class EndpointIdentityStore private constructor( + private val directory: Path, + private val environment: Map, + private val endpointNames: EndpointNameSource, + private val tokenStore: EndpointTokenStore, + private val generateToken: () -> String, + private val beforeConfigReplace: () -> Unit, +) { + constructor( + directory: Path, + environment: Map, + endpointNames: EndpointNameSource, + tokenStore: EndpointTokenStore, + ) : this( + directory = directory, + environment = environment, + endpointNames = endpointNames, + tokenStore = tokenStore, + generateToken = tokenStore::generate, + beforeConfigReplace = {}, + ) + + suspend fun currentOrCreate(): EndpointIdentity { + val stored = loadOrCreateStored() + return applyEnvironment(stored) + } + + suspend fun import( + endpoint: String, + token: String, + validator: EndpointCredentialValidator, + ): Either = either { + loadOrCreateStored() + ensureCredentialsAreLocallyManaged() + ensure(ENDPOINT_PATTERN.matches(endpoint)) { + CredentialValidationError.InvalidInput("Endpoint name is invalid") + } + ensure(isValidToken(token)) { + CredentialValidationError.InvalidInput("Connect token is invalid") + } + + val candidate = EndpointIdentity( + endpoint = endpoint, + token = token, + endpointSource = CredentialSource.IMPORTED, + tokenSource = CredentialSource.IMPORTED, + ) + validator.validate(candidate).bind() + commit(candidate) + candidate + } + + suspend fun importTokenFile( + endpoint: String, + tokenFile: Path, + validator: EndpointCredentialValidator, + ): Either = either { + val token = readImportedToken(tokenFile).bind() + import(endpoint, token, validator).bind() + } + + suspend fun resetConfirmed(): Either = either { + val previous = loadOrCreateStored() + ensureCredentialsAreLocallyManaged() + + val endpoint = nextEndpointDifferentFrom(previous.endpoint) + val token = generateToken() + ensure(isValidToken(token)) { + CredentialValidationError.InvalidInput("Generated Connect token is invalid") + } + val replacement = EndpointIdentity( + endpoint = endpoint, + token = token, + endpointSource = CredentialSource.GENERATED, + tokenSource = CredentialSource.GENERATED, + ) + commit(replacement) + replacement + } + + private suspend fun loadOrCreateStored(): EndpointIdentity { + Files.createDirectories(directory) + recoverInterruptedTransaction() + + val hasConfig = Files.exists(configFile) + val hasToken = Files.exists(tokenFile) + if (hasConfig != hasToken) { + throw IOException( + "Connect identity is incomplete; restore both config.json and token.json or reset it", + ) + } + if (hasConfig) { + return readStoredIdentity() + } + + val endpoint = endpointNames.create() + require(ENDPOINT_PATTERN.matches(endpoint)) { + "Generated endpoint name is invalid" + } + val token = generateToken() + require(isValidToken(token)) { + "Generated Connect token is invalid" + } + return EndpointIdentity( + endpoint = endpoint, + token = token, + endpointSource = CredentialSource.GENERATED, + tokenSource = CredentialSource.GENERATED, + ).also(::commit) + } + + private fun readStoredIdentity(): EndpointIdentity { + val config = readConfig(configFile) + val token = tokenStore.load(tokenFile, emptyMap()).orElseThrow { + IOException("Connect token file does not contain a token") + } + return EndpointIdentity( + endpoint = config.endpoint, + token = token, + endpointSource = config.endpointSource, + tokenSource = config.tokenSource, + ) + } + + private fun applyEnvironment(stored: EndpointIdentity): EndpointIdentity { + val endpointOverride = environment[ENV_ENDPOINT] + if (endpointOverride != null && !ENDPOINT_PATTERN.matches(endpointOverride)) { + throw IllegalArgumentException("CONNECT_ENDPOINT is not a valid endpoint name") + } + val resolvedToken = tokenStore.load(tokenFile, environment).orElseThrow { + IOException("Connect token file does not contain a token") + } + return stored.copy( + endpoint = endpointOverride ?: stored.endpoint, + token = resolvedToken, + endpointSource = if (endpointOverride == null) { + stored.endpointSource + } else { + CredentialSource.ENVIRONMENT + }, + tokenSource = if (environment.containsKey(EndpointTokenStore.ENV_TOKEN)) { + CredentialSource.ENVIRONMENT + } else { + stored.tokenSource + }, + ) + } + + private fun arrow.core.raise.Raise + .ensureCredentialsAreLocallyManaged() { + val managedFields = buildList { + if (environment.containsKey(ENV_ENDPOINT)) add(ENV_ENDPOINT) + if (environment.containsKey(EndpointTokenStore.ENV_TOKEN)) { + add(EndpointTokenStore.ENV_TOKEN) + } + } + ensure(managedFields.isEmpty()) { + CredentialValidationError.ManagedByEnvironment( + fields = nonEmptyListOf( + managedFields.first(), + *managedFields.drop(1).toTypedArray(), + ), + ) + } + } + + private fun readImportedToken( + source: Path, + ): Either = either { + val loaded = try { + tokenStore.load(source, emptyMap()) + } catch (_: IOException) { + raise(CredentialValidationError.InvalidInput("Selected token file is invalid")) + } catch (_: IllegalArgumentException) { + raise(CredentialValidationError.InvalidInput("Selected token file is invalid")) + } + ensureNotNull(loaded.orElse(null)) { + CredentialValidationError.InvalidInput("Selected token file has no token") + } + } + + private suspend fun nextEndpointDifferentFrom(previous: String): String { + repeat(MAX_ENDPOINT_GENERATION_ATTEMPTS) { + val candidate = endpointNames.create() + require(ENDPOINT_PATTERN.matches(candidate)) { + "Generated endpoint name is invalid" + } + if (candidate != previous) { + return candidate + } + } + throw IOException("Could not generate a replacement endpoint name") + } + + private fun commit(identity: EndpointIdentity) { + Files.createDirectories(directory) + val previous = if (Files.exists(configFile) && Files.exists(tokenFile)) { + readStoredIdentity() + } else { + null + } + val id = UUID.randomUUID().toString() + val transaction = IdentityTransaction( + oldEndpoint = previous?.endpoint, + newEndpoint = identity.endpoint, + tokenBackup = "token.json.$id.bak", + configBackup = "config.json.$id.bak", + tokenStage = "token.json.$id.new", + configStage = "config.json.$id.new", + hadToken = Files.exists(tokenFile), + hadConfig = Files.exists(configFile), + committed = false, + ) + writeTransaction(transaction) + + try { + if (transaction.hadToken) { + copyDurable(tokenFile, resolveTransactionFile(transaction.tokenBackup)) + } + if (transaction.hadConfig) { + copyDurable(configFile, resolveTransactionFile(transaction.configBackup)) + } + + tokenStore.save(resolveTransactionFile(transaction.tokenStage), identity.token) + writeAtomic( + resolveTransactionFile(transaction.configStage), + serializeConfig(identity), + ) + moveReplacing(resolveTransactionFile(transaction.tokenStage), tokenFile) + beforeConfigReplace() + moveReplacing(resolveTransactionFile(transaction.configStage), configFile) + forceFile(tokenFile) + forceFile(configFile) + + writeTransaction(transaction.copy(committed = true)) + cleanupTransactionFiles(transaction) + Files.deleteIfExists(transactionFile) + } catch (failure: Throwable) { + try { + recoverInterruptedTransaction() + } catch (recoveryFailure: Throwable) { + failure.addSuppressed(recoveryFailure) + } + throw failure + } + } + + private fun recoverInterruptedTransaction() { + if (!Files.exists(transactionFile)) { + return + } + + val transaction = readTransaction() + if (transaction.committed) { + cleanupTransactionFiles(transaction) + Files.deleteIfExists(transactionFile) + return + } + + restoreOrRemove( + target = tokenFile, + backup = resolveTransactionFile(transaction.tokenBackup), + hadPriorFile = transaction.hadToken, + ) + restoreOrRemove( + target = configFile, + backup = resolveTransactionFile(transaction.configBackup), + hadPriorFile = transaction.hadConfig, + ) + cleanupTransactionFiles(transaction) + Files.deleteIfExists(transactionFile) + } + + private fun restoreOrRemove(target: Path, backup: Path, hadPriorFile: Boolean) { + if (hadPriorFile) { + if (Files.exists(backup)) { + moveReplacing(backup, target) + } + } else { + Files.deleteIfExists(target) + } + } + + private fun cleanupTransactionFiles(transaction: IdentityTransaction) { + Files.deleteIfExists(resolveTransactionFile(transaction.tokenStage)) + Files.deleteIfExists(resolveTransactionFile(transaction.configStage)) + Files.deleteIfExists(resolveTransactionFile(transaction.tokenBackup)) + Files.deleteIfExists(resolveTransactionFile(transaction.configBackup)) + } + + private fun readConfig(file: Path): PersistedConfig { + try { + val json = GSON.fromJson(Files.readString(file), JsonObject::class.java) + ?: throw IOException("Connect identity config is empty") + val endpoint = json.requiredString("endpoint") + if (!ENDPOINT_PATTERN.matches(endpoint)) { + throw IOException("Connect identity config has an invalid endpoint") + } + return PersistedConfig( + endpoint = endpoint, + endpointSource = json.requiredCredentialSource("endpointSource"), + tokenSource = json.requiredCredentialSource("tokenSource"), + ) + } catch (exception: JsonParseException) { + throw IOException("Connect identity config is invalid JSON", exception) + } catch (exception: IllegalStateException) { + throw IOException("Connect identity config is invalid", exception) + } catch (exception: IllegalArgumentException) { + throw IOException("Connect identity config has an invalid credential source", exception) + } + } + + private fun serializeConfig(identity: EndpointIdentity): String { + val json = JsonObject() + json.addProperty("endpoint", identity.endpoint) + json.addProperty("endpointSource", identity.endpointSource.name) + json.addProperty("tokenSource", identity.tokenSource.name) + return GSON.toJson(json) + } + + private fun writeTransaction(transaction: IdentityTransaction) { + val json = JsonObject() + transaction.oldEndpoint?.let { json.addProperty("oldEndpoint", it) } + json.addProperty("newEndpoint", transaction.newEndpoint) + json.addProperty("tokenBackup", transaction.tokenBackup) + json.addProperty("configBackup", transaction.configBackup) + json.addProperty("tokenStage", transaction.tokenStage) + json.addProperty("configStage", transaction.configStage) + json.addProperty("hadToken", transaction.hadToken) + json.addProperty("hadConfig", transaction.hadConfig) + json.addProperty("committed", transaction.committed) + writeAtomic(transactionFile, GSON.toJson(json)) + } + + private fun readTransaction(): IdentityTransaction { + try { + val json = GSON.fromJson(Files.readString(transactionFile), JsonObject::class.java) + ?: throw IOException("Connect identity transaction is empty") + return IdentityTransaction( + oldEndpoint = json.optionalString("oldEndpoint"), + newEndpoint = json.requiredString("newEndpoint"), + tokenBackup = json.requiredFileName("tokenBackup"), + configBackup = json.requiredFileName("configBackup"), + tokenStage = json.requiredFileName("tokenStage"), + configStage = json.requiredFileName("configStage"), + hadToken = json.requiredBoolean("hadToken"), + hadConfig = json.requiredBoolean("hadConfig"), + committed = json.get("committed")?.asBoolean ?: false, + ) + } catch (exception: JsonParseException) { + throw IOException("Connect identity transaction is invalid JSON", exception) + } catch (exception: IllegalStateException) { + throw IOException("Connect identity transaction is invalid", exception) + } + } + + private fun writeAtomic(target: Path, content: String) { + val temporary = Files.createTempFile(directory, target.fileName.toString() + ".", ".tmp") + try { + val bytes = content.toByteArray(StandardCharsets.UTF_8) + FileChannel.open(temporary, WRITE, TRUNCATE_EXISTING).use { channel -> + var remaining = ByteBuffer.wrap(bytes) + while (remaining.hasRemaining()) { + channel.write(remaining) + } + channel.force(true) + } + moveReplacing(temporary, target) + } finally { + Files.deleteIfExists(temporary) + } + } + + private fun copyDurable(source: Path, target: Path) { + val temporary = Files.createTempFile(directory, target.fileName.toString() + ".", ".tmp") + try { + Files.copy(source, temporary, REPLACE_EXISTING, COPY_ATTRIBUTES) + forceFile(temporary) + moveReplacing(temporary, target) + } finally { + Files.deleteIfExists(temporary) + } + } + + private fun forceFile(file: Path) { + FileChannel.open(file, WRITE).use { it.force(true) } + } + + private fun moveReplacing(source: Path, target: Path) { + try { + Files.move(source, target, ATOMIC_MOVE, REPLACE_EXISTING) + } catch (_: AtomicMoveNotSupportedException) { + Files.move(source, target, REPLACE_EXISTING) + } + } + + private fun resolveTransactionFile(name: String): Path { + val candidate = Path.of(name) + require(candidate.nameCount == 1 && candidate.fileName.toString() == name) { + "Transaction file name must not escape the identity directory" + } + return directory.resolve(candidate) + } + + private fun JsonObject.requiredString(name: String): String = + get(name)?.takeUnless { it.isJsonNull }?.asString + ?: throw IOException("Connect identity document is missing $name") + + private fun JsonObject.optionalString(name: String): String? = + get(name)?.takeUnless { it.isJsonNull }?.asString + + private fun JsonObject.requiredBoolean(name: String): Boolean = + get(name)?.takeUnless { it.isJsonNull }?.asBoolean + ?: throw IOException("Connect identity document is missing $name") + + private fun JsonObject.requiredCredentialSource(name: String): CredentialSource = + CredentialSource.valueOf(requiredString(name)) + + private fun JsonObject.requiredFileName(name: String): String = + requiredString(name).also(::resolveTransactionFile) + + private data class PersistedConfig( + val endpoint: String, + val endpointSource: CredentialSource, + val tokenSource: CredentialSource, + ) + + private data class IdentityTransaction( + val oldEndpoint: String?, + val newEndpoint: String, + val tokenBackup: String, + val configBackup: String, + val tokenStage: String, + val configStage: String, + val hadToken: Boolean, + val hadConfig: Boolean, + val committed: Boolean, + ) + + private val configFile: Path + get() = directory.resolve(CONFIG_FILE_NAME) + + private val tokenFile: Path + get() = directory.resolve(TOKEN_FILE_NAME) + + private val transactionFile: Path + get() = directory.resolve(TRANSACTION_FILE_NAME) + + companion object { + const val ENV_ENDPOINT = "CONNECT_ENDPOINT" + const val CONFIG_FILE_NAME = "config.json" + const val TOKEN_FILE_NAME = "token.json" + const val TRANSACTION_FILE_NAME = "identity-transaction.json" + + private val GSON = Gson() + private val ENDPOINT_PATTERN = Regex("^[a-z0-9][a-z0-9-]{2,62}$") + private const val MAX_ENDPOINT_GENERATION_ATTEMPTS = 8 + + internal fun testing( + directory: Path, + environment: Map, + endpointNames: EndpointNameSource, + tokenStore: EndpointTokenStore, + generateToken: () -> String, + beforeConfigReplace: () -> Unit = {}, + ) = EndpointIdentityStore( + directory = directory, + environment = environment, + endpointNames = endpointNames, + tokenStore = tokenStore, + generateToken = generateToken, + beforeConfigReplace = beforeConfigReplace, + ) + + private fun isValidToken(token: String): Boolean = + token.startsWith("T-") && + token.length > 2 && + token.none(Char::isWhitespace) + } +} diff --git a/share/common/src/main/kotlin/com/minekube/connect/share/recovery/RecoveryArchive.kt b/share/common/src/main/kotlin/com/minekube/connect/share/recovery/RecoveryArchive.kt new file mode 100644 index 000000000..014bf718e --- /dev/null +++ b/share/common/src/main/kotlin/com/minekube/connect/share/recovery/RecoveryArchive.kt @@ -0,0 +1,346 @@ +package com.minekube.connect.share.recovery + +import arrow.core.Either +import arrow.core.left +import arrow.core.right +import com.google.gson.JsonArray +import com.google.gson.JsonObject +import com.google.gson.JsonParser +import java.nio.ByteBuffer +import java.security.SecureRandom +import java.util.Base64 +import javax.crypto.AEADBadTagException +import javax.crypto.Cipher +import javax.crypto.SecretKeyFactory +import javax.crypto.spec.GCMParameterSpec +import javax.crypto.spec.PBEKeySpec +import javax.crypto.spec.SecretKeySpec + +data class RecoveryEntry( + val fileName: String, + val contents: ByteArray, +) { + override fun toString(): String = + "RecoveryEntry(fileName=$fileName, contents=)" +} + +sealed interface RecoveryArchiveError { + data object WeakPassphrase : RecoveryArchiveError + data object AuthenticationFailed : RecoveryArchiveError + data object InvalidArchive : RecoveryArchiveError + data object UnsupportedVersion : RecoveryArchiveError + data object ArchiveTooLarge : RecoveryArchiveError + data object EntryTooLarge : RecoveryArchiveError + data object UnknownEntry : RecoveryArchiveError + data object DuplicateEntry : RecoveryArchiveError + data object MissingRequiredEntry : RecoveryArchiveError + data object IncompleteEndpointIdentity : RecoveryArchiveError +} + +/** + * An offline, authenticated archive for Connect Share recovery material. + * + * The fixed-size envelope header is authenticated as AES-GCM additional data. + * Archive contents and passphrases must never be logged or rendered. + */ +class RecoveryArchive private constructor( + private val iterations: Int, + private val secureRandom: SecureRandom, +) { + fun encrypt( + entries: List, + passphrase: CharArray, + ): Either { + validatePassphrase(passphrase)?.let { return it.left() } + validateEntries(entries)?.let { return it.left() } + + val plaintext = try { + encodeManifest(entries) + } catch (_: RuntimeException) { + return RecoveryArchiveError.InvalidArchive.left() + } + if (plaintext.size > MAX_ARCHIVE_BYTES - HEADER_BYTES - GCM_TAG_BYTES) { + plaintext.fill(0) + return RecoveryArchiveError.ArchiveTooLarge.left() + } + + val salt = ByteArray(SALT_BYTES).also(secureRandom::nextBytes) + val nonce = ByteArray(NONCE_BYTES).also(secureRandom::nextBytes) + val header = header(iterations, salt, nonce) + val key = deriveKey(passphrase, salt, iterations) + ?: run { + plaintext.fill(0) + salt.fill(0) + nonce.fill(0) + return RecoveryArchiveError.InvalidArchive.left() + } + return try { + val cipher = Cipher.getInstance(CIPHER_TRANSFORMATION) + cipher.init( + Cipher.ENCRYPT_MODE, + SecretKeySpec(key, "AES"), + GCMParameterSpec(GCM_TAG_BITS, nonce), + ) + cipher.updateAAD(header) + val ciphertext = cipher.doFinal(plaintext) + val result = header + ciphertext + if (result.size > MAX_ARCHIVE_BYTES) { + RecoveryArchiveError.ArchiveTooLarge.left() + } else { + result.right() + } + } catch (_: RuntimeException) { + RecoveryArchiveError.InvalidArchive.left() + } catch (_: java.security.GeneralSecurityException) { + RecoveryArchiveError.InvalidArchive.left() + } finally { + plaintext.fill(0) + key.fill(0) + salt.fill(0) + nonce.fill(0) + } + } + + fun decrypt( + archive: ByteArray, + passphrase: CharArray, + ): Either> { + validatePassphrase(passphrase)?.let { return it.left() } + if (archive.size > MAX_ARCHIVE_BYTES) { + return RecoveryArchiveError.ArchiveTooLarge.left() + } + if (archive.size < HEADER_BYTES + GCM_TAG_BYTES) { + return RecoveryArchiveError.InvalidArchive.left() + } + + val envelope = ByteBuffer.wrap(archive) + val magic = ByteArray(MAGIC.size).also(envelope::get) + if (!magic.contentEquals(MAGIC)) { + return RecoveryArchiveError.InvalidArchive.left() + } + val version = envelope.get().toInt() and 0xff + if (version != WIRE_VERSION) { + return RecoveryArchiveError.UnsupportedVersion.left() + } + if (envelope.get() != KDF_ID || envelope.get() != CIPHER_ID) { + return RecoveryArchiveError.UnsupportedVersion.left() + } + val archiveIterations = envelope.int + if (archiveIterations !in MIN_KDF_ITERATIONS..MAX_KDF_ITERATIONS) { + return RecoveryArchiveError.InvalidArchive.left() + } + val salt = ByteArray(SALT_BYTES).also(envelope::get) + val nonce = ByteArray(NONCE_BYTES).also(envelope::get) + val header = archive.copyOfRange(0, HEADER_BYTES) + val ciphertext = archive.copyOfRange(HEADER_BYTES, archive.size) + val key = deriveKey(passphrase, salt, archiveIterations) + ?: run { + salt.fill(0) + nonce.fill(0) + ciphertext.fill(0) + return RecoveryArchiveError.InvalidArchive.left() + } + + val plaintext = try { + val cipher = Cipher.getInstance(CIPHER_TRANSFORMATION) + cipher.init( + Cipher.DECRYPT_MODE, + SecretKeySpec(key, "AES"), + GCMParameterSpec(GCM_TAG_BITS, nonce), + ) + cipher.updateAAD(header) + cipher.doFinal(ciphertext) + } catch (_: AEADBadTagException) { + return RecoveryArchiveError.AuthenticationFailed.left() + } catch (_: RuntimeException) { + return RecoveryArchiveError.InvalidArchive.left() + } catch (_: java.security.GeneralSecurityException) { + return RecoveryArchiveError.InvalidArchive.left() + } finally { + key.fill(0) + salt.fill(0) + nonce.fill(0) + ciphertext.fill(0) + } + + return try { + decodeManifest(plaintext) + } finally { + plaintext.fill(0) + } + } + + private fun deriveKey( + passphrase: CharArray, + salt: ByteArray, + iterations: Int, + ): ByteArray? { + val specification = PBEKeySpec(passphrase, salt, iterations, KEY_BITS) + return try { + SecretKeyFactory.getInstance(KDF_ALGORITHM) + .generateSecret(specification) + .encoded + } catch (_: java.security.GeneralSecurityException) { + null + } finally { + specification.clearPassword() + } + } + + private fun encodeManifest(entries: List): ByteArray { + val root = JsonObject().apply { + addProperty("version", MANIFEST_VERSION) + add("entries", JsonArray().apply { + entries.forEach { entry -> + add(JsonObject().apply { + addProperty("name", entry.fileName) + addProperty("length", entry.contents.size) + addProperty( + "content", + Base64.getEncoder().encodeToString(entry.contents), + ) + }) + } + }) + } + return root.toString().encodeToByteArray() + } + + private fun decodeManifest( + plaintext: ByteArray, + ): Either> { + val entries = try { + val root = JsonParser.parseString(plaintext.decodeToString()).asJsonObject + if (root.get("version")?.asInt != MANIFEST_VERSION) { + return RecoveryArchiveError.UnsupportedVersion.left() + } + val encodedEntries = root.getAsJsonArray("entries") + ?: return RecoveryArchiveError.InvalidArchive.left() + encodedEntries.map { element -> + val value = element.asJsonObject + val fileName = value.get("name")?.asString + ?: return RecoveryArchiveError.InvalidArchive.left() + val expectedLength = value.get("length")?.asInt + ?: return RecoveryArchiveError.InvalidArchive.left() + val content = Base64.getDecoder().decode( + value.get("content")?.asString + ?: return RecoveryArchiveError.InvalidArchive.left(), + ) + if (expectedLength != content.size) { + content.fill(0) + return RecoveryArchiveError.InvalidArchive.left() + } + RecoveryEntry(fileName, content) + } + } catch (_: RuntimeException) { + return RecoveryArchiveError.InvalidArchive.left() + } + validateEntries(entries)?.let { failure -> + entries.forEach { it.contents.fill(0) } + return failure.left() + } + return entries.right() + } + + private fun validatePassphrase( + passphrase: CharArray, + ): RecoveryArchiveError? = + RecoveryArchiveError.WeakPassphrase.takeIf { + passphrase.size < MIN_PASSPHRASE_CHARS + } + + private fun validateEntries( + entries: List, + ): RecoveryArchiveError? { + if (entries.any { it.fileName !in ALLOWED_FILES }) { + return RecoveryArchiveError.UnknownEntry + } + if (entries.map(RecoveryEntry::fileName).distinct().size != entries.size) { + return RecoveryArchiveError.DuplicateEntry + } + if (entries.any { it.contents.size > MAX_ENTRY_BYTES }) { + return RecoveryArchiveError.EntryTooLarge + } + if (!entries.mapTo(mutableSetOf(), RecoveryEntry::fileName) + .containsAll(REQUIRED_FILES) + ) { + return RecoveryArchiveError.MissingRequiredEntry + } + val names = entries.mapTo(mutableSetOf(), RecoveryEntry::fileName) + if ( + (ENDPOINT_CONFIG_FILE in names) xor + (ENDPOINT_TOKEN_FILE in names) + ) { + return RecoveryArchiveError.IncompleteEndpointIdentity + } + return null + } + + private fun header( + iterations: Int, + salt: ByteArray, + nonce: ByteArray, + ): ByteArray = ByteBuffer.allocate(HEADER_BYTES) + .put(MAGIC) + .put(WIRE_VERSION.toByte()) + .put(KDF_ID) + .put(CIPHER_ID) + .putInt(iterations) + .put(salt) + .put(nonce) + .array() + + companion object { + const val SOCIAL_IDENTITY_FILE = "share-libp2p-social-identity.key" + const val GAMEPLAY_IDENTITY_FILE = "share-libp2p-identity.key" + const val ACCESS_IDENTITY_FILE = "share-access-identity.json" + const val FRIENDS_FILE = "friends.json" + const val PREFERENCES_FILE = "share-preferences.json" + const val ENDPOINT_CONFIG_FILE = "config.json" + const val ENDPOINT_TOKEN_FILE = "token.json" + + const val MAX_ARCHIVE_BYTES = 16 * 1024 * 1024 + const val MAX_ENTRY_BYTES = 4 * 1024 * 1024 + const val VERSION_OFFSET = 4 + + private const val WIRE_VERSION = 1 + private const val MANIFEST_VERSION = 1 + private const val PRODUCTION_KDF_ITERATIONS = 600_000 + private const val MIN_KDF_ITERATIONS = 1 + private const val MAX_KDF_ITERATIONS = 2_000_000 + private const val MIN_PASSPHRASE_CHARS = 12 + private const val KEY_BITS = 256 + private const val GCM_TAG_BITS = 128 + private const val GCM_TAG_BYTES = GCM_TAG_BITS / 8 + private const val SALT_BYTES = 16 + private const val NONCE_BYTES = 12 + private const val KDF_ID: Byte = 1 + private const val CIPHER_ID: Byte = 1 + private const val KDF_ALGORITHM = "PBKDF2WithHmacSHA256" + private const val CIPHER_TRANSFORMATION = "AES/GCM/NoPadding" + private val MAGIC = byteArrayOf('C'.code.toByte(), 'S'.code.toByte(), 'R'.code.toByte(), 'B'.code.toByte()) + private val REQUIRED_FILES = setOf( + SOCIAL_IDENTITY_FILE, + GAMEPLAY_IDENTITY_FILE, + ACCESS_IDENTITY_FILE, + FRIENDS_FILE, + ) + private val ALLOWED_FILES = REQUIRED_FILES + setOf( + PREFERENCES_FILE, + ENDPOINT_CONFIG_FILE, + ENDPOINT_TOKEN_FILE, + ) + private val HEADER_BYTES = MAGIC.size + 1 + 1 + 1 + Int.SIZE_BYTES + + SALT_BYTES + NONCE_BYTES + + fun production(): RecoveryArchive = RecoveryArchive( + iterations = PRODUCTION_KDF_ITERATIONS, + secureRandom = SecureRandom(), + ) + + internal fun testing(iterations: Int): RecoveryArchive { + require(iterations in MIN_KDF_ITERATIONS..MAX_KDF_ITERATIONS) + return RecoveryArchive(iterations, SecureRandom()) + } + } +} diff --git a/share/common/src/main/kotlin/com/minekube/connect/share/recovery/RecoveryStore.kt b/share/common/src/main/kotlin/com/minekube/connect/share/recovery/RecoveryStore.kt new file mode 100644 index 000000000..78fbf07ab --- /dev/null +++ b/share/common/src/main/kotlin/com/minekube/connect/share/recovery/RecoveryStore.kt @@ -0,0 +1,496 @@ +package com.minekube.connect.share.recovery + +import arrow.core.Either +import arrow.core.left +import arrow.core.right +import java.io.IOException +import java.nio.ByteBuffer +import java.nio.channels.FileChannel +import java.nio.file.AtomicMoveNotSupportedException +import java.nio.file.Files +import java.nio.file.LinkOption.NOFOLLOW_LINKS +import java.nio.file.Path +import java.nio.file.StandardCopyOption.ATOMIC_MOVE +import java.nio.file.StandardCopyOption.COPY_ATTRIBUTES +import java.nio.file.StandardCopyOption.REPLACE_EXISTING +import java.nio.file.StandardOpenOption.CREATE_NEW +import java.nio.file.StandardOpenOption.TRUNCATE_EXISTING +import java.nio.file.StandardOpenOption.WRITE +import java.nio.file.attribute.PosixFilePermission +import java.nio.file.attribute.PosixFilePermissions + +data class RecoverySummary( + val entryCount: Int, + val includesPreferences: Boolean, + val includesEndpointIdentity: Boolean, +) + +sealed interface RecoveryStoreError { + data class ArchiveFailure( + val reason: RecoveryArchiveError, + ) : RecoveryStoreError + + data object MissingRequiredMaterial : RecoveryStoreError + data object UnsafeMaterial : RecoveryStoreError + data object BackupReadFailed : RecoveryStoreError + data object BackupWriteFailed : RecoveryStoreError + data object ReplacementFailed : RecoveryStoreError +} + +/** + * Reads and replaces only Connect Share's explicitly recoverable files. + * + * Callers must stop the active Share runtime before importing. Every import is + * staged and backed up before a durable marker permits the first replacement. + */ +class RecoveryStore( + private val directory: Path, + private val archive: RecoveryArchive = RecoveryArchive.production(), + private val beforeReplace: (index: Int) -> Unit = {}, +) { + private val operationLock = Any() + + fun exportTo( + target: Path, + passphrase: CharArray, + ): Either = synchronized(operationLock) { + try { + Files.createDirectories(directory) + ensureSafeDirectory(directory) + recoverInterruptedTransaction() + } catch (_: IOException) { + return@synchronized RecoveryStoreError.BackupWriteFailed.left() + } catch (_: SecurityException) { + return@synchronized RecoveryStoreError.UnsafeMaterial.left() + } + + val entries = when (val loaded = loadEntries()) { + is Either.Left -> return@synchronized loaded + is Either.Right -> loaded.value + } + try { + val encrypted = archive.encrypt(entries, passphrase) + val failure = encrypted.leftOrNull() + if (failure != null) { + return@synchronized RecoveryStoreError.ArchiveFailure(failure).left() + } + val bytes = encrypted.getOrNull()!! + try { + writeBackupAtomically(target, bytes) + } catch (_: IOException) { + return@synchronized RecoveryStoreError.BackupWriteFailed.left() + } catch (_: SecurityException) { + return@synchronized RecoveryStoreError.BackupWriteFailed.left() + } finally { + bytes.fill(0) + } + summary(entries).right() + } finally { + entries.forEach { it.contents.fill(0) } + } + } + + fun importFrom( + source: Path, + passphrase: CharArray, + ): Either = synchronized(operationLock) { + try { + Files.createDirectories(directory) + ensureSafeDirectory(directory) + recoverInterruptedTransaction() + } catch (_: IOException) { + return@synchronized RecoveryStoreError.ReplacementFailed.left() + } catch (_: SecurityException) { + return@synchronized RecoveryStoreError.UnsafeMaterial.left() + } + + val entries = when (val loaded = readArchiveEntries(source, passphrase)) { + is Either.Left -> return@synchronized loaded + is Either.Right -> loaded.value + } + + try { + replaceTransactionally(entries) + summary(entries).right() + } catch (failure: Exception) { + try { + recoverInterruptedTransaction() + } catch (recoveryFailure: Exception) { + failure.addSuppressed(recoveryFailure) + } + RecoveryStoreError.ReplacementFailed.left() + } finally { + entries.forEach { it.contents.fill(0) } + } + } + + fun preview( + source: Path, + passphrase: CharArray, + ): Either = synchronized(operationLock) { + val entries = when (val loaded = readArchiveEntries(source, passphrase)) { + is Either.Left -> return@synchronized loaded + is Either.Right -> loaded.value + } + try { + summary(entries).right() + } finally { + entries.forEach { it.contents.fill(0) } + } + } + + private fun readArchiveEntries( + source: Path, + passphrase: CharArray, + ): Either> { + val encrypted = try { + if ( + Files.isSymbolicLink(source) || + !Files.isRegularFile(source, NOFOLLOW_LINKS) || + Files.size(source) > RecoveryArchive.MAX_ARCHIVE_BYTES + ) { + return RecoveryStoreError.BackupReadFailed.left() + } + Files.readAllBytes(source) + } catch (_: IOException) { + return RecoveryStoreError.BackupReadFailed.left() + } catch (_: SecurityException) { + return RecoveryStoreError.BackupReadFailed.left() + } + return try { + archive.decrypt(encrypted, passphrase) + .mapLeft(RecoveryStoreError::ArchiveFailure) + } finally { + encrypted.fill(0) + } + } + + private fun loadEntries(): Either> { + val entries = mutableListOf() + try { + FILE_NAMES.forEach { fileName -> + val file = directory.resolve(fileName) + if (!Files.exists(file, NOFOLLOW_LINKS)) { + if (fileName in REQUIRED_FILE_NAMES) { + entries.forEach { it.contents.fill(0) } + return RecoveryStoreError.MissingRequiredMaterial.left() + } + return@forEach + } + if ( + Files.isSymbolicLink(file) || + !Files.isRegularFile(file, NOFOLLOW_LINKS) + ) { + entries.forEach { it.contents.fill(0) } + return RecoveryStoreError.UnsafeMaterial.left() + } + if (Files.size(file) > RecoveryArchive.MAX_ENTRY_BYTES) { + entries.forEach { it.contents.fill(0) } + return RecoveryStoreError.UnsafeMaterial.left() + } + entries += RecoveryEntry(fileName, Files.readAllBytes(file)) + } + } catch (_: IOException) { + entries.forEach { it.contents.fill(0) } + return RecoveryStoreError.BackupReadFailed.left() + } catch (_: SecurityException) { + entries.forEach { it.contents.fill(0) } + return RecoveryStoreError.UnsafeMaterial.left() + } + return entries.right() + } + + private fun replaceTransactionally(entries: List) { + ensureSafeDirectory(directory) + val transaction = directory.resolve(TRANSACTION_DIRECTORY) + if (Files.exists(transaction, NOFOLLOW_LINKS)) { + throw IOException("A recovery transaction already exists") + } + createOwnerOnlyDirectory(transaction) + + val imported = entries.associateBy(RecoveryEntry::fileName) + val hadPrior = linkedMapOf() + entries.forEach { entry -> + writeDurable(transaction.resolve(stageName(entry.fileName)), entry.contents) + } + FILE_NAMES.forEach { fileName -> + val target = directory.resolve(fileName) + ensureSafeTarget(target) + val exists = Files.exists(target, NOFOLLOW_LINKS) + hadPrior[fileName] = exists + if (exists) { + copyDurable(target, transaction.resolve(backupName(fileName))) + } + } + writeState(transaction, hadPrior, committed = false) + + FILE_NAMES.forEachIndexed { index, fileName -> + beforeReplace(index + 1) + val target = directory.resolve(fileName) + val entry = imported[fileName] + if (entry == null) { + Files.deleteIfExists(target) + } else { + moveReplacing(transaction.resolve(stageName(fileName)), target) + setOwnerOnlyFile(target) + forceFile(target) + } + } + writeState(transaction, hadPrior, committed = true) + cleanupTransaction(transaction) + } + + private fun recoverInterruptedTransaction() { + val transaction = directory.resolve(TRANSACTION_DIRECTORY) + if (!Files.exists(transaction, NOFOLLOW_LINKS)) { + return + } + ensureSafeDirectory(transaction) + val state = transaction.resolve(STATE_FILE) + if (!Files.exists(state, NOFOLLOW_LINKS)) { + cleanupTransaction(transaction) + return + } + val transactionState = readState(state) + if (!transactionState.committed) { + FILE_NAMES.forEach { fileName -> + val target = directory.resolve(fileName) + if (transactionState.hadPrior.getValue(fileName)) { + val backup = transaction.resolve(backupName(fileName)) + if (!Files.isRegularFile(backup, NOFOLLOW_LINKS)) { + throw IOException("Recovery backup is incomplete") + } + Files.copy(backup, target, REPLACE_EXISTING, COPY_ATTRIBUTES) + setOwnerOnlyFile(target) + forceFile(target) + } else { + Files.deleteIfExists(target) + } + } + } + cleanupTransaction(transaction) + } + + private fun writeBackupAtomically(target: Path, bytes: ByteArray) { + val absolute = target.toAbsolutePath().normalize() + val parent = absolute.parent ?: throw IOException("Backup has no parent") + Files.createDirectories(parent) + val resolvedTarget = parent.toRealPath().resolve(absolute.fileName) + val resolvedDataDirectory = directory.toRealPath() + if ( + resolvedTarget in FILE_NAMES.map(resolvedDataDirectory::resolve) || + resolvedTarget == resolvedDataDirectory.resolve(TRANSACTION_DIRECTORY) + ) { + throw IOException("Backup target overlaps live Share data") + } + if (Files.exists(absolute, NOFOLLOW_LINKS) && Files.isSymbolicLink(absolute)) { + throw IOException("Backup target is unsafe") + } + val temporary = createOwnerOnlyTempFile( + parent, + absolute.fileName.toString() + ".", + ".tmp", + ) + try { + writeDurable(temporary, bytes, create = false) + moveReplacing(temporary, absolute) + setOwnerOnlyFile(absolute) + forceFile(absolute) + } finally { + Files.deleteIfExists(temporary) + } + } + + private fun writeState( + transaction: Path, + hadPrior: Map, + committed: Boolean, + ) { + val content = buildString { + append("version=1\n") + append("committed=").append(committed).append('\n') + FILE_NAMES.forEach { fileName -> + append(fileName).append('=').append(hadPrior.getValue(fileName)).append('\n') + } + }.encodeToByteArray() + val temporary = transaction.resolve(STATE_TEMP_FILE) + try { + Files.deleteIfExists(temporary) + writeDurable(temporary, content) + moveReplacing(temporary, transaction.resolve(STATE_FILE)) + forceFile(transaction.resolve(STATE_FILE)) + } finally { + content.fill(0) + Files.deleteIfExists(temporary) + } + } + + private fun readState(state: Path): TransactionState { + if (Files.isSymbolicLink(state) || !Files.isRegularFile(state, NOFOLLOW_LINKS)) { + throw IOException("Recovery transaction state is unsafe") + } + val values = Files.readAllLines(state).associate { line -> + val separator = line.indexOf('=') + if (separator <= 0) { + throw IOException("Recovery transaction state is invalid") + } + line.substring(0, separator) to line.substring(separator + 1) + } + if (values["version"] != "1") { + throw IOException("Recovery transaction version is unsupported") + } + val committed = values["committed"]?.toBooleanStrictOrNull() + ?: throw IOException("Recovery transaction state is invalid") + val hadPrior = FILE_NAMES.associateWith { fileName -> + values[fileName]?.toBooleanStrictOrNull() + ?: throw IOException("Recovery transaction state is incomplete") + } + return TransactionState(committed, hadPrior) + } + + private fun cleanupTransaction(transaction: Path) { + FILE_NAMES.forEach { fileName -> + Files.deleteIfExists(transaction.resolve(stageName(fileName))) + Files.deleteIfExists(transaction.resolve(backupName(fileName))) + } + Files.deleteIfExists(transaction.resolve(STATE_FILE)) + Files.deleteIfExists(transaction.resolve(STATE_TEMP_FILE)) + Files.deleteIfExists(transaction) + } + + private fun writeDurable( + target: Path, + bytes: ByteArray, + create: Boolean = true, + ) { + val options = if (create) { + arrayOf(CREATE_NEW, WRITE) + } else { + arrayOf(WRITE, TRUNCATE_EXISTING) + } + FileChannel.open(target, *options).use { channel -> + val buffer = ByteBuffer.wrap(bytes) + while (buffer.hasRemaining()) { + channel.write(buffer) + } + channel.force(true) + } + setOwnerOnlyFile(target) + } + + private fun copyDurable(source: Path, target: Path) { + Files.copy(source, target, COPY_ATTRIBUTES) + setOwnerOnlyFile(target) + forceFile(target) + } + + private fun forceFile(file: Path) { + FileChannel.open(file, WRITE).use { it.force(true) } + } + + private fun moveReplacing(source: Path, target: Path) { + try { + Files.move(source, target, ATOMIC_MOVE, REPLACE_EXISTING) + } catch (_: AtomicMoveNotSupportedException) { + Files.move(source, target, REPLACE_EXISTING) + } + } + + private fun ensureSafeDirectory(path: Path) { + if (Files.isSymbolicLink(path) || !Files.isDirectory(path, NOFOLLOW_LINKS)) { + throw IOException("Recovery directory is unsafe") + } + } + + private fun ensureSafeTarget(path: Path) { + if ( + Files.exists(path, NOFOLLOW_LINKS) && + (Files.isSymbolicLink(path) || !Files.isRegularFile(path, NOFOLLOW_LINKS)) + ) { + throw IOException("Recovery target is unsafe") + } + } + + private fun setOwnerOnlyFile(path: Path) { + try { + Files.setPosixFilePermissions(path, OWNER_ONLY_FILE) + } catch (_: UnsupportedOperationException) { + // POSIX permissions are not available on every supported platform. + } + } + + private fun createOwnerOnlyDirectory(path: Path) { + try { + Files.createDirectory( + path, + PosixFilePermissions.asFileAttribute(OWNER_ONLY_DIRECTORY), + ) + } catch (_: UnsupportedOperationException) { + Files.createDirectory(path) + } + } + + private fun createOwnerOnlyTempFile( + directory: Path, + prefix: String, + suffix: String, + ): Path = try { + Files.createTempFile( + directory, + prefix, + suffix, + PosixFilePermissions.asFileAttribute(OWNER_ONLY_FILE), + ) + } catch (_: UnsupportedOperationException) { + Files.createTempFile(directory, prefix, suffix).also(::setOwnerOnlyFile) + } + + private fun summary(entries: List) = RecoverySummary( + entryCount = entries.size, + includesPreferences = entries.any { + it.fileName == RecoveryArchive.PREFERENCES_FILE + }, + includesEndpointIdentity = entries.any { + it.fileName == RecoveryArchive.ENDPOINT_CONFIG_FILE + } && entries.any { + it.fileName == RecoveryArchive.ENDPOINT_TOKEN_FILE + }, + ) + + private fun stageName(fileName: String) = "$fileName.new" + + private fun backupName(fileName: String) = "$fileName.bak" + + private data class TransactionState( + val committed: Boolean, + val hadPrior: Map, + ) + + companion object { + const val TRANSACTION_DIRECTORY = ".connect-share-recovery-transaction" + private const val STATE_FILE = "state" + private const val STATE_TEMP_FILE = "state.new" + + val FILE_NAMES = listOf( + RecoveryArchive.SOCIAL_IDENTITY_FILE, + RecoveryArchive.GAMEPLAY_IDENTITY_FILE, + RecoveryArchive.ACCESS_IDENTITY_FILE, + RecoveryArchive.FRIENDS_FILE, + RecoveryArchive.PREFERENCES_FILE, + RecoveryArchive.ENDPOINT_CONFIG_FILE, + RecoveryArchive.ENDPOINT_TOKEN_FILE, + ) + private val REQUIRED_FILE_NAMES = setOf( + RecoveryArchive.SOCIAL_IDENTITY_FILE, + RecoveryArchive.GAMEPLAY_IDENTITY_FILE, + RecoveryArchive.ACCESS_IDENTITY_FILE, + RecoveryArchive.FRIENDS_FILE, + ) + private val OWNER_ONLY_FILE = setOf( + PosixFilePermission.OWNER_READ, + PosixFilePermission.OWNER_WRITE, + ) + private val OWNER_ONLY_DIRECTORY = OWNER_ONLY_FILE + + PosixFilePermission.OWNER_EXECUTE + } +} diff --git a/share/common/src/test/kotlin/com/minekube/connect/share/AdapterContractTest.kt b/share/common/src/test/kotlin/com/minekube/connect/share/AdapterContractTest.kt new file mode 100644 index 000000000..b280e2e12 --- /dev/null +++ b/share/common/src/test/kotlin/com/minekube/connect/share/AdapterContractTest.kt @@ -0,0 +1,150 @@ +package com.minekube.connect.share + +import io.netty.channel.Channel +import io.netty.channel.ChannelInitializer +import io.netty.channel.DefaultEventLoopGroup +import io.netty.channel.EventLoopGroup +import io.netty.channel.local.LocalAddress +import java.net.InetAddress +import java.net.InetSocketAddress +import java.net.SocketAddress +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.test.assertTrue +import kotlinx.coroutines.runBlocking + +class AdapterContractTest { + @Test + fun `every version bridge is loopback local repeatable and exactly released`() = runBlocking { + val harness = FakeVersionTransport() + val binder = FakeLocalBinder() + val bridge = VersionedMinecraftBridge(harness, binder) + + val first = bridge.open(options) + assertTrue(harness.boundAddress.address.isLoopbackAddress) + assertIs(first.address) + assertFailsWith { + bridge.open(options) + } + first.close() + + val second = bridge.open(options) + assertTrue(harness.boundAddress.address.isLoopbackAddress) + assertIs(second.address) + second.close() + + assertEquals(-1, harness.publishedPort) + assertEquals(0, harness.listenerCount) + assertEquals(2, harness.publishCount) + assertEquals(listOf(harness.eventLoopGroup, harness.eventLoopGroup), binder.eventLoopGroups) + harness.eventLoopGroup.shutdownGracefully().syncUninterruptibly() + } + + @Test + fun `release continues through admission and local channel failures`() = runBlocking { + val transport = FakeVersionTransport() + val local = FailingLocalBinder() + val bridge = VersionedMinecraftBridge( + transport = transport, + localBinder = local, + loginAdmissionAcquire = { + AutoCloseable { + throw IllegalStateException("admission close failed") + } + }, + ) + val target = bridge.open(options) + + val failure = assertFailsWith { + target.close() + } + + assertEquals("admission close failed", failure.message) + assertTrue(local.closed) + assertEquals(1, transport.publishedCloseCount) + assertEquals(-1, transport.publishedPort) + assertEquals(0, transport.listenerCount) + } + + private class FakeVersionTransport : MinecraftVersionTransport { + var publishedPort = -1 + var listenerCount = 0 + var publishCount = 0 + var publishedCloseCount = 0 + val eventLoopGroup: EventLoopGroup = DefaultEventLoopGroup(1) + lateinit var boundAddress: InetSocketAddress + + override fun publish(options: ShareOptions): PublishedMinecraftTransport { + check(publishedPort == -1) + publishCount++ + boundAddress = InetSocketAddress(InetAddress.getLoopbackAddress(), 25565) + publishedPort = boundAddress.port + listenerCount++ + return object : PublishedMinecraftTransport { + override val address = boundAddress + override val childInitializer = NoopInitializer + override val eventLoopGroup = this@FakeVersionTransport.eventLoopGroup + + override fun addLocalListener(listener: LocalShareChannel) { + listenerCount++ + } + + override fun removeLocalListener(listener: LocalShareChannel) { + listenerCount-- + } + + override fun close() { + if (publishedPort != -1) { + publishedCloseCount++ + publishedPort = -1 + listenerCount-- + } + } + } + } + } + + private class FailingLocalBinder : LocalShareChannelBinder { + var closed = false + + override fun bind( + childInitializer: ChannelInitializer, + eventLoopGroup: EventLoopGroup, + ): LocalShareChannel = object : LocalShareChannel { + override val address: SocketAddress = LocalAddress("failing-local") + + override fun close() { + closed = true + throw IllegalStateException("local close failed") + } + } + } + + private class FakeLocalBinder : LocalShareChannelBinder { + val eventLoopGroups = mutableListOf() + + override fun bind( + childInitializer: ChannelInitializer, + eventLoopGroup: EventLoopGroup, + ): LocalShareChannel { + eventLoopGroups += eventLoopGroup + return object : LocalShareChannel { + override val address: SocketAddress = LocalAddress("adapter-contract") + override fun close() = Unit + } + } + } + + private object NoopInitializer : ChannelInitializer() { + override fun initChannel(channel: Channel) = Unit + } + + private companion object { + val options = ShareOptions( + gameMode = ShareGameMode.SURVIVAL, + allowCheats = false, + ) + } +} diff --git a/share/common/src/test/kotlin/com/minekube/connect/share/BuildPinsTest.kt b/share/common/src/test/kotlin/com/minekube/connect/share/BuildPinsTest.kt new file mode 100644 index 000000000..1c94ac0b6 --- /dev/null +++ b/share/common/src/test/kotlin/com/minekube/connect/share/BuildPinsTest.kt @@ -0,0 +1,12 @@ +package com.minekube.connect.share + +import kotlin.test.Test +import kotlin.test.assertEquals + +class BuildPinsTest { + @Test + fun wireProtocolStartsAtOne() { + assertEquals(1, ShareBuild.WIRE_PROTOCOL) + assertEquals("connect-share", ShareBuild.MOD_ID) + } +} diff --git a/share/common/src/test/kotlin/com/minekube/connect/share/GatewayMinecraftBridgeTest.kt b/share/common/src/test/kotlin/com/minekube/connect/share/GatewayMinecraftBridgeTest.kt new file mode 100644 index 000000000..085973437 --- /dev/null +++ b/share/common/src/test/kotlin/com/minekube/connect/share/GatewayMinecraftBridgeTest.kt @@ -0,0 +1,168 @@ +package com.minekube.connect.share + +import com.minekube.connect.share.friend.FriendControlResponse +import io.netty.buffer.ByteBuf +import io.netty.buffer.Unpooled +import io.netty.channel.Channel +import io.netty.channel.ChannelHandlerContext +import io.netty.channel.ChannelInboundHandlerAdapter +import io.netty.channel.ChannelInitializer +import io.netty.channel.DefaultEventLoopGroup +import io.netty.channel.EventLoopGroup +import io.netty.channel.local.LocalAddress +import java.net.InetAddress +import java.net.InetSocketAddress +import java.net.Socket +import java.util.concurrent.CompletableFuture +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue +import kotlinx.coroutines.runBlocking + +class GatewayMinecraftBridgeTest { + @Test + fun `world bridge activates stable gateway targets only for world lifetime`() = + runBlocking { + val transport = FakeTransport() + ShareConnectionGateway.bind { _, _ -> + CompletableFuture.completedFuture( + FriendControlResponse.Invalid, + ) + }.use { gateway -> + val bridge = VersionedMinecraftBridge( + transport = transport, + gateway = gateway, + ) + + val target = bridge.open( + ShareOptions( + gameMode = ShareGameMode.SURVIVAL, + allowCheats = false, + ), + ) + + assertIs(target.address) + assertEquals( + gateway.serverSocketAddress, + target.address, + ) + assertEquals(gateway.directAddress, target.directAddress) + assertContentEquals( + MINECRAFT_BYTES, + exchange(gateway.directAddress, MINECRAFT_BYTES), + ) + assertEquals(0, transport.localListenersAdded) + + target.close() + + assertTrue( + exchangeClosed( + gateway.directAddress, + MINECRAFT_BYTES, + ), + ) + assertTrue(transport.published.closed) + assertEquals(0, transport.localListenersRemoved) + } + } + + private fun exchange( + address: InetSocketAddress, + bytes: ByteArray, + ): ByteArray = Socket().use { socket -> + socket.soTimeout = 2_000 + socket.connect(address) + socket.getOutputStream().apply { + write(bytes) + flush() + } + socket.getInputStream().readNBytes(bytes.size) + } + + private fun exchangeClosed( + address: InetSocketAddress, + bytes: ByteArray, + ): Boolean = Socket().use { socket -> + socket.soTimeout = 2_000 + socket.connect(address) + socket.getOutputStream().apply { + write(bytes) + flush() + } + socket.getInputStream().read() == -1 + } + + private class FakeTransport : MinecraftVersionTransport { + val published = FakePublishedTransport() + var localListenersAdded = 0 + var localListenersRemoved = 0 + + override fun publish( + options: ShareOptions, + ): PublishedMinecraftTransport = published.also { + it.onAdd = { localListenersAdded++ } + it.onRemove = { localListenersRemoved++ } + } + } + + private class FakePublishedTransport : PublishedMinecraftTransport { + override val address = InetSocketAddress( + InetAddress.getLoopbackAddress(), + 24_455, + ) + override val childInitializer = + object : ChannelInitializer() { + override fun initChannel(channel: Channel) { + channel.pipeline().addLast( + object : ChannelInboundHandlerAdapter() { + override fun channelRead( + context: ChannelHandlerContext, + message: Any, + ) { + val buffer = message as ByteBuf + val bytes = ByteArray(buffer.readableBytes()) + buffer.readBytes(bytes) + buffer.release() + context.writeAndFlush( + Unpooled.wrappedBuffer(bytes), + ) + } + }, + ) + } + } + override val eventLoopGroup: EventLoopGroup = DefaultEventLoopGroup(1) + var onAdd: () -> Unit = {} + var onRemove: () -> Unit = {} + var closed = false + + override fun addLocalListener(listener: LocalShareChannel) { + onAdd() + } + + override fun removeLocalListener(listener: LocalShareChannel) { + onRemove() + } + + override fun close() { + closed = true + eventLoopGroup.shutdownGracefully().syncUninterruptibly() + } + } + + private companion object { + val MINECRAFT_BYTES = byteArrayOf( + 0x10, + 0x00, + 0xb3.toByte(), + 0x08, + 0x09, + *"localhost".toByteArray(), + 0x63, + 0xdd.toByte(), + 0x02, + ) + } +} diff --git a/share/common/src/test/kotlin/com/minekube/connect/share/ShareConnectionGatewayTest.kt b/share/common/src/test/kotlin/com/minekube/connect/share/ShareConnectionGatewayTest.kt new file mode 100644 index 000000000..94cabfbf3 --- /dev/null +++ b/share/common/src/test/kotlin/com/minekube/connect/share/ShareConnectionGatewayTest.kt @@ -0,0 +1,448 @@ +package com.minekube.connect.share + +import com.minekube.connect.network.netty.LocalChannelWithSessionContext +import com.minekube.connect.share.friend.FriendControlDecode +import com.minekube.connect.share.friend.FriendControlContext +import com.minekube.connect.share.friend.FriendControlRequest +import com.minekube.connect.share.friend.FriendControlResponse +import com.minekube.connect.share.friend.FriendControlServer +import com.minekube.connect.share.friend.FriendControlWire +import io.netty.bootstrap.Bootstrap +import io.netty.buffer.ByteBuf +import io.netty.buffer.Unpooled +import io.netty.channel.Channel +import io.netty.channel.ChannelHandlerContext +import io.netty.channel.ChannelInboundHandlerAdapter +import io.netty.channel.ChannelInitializer +import io.netty.channel.DefaultEventLoopGroup +import io.netty.channel.SimpleChannelInboundHandler +import io.netty.channel.local.LocalAddress +import io.netty.util.ReferenceCountUtil +import java.io.ByteArrayOutputStream +import java.net.Socket +import java.util.UUID +import java.util.concurrent.CompletableFuture +import java.util.concurrent.CompletionStage +import java.util.concurrent.ThreadFactory +import java.util.concurrent.TimeUnit +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertSame +import kotlin.test.assertTrue + +class ShareConnectionGatewayTest { + @Test + fun `Minecraft packets run on the supplied loader thread group`() { + val loaderThreadGroup = ThreadGroup("test-loader-server") + val threadFactory = ThreadFactory { task -> + Thread(loaderThreadGroup, task) + } + ShareConnectionGateway.bind( + friendServer = { _, _ -> + CompletableFuture.completedFuture( + FriendControlResponse.Invalid, + ) + }, + minecraftThreadFactory = threadFactory, + ).use { gateway -> + val observed = CompletableFuture() + gateway.activateMinecraft( + object : ChannelInitializer() { + override fun initChannel(channel: Channel) { + channel.pipeline().addLast( + object : ChannelInboundHandlerAdapter() { + override fun channelRead( + context: ChannelHandlerContext, + message: Any, + ) { + ReferenceCountUtil.release(message) + observed.complete( + Thread.currentThread().threadGroup, + ) + context.close() + } + }, + ) + } + }, + ).use { + Socket().use { socket -> + socket.connect(gateway.directAddress) + socket.getOutputStream().apply { + write(MINECRAFT_LOGIN_HANDSHAKE) + flush() + } + } + assertSame( + loaderThreadGroup, + observed.get(2, TimeUnit.SECONDS), + ) + } + } + } + + @Test + fun `host privacy rejects Minecraft status without blocking login`() { + val server = object : FriendControlServer { + override fun handle( + context: FriendControlContext, + request: FriendControlRequest, + ): CompletionStage = + CompletableFuture.completedFuture( + FriendControlResponse.Invalid, + ) + + override fun allowsMinecraftStatus( + context: FriendControlContext, + ) = false + } + ShareConnectionGateway.bind(server).use { gateway -> + val received = mutableListOf() + gateway.activateMinecraft( + object : ChannelInitializer() { + override fun initChannel(channel: Channel) { + channel.pipeline().addLast( + object : ChannelInboundHandlerAdapter() { + override fun channelRead( + context: ChannelHandlerContext, + message: Any, + ) { + val buffer = message as ByteBuf + received += ByteArray(buffer.readableBytes()) + .also(buffer::readBytes) + buffer.release() + context.close() + } + }, + ) + } + }, + ).use { + Socket().use { socket -> + socket.soTimeout = 2_000 + socket.connect(gateway.directAddress) + socket.getOutputStream().apply { + write(MINECRAFT_STATUS_HANDSHAKE, 0, 3) + flush() + write( + MINECRAFT_STATUS_HANDSHAKE, + 3, + MINECRAFT_STATUS_HANDSHAKE.size - 3, + ) + flush() + } + assertEquals(-1, socket.getInputStream().read()) + } + assertTrue(received.isEmpty()) + + Socket().use { socket -> + socket.soTimeout = 2_000 + socket.connect(gateway.directAddress) + socket.getOutputStream().apply { + write(MINECRAFT_LOGIN_HANDSHAKE) + flush() + } + assertEquals(-1, socket.getInputStream().read()) + } + assertContentEquals( + MINECRAFT_LOGIN_HANDSHAKE, + received.single(), + ) + } + } + } + + @Test + fun `friend control is reachable before a Minecraft world exists`() { + val requests = mutableListOf() + ShareConnectionGateway.bind { _, request -> + requests += request + CompletableFuture.completedFuture( + FriendControlResponse.Accepted(HOST_CARD), + ) + }.use { gateway -> + Socket().use { socket -> + socket.connect(gateway.directAddress) + socket.getOutputStream().apply { + write( + FriendControlWire.encodeRequest( + request = REQUEST, + ), + ) + flush() + } + + assertEquals( + FriendControlResponse.Received, + socket.getInputStream().readControlResponse(), + ) + assertEquals( + FriendControlResponse.Accepted(HOST_CARD), + socket.getInputStream().readControlResponse(), + ) + } + } + + assertEquals(listOf(REQUEST), requests) + } + + @Test + fun `ordinary Minecraft bytes are rejected until a world is active`() { + ShareConnectionGateway.bind { _, _ -> + CompletableFuture.completedFuture(FriendControlResponse.Invalid) + }.use { gateway -> + Socket().use { socket -> + socket.soTimeout = 2_000 + socket.connect(gateway.directAddress) + socket.getOutputStream().apply { + write(ORDINARY_MINECRAFT_BYTES) + flush() + } + + assertEquals(-1, socket.getInputStream().read()) + } + } + } + + @Test + fun `ordinary Minecraft bytes route through only the active world`() { + ShareConnectionGateway.bind { _, _ -> + CompletableFuture.completedFuture(FriendControlResponse.Invalid) + }.use { gateway -> + val received = CompletableFuture() + val activated = CompletableFuture() + val world = gateway.activateMinecraft( + object : ChannelInitializer() { + override fun initChannel(channel: Channel) { + channel.pipeline().addLast( + object : ChannelInboundHandlerAdapter() { + override fun channelActive( + context: ChannelHandlerContext, + ) { + activated.complete(Unit) + context.fireChannelActive() + } + + override fun channelRead( + context: ChannelHandlerContext, + message: Any, + ) { + val buffer = message as ByteBuf + val bytes = ByteArray(buffer.readableBytes()) + buffer.readBytes(bytes) + buffer.release() + received.complete(bytes) + context.writeAndFlush( + Unpooled.wrappedBuffer(bytes), + ) + } + }, + ) + } + }, + ) + world.use { + Socket().use { socket -> + socket.soTimeout = 2_000 + socket.connect(gateway.directAddress) + socket.getOutputStream().apply { + write(ORDINARY_MINECRAFT_BYTES) + flush() + } + + assertContentEquals( + ORDINARY_MINECRAFT_BYTES, + socket.getInputStream().readNBytes( + ORDINARY_MINECRAFT_BYTES.size, + ), + ) + } + assertContentEquals( + ORDINARY_MINECRAFT_BYTES, + received.get(2, TimeUnit.SECONDS), + ) + assertEquals(Unit, activated.get(2, TimeUnit.SECONDS)) + } + + Socket().use { socket -> + socket.soTimeout = 2_000 + socket.connect(gateway.directAddress) + socket.getOutputStream().apply { + write(ORDINARY_MINECRAFT_BYTES) + flush() + } + assertEquals(-1, socket.getInputStream().read()) + } + } + } + + @Test + fun `Connect local channel reaches the same always-on control handler`() { + ShareConnectionGateway.bind { _, _ -> + CompletableFuture.completedFuture( + FriendControlResponse.Accepted(HOST_CARD), + ) + }.use { gateway -> + assertIs(gateway.serverSocketAddress) + val eventLoop = DefaultEventLoopGroup(1) + try { + val responses = CompletableFuture>() + val channel = Bootstrap() + .channel(LocalChannelWithSessionContext::class.java) + .group(eventLoop) + .handler( + object : + ChannelInitializer() { + override fun initChannel( + channel: LocalChannelWithSessionContext, + ) { + channel.pipeline().addLast( + object : + SimpleChannelInboundHandler() { + private val bytes = + ByteArrayOutputStream() + + override fun channelRead0( + context: ChannelHandlerContext, + message: ByteBuf, + ) { + val part = ByteArray( + message.readableBytes(), + ) + message.readBytes(part) + bytes.write(part) + val decoded = decodeResponses( + bytes.toByteArray(), + ) + if (decoded.size == 2) { + responses.complete(decoded) + } + } + }, + ) + } + }, + ) + .remoteAddress(gateway.serverSocketAddress) + .connect() + .syncUninterruptibly() + .channel() + try { + channel.writeAndFlush( + Unpooled.wrappedBuffer( + FriendControlWire.encodeRequest( + request = REQUEST, + ), + ), + ).syncUninterruptibly() + assertEquals( + listOf( + FriendControlResponse.Received, + FriendControlResponse.Accepted(HOST_CARD), + ), + responses.get(2, TimeUnit.SECONDS), + ) + } finally { + channel.close().syncUninterruptibly() + } + } finally { + eventLoop.shutdownGracefully().syncUninterruptibly() + } + } + } + + @Test + fun `closing gateway releases both listeners`() { + val gateway = ShareConnectionGateway.bind { _, _ -> + CompletableFuture.completedFuture(FriendControlResponse.Invalid) + } + val direct = gateway.directAddress + val local = gateway.serverSocketAddress + + gateway.close() + + assertTrue(gateway.isClosed) + assertTrue( + runCatching { + Socket().use { it.connect(direct, 250) } + }.isFailure, + ) + assertIs(local) + } + + private fun java.io.InputStream.readControlResponse(): + FriendControlResponse { + val frame = ByteArrayOutputStream() + var length = 0 + var shift = 0 + while (shift < 35) { + val byte = read() + check(byte >= 0) + frame.write(byte) + length = length or ((byte and 0x7f) shl shift) + if (byte and 0x80 == 0) { + break + } + shift += 7 + } + repeat(length) { + frame.write(read().also { check(it >= 0) }) + } + return assertIs>( + FriendControlWire.decodeResponse(frame.toByteArray()), + ).value + } + + private fun decodeResponses(bytes: ByteArray): List { + val decoded = mutableListOf() + var offset = 0 + while (offset < bytes.size) { + val next = FriendControlWire.decodeResponse( + bytes.copyOfRange(offset, bytes.size), + ) + when (next) { + is FriendControlDecode.Decoded -> { + decoded += next.value + offset += next.consumedBytes + } + + FriendControlDecode.Incomplete -> return decoded + FriendControlDecode.Invalid -> + error("invalid friend control response") + } + } + return decoded + } + + private companion object { + val MINECRAFT_STATUS_HANDSHAKE = + byteArrayOf(0x10, 0x00, 0xff.toByte(), 0x05, 0x09) + + "localhost".encodeToByteArray() + + byteArrayOf(0x63, 0xdd.toByte(), 0x01) + val MINECRAFT_LOGIN_HANDSHAKE = + MINECRAFT_STATUS_HANDSHAKE.copyOf().also { + it[it.lastIndex] = 0x02 + } + val REQUEST = FriendControlRequest( + requestId = UUID.fromString( + "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + ), + displayName = "bob", + invitation = "minekube://share/sender-card", + ) + const val HOST_CARD = "minekube://share/host-card" + val ORDINARY_MINECRAFT_BYTES = byteArrayOf( + 0x10, + 0x00, + 0xb3.toByte(), + 0x08, + 0x09, + *"localhost".toByteArray(), + 0x63, + 0xdd.toByte(), + 0x02, + ) + } +} diff --git a/share/common/src/test/kotlin/com/minekube/connect/share/ShareCoordinatorTest.kt b/share/common/src/test/kotlin/com/minekube/connect/share/ShareCoordinatorTest.kt new file mode 100644 index 000000000..daa158288 --- /dev/null +++ b/share/common/src/test/kotlin/com/minekube/connect/share/ShareCoordinatorTest.kt @@ -0,0 +1,455 @@ +package com.minekube.connect.share + +import arrow.core.Either +import com.minekube.connect.share.admission.AdmissionAnswer +import com.minekube.connect.share.admission.AdmissionController +import com.minekube.connect.share.admission.AdmissionIdentity +import com.minekube.connect.share.admission.Ingress +import com.minekube.connect.share.identity.CredentialSource +import com.minekube.connect.share.identity.EndpointIdentity +import java.net.InetSocketAddress +import java.util.UUID +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest + +@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) +class ShareCoordinatorTest { + @Test + fun `Connect uses the private local target while direct uses loopback TCP`() = runTest { + val events = mutableListOf() + val connectTarget = + io.netty.channel.local.LocalAddress("connect-share-test") + val directTarget = InetSocketAddress("127.0.0.1", 41_234) + val fixture = fixture( + events = events, + connectTarget = connectTarget, + directTarget = directTarget, + ingressStart = { identity, target -> + assertEquals(connectTarget, target) + ConnectShareHandle( + endpoint = identity.endpoint, + publicAddress = + "${identity.endpoint}.play.minekube.net", + close = {}, + ) + }, + directStart = { _, target, _ -> + assertEquals(directTarget, target) + DIRECT_HANDLE + }, + ) + + assertIs>( + fixture.coordinator.start(OPTIONS), + ) + } + + @Test + fun `start orders bridge before ingress`() = runTest { + val events = mutableListOf() + val fixture = fixture( + events = events, + identityProvider = { + events += "identity" + IDENTITY + }, + ) + + val result = fixture.coordinator.start(OPTIONS) + + val sharing = assertIs>(result).value + assertEquals( + listOf("bridge-open", "identity", "ingress-start"), + events, + ) + assertEquals("amber-fox", sharing.endpoint) + assertEquals("amber-fox.play.minekube.net", sharing.address) + assertEquals(sharing, fixture.coordinator.state.value) + } + + @Test + fun `connect failure closes bridge and enters failed`() = runTest { + val events = mutableListOf() + val reports = mutableListOf() + val fixture = fixture( + events = events, + failureReporter = reports::add, + ingressStart = { _, _ -> + events += "ingress-start" + error("T-secret") + }, + ) + + val result = fixture.coordinator.start(OPTIONS) + + assertIs>(result) + val failed = assertIs(fixture.coordinator.state.value) + assertEquals(listOf("bridge-open", "ingress-start", "bridge-close"), events) + assertFalse(failed.safeMessage.contains("T-secret")) + assertTrue(reports.single().contains("start", ignoreCase = true)) + assertFalse(reports.single().contains("T-secret")) + } + + @Test + fun `direct sharing remains available when Connect fails`() = runTest { + val events = mutableListOf() + val fixture = fixture( + events = events, + ingressStart = { _, _ -> + events += "ingress-start" + error("Connect unavailable") + }, + directStart = { _, _, connectAddress -> + events += "direct-start" + assertEquals(null, connectAddress) + DIRECT_HANDLE + }, + ) + + val result = fixture.coordinator.start( + OPTIONS.copy(allowInternetDirect = true), + ) + + val sharing = assertIs>(result).value + assertEquals(null, sharing.address) + assertEquals(DIRECT_HANDLE.invitation, sharing.invitation) + assertFalse(sharing.connectAvailable) + assertTrue(sharing.lanDirectAvailable) + assertTrue(sharing.internetDirectAvailable) + assertEquals( + listOf("bridge-open", "ingress-start", "direct-start"), + events, + ) + } + + @Test + fun `copyable invitation follows renewal while share stays active`() = runTest { + var invitation = "minekube://share/first" + val fixture = fixture( + events = mutableListOf(), + directStart = { _, _, _ -> + DirectShareHandle( + invitationProvider = { invitation }, + lanAvailable = true, + internetAvailable = true, + close = {}, + ) + }, + ) + fixture.coordinator.start(OPTIONS) + + assertEquals("minekube://share/first", fixture.coordinator.currentInvitation()) + invitation = "minekube://share/renewed" + assertEquals("minekube://share/renewed", fixture.coordinator.currentInvitation()) + } + + @Test + fun `Connect sharing remains available when direct setup fails`() = runTest { + val events = mutableListOf() + val fixture = fixture( + events = events, + directStart = { _, _, _ -> + events += "direct-start" + error("direct candidate secret") + }, + ) + + val result = fixture.coordinator.start(OPTIONS) + + val sharing = assertIs>(result).value + assertEquals("amber-fox.play.minekube.net", sharing.address) + assertTrue(sharing.connectAvailable) + assertFalse(sharing.lanDirectAvailable) + assertEquals( + listOf("bridge-open", "ingress-start", "direct-start"), + events, + ) + } + + @Test + fun `both ingress failures close the bridge and fail the share`() = runTest { + val events = mutableListOf() + val fixture = fixture( + events = events, + ingressStart = { _, _ -> + events += "ingress-start" + error("Connect unavailable") + }, + directStart = { _, _, _ -> + events += "direct-start" + error("Direct unavailable") + }, + ) + + val result = fixture.coordinator.start(OPTIONS) + + assertIs>(result) + assertEquals( + listOf( + "bridge-open", + "ingress-start", + "direct-start", + "bridge-close", + ), + events, + ) + } + + @Test + fun `stop closes ingress then bridge and clears admission`() = runTest { + val events = mutableListOf() + val fixture = fixture(events) + assertIs>( + fixture.coordinator.start(OPTIONS), + ) + val waiting = async { + fixture.admission.request( + AdmissionIdentity.UnverifiedOffline( + name = "Alex", + uuid = UUID.fromString("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"), + connectionId = "connection-1", + ingress = Ingress.CONNECT, + ), + ) + } + runCurrent() + + val result = fixture.coordinator.stop() + + assertIs>(result) + assertEquals( + listOf( + "bridge-open", + "ingress-start", + "ingress-close", + "bridge-close", + ), + events, + ) + assertEquals(AdmissionAnswer.STOPPED, waiting.await()) + assertTrue(fixture.admission.pending.value.isEmpty()) + assertEquals(ShareState.Idle, fixture.coordinator.state.value) + } + + @Test + fun `stop closes direct before Connect and the bridge`() = runTest { + val events = mutableListOf() + val fixture = fixture( + events = events, + directStart = { _, _, _ -> + events += "direct-start" + DIRECT_HANDLE.copy( + close = { + events += "direct-close" + }, + ) + }, + ) + fixture.coordinator.start(OPTIONS) + + fixture.coordinator.stop() + + assertEquals( + listOf( + "bridge-open", + "ingress-start", + "direct-start", + "direct-close", + "ingress-close", + "bridge-close", + ), + events, + ) + } + + @Test + fun `stop attempts every release when ingress close fails`() = runTest { + val events = mutableListOf() + val reports = mutableListOf() + val fixture = fixture( + events = events, + failureReporter = reports::add, + ingressClose = { + events += "ingress-close" + error("T-cleanup-secret") + }, + ) + fixture.coordinator.start(OPTIONS) + + val result = fixture.coordinator.stop() + + assertIs>(result) + assertTrue(events.indexOf("bridge-close") > events.indexOf("ingress-close")) + assertEquals(ShareState.Idle, fixture.coordinator.state.value) + assertFalse(reports.single().contains("T-cleanup-secret")) + } + + @Test + fun `stop is idempotent`() = runTest { + val events = mutableListOf() + val fixture = fixture(events) + fixture.coordinator.start(OPTIONS) + + fixture.coordinator.stop() + fixture.coordinator.stop() + + assertEquals(1, events.count { it == "ingress-close" }) + assertEquals(1, events.count { it == "bridge-close" }) + assertEquals(ShareState.Idle, fixture.coordinator.state.value) + } + + @Test + fun `world replacement stops active share`() = runTest { + val events = mutableListOf() + val fixture = fixture(events) + fixture.coordinator.start(OPTIONS) + + fixture.coordinator.worldReplaced() + + assertEquals(1, events.count { it == "ingress-close" }) + assertEquals(1, events.count { it == "bridge-close" }) + assertEquals(ShareState.Idle, fixture.coordinator.state.value) + } + + @Test + fun `capacity outside one through sixteen is rejected`() { + assertFailsWith { + ShareOptions( + gameMode = ShareGameMode.SURVIVAL, + allowCheats = false, + maxGuests = 0, + ) + } + assertFailsWith { + ShareOptions( + gameMode = ShareGameMode.SURVIVAL, + allowCheats = false, + maxGuests = 17, + ) + } + } + + @Test + fun `start cancellation releases the bridge and remains cancellation`() = runTest { + val events = mutableListOf() + val fixture = fixture( + events = events, + identityProvider = { + awaitCancellation() + }, + ) + val starting = launch { + fixture.coordinator.start(OPTIONS) + } + runCurrent() + + starting.cancelAndJoin() + + assertEquals(listOf("bridge-open", "bridge-close"), events) + assertEquals(ShareState.Idle, fixture.coordinator.state.value) + } + + private fun kotlinx.coroutines.test.TestScope.fixture( + events: MutableList, + connectTarget: java.net.SocketAddress = + InetSocketAddress.createUnresolved("127.0.0.1", 25565), + directTarget: java.net.SocketAddress = connectTarget, + identityProvider: suspend () -> EndpointIdentity = { IDENTITY }, + ingressStart: suspend ( + EndpointIdentity, + java.net.SocketAddress, + ) -> ConnectShareHandle = { identity, _ -> + events += "ingress-start" + ConnectShareHandle( + endpoint = identity.endpoint, + publicAddress = "${identity.endpoint}.play.minekube.net", + close = { + events += "ingress-close" + }, + ) + }, + ingressClose: suspend () -> Unit = { + events += "ingress-close" + }, + directStart: (suspend ( + ShareOptions, + java.net.SocketAddress, + String?, + ) -> DirectShareHandle)? = null, + failureReporter: (String) -> Unit = {}, + ): Fixture { + val admission = AdmissionController( + scope = backgroundScope, + timeout = 30.seconds, + maxPending = 16, + connectedCount = { 0 }, + maxGuests = { OPTIONS.maxGuests }, + ) + val bridge = MinecraftShareBridge { + events += "bridge-open" + LocalShareTarget( + address = connectTarget, + directAddress = directTarget, + close = { + events += "bridge-close" + }, + ) + } + val ingress = ConnectShareIngress { identity, target -> + val handle = ingressStart(identity, target) + handle.copy(close = ingressClose) + } + val direct = directStart?.let { start -> + DirectShareIngress { options, target, connectAddress -> + start(options, target, connectAddress) + } + } + return Fixture( + coordinator = ShareCoordinator( + bridge = bridge, + ingress = ingress, + identityProvider = identityProvider, + admission = admission, + directIngress = direct, + failureReporter = failureReporter, + ), + admission = admission, + ) + } + + private data class Fixture( + val coordinator: ShareCoordinator, + val admission: AdmissionController, + ) + + private companion object { + val OPTIONS = ShareOptions( + gameMode = ShareGameMode.SURVIVAL, + allowCheats = false, + maxGuests = 8, + ) + val DIRECT_HANDLE = DirectShareHandle( + invitation = "minekube://share/signed-invitation", + lanAvailable = true, + internetAvailable = true, + close = {}, + ) + val IDENTITY = EndpointIdentity( + endpoint = "amber-fox", + token = "T-AAAAAAAAAAAAAAAAAAAA", + endpointSource = CredentialSource.GENERATED, + tokenSource = CredentialSource.GENERATED, + ) + } +} diff --git a/share/common/src/test/kotlin/com/minekube/connect/share/admission/AdmissionControllerTest.kt b/share/common/src/test/kotlin/com/minekube/connect/share/admission/AdmissionControllerTest.kt new file mode 100644 index 000000000..25d018cb8 --- /dev/null +++ b/share/common/src/test/kotlin/com/minekube/connect/share/admission/AdmissionControllerTest.kt @@ -0,0 +1,469 @@ +package com.minekube.connect.share.admission + +import java.util.UUID +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.async +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest + +@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) +class AdmissionControllerTest { + @Test + fun `cancelled request disappears immediately`() = runTest { + val controller = controller() + val request = async { + controller.request(offline("Alex", "connection-cancelled")) + } + runCurrent() + assertEquals(1, controller.pending.value.size) + + request.cancelAndJoin() + runCurrent() + + assertTrue(controller.pending.value.isEmpty()) + } + + @Test + fun `friend request bypasses world capacity and is labeled separately`() = runTest { + val controller = controller( + connectedCount = { 8 }, + maxGuests = { 8 }, + ) + val request = async { + controller.request( + offline("bob", "friend-request"), + purpose = AdmissionPurpose.FRIEND, + ) + } + runCurrent() + + val pending = controller.pending.value.single() + assertEquals(AdmissionPurpose.FRIEND, pending.purpose) + controller.answer(pending.requestId, allow = false) + assertEquals(AdmissionAnswer.DENY, request.await()) + } + + @Test + fun `remote cancellation declines pending friend request for direct peer`() = runTest { + val controller = controller() + val request = async { + controller.request( + offline("bob", "friend-request").copy( + ingress = Ingress.DIRECT_LAN, + directPeerId = "12D3KooWFriend", + ), + purpose = AdmissionPurpose.FRIEND, + ) + } + runCurrent() + + assertEquals( + 1, + controller.denyDirectPeer( + "12D3KooWFriend", + AdmissionPurpose.FRIEND, + ), + ) + assertEquals(AdmissionAnswer.DENY, request.await()) + assertTrue(controller.pending.value.isEmpty()) + } + + @Test + fun `authenticated UUID approval is reused only during current share`() = runTest { + val controller = controller() + val identity = authenticated("Alex", AUTHENTICATED_UUID) + val first = async { controller.request(identity) } + runCurrent() + + controller.answer(controller.pending.value.single().requestId, allow = true) + assertEquals(AdmissionAnswer.ALLOW, first.await()) + assertEquals( + AdmissionAnswer.ALLOW, + controller.request(identity.copy(name = "Renamed")), + ) + + controller.resetShare() + val afterReset = async { controller.request(identity) } + runCurrent() + + assertEquals(1, controller.pending.value.size) + controller.resetShare() + assertEquals(AdmissionAnswer.STOPPED, afterReset.await()) + } + + @Test + fun `offline reconnect with copied name requires a new approval`() = runTest { + val controller = controller() + val first = async { + controller.request(offline("Alex", "connection-1")) + } + runCurrent() + controller.answer(controller.pending.value.single().requestId, allow = true) + assertEquals(AdmissionAnswer.ALLOW, first.await()) + + val reconnect = async { + controller.request(offline("Alex", "connection-2")) + } + runCurrent() + + val pendingIdentity = assertIs( + controller.pending.value.single().identity, + ) + assertEquals("connection-2", pendingIdentity.connectionId) + controller.answer(controller.pending.value.single().requestId, allow = false) + assertEquals(AdmissionAnswer.DENY, reconnect.await()) + } + + @Test + fun `duplicate live requests share one decision`() = runTest { + val controller = controller() + val identity = authenticated("Alex", AUTHENTICATED_UUID) + val first = async { controller.request(identity) } + val duplicate = async { controller.request(identity) } + runCurrent() + + assertEquals(1, controller.pending.value.size) + controller.answer(controller.pending.value.single().requestId, allow = true) + + assertEquals(AdmissionAnswer.ALLOW, first.await()) + assertEquals(AdmissionAnswer.ALLOW, duplicate.await()) + } + + @Test + fun `seventeenth pending request is rejected`() = runTest { + val controller = controller() + val pending = (1..16).map { index -> + async { + controller.request( + offline("Guest$index", "connection-$index"), + ) + } + } + runCurrent() + + val seventeenth = controller.request( + offline("Guest17", "connection-17"), + ) + + assertEquals(AdmissionAnswer.CAPACITY, seventeenth) + assertEquals(16, controller.pending.value.size) + controller.resetShare() + pending.forEach { + assertEquals(AdmissionAnswer.STOPPED, it.await()) + } + } + + @Test + fun `request expires after thirty seconds`() = runTest { + val controller = controller() + val request = async { + controller.request(offline("Alex", "connection-1")) + } + runCurrent() + + advanceTimeBy(29.seconds.inWholeMilliseconds) + runCurrent() + assertEquals(1, controller.pending.value.size) + + advanceTimeBy(1.seconds.inWholeMilliseconds) + runCurrent() + assertEquals(AdmissionAnswer.TIMEOUT, request.await()) + assertTrue(controller.pending.value.isEmpty()) + } + + @Test + fun `stop resolves all pending requests and clears approvals`() = runTest { + val controller = controller() + val approved = authenticated("Alex", AUTHENTICATED_UUID) + val approval = async { controller.request(approved) } + runCurrent() + controller.answer(controller.pending.value.single().requestId, allow = true) + assertEquals(AdmissionAnswer.ALLOW, approval.await()) + + val pending = async { + controller.request(offline("Steve", "connection-1")) + } + runCurrent() + controller.resetShare() + + assertEquals(AdmissionAnswer.STOPPED, pending.await()) + assertTrue(controller.pending.value.isEmpty()) + + val approvalAfterStop = async { controller.request(approved) } + runCurrent() + assertEquals(1, controller.pending.value.size) + controller.resetShare() + assertEquals(AdmissionAnswer.STOPPED, approvalAfterStop.await()) + } + + @Test + fun `capacity rejects before adding a pending card`() = runTest { + var connected = 8 + val controller = controller( + connectedCount = { connected }, + maxGuests = { 8 }, + ) + + val answer = controller.request( + offline("Alex", "connection-1"), + ) + + assertEquals(AdmissionAnswer.CAPACITY, answer) + assertTrue(controller.pending.value.isEmpty()) + + connected = 0 + val pending = async { + controller.request(offline("Alex", "connection-2")) + } + runCurrent() + assertEquals(1, controller.pending.value.size) + controller.resetShare() + assertEquals(AdmissionAnswer.STOPPED, pending.await()) + } + + @Test + fun `saved direct peer can join automatically without a pending card`() = runTest { + val controller = controller( + autoApprove = { it.directPeerId == "12D3KooWSavedFriend" }, + ) + val saved = authenticated("Alex", AUTHENTICATED_UUID).copy( + directPeerId = "12D3KooWSavedFriend", + ingress = Ingress.DIRECT_LAN, + ) + + val answer = controller.request(saved) + + assertEquals(AdmissionAnswer.ALLOW, answer) + assertTrue(controller.pending.value.isEmpty()) + + val unknown = async { + controller.request( + saved.copy(directPeerId = "12D3KooWUnknownFriend"), + ) + } + runCurrent() + assertEquals(1, controller.pending.value.size) + controller.resetShare() + assertEquals(AdmissionAnswer.STOPPED, unknown.await()) + } + + @Test + fun `approved friend request authorizes exactly one following gameplay join`() = runTest { + val controller = controller() + val requestedIdentity = offline("RoboFlax2", "friend-request").copy( + directPeerId = "12D3KooWFriend", + ingress = Ingress.DIRECT_LAN, + ) + controller.approveNextJoin(requestedIdentity) + + assertEquals( + AdmissionAnswer.ALLOW, + controller.request( + requestedIdentity.copy(connectionId = "gameplay-1"), + ), + ) + val second = async { + controller.request( + requestedIdentity.copy(connectionId = "gameplay-2"), + ) + } + runCurrent() + + assertEquals(1, controller.pending.value.size) + controller.resetShare() + assertEquals(AdmissionAnswer.STOPPED, second.await()) + } + + @Test + fun `approved friend request does not authorize another gameplay identity`() = runTest { + val controller = controller() + val requestedIdentity = offline("RoboFlax2", "friend-request").copy( + uuid = AUTHENTICATED_UUID, + directPeerId = "12D3KooWFriend", + ingress = Ingress.DIRECT_LAN, + ) + controller.approveNextJoin(requestedIdentity) + + val otherIdentity = async { + controller.request( + authenticated("RoboFlax2", AUTHENTICATED_UUID).copy( + directPeerId = "12D3KooWOtherFriend", + ), + ) + } + runCurrent() + + assertEquals(1, controller.pending.value.size) + controller.resetShare() + assertEquals(AdmissionAnswer.STOPPED, otherIdentity.await()) + } + + @Test + fun `approved direct join allows the matching Connect fallback identity`() = runTest { + val controller = controller() + val requestedIdentity = offline("RoboFlax2", "friend-request").copy( + uuid = AUTHENTICATED_UUID, + directPeerId = "12D3KooWFriend", + ingress = Ingress.DIRECT_LAN, + ) + controller.approveNextJoin(requestedIdentity) + + assertEquals( + AdmissionAnswer.ALLOW, + controller.request( + requestedIdentity.copy( + connectionId = "connect-gameplay", + directPeerId = null, + ingress = Ingress.CONNECT, + ), + ), + ) + } + + @Test + fun `preapproved join expires before a late gameplay connection`() = runTest { + var nowMillis = 1_000L + val controller = controller(nowMillis = { nowMillis }) + val requestedIdentity = offline("RoboFlax2", "friend-request").copy( + directPeerId = "12D3KooWFriend", + ingress = Ingress.DIRECT_LAN, + ) + controller.approveNextJoin(requestedIdentity) + nowMillis += 30_001L + + val late = async { + controller.request( + requestedIdentity.copy(connectionId = "late-gameplay"), + ) + } + runCurrent() + + assertEquals(1, controller.pending.value.size) + controller.resetShare() + assertEquals(AdmissionAnswer.STOPPED, late.await()) + } + + @Test + fun `preapproved joins are bounded and evict the oldest grant`() = runTest { + val controller = controller(maxPending = 2) + val identities = (1..3).map { index -> + offline("Player$index", "friend-request-$index").copy( + directPeerId = "12D3KooWFriend$index", + ingress = Ingress.DIRECT_LAN, + ) + } + identities.forEach(controller::approveNextJoin) + + val evicted = async { + controller.request( + identities.first().copy(connectionId = "gameplay-1"), + ) + } + runCurrent() + assertEquals(1, controller.pending.value.size) + assertEquals( + AdmissionAnswer.ALLOW, + controller.request( + identities.last().copy(connectionId = "gameplay-3"), + ), + ) + controller.resetShare() + assertEquals(AdmissionAnswer.STOPPED, evicted.await()) + } + + @Test + fun `removing a direct peer revokes every peer-scoped admission grant`() = runTest { + val controller = controller() + val peerId = "12D3KooWRemovedFriend" + val authenticated = authenticated("Alex", AUTHENTICATED_UUID).copy( + directPeerId = peerId, + ) + val pending = async { controller.request(authenticated) } + runCurrent() + controller.answer(controller.pending.value.single().requestId, allow = true) + assertEquals(AdmissionAnswer.ALLOW, pending.await()) + + val offline = offline("Alex", "friend-request").copy( + uuid = AUTHENTICATED_UUID, + directPeerId = peerId, + ) + controller.approveNextJoin(offline) + + assertEquals(0, controller.revokeDirectPeer(peerId)) + val revokedAuthenticated = async { controller.request(authenticated) } + val revokedOffline = async { + controller.request(offline.copy(connectionId = "gameplay")) + } + runCurrent() + + assertEquals(2, controller.pending.value.size) + controller.resetShare() + assertEquals(AdmissionAnswer.STOPPED, revokedAuthenticated.await()) + assertEquals(AdmissionAnswer.STOPPED, revokedOffline.await()) + } + + @Test + fun `removing a linked peer denies uuid-bound pending Connect admission`() = runTest { + val controller = controller() + val pendingIdentity = authenticated("Alex", AUTHENTICATED_UUID).copy( + directPeerId = null, + ) + val pending = async { controller.request(pendingIdentity) } + runCurrent() + + assertEquals( + 1, + controller.revokeDirectPeer( + peerId = "12D3KooWRemovedFriend", + minecraftUuid = AUTHENTICATED_UUID, + ), + ) + assertEquals(AdmissionAnswer.DENY, pending.await()) + assertTrue(controller.pending.value.isEmpty()) + } + + private fun kotlinx.coroutines.test.TestScope.controller( + connectedCount: () -> Int = { 0 }, + maxGuests: () -> Int = { 8 }, + autoApprove: (AdmissionIdentity) -> Boolean = { false }, + maxPending: Int = 16, + nowMillis: () -> Long = System::currentTimeMillis, + ) = AdmissionController( + scope = backgroundScope, + timeout = 30.seconds, + maxPending = maxPending, + connectedCount = connectedCount, + maxGuests = maxGuests, + autoApprove = autoApprove, + nowMillis = nowMillis, + ) + + private fun authenticated( + name: String, + uuid: UUID, + ) = AdmissionIdentity.Authenticated( + name = name, + uuid = uuid, + source = AuthSource.CONNECT, + ) + + private fun offline( + name: String, + connectionId: String, + ) = AdmissionIdentity.UnverifiedOffline( + name = name, + uuid = UUID.nameUUIDFromBytes("OfflinePlayer:$name".toByteArray()), + connectionId = connectionId, + ingress = Ingress.CONNECT, + ) + + private companion object { + val AUTHENTICATED_UUID: UUID = UUID.fromString("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee") + } +} diff --git a/share/common/src/test/kotlin/com/minekube/connect/share/admission/NewAdmissionTrackerTest.kt b/share/common/src/test/kotlin/com/minekube/connect/share/admission/NewAdmissionTrackerTest.kt new file mode 100644 index 000000000..443ed8df2 --- /dev/null +++ b/share/common/src/test/kotlin/com/minekube/connect/share/admission/NewAdmissionTrackerTest.kt @@ -0,0 +1,33 @@ +package com.minekube.connect.share.admission + +import java.util.UUID +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class NewAdmissionTrackerTest { + @Test + fun `only newly pending requests produce notifications`() { + val tracker = NewAdmissionTracker() + val first = pending("Alex") + val second = pending("Steve") + + assertEquals(listOf(first), tracker.update(listOf(first))) + assertTrue(tracker.update(listOf(first)).isEmpty()) + assertEquals( + listOf(second), + tracker.update(listOf(first, second)), + ) + assertTrue(tracker.update(emptyList()).isEmpty()) + } + + private fun pending(name: String) = PendingAdmission( + requestId = UUID.randomUUID(), + identity = AdmissionIdentity.UnverifiedOffline( + name = name, + uuid = UUID.randomUUID(), + connectionId = UUID.randomUUID().toString(), + ingress = Ingress.DIRECT_LAN, + ), + ) +} diff --git a/share/common/src/test/kotlin/com/minekube/connect/share/direct/DirectSessionRegistryTest.kt b/share/common/src/test/kotlin/com/minekube/connect/share/direct/DirectSessionRegistryTest.kt new file mode 100644 index 000000000..bd91716b5 --- /dev/null +++ b/share/common/src/test/kotlin/com/minekube/connect/share/direct/DirectSessionRegistryTest.kt @@ -0,0 +1,68 @@ +package com.minekube.connect.share.direct + +import com.minekube.connect.tunnel.p2p.DirectP2pAuthMode +import com.minekube.connect.tunnel.p2p.DirectP2pRoute +import com.minekube.connect.tunnel.p2p.DirectP2pSession +import java.net.InetAddress +import java.net.InetSocketAddress +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class DirectSessionRegistryTest { + @AfterTest + fun clear() { + DirectSessionRegistry.clear() + } + + @Test + fun `loopback source port claims a direct session exactly once`() { + DirectSessionRegistry.register( + sourcePort = 41_234, + session = SESSION, + nowNanos = 100, + ) + val remote = InetSocketAddress( + InetAddress.getLoopbackAddress(), + 41_234, + ) + + assertEquals(SESSION, DirectSessionRegistry.claim(remote, nowNanos = 101)) + assertNull(DirectSessionRegistry.claim(remote, nowNanos = 102)) + } + + @Test + fun `non-loopback and expired registrations are never claimed`() { + DirectSessionRegistry.register( + sourcePort = 41_234, + session = SESSION, + nowNanos = 100, + ) + + assertNull( + DirectSessionRegistry.claim( + InetSocketAddress("192.168.1.20", 41_234), + nowNanos = 101, + ), + ) + assertNull( + DirectSessionRegistry.claim( + InetSocketAddress( + InetAddress.getLoopbackAddress(), + 41_234, + ), + nowNanos = 10_000_000_101L, + ), + ) + } + + private companion object { + val SESSION = DirectP2pSession( + "12D3KooWGuest", + DirectP2pAuthMode.OFFLINE, + DirectP2pRoute.LAN, + "connection-1", + ) + } +} diff --git a/share/common/src/test/kotlin/com/minekube/connect/share/direct/ShareInviteCodecTest.kt b/share/common/src/test/kotlin/com/minekube/connect/share/direct/ShareInviteCodecTest.kt new file mode 100644 index 000000000..ad65e8aba --- /dev/null +++ b/share/common/src/test/kotlin/com/minekube/connect/share/direct/ShareInviteCodecTest.kt @@ -0,0 +1,192 @@ +package com.minekube.connect.share.direct + +import arrow.core.Either +import java.security.KeyPair +import java.security.KeyPairGenerator +import java.security.Signature +import java.time.Instant +import java.util.Base64 +import java.util.UUID +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertTrue + +class ShareInviteCodecTest { + @Test + fun `signed invitation round trips without leaking its capability`() { + val keyPair = KeyPairGenerator.getInstance("Ed25519").generateKeyPair() + val invite = payload().signWith(keyPair) + + val uri = ShareInviteCodec.encode(invite) + val decoded = ShareInviteCodec.decode( + uri = uri, + now = Instant.ofEpochMilli(NOW), + ) + + assertEquals(invite, assertIs>(decoded).value) + assertTrue(uri.startsWith("minekube://share/")) + assertFalse(invite.toString().contains(CAPABILITY)) + assertFalse(decoded.toString().contains(CAPABILITY)) + } + + @Test + fun `tampering is rejected before dialing`() { + val keyPair = KeyPairGenerator.getInstance("Ed25519").generateKeyPair() + val uri = ShareInviteCodec.encode(payload().signWith(keyPair)) + val encoded = uri.substringAfterLast('/') + val bytes = Base64.getUrlDecoder().decode(encoded) + bytes[bytes.lastIndex - 4] = (bytes[bytes.lastIndex - 4].toInt() xor 1).toByte() + + val decoded = ShareInviteCodec.decode( + "minekube://share/${Base64.getUrlEncoder().withoutPadding().encodeToString(bytes)}", + Instant.ofEpochMilli(NOW), + ) + + assertIs>(decoded) + } + + @Test + fun `new signed invitations carry the sender username`() { + val keyPair = KeyPairGenerator.getInstance("Ed25519").generateKeyPair() + val invite = payload(displayName = "RoboFlax2").signWith(keyPair) + + val decoded = assertIs>( + ShareInviteCodec.decode( + ShareInviteCodec.encode(invite), + Instant.ofEpochMilli(NOW), + ), + ).value + + assertEquals("RoboFlax2", decoded.payload.displayName) + } + + @Test + fun `legacy version one invitations remain readable without a username`() { + val keyPair = KeyPairGenerator.getInstance("Ed25519").generateKeyPair() + val invite = payload( + wireVersion = 1, + displayName = null, + ).signWith(keyPair) + + val decoded = assertIs>( + ShareInviteCodec.decode( + ShareInviteCodec.encode(invite), + Instant.ofEpochMilli(NOW), + ), + ).value + + assertEquals(1, decoded.payload.wireVersion) + assertEquals(null, decoded.payload.displayName) + } + + @Test + fun `expired and unsupported invitations are rejected`() { + val keyPair = KeyPairGenerator.getInstance("Ed25519").generateKeyPair() + val expired = payload(expiresAt = NOW - 1).signWith(keyPair) + val unsupported = payload(wireVersion = ShareInviteCodec.WIRE_VERSION + 1) + .signWith(keyPair) + + assertIs>( + ShareInviteCodec.decode( + ShareInviteCodec.encode(expired), + Instant.ofEpochMilli(NOW), + ), + ) + assertIs>( + ShareInviteCodec.decode( + ShareInviteCodec.encode(unsupported), + Instant.ofEpochMilli(NOW), + ), + ) + } + + @Test + fun `direct invitations reject circuit relay candidates`() { + val keyPair = KeyPairGenerator.getInstance("Ed25519").generateKeyPair() + val relayed = payload( + directCandidates = listOf( + "/ip4/203.0.113.8/tcp/4001/p2p/QmRelay/p2p-circuit/p2p/QmHost", + ), + ).signWith(keyPair) + + val decoded = ShareInviteCodec.decode( + ShareInviteCodec.encode(relayed), + Instant.ofEpochMilli(NOW), + ) + + assertIs>(decoded) + } + + @Test + fun `direct candidates must name the signed host peer`() { + val keyPair = KeyPairGenerator.getInstance("Ed25519").generateKeyPair() + val mismatched = payload( + directCandidates = listOf( + "/ip4/203.0.113.8/tcp/4001/p2p/12D3KooWAttacker", + ), + ).signWith(keyPair) + + val decoded = ShareInviteCodec.decode( + ShareInviteCodec.encode(mismatched), + Instant.ofEpochMilli(NOW), + ) + + assertIs>(decoded) + } + + @Test + fun `invitations reject excessive direct candidate lists`() { + val keyPair = KeyPairGenerator.getInstance("Ed25519").generateKeyPair() + val oversized = payload( + directCandidates = List(257) { + "/ip6/2001:db8::8/tcp/4001/p2p/12D3KooWHost" + }, + ).signWith(keyPair) + + val decoded = ShareInviteCodec.decode( + ShareInviteCodec.encode(oversized), + Instant.ofEpochMilli(NOW), + ) + + assertIs>(decoded) + } + + private fun payload( + wireVersion: Int = ShareInviteCodec.WIRE_VERSION, + expiresAt: Long = NOW + 60_000, + displayName: String? = null, + directCandidates: List = listOf( + "/ip6/2001:db8::8/tcp/4001/p2p/12D3KooWHost", + ), + ) = ShareInvitePayload( + wireVersion = wireVersion, + shareId = UUID.fromString("9e511188-31a9-43ac-9107-29d94410d554"), + expiresAtEpochMillis = expiresAt, + connectAddress = "amber-fox.play.minekube.net", + peerId = "12D3KooWHost", + internetDirectEnabled = true, + directCandidates = directCandidates, + capability = CAPABILITY, + displayName = displayName, + ) + + private fun ShareInvitePayload.signWith(keyPair: KeyPair): SignedShareInvite { + val publicKey = keyPair.public.encoded + val unsigned = ShareInviteCodec.unsignedBytes(this, publicKey) + val signer = Signature.getInstance("Ed25519") + signer.initSign(keyPair.private) + signer.update(unsigned) + return SignedShareInvite( + payload = this, + publicKey = publicKey, + signature = signer.sign(), + ) + } + + private companion object { + const val NOW = 1_785_384_000_000 + const val CAPABILITY = "capability-secret-123456789" + } +} diff --git a/share/common/src/test/kotlin/com/minekube/connect/share/direct/TransportSelectorTest.kt b/share/common/src/test/kotlin/com/minekube/connect/share/direct/TransportSelectorTest.kt new file mode 100644 index 000000000..fd536c80e --- /dev/null +++ b/share/common/src/test/kotlin/com/minekube/connect/share/direct/TransportSelectorTest.kt @@ -0,0 +1,91 @@ +package com.minekube.connect.share.direct + +import arrow.core.Either +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlinx.coroutines.test.runTest + +class TransportSelectorTest { + @Test + fun `same LAN is attempted before internet and Connect`() { + val plan = TransportSelector.plan( + sameLan = true, + hostInternetOptIn = true, + guestInternetOptIn = true, + connectAddress = "amber-fox.play.minekube.net", + ) + + assertEquals( + listOf( + ShareRoute.DIRECT_LAN, + ShareRoute.DIRECT_INTERNET, + ShareRoute.CONNECT, + ), + plan, + ) + } + + @Test + fun `internet direct requires opt in from both peers`() { + assertEquals( + listOf(ShareRoute.CONNECT), + TransportSelector.plan( + sameLan = false, + hostInternetOptIn = true, + guestInternetOptIn = false, + connectAddress = "amber-fox.play.minekube.net", + ), + ) + assertEquals( + listOf(ShareRoute.CONNECT), + TransportSelector.plan( + sameLan = false, + hostInternetOptIn = false, + guestInternetOptIn = true, + connectAddress = "amber-fox.play.minekube.net", + ), + ) + } + + @Test + fun `failed direct attempts fall back to Connect exactly once`() = runTest { + val attempts = mutableListOf() + val result = ShareJoinCoordinator( + attempt = { route -> + attempts += route + if (route == ShareRoute.CONNECT) { + Either.Right(Unit) + } else { + Either.Left(ShareJoinError.RouteUnavailable) + } + }, + ).join( + listOf( + ShareRoute.DIRECT_LAN, + ShareRoute.DIRECT_INTERNET, + ShareRoute.CONNECT, + ShareRoute.CONNECT, + ), + ) + + assertEquals(ShareRoute.CONNECT, assertIs>(result).value) + assertEquals( + listOf( + ShareRoute.DIRECT_LAN, + ShareRoute.DIRECT_INTERNET, + ShareRoute.CONNECT, + ), + attempts, + ) + } + + @Test + fun `no direct route and no Connect returns an actionable failure`() = runTest { + val result = ShareJoinCoordinator { + Either.Left(ShareJoinError.RouteUnavailable) + }.join(listOf(ShareRoute.DIRECT_LAN)) + + assertIs>(result) + } +} diff --git a/share/common/src/test/kotlin/com/minekube/connect/share/friend/CompatibilityProfileTest.kt b/share/common/src/test/kotlin/com/minekube/connect/share/friend/CompatibilityProfileTest.kt new file mode 100644 index 000000000..a54dc0d51 --- /dev/null +++ b/share/common/src/test/kotlin/com/minekube/connect/share/friend/CompatibilityProfileTest.kt @@ -0,0 +1,88 @@ +package com.minekube.connect.share.friend + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue + +class CompatibilityProfileTest { + @Test + fun `matching profiles are compatible regardless of mod ordering`() { + val local = profile( + mods = listOf( + RequiredMod("fabric-api", "1.0"), + RequiredMod("example", "2.0"), + ), + ) + val remote = profile(mods = local.requiredMods.reversed()) + + assertEquals(CompatibilityReport.Compatible, local.compareTo(remote)) + assertEquals(local.fingerprint(), remote.fingerprint()) + } + + @Test + fun `minecraft loader missing mod and version differences are distinct`() { + val local = profile( + minecraft = "1.21.1", + loader = ModLoader.FABRIC, + mods = listOf( + RequiredMod("shared", "1.0"), + RequiredMod("local-only", "3.0"), + ), + ) + val remote = profile( + minecraft = "1.20.1", + loader = ModLoader.NEOFORGE, + mods = listOf( + RequiredMod("shared", "2.0"), + RequiredMod("remote-only", "4.0"), + ), + ) + + val mismatch = assertIs( + local.compareTo(remote), + ) + + assertTrue(mismatch.differences.any { + it is CompatibilityDifference.MinecraftVersion + }) + assertTrue(mismatch.differences.any { + it is CompatibilityDifference.Loader + }) + assertTrue(mismatch.differences.any { + it is CompatibilityDifference.MissingLocal && + it.modId == "remote-only" + }) + assertTrue(mismatch.differences.any { + it is CompatibilityDifference.MissingRemote && + it.modId == "local-only" + }) + assertTrue(mismatch.differences.any { + it is CompatibilityDifference.ModVersion && + it.modId == "shared" + }) + } + + @Test + fun `pack link is carried but excluded from compatibility fingerprint`() { + val first = profile().copy( + pack = PackReference( + platform = PackPlatform.MODRINTH, + projectId = "pack", + versionId = "one", + url = "https://modrinth.com/modpack/pack/version/one", + ), + ) + val second = first.copy( + pack = first.pack?.copy(versionId = "two"), + ) + + assertEquals(first.fingerprint(), second.fingerprint()) + } + + private fun profile( + minecraft: String = "1.21.1", + loader: ModLoader = ModLoader.FABRIC, + mods: List = listOf(RequiredMod("connect-share", "1")), + ) = CompatibilityProfile(minecraft, loader, mods) +} diff --git a/share/common/src/test/kotlin/com/minekube/connect/share/friend/FriendControlChannelHandlerTest.kt b/share/common/src/test/kotlin/com/minekube/connect/share/friend/FriendControlChannelHandlerTest.kt new file mode 100644 index 000000000..9e7a333a4 --- /dev/null +++ b/share/common/src/test/kotlin/com/minekube/connect/share/friend/FriendControlChannelHandlerTest.kt @@ -0,0 +1,190 @@ +package com.minekube.connect.share.friend + +import com.minekube.connect.share.admission.Ingress +import com.minekube.connect.share.direct.DirectSessionAttributes +import com.minekube.connect.tunnel.p2p.DirectP2pAuthMode +import com.minekube.connect.tunnel.p2p.DirectP2pRoute +import com.minekube.connect.tunnel.p2p.DirectP2pSession +import io.netty.buffer.ByteBuf +import io.netty.buffer.Unpooled +import io.netty.channel.embedded.EmbeddedChannel +import java.util.UUID +import java.util.concurrent.CompletableFuture +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class FriendControlChannelHandlerTest { + @Test + fun `ordinary Minecraft traffic passes through unchanged`() { + val ordinary = ORDINARY_MINECRAFT_HANDSHAKE + val channel = EmbeddedChannel( + FriendControlChannelHandler { _, _ -> + error("Ordinary traffic must not reach friend control") + }, + ) + + assertTrue( + channel.writeInbound( + Unpooled.wrappedBuffer(ordinary), + ), + ) + val forwarded = channel.readInbound() + val actual = ByteArray(forwarded.readableBytes()) + forwarded.readBytes(actual) + forwarded.release() + + assertTrue(ordinary.contentEquals(actual)) + channel.finishAndReleaseAll() + } + + @Test + fun `fragmented request is intercepted and responses stream without vanilla`() { + val response = CompletableFuture() + val received = mutableListOf>() + val channel = EmbeddedChannel( + FriendControlChannelHandler { context, request -> + received += context to request + response + }, + ) + channel.attr(DirectSessionAttributes.SESSION).set(DIRECT_SESSION) + val encoded = FriendControlWire.encodeRequest( + request = REQUEST, + ) + + channel.writeInbound( + Unpooled.wrappedBuffer(encoded.copyOfRange(0, 7)), + ) + assertTrue(received.isEmpty()) + channel.writeInbound( + Unpooled.wrappedBuffer(encoded.copyOfRange(7, encoded.size)), + ) + + assertEquals(REQUEST, received.single().second) + assertEquals(Ingress.DIRECT_LAN, received.single().first.ingress) + assertEquals( + DIRECT_SESSION.peerId(), + received.single().first.directPeerId, + ) + assertNull(channel.readInbound()) + assertEquals( + FriendControlResponse.Received, + channel.readControlResponse(), + ) + + response.complete( + FriendControlResponse.Accepted( + "minekube://share/host-card", + ), + ) + channel.runPendingTasks() + + assertEquals( + FriendControlResponse.Accepted( + "minekube://share/host-card", + ), + channel.readControlResponse(), + ) + assertTrue(!channel.isOpen) + channel.finishAndReleaseAll() + } + + @Test + fun `closing sender cancels remote pending decision`() { + val response = CompletableFuture() + val channel = EmbeddedChannel( + FriendControlChannelHandler { _, _ -> response }, + ) + channel.writeInbound( + Unpooled.wrappedBuffer( + FriendControlWire.encodeRequest( + request = REQUEST, + ), + ), + ) + channel.readOutbound()?.release() + + channel.close() + + assertTrue(response.isCancelled) + channel.finishAndReleaseAll() + } + + @Test + fun `removal command is dispatched on the authenticated direct session`() { + val removal = FriendRemovalRequest(UUID.randomUUID()) + var received: Pair? = null + val server = object : FriendControlServer { + override fun handle( + context: FriendControlContext, + request: FriendControlRequest, + ): java.util.concurrent.CompletionStage = + CompletableFuture.completedFuture( + FriendControlResponse.Invalid, + ) + + override fun handleRemoval( + context: FriendControlContext, + request: FriendRemovalRequest, + ): CompletableFuture { + received = context to request + return CompletableFuture.completedFuture( + FriendControlResponse.Removed, + ) + } + } + val channel = EmbeddedChannel(FriendControlChannelHandler(server)) + channel.attr(DirectSessionAttributes.SESSION).set(DIRECT_SESSION) + + channel.writeInbound( + Unpooled.wrappedBuffer(FriendControlWire.encodeRemoval(removal)), + ) + channel.runPendingTasks() + + assertEquals(removal, received?.second) + assertEquals(DIRECT_SESSION.peerId(), received?.first?.directPeerId) + assertEquals(FriendControlResponse.Received, channel.readControlResponse()) + assertEquals(FriendControlResponse.Removed, channel.readControlResponse()) + channel.finishAndReleaseAll() + } + + private fun EmbeddedChannel.readControlResponse(): FriendControlResponse { + val buffer = readOutbound() + val bytes = ByteArray(buffer.readableBytes()) + buffer.readBytes(bytes) + buffer.release() + return assertIs< + FriendControlDecode.Decoded + >(FriendControlWire.decodeResponse(bytes)).value + } + + private companion object { + val REQUEST = FriendControlRequest( + requestId = UUID.fromString( + "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + ), + displayName = "bob", + invitation = "minekube://share/sender-card", + ) + val DIRECT_SESSION = DirectP2pSession( + "12D3KooWSender", + DirectP2pAuthMode.OFFLINE, + DirectP2pRoute.LAN, + "direct-control-session", + ) + val ORDINARY_MINECRAFT_HANDSHAKE = byteArrayOf( + 0x10, + 0x00, + 0xb3.toByte(), + 0x08, + 0x09, + *"localhost".toByteArray(), + 0x63, + 0xdd.toByte(), + 0x02, + ) + } +} diff --git a/share/common/src/test/kotlin/com/minekube/connect/share/friend/FriendControlWireTest.kt b/share/common/src/test/kotlin/com/minekube/connect/share/friend/FriendControlWireTest.kt new file mode 100644 index 000000000..87b5a9fee --- /dev/null +++ b/share/common/src/test/kotlin/com/minekube/connect/share/friend/FriendControlWireTest.kt @@ -0,0 +1,255 @@ +package com.minekube.connect.share.friend + +import java.io.ByteArrayOutputStream +import java.util.UUID +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertTrue + +class FriendControlWireTest { + @Test + fun `request is a raw control frame instead of a Minecraft status ping`() { + val request = FriendControlRequest( + requestId = REQUEST_ID, + displayName = "bob", + invitation = "minekube://share/signed-bob-card", + ) + + val encoded = FriendControlWire.encodeRequest( + request = request, + ) + val decoded = assertIs>( + FriendControlWire.decodeRequest(encoded), + ) + + assertEquals(request, decoded.value) + assertEquals(encoded.size, decoded.consumedBytes) + assertFalse(FriendControlWire.isStatusHandshake(encoded)) + } + + @Test + fun `new decoder accepts legacy request frames with request id fallback`() { + val request = FriendControlRequest( + requestId = REQUEST_ID, + displayName = "bob", + invitation = "minekube://share/signed-bob-card", + ) + + val decoded = assertIs>( + FriendControlWire.decodeRequest(legacyRequest(request)), + ) + + assertEquals(REQUEST_ID, decoded.value.requestId) + assertEquals(REQUEST_ID, decoded.value.relationshipId) + } + + @Test + fun `all server outcomes use bounded response frames`() { + val responses = listOf( + FriendControlResponse.Received, + FriendControlResponse.Accepted( + "minekube://share/signed-host-card", + ), + FriendControlResponse.Declined, + FriendControlResponse.TimedOut, + FriendControlResponse.Invalid, + FriendControlResponse.Removed, + FriendControlResponse.Activity( + FriendActivity( + FriendActivityKind.PLAYING_SERVER, + "Hypixel", + compatibility = CompatibilityProfile( + minecraftVersion = "1.21.1", + loader = ModLoader.FABRIC, + requiredMods = listOf( + RequiredMod("fabric-api", "1.0"), + ), + pack = PackReference( + platform = PackPlatform.MODRINTH, + projectId = "example-pack", + versionId = "v1", + url = "https://modrinth.com/modpack/example-pack/version/v1", + ), + ), + ), + ), + FriendControlResponse.JoinAccepted("mc.hypixel.net"), + FriendControlResponse.SharedWorldJoinAccepted, + ) + + responses.forEach { response -> + val encoded = FriendControlWire.encodeResponse(response) + val decoded = assertIs< + FriendControlDecode.Decoded + >(FriendControlWire.decodeResponse(encoded)) + assertEquals(response, decoded.value) + assertEquals(encoded.size, decoded.consumedBytes) + } + } + + @Test + fun `compatibility fingerprint is carried and validated on the wire`() { + val profile = CompatibilityProfile( + minecraftVersion = "1.21.1", + loader = ModLoader.FABRIC, + requiredMods = listOf(RequiredMod("world-mod", "2.0")), + ) + val encoded = FriendControlWire.encodeResponse( + FriendControlResponse.Activity( + FriendActivity( + kind = FriendActivityKind.HOSTING_WORLD, + compatibility = profile, + ), + ), + ) + val fingerprint = profile.fingerprint().encodeToByteArray() + val fingerprintStart = encoded.indexOf(fingerprint) + + assertTrue(fingerprintStart >= 0) + val tampered = encoded.copyOf().also { bytes -> + bytes[fingerprintStart] = + if (bytes[fingerprintStart] == '0'.code.toByte()) { + '1'.code.toByte() + } else { + '0'.code.toByte() + } + } + assertIs( + FriendControlWire.decodeResponse(tampered), + ) + } + + @Test + fun `activity and join requests round trip without exposing a server address`() { + val activity = FriendActivityRequest(REQUEST_ID) + val join = FriendJoinRequest( + requestId = REQUEST_ID, + playerName = "RoboFlax2", + playerUuid = PLAYER_UUID, + ) + + assertEquals( + activity, + assertIs>( + FriendControlWire.decodeActivityRequest( + FriendControlWire.encodeActivityRequest(activity), + ), + ).value, + ) + assertEquals( + join, + assertIs>( + FriendControlWire.decodeJoinRequest( + FriendControlWire.encodeJoinRequest(join), + ), + ).value, + ) + } + + @Test + fun `removal command round trips with a stable operation id`() { + val removal = FriendRemovalRequest(REQUEST_ID) + + val encoded = FriendControlWire.encodeRemoval(removal) + val decoded = assertIs< + FriendControlDecode.Decoded + >(FriendControlWire.decodeRemoval(encoded)) + + assertEquals(removal, decoded.value) + assertEquals(encoded.size, decoded.consumedBytes) + assertEquals( + FriendControlMessageKind.REMOVAL, + assertIs>( + FriendControlWire.inspectControlMessage(encoded), + ).value, + ) + } + + @Test + fun `new decoder accepts legacy removal frames with operation id fallback`() { + val decoded = assertIs>( + FriendControlWire.decodeRemoval(legacyRemoval(REQUEST_ID)), + ) + + assertEquals(REQUEST_ID, decoded.value.operationId) + assertEquals(REQUEST_ID, decoded.value.relationshipId) + } + + @Test + fun `partial and oversized control frames are never accepted`() { + val encoded = FriendControlWire.encodeRequest( + request = FriendControlRequest( + requestId = REQUEST_ID, + displayName = "bob", + invitation = "minekube://share/signed-bob-card", + ), + ) + + assertIs( + FriendControlWire.decodeRequest(encoded.copyOf(encoded.size - 1)), + ) + assertIs( + FriendControlWire.decodeRequest( + encoded + ByteArray(FriendControlWire.MAX_REQUEST_BYTES), + ), + ) + } + + private companion object { + val REQUEST_ID: UUID = + UUID.fromString("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee") + val PLAYER_UUID: UUID = + UUID.fromString("11111111-2222-3333-4444-555555555555") + + fun legacyRequest(request: FriendControlRequest): ByteArray { + val current = FriendControlWire.encodeRequest(request) + val bodyStart = varIntLength(current) + val body = current.copyOfRange(bodyStart, current.size) + val packetIdLength = varIntLength(body) + val legacyBody = body.copyOfRange(0, packetIdLength + 16) + + body.copyOfRange(packetIdLength + 32, body.size) + return frame(legacyBody) + } + + fun legacyRemoval(operationId: UUID): ByteArray { + val current = FriendControlWire.encodeRemoval( + FriendRemovalRequest(operationId), + ) + val bodyStart = varIntLength(current) + val body = current.copyOfRange(bodyStart, current.size) + val packetIdLength = varIntLength(body) + return frame(body.copyOfRange(0, packetIdLength + 16)) + } + + fun ByteArray.indexOf(sequence: ByteArray): Int = + indices.firstOrNull { start -> + start + sequence.size <= size && + sequence.indices.all { offset -> + this[start + offset] == sequence[offset] + } + } ?: -1 + + fun frame(body: ByteArray): ByteArray = ByteArrayOutputStream().apply { + writeVarInt(body.size) + write(body) + }.toByteArray() + + fun varIntLength(bytes: ByteArray): Int { + var index = 0 + while (bytes[index++].toInt() and 0x80 != 0) Unit + return index + } + + fun ByteArrayOutputStream.writeVarInt(value: Int) { + var remaining = value + do { + var byte = remaining and 0x7f + remaining = remaining ushr 7 + if (remaining != 0) byte = byte or 0x80 + write(byte) + } while (remaining != 0) + } + } +} diff --git a/share/common/src/test/kotlin/com/minekube/connect/share/friend/FriendStoreTest.kt b/share/common/src/test/kotlin/com/minekube/connect/share/friend/FriendStoreTest.kt new file mode 100644 index 000000000..01b8ed304 --- /dev/null +++ b/share/common/src/test/kotlin/com/minekube/connect/share/friend/FriendStoreTest.kt @@ -0,0 +1,555 @@ +package com.minekube.connect.share.friend + +import arrow.core.Either +import com.minekube.connect.share.direct.ShareInviteCodec +import com.minekube.connect.share.direct.ShareInvitePayload +import com.minekube.connect.share.direct.SignedShareInvite +import java.nio.file.Files +import java.nio.file.Path +import java.security.KeyPair +import java.security.KeyPairGenerator +import java.security.Signature +import java.time.Instant +import java.util.UUID +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertTrue +import org.junit.jupiter.api.io.TempDir + +class FriendStoreTest { + @TempDir + lateinit var tempDir: Path + + @Test + fun `loaded relationships are served from memory instead of rereading each tick`() { + val store = FriendStore(tempDir) + store.accept(signedLink(), "Robin", NOW) + val loaded = store.all() + Files.writeString( + tempDir.resolve(FriendStore.FILE_NAME), + "{broken-json", + ) + + assertEquals(loaded, store.all()) + assertEquals(loaded, store.all()) + assertTrue( + runCatching { FriendStore(tempDir).all() }.isFailure, + ) + } + + @Test + fun `sending a signed link stores only an outgoing request`() { + val store = FriendStore(tempDir) + + val request = assertIs>( + store.sendRequest(signedLink(), "Robin", NOW), + ).value + + assertEquals( + FriendRelationshipStatus.PENDING_OUTGOING, + request.relationshipStatus, + ) + assertTrue(store.all().isEmpty()) + assertEquals( + listOf(request), + FriendStore(tempDir).outgoingRequests(), + ) + } + + @Test + fun `direct internet social candidates survive restart`() { + val store = FriendStore(tempDir) + val saved = store.accept( + signedLink( + internetDirectEnabled = true, + directCandidates = listOf(INTERNET_ADDRESS), + ), + "Robin", + NOW, + ).getOrNull()!! + + assertTrue(saved.internetDirectEnabled) + assertEquals(listOf(INTERNET_ADDRESS), saved.directCandidates) + assertEquals(saved, FriendStore(tempDir).all().single()) + } + + @Test + fun `guest internet consent is durable and disabled by default`() { + val store = FriendStore(tempDir) + val saved = store.accept(signedLink(), "Robin", NOW) + .getOrNull()!! + + assertFalse(saved.internetDirectGuestOptIn) + assertIs>( + store.setInternetDirectGuestOptIn(PEER_ID, true), + ) + + val reloaded = FriendStore(tempDir).all().single() + assertTrue(reloaded.internetDirectGuestOptIn) + } + + @Test + fun `sending a request can persist explicit internet consent`() { + val store = FriendStore(tempDir) + + val request = assertIs>( + store.sendRequest( + signedLink(), + "Robin", + NOW, + internetDirectGuestOptIn = true, + ), + ).value + + assertTrue(request.internetDirectGuestOptIn) + assertTrue( + FriendStore(tempDir).outgoingRequests().single() + .internetDirectGuestOptIn, + ) + } + + @Test + fun `confirming an outgoing request promotes it across restarts`() { + val store = FriendStore(tempDir) + store.sendRequest(signedLink(), "Robin", NOW) + + val confirmed = assertIs>( + store.confirmOutgoing(PEER_ID), + ).value + + assertEquals( + FriendRelationshipStatus.CONFIRMED, + confirmed.relationshipStatus, + ) + assertEquals( + listOf(confirmed), + FriendStore(tempDir).all(), + ) + assertTrue(FriendStore(tempDir).outgoingRequests().isEmpty()) + } + + @Test + fun `sending the same link never demotes a confirmed friend`() { + val store = FriendStore(tempDir) + store.accept(signedLink(), "Robin", NOW) + + val received = assertIs>( + store.sendRequest(signedLink(), "Robin", NOW), + ).value + + assertEquals( + FriendRelationshipStatus.CONFIRMED, + received.relationshipStatus, + ) + assertEquals(PEER_ID, store.all().single().peerId) + assertTrue(store.outgoingRequests().isEmpty()) + } + + @Test + fun `confirmed incoming generation replaces the old generation`() { + val store = FriendStore(tempDir) + val firstGeneration = UUID.fromString( + "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + ) + val secondGeneration = UUID.fromString( + "11111111-2222-3333-4444-555555555555", + ) + + store.accept( + signedLink(), + "Robin", + NOW, + relationshipId = firstGeneration, + ) + val merged = store.accept( + signedLink(), + "Robin", + NOW, + relationshipId = secondGeneration, + ).getOrNull()!! + + assertEquals(secondGeneration, merged.relationshipId) + assertEquals(secondGeneration, store.all().single().relationshipId) + } + + @Test + fun `version five relationships migrate without trusting asymmetric generations`() { + val store = FriendStore(tempDir) + store.accept(signedLink(), "Robin", NOW) + val file = tempDir.resolve(FriendStore.FILE_NAME) + Files.writeString( + file, + Files.readString(file) + .replace("\"version\":7", "\"version\":5") + .replace(Regex("\"relationshipId\":\"[^\"]+\",?"), "") + .replace(Regex("\"relationshipIdKnown\":(true|false),?"), ""), + ) + + val migrated = FriendStore(tempDir) + val legacy = migrated.all().single() + + assertFalse(legacy.relationshipIdKnown) + assertEquals( + null, + migrated.applyRemoteRemoval(PEER_ID, legacy.relationshipId), + ) + val generation = UUID.randomUUID() + val synchronized = migrated.accept( + signedLink(), + "Robin", + NOW, + relationshipId = generation, + ).getOrNull()!! + + assertTrue(synchronized.relationshipIdKnown) + assertEquals(generation, synchronized.relationshipId) + } + + @Test + fun `legacy unverified relationships migrate to outgoing`() { + val store = FriendStore(tempDir) + store.accept(signedLink(), "Robin", NOW) + stripRelationshipStatus() + + val migrated = FriendStore(tempDir) + + assertTrue(migrated.all().isEmpty()) + assertEquals(PEER_ID, migrated.outgoingRequests().single().peerId) + } + + @Test + fun `broken incoming status migrates to outgoing`() { + val store = FriendStore(tempDir) + store.sendRequest(signedLink(), "Robin", NOW) + replaceRelationshipStatus( + from = "PENDING_OUTGOING", + to = "PENDING_INCOMING", + ) + + val migrated = FriendStore(tempDir) + + assertTrue(migrated.all().isEmpty()) + assertEquals(PEER_ID, migrated.outgoingRequests().single().peerId) + } + + @Test + fun `legacy automatically trusted relationships remain confirmed`() { + val store = FriendStore(tempDir) + store.accept(signedLink(), "Robin", NOW) + store.updatePermissions( + PEER_ID, + FriendPermissions(canJoinAutomatically = true), + ) + stripRelationshipStatus() + + val migrated = FriendStore(tempDir) + + assertEquals(PEER_ID, migrated.all().single().peerId) + assertTrue(migrated.outgoingRequests().isEmpty()) + } + + @Test + fun `accepting one signed link saves a friend across restarts`() { + val link = signedLink() + val store = FriendStore(tempDir) + + val accepted = assertIs>( + store.accept(link, "Robin", NOW), + ).value + val reloaded = FriendStore(tempDir).all() + + assertEquals(listOf(accepted), reloaded) + assertEquals(PEER_ID, accepted.peerId) + assertEquals(SHARE_ID, accepted.shareId) + assertEquals(CONNECT_ADDRESS, accepted.connectAddress) + assertEquals( + FriendRelationshipStatus.CONFIRMED, + accepted.relationshipStatus, + ) + assertTrue(accepted.permissions.notifyWhenOnline) + assertTrue(accepted.permissions.canSeeMyWorlds) + assertFalse(accepted.permissions.canJoinAutomatically) + assertFalse(accepted.toString().contains(CAPABILITY)) + assertContains(accepted.toString(), "capability=") + } + + @Test + fun `friend settings can be managed without exchanging another link`() { + val store = FriendStore(tempDir) + store.accept(signedLink(), "Robin", NOW) + + assertIs>( + store.rename(PEER_ID, "Robin from Discord"), + ) + assertIs>( + store.updatePermissions( + PEER_ID, + FriendPermissions( + notifyWhenOnline = false, + canSeeMyWorlds = true, + canJoinAutomatically = true, + ), + ), + ) + + val managed = FriendStore(tempDir).all().single() + assertEquals("Robin from Discord", managed.displayName) + assertFalse(managed.permissions.notifyWhenOnline) + assertTrue(managed.permissions.canSeeMyWorlds) + assertTrue(managed.permissions.canJoinAutomatically) + } + + @Test + fun `never allow is durable and distinct from ask every time`() { + val store = FriendStore(tempDir) + val friend = store.accept(signedLink(), "Robin", NOW).getOrNull()!! + + store.updatePermissions( + friend.peerId, + friend.permissions.copy( + accessPolicy = FriendAccessPolicy.NEVER_ALLOW, + ), + ) + + val reloaded = FriendStore(tempDir).all().single() + assertEquals( + FriendAccessPolicy.NEVER_ALLOW, + reloaded.permissions.accessPolicy, + ) + assertFalse(reloaded.permissions.canJoinAutomatically) + } + + @Test + fun `blocking revokes friendship and rejects the same identity until unblocked`() { + val store = FriendStore(tempDir) + store.accept(signedLink(), "Robin", NOW) + + assertTrue(store.block(PEER_ID, NOW)) + + val reloaded = FriendStore(tempDir) + assertTrue(reloaded.all().isEmpty()) + assertEquals(PEER_ID, reloaded.blocked().single().peerId) + assertEquals(PEER_ID, reloaded.pendingRemovals().single().friend.peerId) + assertIs>( + reloaded.accept(signedLink(), "Robin", NOW.plusSeconds(1)), + ) + + assertTrue(reloaded.unblock(PEER_ID)) + assertTrue( + reloaded.sendRequest( + signedLink(), + "Robin", + NOW.plusSeconds(2), + ).isRight(), + ) + } + + @Test + fun `removing an unrelated friend preserves blocked identities`() { + val store = FriendStore(tempDir) + val otherPeerId = "12D3KooWOtherFriendPeer" + store.accept(signedLink(), "Robin", NOW) + store.accept( + signedLink( + peerId = otherPeerId, + shareId = UUID.fromString( + "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + ), + capability = "other-friend-capability", + keyPair = KeyPairGenerator.getInstance("Ed25519") + .generateKeyPair(), + ), + "Other", + NOW, + ) + store.block(PEER_ID, NOW) + + assertTrue(store.remove(otherPeerId, NOW)) + + assertEquals(PEER_ID, store.blocked().single().peerId) + } + + @Test + fun `approved friend can be bound to an authenticated Minecraft identity`() { + val store = FriendStore(tempDir) + store.accept(signedLink(), "Robin", NOW) + val minecraftUuid = UUID.fromString( + "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + ) + + assertIs>( + store.linkMinecraftIdentity(PEER_ID, minecraftUuid), + ) + + assertEquals( + minecraftUuid, + FriendStore(tempDir).all().single().minecraftUuid, + ) + } + + @Test + fun `removing a friend revokes the locally stored relationship`() { + val store = FriendStore(tempDir) + store.accept(signedLink(), "Robin", NOW) + + val removed = store.remove(PEER_ID, NOW) + + assertTrue(removed) + assertTrue(FriendStore(tempDir).all().isEmpty()) + val pending = FriendStore(tempDir).pendingRemovals().single() + assertEquals(PEER_ID, pending.friend.peerId) + assertEquals(NOW, pending.removedAt) + assertFalse(store.remove(PEER_ID)) + } + + @Test + fun `acknowledging a removal clears its durable tombstone`() { + val store = FriendStore(tempDir) + store.accept(signedLink(), "Robin", NOW) + store.remove(PEER_ID, NOW) + val operation = store.pendingRemovals().single() + + assertTrue(store.acknowledgeRemoval(operation.operationId)) + + assertTrue(FriendStore(tempDir).pendingRemovals().isEmpty()) + assertFalse(store.acknowledgeRemoval(operation.operationId)) + } + + @Test + fun `remote removal is idempotent and does not create a reply tombstone`() { + val store = FriendStore(tempDir) + store.accept(signedLink(), "Robin", NOW) + val relationshipId = store.relationship(PEER_ID).getOrNull()!!.relationshipId + + assertEquals( + relationshipId, + store.applyRemoteRemoval(PEER_ID, relationshipId)?.relationshipId, + ) + assertEquals(null, store.applyRemoteRemoval(PEER_ID, relationshipId)) + + val reloaded = FriendStore(tempDir) + assertTrue(reloaded.all().isEmpty()) + assertTrue(reloaded.pendingRemovals().isEmpty()) + } + + @Test + fun `stale remote removal cannot delete a re-established relationship`() { + val store = FriendStore(tempDir) + val link = signedLink() + store.accept(link, "Robin", NOW) + store.remove(PEER_ID, NOW) + val stale = store.pendingRemovals().single() + + val readded = store.accept(link, "Robin", NOW.plusSeconds(1)).getOrNull()!! + + assertEquals( + null, + store.applyRemoteRemoval(PEER_ID, stale.friend.relationshipId), + ) + assertEquals(readded, store.all().single()) + } + + @Test + fun `explicitly adding a removed friend cancels the stale removal`() { + val store = FriendStore(tempDir) + store.accept(signedLink(), "Robin", NOW) + store.remove(PEER_ID, NOW) + + store.sendRequest(signedLink(), "Robin", NOW.plusSeconds(1)) + + val reloaded = FriendStore(tempDir) + assertEquals(PEER_ID, reloaded.outgoingRequests().single().peerId) + assertTrue(reloaded.pendingRemovals().isEmpty()) + } + + @Test + fun `invalid or expired links are rejected without changing friends`() { + val store = FriendStore(tempDir) + + val malformed = store.accept("minekube://share/not-valid", "Robin", NOW) + val expired = store.accept( + signedLink(expiresAt = NOW.minusSeconds(1)), + "Robin", + NOW, + ) + + assertIs>(malformed) + assertIs>(expired) + assertTrue(store.all().isEmpty()) + } + + private fun signedLink( + expiresAt: Instant = NOW.plusSeconds(3_600), + internetDirectEnabled: Boolean = false, + directCandidates: List = emptyList(), + peerId: String = PEER_ID, + shareId: UUID = SHARE_ID, + capability: String = CAPABILITY, + keyPair: KeyPair = KEY_PAIR, + ): String { + val payload = ShareInvitePayload( + wireVersion = ShareInviteCodec.WIRE_VERSION, + shareId = shareId, + expiresAtEpochMillis = expiresAt.toEpochMilli(), + connectAddress = CONNECT_ADDRESS, + peerId = peerId, + internetDirectEnabled = internetDirectEnabled, + directCandidates = directCandidates, + capability = capability, + ) + val unsigned = ShareInviteCodec.unsignedBytes( + payload, + keyPair.public.encoded, + ) + val signature = Signature.getInstance("Ed25519").run { + initSign(keyPair.private) + update(unsigned) + sign() + } + return ShareInviteCodec.encode( + SignedShareInvite( + payload = payload, + publicKey = keyPair.public.encoded, + signature = signature, + ), + ) + } + + private fun stripRelationshipStatus() { + val file = tempDir.resolve(FriendStore.FILE_NAME) + val withoutStatus = Files.readString(file).replace( + Regex( + ""","relationshipStatus":"[A-Z_]+"""", + ), + "", + ) + Files.writeString(file, withoutStatus) + } + + private fun replaceRelationshipStatus( + from: String, + to: String, + ) { + val file = tempDir.resolve(FriendStore.FILE_NAME) + Files.writeString( + file, + Files.readString(file).replace(from, to), + ) + } + + private companion object { + val NOW: Instant = Instant.parse("2026-07-31T00:00:00Z") + val SHARE_ID: UUID = + UUID.fromString("9e511188-31a9-43ac-9107-29d94410d554") + const val PEER_ID = "12D3KooWStableFriendPeer" + const val CONNECT_ADDRESS = "purple-del.play.minekube.net" + const val CAPABILITY = "friend-capability-123456789" + const val INTERNET_ADDRESS = + "/ip6/2001:db8::20/tcp/4001/p2p/$PEER_ID" + val KEY_PAIR: KeyPair = + KeyPairGenerator.getInstance("Ed25519").generateKeyPair() + } +} diff --git a/share/common/src/test/kotlin/com/minekube/connect/share/friend/ShareAccessIdentityStoreTest.kt b/share/common/src/test/kotlin/com/minekube/connect/share/friend/ShareAccessIdentityStoreTest.kt new file mode 100644 index 000000000..2d0886fcd --- /dev/null +++ b/share/common/src/test/kotlin/com/minekube/connect/share/friend/ShareAccessIdentityStoreTest.kt @@ -0,0 +1,89 @@ +package com.minekube.connect.share.friend + +import java.nio.file.Files +import java.nio.file.Path +import java.util.UUID +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import org.junit.jupiter.api.io.TempDir + +class ShareAccessIdentityStoreTest { + @TempDir + lateinit var tempDir: Path + + @Test + fun `one access identity survives world changes and reloads`() { + val ids = values(FIRST_ID) + val capabilities = values(FIRST_CAPABILITY) + val store = store(ids, capabilities) + + val firstWorld = store.currentOrCreate() + val secondWorld = store.currentOrCreate() + val afterRestart = store(ids, capabilities).currentOrCreate() + + assertEquals(firstWorld, secondWorld) + assertEquals(firstWorld, afterRestart) + assertEquals(FIRST_ID, firstWorld.shareId) + assertEquals(FIRST_CAPABILITY, firstWorld.capability) + } + + @Test + fun `rotation revokes the prior access identity`() { + val store = store( + values(FIRST_ID, SECOND_ID), + values(FIRST_CAPABILITY, SECOND_CAPABILITY), + ) + val original = store.currentOrCreate() + + val replacement = store.rotate() + + assertNotEquals(original.shareId, replacement.shareId) + assertNotEquals(original.capability, replacement.capability) + assertEquals(replacement, store.currentOrCreate()) + } + + @Test + fun `rendering and persisted file do not expose capability through models`() { + val identity = store( + values(FIRST_ID), + values(FIRST_CAPABILITY), + ).currentOrCreate() + + val rendered = identity.toString() + + assertFalse(rendered.contains(FIRST_CAPABILITY)) + assertContains(rendered, "capability=") + assertContains( + Files.readString( + tempDir.resolve(ShareAccessIdentityStore.FILE_NAME), + ), + FIRST_CAPABILITY, + ) + } + + private fun store( + ids: () -> UUID, + capabilities: () -> String, + ) = ShareAccessIdentityStore.testing( + directory = tempDir, + generateShareId = ids, + generateCapability = capabilities, + ) + + private fun values(vararg values: A): () -> A { + val remaining = ArrayDeque(values.toList()) + return { remaining.removeFirst() } + } + + private companion object { + val FIRST_ID: UUID = + UUID.fromString("9e511188-31a9-43ac-9107-29d94410d554") + val SECOND_ID: UUID = + UUID.fromString("28c493d0-2bb0-4e2f-bacb-8af429073077") + const val FIRST_CAPABILITY = "first-capability-123456789" + const val SECOND_CAPABILITY = "second-capability-12345678" + } +} diff --git a/share/common/src/test/kotlin/com/minekube/connect/share/friend/SharePreferencesStoreTest.kt b/share/common/src/test/kotlin/com/minekube/connect/share/friend/SharePreferencesStoreTest.kt new file mode 100644 index 000000000..017b83891 --- /dev/null +++ b/share/common/src/test/kotlin/com/minekube/connect/share/friend/SharePreferencesStoreTest.kt @@ -0,0 +1,43 @@ +package com.minekube.connect.share.friend + +import java.nio.file.Path +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import org.junit.jupiter.api.io.TempDir + +class SharePreferencesStoreTest { + @TempDir + lateinit var tempDir: Path + + @Test + fun `share with friends remains enabled across restarts until disabled`() { + val store = SharePreferencesStore(tempDir) + + assertFalse(store.load().shareWithFriends) + + store.save(SharePreferences(shareWithFriends = true)) + assertTrue(SharePreferencesStore(tempDir).load().shareWithFriends) + + store.save(SharePreferences(shareWithFriends = false)) + assertFalse(SharePreferencesStore(tempDir).load().shareWithFriends) + } + + @Test + fun `independent presence privacy choices survive restart`() { + val preferences = SharePreferences( + shareWithFriends = true, + presence = PresencePrivacy( + showOnline = true, + showPlaying = false, + showCurrentServer = false, + showJoinable = true, + ), + ) + + SharePreferencesStore(tempDir).save(preferences) + + assertEquals(preferences, SharePreferencesStore(tempDir).load()) + } +} diff --git a/share/common/src/test/kotlin/com/minekube/connect/share/identity/EndpointIdentityStoreTest.kt b/share/common/src/test/kotlin/com/minekube/connect/share/identity/EndpointIdentityStoreTest.kt new file mode 100644 index 000000000..33913df5e --- /dev/null +++ b/share/common/src/test/kotlin/com/minekube/connect/share/identity/EndpointIdentityStoreTest.kt @@ -0,0 +1,339 @@ +package com.minekube.connect.share.identity + +import arrow.core.Either +import com.google.gson.Gson +import com.google.gson.JsonObject +import com.minekube.connect.identity.EndpointTokenStore +import java.nio.file.Files +import java.nio.file.Path +import java.util.concurrent.CancellationException +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNotEquals +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.io.TempDir + +class EndpointIdentityStoreTest { + @TempDir + lateinit var tempDir: Path + + private val tokenStore = EndpointTokenStore() + + @Test + fun `one generated identity survives reload and world changes`() = runTest { + val endpoints = values("amber-fox") + val tokens = values("T-AAAAAAAAAAAAAAAAAAAA") + val store = store(endpoints, tokens) + + val firstWorld = store.currentOrCreate() + val secondWorld = store.currentOrCreate() + val afterRestart = store(endpoints, tokens).currentOrCreate() + + assertEquals(firstWorld, secondWorld) + assertEquals(firstWorld, afterRestart) + assertEquals(CredentialSource.GENERATED, firstWorld.endpointSource) + assertEquals(CredentialSource.GENERATED, firstWorld.tokenSource) + } + + @Test + fun `environment overrides are resolved per field`() = runTest { + val endpoints = values("amber-fox") + val tokens = values("T-AAAAAAAAAAAAAAAAAAAA") + val persisted = store(endpoints, tokens).currentOrCreate() + + val endpointManaged = store( + endpoints, + tokens, + environment = mapOf(EndpointIdentityStore.ENV_ENDPOINT to "managed-endpoint"), + ).currentOrCreate() + val tokenManaged = store( + endpoints, + tokens, + environment = mapOf(EndpointTokenStore.ENV_TOKEN to "T-managed-token"), + ).currentOrCreate() + + assertEquals("managed-endpoint", endpointManaged.endpoint) + assertEquals(persisted.token, endpointManaged.token) + assertEquals(CredentialSource.ENVIRONMENT, endpointManaged.endpointSource) + assertEquals(CredentialSource.GENERATED, endpointManaged.tokenSource) + + assertEquals(persisted.endpoint, tokenManaged.endpoint) + assertEquals("T-managed-token", tokenManaged.token) + assertEquals(CredentialSource.GENERATED, tokenManaged.endpointSource) + assertEquals(CredentialSource.ENVIRONMENT, tokenManaged.tokenSource) + } + + @Test + fun `environment-managed credentials cannot be imported or reset`() = runTest { + val endpoints = values("amber-fox") + val tokens = values("T-AAAAAAAAAAAAAAAAAAAA") + store(endpoints, tokens).currentOrCreate() + val managed = store( + endpoints, + tokens, + environment = mapOf(EndpointIdentityStore.ENV_ENDPOINT to "managed-endpoint"), + ) + val before = snapshot() + + val imported = managed.import( + "dashboard-endpoint", + "T-BBBBBBBBBBBBBBBBBBBB", + validValidator, + ) + val reset = managed.resetConfirmed() + + assertIs( + assertIs>(imported).value, + ) + assertIs( + assertIs>(reset).value, + ) + assertSnapshotEquals(before) + } + + @Test + fun `dashboard import commits endpoint and token only after validation`() = runTest { + val store = store( + values("amber-fox"), + values("T-AAAAAAAAAAAAAAAAAAAA"), + ) + store.currentOrCreate() + val before = snapshot() + + val result = store.import( + endpoint = "dashboard-endpoint", + token = "T-BBBBBBBBBBBBBBBBBBBB", + validator = EndpointCredentialValidator { candidate -> + assertEquals("dashboard-endpoint", candidate.endpoint) + assertSnapshotEquals(before) + Either.Right(Unit) + }, + ) + + val imported = assertIs>(result).value + assertEquals("dashboard-endpoint", imported.endpoint) + assertEquals("T-BBBBBBBBBBBBBBBBBBBB", imported.token) + assertEquals(CredentialSource.IMPORTED, imported.endpointSource) + assertEquals(CredentialSource.IMPORTED, imported.tokenSource) + assertNotEquals(before.config.toList(), Files.readAllBytes(configFile()).toList()) + assertNotEquals(before.token.toList(), Files.readAllBytes(tokenFile()).toList()) + } + + @Test + fun `bad token leaves prior identity byte-for-byte intact`() = runTest { + val store = store( + values("amber-fox"), + values("T-AAAAAAAAAAAAAAAAAAAA"), + ) + store.currentOrCreate() + val before = snapshot() + + val result = store.import( + "dashboard-endpoint", + "not-a-connect-token", + validValidator, + ) + + assertIs>(result) + assertSnapshotEquals(before) + } + + @Test + fun `cancelled and failed validation leave prior identity intact`() = runTest { + val store = store( + values("amber-fox"), + values("T-AAAAAAAAAAAAAAAAAAAA"), + ) + store.currentOrCreate() + val before = snapshot() + + val failed = store.import( + "dashboard-endpoint", + "T-BBBBBBBBBBBBBBBBBBBB", + EndpointCredentialValidator { + Either.Left(CredentialValidationError.Rejected("Endpoint credentials were rejected")) + }, + ) + assertIs>(failed) + assertSnapshotEquals(before) + + val thrown = runCatching { + store.import( + "dashboard-endpoint", + "T-BBBBBBBBBBBBBBBBBBBB", + EndpointCredentialValidator { throw CancellationException("screen closed") }, + ) + }.exceptionOrNull() + assertIs(thrown) + assertSnapshotEquals(before) + } + + @Test + fun `plugin token json can be imported`() = runTest { + val store = store( + values("amber-fox"), + values("T-AAAAAAAAAAAAAAAAAAAA"), + ) + store.currentOrCreate() + val pluginTokenFile = tempDir.resolve("existing-plugin").resolve("token.json") + tokenStore.save(pluginTokenFile, "T-PLUGINPLUGINPLUGIN12") + + val result = store.importTokenFile( + endpoint = "plugin-endpoint", + tokenFile = pluginTokenFile, + validator = validValidator, + ) + + val imported = assertIs>(result).value + assertEquals("plugin-endpoint", imported.endpoint) + assertEquals("T-PLUGINPLUGINPLUGIN12", imported.token) + assertEquals( + "T-PLUGINPLUGINPLUGIN12", + tokenStore.load(tokenFile(), emptyMap()).orElseThrow(), + ) + } + + @Test + fun `reset is explicit and creates one replacement identity`() = runTest { + val endpoints = values("amber-fox", "brisk-wolf") + val tokens = values("T-AAAAAAAAAAAAAAAAAAAA", "T-BBBBBBBBBBBBBBBBBBBB") + val store = store(endpoints, tokens) + val original = store.currentOrCreate() + + val reset = assertIs>(store.resetConfirmed()).value + val reloaded = store.currentOrCreate() + + assertNotEquals(original, reset) + assertEquals("brisk-wolf", reset.endpoint) + assertEquals("T-BBBBBBBBBBBBBBBBBBBB", reset.token) + assertEquals(reset, reloaded) + } + + @Test + fun `logs and toString never contain token`() = runTest { + val identity = store( + values("amber-fox"), + values("T-AAAAAAAAAAAAAAAAAAAA"), + ).currentOrCreate() + + val rendered = identity.toString() + + assertFalse(rendered.contains(identity.token)) + assertContains(rendered, "token=") + } + + @Test + fun `second file failure restores the prior identity`() = runTest { + var configReplacements = 0 + val store = store( + values("amber-fox"), + values("T-AAAAAAAAAAAAAAAAAAAA"), + beforeConfigReplace = { + if (configReplacements++ > 0) { + error("injected config move failure") + } + }, + ) + store.currentOrCreate() + val before = snapshot() + + val thrown = runCatching { + store.import( + "dashboard-endpoint", + "T-BBBBBBBBBBBBBBBBBBBB", + validValidator, + ) + }.exceptionOrNull() + + assertIs(thrown) + assertSnapshotEquals(before) + assertFalse(Files.exists(transactionFile())) + } + + @Test + fun `interrupted transaction rolls back on next load`() = runTest { + val endpoints = values("amber-fox") + val tokens = values("T-AAAAAAAAAAAAAAAAAAAA") + val store = store(endpoints, tokens) + val original = store.currentOrCreate() + val before = snapshot() + + val tokenBackup = tempDir.resolve("token.json.manual.bak") + val configBackup = tempDir.resolve("config.json.manual.bak") + val tokenStage = tempDir.resolve("token.json.manual.new") + val configStage = tempDir.resolve("config.json.manual.new") + Files.copy(tokenFile(), tokenBackup) + Files.copy(configFile(), configBackup) + tokenStore.save(tokenFile(), "T-BBBBBBBBBBBBBBBBBBBB") + Files.writeString( + configFile(), + """{"endpoint":"dashboard-endpoint","endpointSource":"IMPORTED","tokenSource":"IMPORTED"}""", + ) + Files.writeString( + transactionFile(), + Gson().toJson( + mapOf( + "oldEndpoint" to "amber-fox", + "newEndpoint" to "dashboard-endpoint", + "tokenBackup" to tokenBackup.fileName.toString(), + "configBackup" to configBackup.fileName.toString(), + "tokenStage" to tokenStage.fileName.toString(), + "configStage" to configStage.fileName.toString(), + "hadToken" to true, + "hadConfig" to true, + ), + ), + ) + + val recovered = store(endpoints, tokens).currentOrCreate() + + assertEquals(original, recovered) + assertSnapshotEquals(before) + assertFalse(Files.exists(transactionFile())) + } + + private fun store( + endpoints: () -> String, + tokens: () -> String, + environment: Map = emptyMap(), + beforeConfigReplace: () -> Unit = {}, + ) = EndpointIdentityStore.testing( + directory = tempDir, + environment = environment, + endpointNames = EndpointNameSource { endpoints() }, + tokenStore = tokenStore, + generateToken = tokens, + beforeConfigReplace = beforeConfigReplace, + ) + + private fun values(vararg values: String): () -> String { + val remaining = ArrayDeque(values.toList()) + return { remaining.removeFirst() } + } + + private fun snapshot() = Snapshot( + config = Files.readAllBytes(configFile()), + token = Files.readAllBytes(tokenFile()), + ) + + private fun assertSnapshotEquals(expected: Snapshot) { + assertContentEquals(expected.config, Files.readAllBytes(configFile())) + assertContentEquals(expected.token, Files.readAllBytes(tokenFile())) + } + + private fun configFile() = tempDir.resolve(EndpointIdentityStore.CONFIG_FILE_NAME) + + private fun tokenFile() = tempDir.resolve(EndpointIdentityStore.TOKEN_FILE_NAME) + + private fun transactionFile() = tempDir.resolve(EndpointIdentityStore.TRANSACTION_FILE_NAME) + + private data class Snapshot(val config: ByteArray, val token: ByteArray) + + private companion object { + val validValidator = EndpointCredentialValidator { Either.Right(Unit) } + } +} diff --git a/share/common/src/test/kotlin/com/minekube/connect/share/recovery/RecoveryArchiveTest.kt b/share/common/src/test/kotlin/com/minekube/connect/share/recovery/RecoveryArchiveTest.kt new file mode 100644 index 000000000..858fc9171 --- /dev/null +++ b/share/common/src/test/kotlin/com/minekube/connect/share/recovery/RecoveryArchiveTest.kt @@ -0,0 +1,154 @@ +package com.minekube.connect.share.recovery + +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs + +class RecoveryArchiveTest { + private val archive = RecoveryArchive.testing(iterations = 10) + + @Test + fun `encrypted archive round trips every allowlisted recovery entry`() { + val encrypted = archive.encrypt(entries(), PASSPHRASE.copyOf()) + .getOrNull()!! + val restored = archive.decrypt(encrypted, PASSPHRASE.copyOf()) + .getOrNull()!! + + assertEquals(entries().map { it.fileName }, restored.map { it.fileName }) + entries().zip(restored).forEach { (expected, actual) -> + assertContentEquals(expected.contents, actual.contents) + } + assertFalse(encrypted.decodeToString().contains("friend-secret")) + } + + @Test + fun `wrong secret and tampering share one authentication failure`() { + val encrypted = archive.encrypt(entries(), PASSPHRASE.copyOf()) + .getOrNull()!! + + assertIs( + archive.decrypt(encrypted, "incorrect recovery secret".toCharArray()) + .leftOrNull(), + ) + val tampered = encrypted.copyOf().also { + it[it.lastIndex] = (it.last().toInt() xor 1).toByte() + } + assertIs( + archive.decrypt(tampered, PASSPHRASE.copyOf()).leftOrNull(), + ) + } + + @Test + fun `unsupported and oversized envelopes fail before decryption`() { + val encrypted = archive.encrypt(entries(), PASSPHRASE.copyOf()) + .getOrNull()!! + val unsupported = encrypted.copyOf().also { + it[RecoveryArchive.VERSION_OFFSET] = 99 + } + + assertIs( + archive.decrypt(unsupported, PASSPHRASE.copyOf()).leftOrNull(), + ) + assertIs( + archive.decrypt( + ByteArray(RecoveryArchive.MAX_ARCHIVE_BYTES + 1), + PASSPHRASE.copyOf(), + ).leftOrNull(), + ) + } + + @Test + fun `weak passphrase never starts encryption or decryption`() { + assertIs( + archive.encrypt(entries(), "short".toCharArray()).leftOrNull(), + ) + assertIs( + archive.decrypt(byteArrayOf(), CharArray(0)).leftOrNull(), + ) + } + + @Test + fun `unknown duplicate oversized and missing required entries are rejected`() { + assertIs( + archive.encrypt( + entries() + RecoveryEntry("latest.log", byteArrayOf(1)), + PASSPHRASE.copyOf(), + ).leftOrNull(), + ) + assertIs( + archive.encrypt( + entries() + entries().first(), + PASSPHRASE.copyOf(), + ).leftOrNull(), + ) + assertIs( + archive.encrypt( + entries().map { + if (it.fileName == RecoveryArchive.FRIENDS_FILE) { + it.copy( + contents = ByteArray( + RecoveryArchive.MAX_ENTRY_BYTES + 1, + ), + ) + } else { + it + } + }, + PASSPHRASE.copyOf(), + ).leftOrNull(), + ) + assertIs( + archive.encrypt( + entries().filterNot { + it.fileName == RecoveryArchive.SOCIAL_IDENTITY_FILE + }, + PASSPHRASE.copyOf(), + ).leftOrNull(), + ) + assertIs( + archive.encrypt( + entries().filterNot { + it.fileName == RecoveryArchive.ENDPOINT_TOKEN_FILE + }, + PASSPHRASE.copyOf(), + ).leftOrNull(), + ) + } + + private fun entries(): List = listOf( + RecoveryEntry( + RecoveryArchive.SOCIAL_IDENTITY_FILE, + "social-private-key".encodeToByteArray(), + ), + RecoveryEntry( + RecoveryArchive.GAMEPLAY_IDENTITY_FILE, + "gameplay-private-key".encodeToByteArray(), + ), + RecoveryEntry( + RecoveryArchive.ACCESS_IDENTITY_FILE, + "{\"capability\":\"friend-secret\"}".encodeToByteArray(), + ), + RecoveryEntry( + RecoveryArchive.FRIENDS_FILE, + "{\"friends\":[\"friend-secret\"]}".encodeToByteArray(), + ), + RecoveryEntry( + RecoveryArchive.PREFERENCES_FILE, + "{\"shareWithFriends\":true}".encodeToByteArray(), + ), + RecoveryEntry( + RecoveryArchive.ENDPOINT_CONFIG_FILE, + "{\"endpoint\":\"redacted\"}".encodeToByteArray(), + ), + RecoveryEntry( + RecoveryArchive.ENDPOINT_TOKEN_FILE, + "{\"token\":\"endpoint-secret\"}".encodeToByteArray(), + ), + ) + + private companion object { + val PASSPHRASE = "correct horse battery staple".toCharArray() + } +} diff --git a/share/common/src/test/kotlin/com/minekube/connect/share/recovery/RecoveryStoreTest.kt b/share/common/src/test/kotlin/com/minekube/connect/share/recovery/RecoveryStoreTest.kt new file mode 100644 index 000000000..d5d01f48a --- /dev/null +++ b/share/common/src/test/kotlin/com/minekube/connect/share/recovery/RecoveryStoreTest.kt @@ -0,0 +1,286 @@ +package com.minekube.connect.share.recovery + +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.attribute.PosixFilePermission +import kotlin.io.path.createDirectories +import kotlin.io.path.readBytes +import kotlin.io.path.writeBytes +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertTrue +import org.junit.jupiter.api.io.TempDir + +class RecoveryStoreTest { + @TempDir + lateinit var tempDir: Path + + @Test + fun `offline export and import restore the complete allowlisted state`() { + val source = tempDir.resolve("source").createDirectories() + val destination = tempDir.resolve("destination").createDirectories() + seed(source, "source") + seed(destination, "destination") + val backup = tempDir.resolve("friends.connect-share-backup") + + val exported = store(source).exportTo(backup, PASSPHRASE.copyOf()) + .getOrNull()!! + val imported = store(destination).importFrom( + backup, + PASSPHRASE.copyOf(), + ).getOrNull()!! + + assertEquals(7, exported.entryCount) + assertEquals(exported, imported) + RecoveryStore.FILE_NAMES.forEach { fileName -> + assertContentEquals( + source.resolve(fileName).readBytes(), + destination.resolve(fileName).readBytes(), + ) + } + } + + @Test + fun `atomic export replaces an existing backup with a complete archive`() { + val source = tempDir.resolve("source").createDirectories() + seed(source, "source") + val backup = tempDir.resolve("backup.bin").also { + it.writeBytes("incomplete old backup".encodeToByteArray()) + } + + val result = store(source).exportTo(backup, PASSPHRASE.copyOf()) + + assertEquals(7, result.getOrNull()!!.entryCount) + assertEquals( + 7, + testArchive().decrypt(backup.readBytes(), PASSPHRASE.copyOf()) + .getOrNull()!! + .size, + ) + } + + @Test + fun `export cannot overwrite live Share recovery material`() { + val source = tempDir.resolve("source").createDirectories() + seed(source, "source") + val friends = source.resolve(RecoveryArchive.FRIENDS_FILE) + val original = friends.readBytes() + + assertIs( + store(source).exportTo(friends, PASSPHRASE.copyOf()).leftOrNull(), + ) + assertContentEquals(original, friends.readBytes()) + } + + @Test + fun `optional files omitted by the backup are removed on restore`() { + val source = tempDir.resolve("source").createDirectories() + val destination = tempDir.resolve("destination").createDirectories() + seed(source, "source") + seed(destination, "destination") + val optional = setOf( + RecoveryArchive.PREFERENCES_FILE, + RecoveryArchive.ENDPOINT_CONFIG_FILE, + RecoveryArchive.ENDPOINT_TOKEN_FILE, + ) + optional.forEach { Files.delete(source.resolve(it)) } + val backup = tempDir.resolve("backup.bin") + + val exported = store(source).exportTo(backup, PASSPHRASE.copyOf()) + .getOrNull()!! + val imported = store(destination).importFrom( + backup, + PASSPHRASE.copyOf(), + ).getOrNull()!! + + assertEquals(4, exported.entryCount) + assertEquals(exported, imported) + optional.forEach { assertFalse(Files.exists(destination.resolve(it))) } + } + + @Test + fun `wrong secret and malformed backup leave live files unchanged`() { + val source = tempDir.resolve("source").createDirectories() + val destination = tempDir.resolve("destination").createDirectories() + seed(source, "source") + seed(destination, "destination") + val original = snapshot(destination) + val backup = tempDir.resolve("backup.bin") + store(source).exportTo(backup, PASSPHRASE.copyOf()) + + assertIs( + store(destination).importFrom( + backup, + "incorrect recovery secret".toCharArray(), + ).leftOrNull(), + ) + assertEquals(original, snapshot(destination)) + + backup.writeBytes(byteArrayOf(1, 2, 3)) + assertIs( + store(destination).importFrom( + backup, + PASSPHRASE.copyOf(), + ).leftOrNull(), + ) + assertEquals(original, snapshot(destination)) + } + + @Test + fun `preview authenticates and summarizes without changing live files`() { + val source = tempDir.resolve("source").createDirectories() + val destination = tempDir.resolve("destination").createDirectories() + seed(source, "source") + seed(destination, "destination") + val original = snapshot(destination) + val backup = tempDir.resolve("backup.bin") + store(source).exportTo(backup, PASSPHRASE.copyOf()) + + val preview = store(destination).preview( + backup, + PASSPHRASE.copyOf(), + ).getOrNull()!! + + assertEquals(7, preview.entryCount) + assertTrue(preview.includesPreferences) + assertTrue(preview.includesEndpointIdentity) + assertEquals(original, snapshot(destination)) + } + + @Test + fun `replacement failure rolls every changed file back`() { + val source = tempDir.resolve("source").createDirectories() + val destination = tempDir.resolve("destination").createDirectories() + seed(source, "source") + seed(destination, "destination") + val original = snapshot(destination) + val backup = tempDir.resolve("backup.bin") + store(source).exportTo(backup, PASSPHRASE.copyOf()) + + val failing = RecoveryStore( + directory = destination, + archive = testArchive(), + beforeReplace = { index -> + if (index == 2) error("injected replacement failure") + }, + ) + + assertIs( + failing.importFrom(backup, PASSPHRASE.copyOf()).leftOrNull(), + ) + assertEquals(original, snapshot(destination)) + assertFalse(Files.exists(destination.resolve(RecoveryStore.TRANSACTION_DIRECTORY))) + } + + @Test + fun `a new operation rolls back an interrupted transaction`() { + val source = tempDir.resolve("source").createDirectories() + val destination = tempDir.resolve("destination").createDirectories() + seed(source, "source") + seed(destination, "destination") + val original = snapshot(destination) + val backup = tempDir.resolve("backup.bin") + store(source).exportTo(backup, PASSPHRASE.copyOf()) + val interrupted = RecoveryStore( + directory = destination, + archive = testArchive(), + beforeReplace = { index -> + if (index == 2) throw SimulatedPowerLoss + }, + ) + + assertFailsWith { + interrupted.importFrom(backup, PASSPHRASE.copyOf()) + } + assertTrue(Files.exists(destination.resolve(RecoveryStore.TRANSACTION_DIRECTORY))) + + store(destination).exportTo( + tempDir.resolve("after-recovery.bin"), + PASSPHRASE.copyOf(), + ) + assertEquals(original, snapshot(destination)) + assertFalse(Files.exists(destination.resolve(RecoveryStore.TRANSACTION_DIRECTORY))) + } + + @Test + fun `export rejects symlinks and missing required material`() { + val source = tempDir.resolve("source").createDirectories() + seed(source, "source") + Files.delete(source.resolve(RecoveryArchive.FRIENDS_FILE)) + + assertIs( + store(source).exportTo( + tempDir.resolve("missing.bin"), + PASSPHRASE.copyOf(), + ).leftOrNull(), + ) + + source.resolve(RecoveryArchive.FRIENDS_FILE).writeBytes(byteArrayOf(1)) + val linkTarget = tempDir.resolve("outside.key").also { + it.writeBytes(byteArrayOf(2)) + } + Files.delete(source.resolve(RecoveryArchive.SOCIAL_IDENTITY_FILE)) + try { + Files.createSymbolicLink( + source.resolve(RecoveryArchive.SOCIAL_IDENTITY_FILE), + linkTarget, + ) + } catch (_: UnsupportedOperationException) { + return + } + assertIs( + store(source).exportTo( + tempDir.resolve("symlink.bin"), + PASSPHRASE.copyOf(), + ).leftOrNull(), + ) + } + + @Test + fun `backup is owner only where posix permissions are available`() { + val source = tempDir.resolve("source").createDirectories() + seed(source, "source") + val backup = tempDir.resolve("backup.bin") + + store(source).exportTo(backup, PASSPHRASE.copyOf()) + + val view = Files.getFileAttributeView( + backup, + java.nio.file.attribute.PosixFileAttributeView::class.java, + ) ?: return + assertEquals( + setOf(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE), + view.readAttributes().permissions(), + ) + } + + private fun store(directory: Path) = RecoveryStore( + directory = directory, + archive = testArchive(), + ) + + private fun testArchive() = RecoveryArchive.testing(iterations = 10) + + private fun seed(directory: Path, prefix: String) { + RecoveryStore.FILE_NAMES.forEach { fileName -> + directory.resolve(fileName).writeBytes( + "$prefix-$fileName".encodeToByteArray(), + ) + } + } + + private fun snapshot(directory: Path): Map> = + RecoveryStore.FILE_NAMES.associateWith { fileName -> + directory.resolve(fileName).readBytes().toList() + } + + private data object SimulatedPowerLoss : Error() + + private companion object { + val PASSPHRASE = "correct horse battery staple".toCharArray() + } +} diff --git a/share/fabric-1.20.1/build.gradle.kts b/share/fabric-1.20.1/build.gradle.kts new file mode 100644 index 000000000..c945f99d9 --- /dev/null +++ b/share/fabric-1.20.1/build.gradle.kts @@ -0,0 +1,162 @@ +import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + id("connect.shadow-conventions") + id("net.fabricmc.fabric-loom-remap") + id("org.jetbrains.kotlin.jvm") +} + +base { + archivesName = "connect-share-fabric-1.20.1" +} + +java { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } +} + +kotlin { + jvmToolchain(21) + compilerOptions.jvmTarget.set(JvmTarget.JVM_17) +} + +repositories { + maven("https://repo.opencollab.dev/maven-releases") { + mavenContent { releasesOnly() } + } + maven("https://repo.opencollab.dev/maven-snapshots") { + mavenContent { snapshotsOnly() } + } + maven("https://dl.cloudsmith.io/public/libp2p/jvm-libp2p/maven/") + maven("https://dl.cloudsmith.io/public/consensys/maven/maven/") + maven("https://jitpack.io") { + content { includeGroupByRegex("com\\.github\\..*") } + } +} + +val connectShareParentRuntime by configurations.creating { + isCanBeConsumed = false + isCanBeResolved = true +} + +dependencies { + minecraft("com.mojang:minecraft:1.20.1") + mappings(loom.officialMojangMappings()) + modImplementation("net.fabricmc:fabric-loader:${Versions.fabricLoaderVersion}") + modImplementation("net.fabricmc.fabric-api:fabric-api:${Versions.fabricApi1201Version}") + modImplementation("net.fabricmc:fabric-language-kotlin:${Versions.fabricLanguageKotlinVersion}") + compileOnly("org.jspecify:jspecify:1.0.0") + + implementation(projects.core) + implementation(projects.share.common) + implementation(projects.share.fabricCommon) + connectShareParentRuntime(projects.share.fabricCommon) { + exclude(group = "io.libp2p") + exclude(group = "io.netty") + exclude(group = "org.jetbrains.kotlin") + exclude(group = "org.jetbrains.kotlinx") + exclude(group = "com.google.errorprone", module = "javac") + } + + testImplementation(kotlin("test")) + testRuntimeOnly("org.junit.platform:junit-platform-launcher") +} + +tasks.test { + useJUnitPlatform() + dependsOn(tasks.remapJar) + systemProperty( + "connectShareArtifact", + tasks.remapJar.flatMap { it.archiveFile }.get().asFile.absolutePath, + ) +} + +tasks.processResources { + inputs.property("version", project.version) + filesMatching("fabric.mod.json") { + expand("version" to project.version) + } +} + +relocate("arrow") +relocate("aopalliance") +relocate("cloud.commandframework") +relocate("com.google.common") +relocate("com.google.gson") +relocate("com.google.inject") +relocate("com.google.protobuf") +relocate("it.unimi.dsi.fastutil") +relocate("io.grpc") +relocate("io.leangen.geantyref") +relocate("jakarta.inject") +relocate("javax.inject") +relocate("okhttp3") +relocate("okio") +relocate("org.bstats") +relocate("org.geysermc.configutils") +relocate("org.yaml.snakeyaml") + +val libp2pRuntimeJar = tasks.named("libp2pRuntimeJar") { + dependsOn(":core:classes") + from(rootProject.project(":core").layout.buildDirectory.dir("classes/java/main")) { + include( + "com/minekube/connect/tunnel/p2p/DirectP2pNodeRuntime*.class", + "com/minekube/connect/tunnel/p2p/Libp2pEndpointRuntime*.class", + "com/minekube/connect/tunnel/p2p/impl/Libp2pTunnelTransportRuntime*.class", + ) + } +} +val minecraftGameProfileFactory = + "com/minekube/connect/share/fabric/v1_20_1/" + + "MinecraftGameProfileFactory.class" +val connectShareShadowJar = tasks.named("shadowJar") { + configurations = listOf(connectShareParentRuntime) + archiveBaseName.set("connect-share-fabric-1.20.1") + archiveVersion.set(project.version.toString()) + archiveClassifier.set("dev-parent-shadow") + mergeServiceFiles() + from(rootProject.file("LICENSE")) + // Authlib's PropertyMap constructor must keep Minecraft's Guava ABI. + exclude(minecraftGameProfileFactory) +} +val connectShareJar = tasks.register("connectShareJar") { + dependsOn(connectShareShadowJar, libp2pRuntimeJar) + archiveBaseName.set("connect-share-fabric-1.20.1") + archiveVersion.set(project.version.toString()) + archiveClassifier.set("dev-shadow") + duplicatesStrategy = DuplicatesStrategy.EXCLUDE + from({ + zipTree(connectShareShadowJar.get().archiveFile.get().asFile) + }) + from(libp2pRuntimeJar) { + into("META-INF/connect") + } + from(sourceSets.main.get().output) { + include(minecraftGameProfileFactory) + } +} + +tasks.remapJar { + dependsOn(connectShareJar) + inputFile.set(connectShareJar.flatMap { it.archiveFile }) + archiveBaseName.set("connect-share-fabric-1.20.1") + archiveVersion.set(project.version.toString()) + archiveClassifier.set("") +} + +val verifyConnectShareArtifactSize = tasks.register("verifyConnectShareArtifactSize") { + dependsOn(tasks.remapJar) + val artifact = tasks.remapJar.flatMap { it.archiveFile } + inputs.file(artifact) + doLast { + val bytes = artifact.get().asFile.length() + val limit = 63L * 1024L * 1024L + logger.lifecycle("Connect Share Fabric 1.20.1 artifact: {} MiB", "%.1f".format(bytes / 1024.0 / 1024.0)) + check(bytes <= limit) { "Connect Share Fabric 1.20.1 exceeds the 63 MiB release budget ($bytes bytes)" } + } +} +tasks.check { dependsOn(verifyConnectShareArtifactSize) } diff --git a/share/fabric-1.20.1/src/main/java/com/minekube/connect/share/fabric/v1_20_1/MinecraftGameProfileFactory.java b/share/fabric-1.20.1/src/main/java/com/minekube/connect/share/fabric/v1_20_1/MinecraftGameProfileFactory.java new file mode 100644 index 000000000..f1ecd9c06 --- /dev/null +++ b/share/fabric-1.20.1/src/main/java/com/minekube/connect/share/fabric/v1_20_1/MinecraftGameProfileFactory.java @@ -0,0 +1,18 @@ +package com.minekube.connect.share.fabric.v1_20_1; + +import com.mojang.authlib.GameProfile; +import com.mojang.authlib.properties.Property; +import java.util.List; +import java.util.UUID; + +final class MinecraftGameProfileFactory { + private MinecraftGameProfileFactory() {} + + static GameProfile create(UUID id, String username, List properties) { + GameProfile profile = new GameProfile(id, username); + for (Property property : properties) { + profile.getProperties().put(property.getName(), property); + } + return profile; + } +} diff --git a/share/fabric-1.20.1/src/main/java/com/minekube/connect/share/fabric/v1_20_1/mixin/ConnectionAccessor.java b/share/fabric-1.20.1/src/main/java/com/minekube/connect/share/fabric/v1_20_1/mixin/ConnectionAccessor.java new file mode 100644 index 000000000..b1261b050 --- /dev/null +++ b/share/fabric-1.20.1/src/main/java/com/minekube/connect/share/fabric/v1_20_1/mixin/ConnectionAccessor.java @@ -0,0 +1,12 @@ +package com.minekube.connect.share.fabric.v1_20_1.mixin; + +import io.netty.channel.Channel; +import net.minecraft.network.Connection; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.gen.Accessor; + +@Mixin(Connection.class) +public interface ConnectionAccessor { + @Accessor("channel") + Channel getConnectShareChannel(); +} diff --git a/share/fabric-1.20.1/src/main/java/com/minekube/connect/share/fabric/v1_20_1/mixin/IntegratedServerAccessor.java b/share/fabric-1.20.1/src/main/java/com/minekube/connect/share/fabric/v1_20_1/mixin/IntegratedServerAccessor.java new file mode 100644 index 000000000..b7f0d8106 --- /dev/null +++ b/share/fabric-1.20.1/src/main/java/com/minekube/connect/share/fabric/v1_20_1/mixin/IntegratedServerAccessor.java @@ -0,0 +1,19 @@ +package com.minekube.connect.share.fabric.v1_20_1.mixin; + +import net.minecraft.client.server.IntegratedServer; +import net.minecraft.client.server.LanServerPinger; +import org.jspecify.annotations.Nullable; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.gen.Accessor; + +@Mixin(IntegratedServer.class) +public interface IntegratedServerAccessor { + @Accessor("publishedPort") + void setConnectSharePublishedPort(int port); + + @Accessor("lanPinger") + @Nullable LanServerPinger getConnectShareLanPinger(); + + @Accessor("lanPinger") + void setConnectShareLanPinger(@Nullable LanServerPinger pinger); +} diff --git a/share/fabric-1.20.1/src/main/java/com/minekube/connect/share/fabric/v1_20_1/mixin/IntegratedServerMixin.java b/share/fabric-1.20.1/src/main/java/com/minekube/connect/share/fabric/v1_20_1/mixin/IntegratedServerMixin.java new file mode 100644 index 000000000..17bb94ab6 --- /dev/null +++ b/share/fabric-1.20.1/src/main/java/com/minekube/connect/share/fabric/v1_20_1/mixin/IntegratedServerMixin.java @@ -0,0 +1,24 @@ +package com.minekube.connect.share.fabric.v1_20_1.mixin; + +import com.minekube.connect.share.CapturedServerTransport; +import net.minecraft.client.server.IntegratedServer; +import net.minecraft.client.server.LanServerPinger; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Redirect; + +@Mixin(IntegratedServer.class) +public abstract class IntegratedServerMixin { + @Redirect( + method = "publishServer", + at = @At( + value = "INVOKE", + target = "Lnet/minecraft/client/server/LanServerPinger;start()V")) + private void connectShare$suppressLanAdvertisement(LanServerPinger pinger) { + if (CapturedServerTransport.isShareStartArmed()) { + pinger.interrupt(); + } else { + pinger.start(); + } + } +} diff --git a/share/fabric-1.20.1/src/main/java/com/minekube/connect/share/fabric/v1_20_1/mixin/LanServerPingerAccessor.java b/share/fabric-1.20.1/src/main/java/com/minekube/connect/share/fabric/v1_20_1/mixin/LanServerPingerAccessor.java new file mode 100644 index 000000000..86eeeb62c --- /dev/null +++ b/share/fabric-1.20.1/src/main/java/com/minekube/connect/share/fabric/v1_20_1/mixin/LanServerPingerAccessor.java @@ -0,0 +1,12 @@ +package com.minekube.connect.share.fabric.v1_20_1.mixin; + +import java.net.DatagramSocket; +import net.minecraft.client.server.LanServerPinger; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.gen.Accessor; + +@Mixin(LanServerPinger.class) +public interface LanServerPingerAccessor { + @Accessor("socket") + DatagramSocket getConnectShareSocket(); +} diff --git a/share/fabric-1.20.1/src/main/java/com/minekube/connect/share/fabric/v1_20_1/mixin/PauseScreenMixin.java b/share/fabric-1.20.1/src/main/java/com/minekube/connect/share/fabric/v1_20_1/mixin/PauseScreenMixin.java new file mode 100644 index 000000000..ca29678a5 --- /dev/null +++ b/share/fabric-1.20.1/src/main/java/com/minekube/connect/share/fabric/v1_20_1/mixin/PauseScreenMixin.java @@ -0,0 +1,85 @@ +package com.minekube.connect.share.fabric.v1_20_1.mixin; + +import com.minekube.connect.share.fabric.ConnectShareClient; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.network.chat.Component; +import org.jspecify.annotations.Nullable; +import org.spongepowered.asm.mixin.Final; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.Unique; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +@Mixin(net.minecraft.client.gui.screens.PauseScreen.class) +abstract class PauseScreenMixin extends Screen { + @Shadow @Final private boolean showPauseMenu; + @Shadow private @Nullable Button disconnectButton; + @Unique private @Nullable Button connectShareButton; + @Unique private @Nullable Button connectShareFriendsButton; + + protected PauseScreenMixin(Component title) { + super(title); + } + + @Inject(method = "init", at = @At("TAIL")) + private void connectShare$addButtons(CallbackInfo ci) { + Minecraft client = Minecraft.getInstance(); + if (!showPauseMenu + || disconnectButton == null + || !ConnectShareClient.isInstalled()) { + return; + } + + int rowX = disconnectButton.getX(); + int rowY = disconnectButton.getY(); + disconnectButton.setY(rowY + 24); + removeWidget(disconnectButton); + if (client.hasSingleplayerServer()) { + connectShareButton = addRenderableWidget( + Button.builder( + Component.translatable( + ConnectShareClient.pauseButtonTranslationKey()), + button -> ConnectShareClient.openPauseScreen(this)) + .bounds(rowX, rowY, 100, 20) + .build()); + connectShareFriendsButton = addRenderableWidget( + Button.builder( + connectShare$friendsLabel(), + button -> ConnectShareClient.openJoinScreen(this)) + .bounds(rowX + 104, rowY, 100, 20) + .build()); + } else { + connectShareFriendsButton = addRenderableWidget( + Button.builder( + connectShare$friendsLabel(), + button -> ConnectShareClient.openJoinScreen(this)) + .bounds(rowX, rowY, 204, 20) + .build()); + } + addRenderableWidget(disconnectButton); + } + + @Inject(method = "tick", at = @At("TAIL")) + private void connectShare$refreshButtons(CallbackInfo ci) { + if (connectShareButton != null) { + connectShareButton.setMessage( + Component.translatable(ConnectShareClient.pauseButtonTranslationKey())); + } + if (connectShareFriendsButton != null) { + connectShareFriendsButton.setMessage(connectShare$friendsLabel()); + } + } + + @Unique + private Component connectShare$friendsLabel() { + int count = ConnectShareClient.friendsButtonCount(); + String key = ConnectShareClient.friendsButtonTranslationKey(); + return count > 0 + ? Component.translatable(key, count) + : Component.translatable(key); + } +} diff --git a/share/fabric-1.20.1/src/main/java/com/minekube/connect/share/fabric/v1_20_1/mixin/ServerConnectionListenerAccessor.java b/share/fabric-1.20.1/src/main/java/com/minekube/connect/share/fabric/v1_20_1/mixin/ServerConnectionListenerAccessor.java new file mode 100644 index 000000000..7b98e7b2f --- /dev/null +++ b/share/fabric-1.20.1/src/main/java/com/minekube/connect/share/fabric/v1_20_1/mixin/ServerConnectionListenerAccessor.java @@ -0,0 +1,13 @@ +package com.minekube.connect.share.fabric.v1_20_1.mixin; + +import io.netty.channel.ChannelFuture; +import java.util.List; +import net.minecraft.server.network.ServerConnectionListener; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.gen.Accessor; + +@Mixin(ServerConnectionListener.class) +public interface ServerConnectionListenerAccessor { + @Accessor("channels") + List getConnectShareChannels(); +} diff --git a/share/fabric-1.20.1/src/main/java/com/minekube/connect/share/fabric/v1_20_1/mixin/ServerConnectionListenerMixin.java b/share/fabric-1.20.1/src/main/java/com/minekube/connect/share/fabric/v1_20_1/mixin/ServerConnectionListenerMixin.java new file mode 100644 index 000000000..bfc83cbbf --- /dev/null +++ b/share/fabric-1.20.1/src/main/java/com/minekube/connect/share/fabric/v1_20_1/mixin/ServerConnectionListenerMixin.java @@ -0,0 +1,57 @@ +package com.minekube.connect.share.fabric.v1_20_1.mixin; + +import com.minekube.connect.share.CapturedServerTransport; +import io.netty.channel.Channel; +import io.netty.channel.ChannelHandler; +import io.netty.channel.ChannelInitializer; +import io.netty.channel.EventLoopGroup; +import java.net.InetAddress; +import net.minecraft.server.network.ServerConnectionListener; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.ModifyArg; +import org.spongepowered.asm.mixin.injection.ModifyVariable; + +@Mixin(ServerConnectionListener.class) +public abstract class ServerConnectionListenerMixin { + @ModifyVariable( + method = "startTcpServerListener", + at = @At("HEAD"), + argsOnly = true, + ordinal = 0) + private InetAddress connectShare$forceLoopback(InetAddress requestedAddress) { + return CapturedServerTransport.isShareStartArmed() + ? InetAddress.getLoopbackAddress() + : requestedAddress; + } + + @ModifyArg( + method = "startTcpServerListener", + at = @At( + value = "INVOKE", + target = "Lio/netty/bootstrap/ServerBootstrap;childHandler" + + "(Lio/netty/channel/ChannelHandler;)" + + "Lio/netty/bootstrap/ServerBootstrap;"), + index = 0) + @SuppressWarnings("unchecked") + private ChannelHandler connectShare$captureChildInitializer(ChannelHandler handler) { + if (CapturedServerTransport.isShareStartArmed() + && handler instanceof ChannelInitializer) { + CapturedServerTransport.captureChildInitializer( + (ChannelInitializer) handler); + } + return handler; + } + + @ModifyArg( + method = "startTcpServerListener", + at = @At( + value = "INVOKE", + target = "Lio/netty/bootstrap/ServerBootstrap;group" + + "(Lio/netty/channel/EventLoopGroup;)" + + "Lio/netty/bootstrap/ServerBootstrap;"), + index = 0) + private EventLoopGroup connectShare$captureEventLoopGroup(EventLoopGroup group) { + return CapturedServerTransport.captureEventLoopGroup(group); + } +} diff --git a/share/fabric-1.20.1/src/main/java/com/minekube/connect/share/fabric/v1_20_1/mixin/ServerLoginPacketListenerMixin.java b/share/fabric-1.20.1/src/main/java/com/minekube/connect/share/fabric/v1_20_1/mixin/ServerLoginPacketListenerMixin.java new file mode 100644 index 000000000..359bb8e6b --- /dev/null +++ b/share/fabric-1.20.1/src/main/java/com/minekube/connect/share/fabric/v1_20_1/mixin/ServerLoginPacketListenerMixin.java @@ -0,0 +1,103 @@ +package com.minekube.connect.share.fabric.v1_20_1.mixin; + +import com.mojang.authlib.GameProfile; +import com.minekube.connect.share.fabric.v1_20_1.Minecraft1201LoginBridge; +import net.minecraft.network.Connection; +import net.minecraft.network.chat.Component; +import net.minecraft.network.protocol.login.ServerboundHelloPacket; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.network.ServerLoginPacketListenerImpl; +import org.spongepowered.asm.mixin.Final; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.Unique; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +@Mixin(ServerLoginPacketListenerImpl.class) +public abstract class ServerLoginPacketListenerMixin { + @Shadow @Final private MinecraftServer server; + @Shadow @Final private Connection connection; + @Shadow private GameProfile gameProfile; + + @Shadow + public abstract void handleAcceptedLogin(); + + @Shadow + public abstract void disconnect(Component reason); + + @Unique private boolean connectShare$admissionStarted; + @Unique private boolean connectShare$admissionAllowed; + + @Inject(method = "handleHello", at = @At("HEAD"), cancellable = true) + private void connectShare$acceptConnectProfile( + ServerboundHelloPacket hello, + CallbackInfo callback) { + GameProfile profile = null; + if (Minecraft1201LoginBridge.hasConnectIdentity(connection)) { + profile = Minecraft1201LoginBridge.authenticatedProfile(connection, hello.name()); + if (profile == null) { + disconnect(Component.literal("Connect identity is invalid")); + callback.cancel(); + return; + } + } else if (Minecraft1201LoginBridge.shouldUseOfflineDirectProfile(connection)) { + profile = Minecraft1201LoginBridge.offlineProfile(hello.name()); + if (profile == null) { + disconnect(Component.literal("Invalid characters in username")); + callback.cancel(); + return; + } + } + if (profile == null) { + return; + } + + gameProfile = profile; + if (Minecraft1201LoginBridge.hasDirectSession(connection) + || Minecraft1201LoginBridge.isPassthroughConnect(connection)) { + connectShare$beginAdmission(profile); + } else { + connectShare$admissionAllowed = true; + Minecraft1201LoginBridge.continueApprovedLogin( + this, + this::handleAcceptedLogin); + } + callback.cancel(); + } + + @Inject(method = "handleAcceptedLogin", at = @At("HEAD"), cancellable = true) + private void connectShare$awaitAdmission(CallbackInfo callback) { + boolean direct = Minecraft1201LoginBridge.hasDirectSession(connection); + if (!direct && !Minecraft1201LoginBridge.isPassthroughConnect(connection)) { + return; + } + if (connectShare$admissionAllowed) { + return; + } + callback.cancel(); + connectShare$beginAdmission(gameProfile); + } + + @Unique + private void connectShare$beginAdmission(GameProfile profile) { + if (connectShare$admissionStarted) { + return; + } + connectShare$admissionStarted = true; + Runnable allow = () -> { + connectShare$admissionAllowed = true; + Minecraft1201LoginBridge.continueApprovedLogin( + this, + this::handleAcceptedLogin); + }; + if (Minecraft1201LoginBridge.hasDirectSession(connection)) { + Minecraft1201LoginBridge.requestDirectAdmission( + connection, server, profile, allow, this::disconnect); + } else { + Minecraft1201LoginBridge.requestPassthroughAdmission( + connection, server, profile, allow, this::disconnect); + } + } +} diff --git a/share/fabric-1.20.1/src/main/java/com/minekube/connect/share/fabric/v1_20_1/mixin/TitleScreenMixin.java b/share/fabric-1.20.1/src/main/java/com/minekube/connect/share/fabric/v1_20_1/mixin/TitleScreenMixin.java new file mode 100644 index 000000000..26f3ecca3 --- /dev/null +++ b/share/fabric-1.20.1/src/main/java/com/minekube/connect/share/fabric/v1_20_1/mixin/TitleScreenMixin.java @@ -0,0 +1,49 @@ +package com.minekube.connect.share.fabric.v1_20_1.mixin; + +import com.minekube.connect.share.fabric.ConnectShareClient; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.network.chat.Component; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Unique; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +@Mixin(net.minecraft.client.gui.screens.TitleScreen.class) +abstract class TitleScreenMixin extends Screen { + @Unique private Button connectShare$friendsButton; + + protected TitleScreenMixin(Component title) { + super(title); + } + + @Inject(method = "init", at = @At("TAIL")) + private void connectShare$addJoinButton(CallbackInfo ci) { + if (!ConnectShareClient.isInstalled()) { + return; + } + connectShare$friendsButton = addRenderableWidget( + Button.builder( + connectShare$friendsLabel(), + button -> ConnectShareClient.openJoinScreen(this)) + .bounds(width - 74, 4, 70, 20) + .build()); + } + + @Inject(method = "tick", at = @At("TAIL")) + private void connectShare$refreshJoinButton(CallbackInfo ci) { + if (connectShare$friendsButton != null) { + connectShare$friendsButton.setMessage(connectShare$friendsLabel()); + } + } + + @Unique + private Component connectShare$friendsLabel() { + int count = ConnectShareClient.friendsButtonCount(); + String key = ConnectShareClient.friendsButtonTranslationKey(); + return count > 0 + ? Component.translatable(key, count) + : Component.translatable(key); + } +} diff --git a/share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/BlockedFriendsScreen.kt b/share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/BlockedFriendsScreen.kt new file mode 100644 index 000000000..f7ed4235d --- /dev/null +++ b/share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/BlockedFriendsScreen.kt @@ -0,0 +1,142 @@ +package com.minekube.connect.share.fabric.v1_20_1 + +import com.minekube.connect.share.fabric.ConnectShareClient +import com.minekube.connect.share.fabric.ui.AdaptiveShareLayout +import com.minekube.connect.share.fabric.ui.page +import net.minecraft.ChatFormatting +import net.minecraft.client.gui.components.Button +import net.minecraft.client.gui.components.MultiLineTextWidget +import net.minecraft.client.gui.components.StringWidget +import net.minecraft.client.gui.screens.Screen +import net.minecraft.network.chat.CommonComponents +import net.minecraft.network.chat.Component + +class BlockedFriendsScreen( + private val parent: Screen, +) : Screen(Component.translatable("connect_share.privacy.blocked_title")) { + private var offset = 0 + + override fun init() { + val friends = ConnectShareClient.friendsViewModel() + val layout = AdaptiveShareLayout.friends(width, height) + val blocked = friends.state.value.blocked + val page = blocked.page(offset, layout.visibleRows) + offset = page.offset + + addRenderableWidget( + centered(title.copy().withStyle(ChatFormatting.BOLD), layout.headerY), + ) + addRenderableWidget( + MultiLineTextWidget( + layout.contentX, + layout.subtitleY, + Component.translatable( + "connect_share.privacy.blocked_description", + ).withStyle(ChatFormatting.GRAY), + font, + ).setMaxWidth(layout.contentWidth).setCentered(true), + ) + + page.items.forEachIndexed { index, identity -> + val y = layout.rowY(index) + val buttonWidth = 88 + addRenderableWidget( + StringWidget( + layout.contentX, + y, + layout.contentWidth - buttonWidth - 6, + 20, + Component.literal(identity.displayName), + font, + ), + ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.privacy.unblock"), + ) { + friends.unblock(identity.peerId) + rebuildWidgets() + }.bounds( + layout.contentX + layout.contentWidth - buttonWidth, + y, + buttonWidth, + 20, + ).build(), + ) + } + + if (blocked.isEmpty()) { + addRenderableWidget( + centered( + Component.translatable("connect_share.privacy.blocked_empty") + .withStyle(ChatFormatting.GRAY), + layout.rowsTop + 18, + ), + ) + } else { + addRenderableWidget( + centered( + Component.translatable( + "connect_share.friends.page", + page.pageNumber, + page.pageCount, + ).withStyle(ChatFormatting.DARK_GRAY), + layout.messageY, + ), + ) + } + + val pageButtonWidth = layout.halfButtonWidth + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.page.previous"), + ) { + offset = page.previousOffset ?: 0 + rebuildWidgets() + }.bounds( + layout.contentX, + layout.footerTop, + pageButtonWidth, + 20, + ).build().apply { active = page.hasPrevious }, + ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.page.next"), + ) { + offset = page.nextOffset ?: page.offset + rebuildWidgets() + }.bounds( + layout.contentX + pageButtonWidth + 6, + layout.footerTop, + pageButtonWidth, + 20, + ).build().apply { active = page.hasNext }, + ) + addRenderableWidget( + Button.builder(CommonComponents.GUI_BACK) { onClose() } + .bounds( + layout.contentX, + layout.footerTop + 24, + layout.contentWidth, + 20, + ).build(), + ) + } + + override fun onClose() { + minecraft!!.setScreen(parent) + } + + private fun centered(message: Component, y: Int): StringWidget { + val textWidth = font.width(message) + return StringWidget( + width / 2 - textWidth / 2, + y, + textWidth, + 11, + message, + font, + ) + } +} diff --git a/share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/CompatibilityMismatchScreen.kt b/share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/CompatibilityMismatchScreen.kt new file mode 100644 index 000000000..f9c931c72 --- /dev/null +++ b/share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/CompatibilityMismatchScreen.kt @@ -0,0 +1,142 @@ +package com.minekube.connect.share.fabric.v1_20_1 + +import com.minekube.connect.share.fabric.FriendJoinAttemptFailure +import com.minekube.connect.share.fabric.ui.AdaptiveShareLayout +import com.minekube.connect.share.fabric.ui.presentation +import net.minecraft.ChatFormatting +import net.minecraft.client.gui.components.Button +import net.minecraft.client.gui.components.MultiLineTextWidget +import net.minecraft.client.gui.components.StringWidget +import net.minecraft.client.gui.screens.Screen +import net.minecraft.network.chat.CommonComponents +import net.minecraft.network.chat.Component + +class CompatibilityMismatchScreen( + private val parent: Screen, + private val failure: FriendJoinAttemptFailure.Compatibility, + private val tryAnyway: () -> Unit, +) : Screen(Component.translatable("connect_share.compatibility.title")) { + private var packCopied = false + + override fun init() { + val layout = AdaptiveShareLayout.form(width, height, 1) + addRenderableWidget( + centered(title.copy().withStyle(ChatFormatting.YELLOW), layout.headerY), + ) + addRenderableWidget( + MultiLineTextWidget( + layout.contentX, + layout.subtitleY, + Component.translatable("connect_share.compatibility.description"), + font, + ).setMaxWidth(layout.contentWidth).setCentered(true), + ) + + val visibleDifferences = ((layout.availableBodyHeight - 24) / 15) + .coerceIn(1, 5) + failure.report.differences + .take(visibleDifferences) + .forEachIndexed { index, difference -> + val line = difference.presentation() + addRenderableWidget( + StringWidget( + layout.contentX, + layout.bodyTop + 12 + index * 15, + layout.contentWidth, + 11, + Component.translatable( + line.translationKey, + *line.arguments.toTypedArray(), + ), + font, + ), + ) + } + val hidden = failure.report.differences.size - visibleDifferences + if (hidden > 0) { + addRenderableWidget( + centered( + Component.translatable( + "connect_share.compatibility.more", + hidden, + ).withStyle(ChatFormatting.GRAY), + layout.bodyTop + 12 + visibleDifferences * 15, + ), + ) + } + + failure.report.pack?.let { pack -> + addRenderableWidget( + Button.builder( + Component.translatable( + if (packCopied) { + "connect_share.compatibility.pack_copied" + } else { + "connect_share.compatibility.copy_pack" + }, + ), + ) { + minecraft!!.keyboardHandler.setClipboard(pack.url) + packCopied = true + rebuildWidgets() + }.bounds( + layout.contentX, + layout.footerTop, + layout.contentWidth, + 20, + ).build(), + ) + } + val actionY = layout.footerTop + 24 + if (failure.canTryAnyway) { + addRenderableWidget( + Button.builder( + Component.translatable( + "connect_share.compatibility.try_anyway", + ), + ) { + minecraft!!.setScreen(parent) + tryAnyway() + }.bounds( + layout.contentX, + actionY, + layout.halfButtonWidth, + 20, + ).build(), + ) + } + addRenderableWidget( + Button.builder(CommonComponents.GUI_BACK) { onClose() } + .bounds( + if (failure.canTryAnyway) { + layout.contentX + layout.halfButtonWidth + 6 + } else { + layout.contentX + }, + actionY, + if (failure.canTryAnyway) { + layout.halfButtonWidth + } else { + layout.contentWidth + }, + 20, + ).build(), + ) + } + + override fun onClose() { + minecraft!!.setScreen(parent) + } + + private fun centered(message: Component, y: Int): StringWidget { + val textWidth = font.width(message) + return StringWidget( + width / 2 - textWidth / 2, + y, + textWidth, + 11, + message, + font, + ) + } +} diff --git a/share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/ConnectGameProfileMapper.kt b/share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/ConnectGameProfileMapper.kt new file mode 100644 index 000000000..824fec3b8 --- /dev/null +++ b/share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/ConnectGameProfileMapper.kt @@ -0,0 +1,50 @@ +package com.minekube.connect.share.fabric.v1_20_1 + +import arrow.core.Either +import arrow.core.raise.either +import arrow.core.raise.ensure +import com.mojang.authlib.GameProfile +import com.mojang.authlib.properties.Property +import com.minekube.connect.api.player.GameProfile as ConnectGameProfile + +object ConnectGameProfileMapper { + fun toMinecraft( + source: ConnectGameProfile, + ): Either = either { + ensure( + source.username.isNotBlank() && + source.username.isValidPlayerName(), + ) { + ProfileMappingFailure.InvalidName + } + val properties = source.properties.map { property -> + ensure(property.name.isNotBlank() && property.value.isNotBlank()) { + ProfileMappingFailure.InvalidProperty + } + val signature = property.signature?.takeIf(String::isNotEmpty) + if (signature == null) { + Property(property.name, property.value) + } else { + Property(property.name, property.value, signature) + } + } + MinecraftGameProfileFactory.create( + source.uniqueId, + source.username, + properties, + ) + } + + @JvmStatic + fun toMinecraftOrNull(source: ConnectGameProfile): GameProfile? = + toMinecraft(source).getOrNull() +} + +internal fun String.isValidPlayerName(): Boolean = + length in 1..16 && all { it.isLetterOrDigit() || it == '_' } + +sealed interface ProfileMappingFailure { + data object InvalidName : ProfileMappingFailure + + data object InvalidProperty : ProfileMappingFailure +} diff --git a/share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/ConnectShare12111Client.kt b/share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/ConnectShare12111Client.kt new file mode 100644 index 000000000..70f3c026a --- /dev/null +++ b/share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/ConnectShare12111Client.kt @@ -0,0 +1,498 @@ +package com.minekube.connect.share.fabric.v1_20_1 + +import com.minekube.connect.share.admission.NewAdmissionTracker +import com.minekube.connect.share.admission.AdmissionPurpose +import com.minekube.connect.share.fabric.ConnectShareClient +import com.minekube.connect.share.fabric.ConnectShareInstallation +import com.minekube.connect.share.fabric.FabricLocalLoginAdmission +import com.minekube.connect.share.fabric.FabricLocalLoginAdmissionGate +import com.minekube.connect.share.fabric.FabricShareBootstrap +import com.minekube.connect.share.fabric.FabricShareBrowser +import com.minekube.connect.share.fabric.FriendCardReceiver +import com.minekube.connect.share.fabric.FriendActivityResolver +import com.minekube.connect.share.fabric.SocialEvent +import com.minekube.connect.share.fabric.SocialEventTracker +import com.minekube.connect.share.fabric.FriendPresenceMonitor +import com.minekube.connect.share.fabric.FollowAction +import com.minekube.connect.share.fabric.GuestJoinTarget +import com.minekube.connect.share.fabric.MinecraftStatusProbe +import com.minekube.connect.share.fabric.LoadedCompatibilityProfileFactory +import com.minekube.connect.share.fabric.LoadedMod +import com.minekube.connect.share.fabric.ModSide +import com.minekube.connect.share.fabric.ui.ShareUiMessage +import com.minekube.connect.share.fabric.ui.terminalNotification +import com.minekube.connect.share.fabric.ui.uiMessage +import com.minekube.connect.share.ShareState +import com.minekube.connect.share.friend.FriendStore +import com.minekube.connect.share.friend.FriendActivity +import com.minekube.connect.share.friend.FriendActivityKind +import com.minekube.connect.share.friend.FriendJoinRequest +import com.minekube.connect.share.friend.ModLoader +import com.minekube.connect.tunnel.p2p.DirectP2pAuthMode +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicReference +import java.util.concurrent.ThreadFactory +import java.util.UUID +import java.nio.file.Path +import java.util.logging.Level +import java.util.logging.Logger +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.cancel +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import net.minecraft.SharedConstants +import net.minecraft.client.Minecraft +import net.minecraft.client.gui.components.toasts.SystemToast +import net.minecraft.client.gui.screens.Screen +import net.minecraft.client.gui.screens.ConnectScreen +import net.minecraft.client.gui.screens.TitleScreen +import net.minecraft.client.multiplayer.ServerData +import net.minecraft.client.multiplayer.resolver.ServerAddress +import net.minecraft.network.chat.Component + +class ConnectShare1201Runtime( + private val platform: ConnectShare1201Platform, +) { + fun initialize() { + val client = Minecraft.getInstance() + val clientDispatcher = client.asCoroutineDispatcher() + val scope = CoroutineScope( + SupervisorJob() + clientDispatcher, + ) + val installationReference = + AtomicReference() + val worldAvailableSnapshot = + AtomicBoolean(client.hasSingleplayerServer()) + val playerCountSnapshot = AtomicInteger( + client.singleplayerServer?.playerList?.playerCount ?: 0, + ) + val worldNameSnapshot = AtomicReference( + client.singleplayerServer?.worldData?.levelName + ?: "Minecraft world", + ) + val activitySnapshot = AtomicReference( + FriendActivity(FriendActivityKind.ONLINE), + ) + val activityIdentitySnapshot = AtomicReference(null) + val activityEpochSnapshot = AtomicReference(null) + val joinTargetSnapshot = AtomicReference(null) + val minecraftVersion = + SharedConstants.getCurrentVersion().name + val modVersion = platform.modVersion + val compatibilityProfile = LoadedCompatibilityProfileFactory.create( + minecraftVersion = minecraftVersion, + loader = platform.loader, + mods = platform.loadedMods, + packEnvironment = System.getenv(), + ) + val dataDirectory = platform.configDirectory + .resolve("minekube-connect-share") + val friendStore = FriendStore(dataDirectory) + val browserReference = + AtomicReference() + val statusProbe = MinecraftStatusProbe() + val remotePresence = FriendPresenceMonitor( + store = friendStore, + directProbe = { friend -> + browserReference.get()?.probeDirect( + friend = friend, + authMode = DirectP2pAuthMode.OFFLINE, + probe = statusProbe, + ) + }, + ) + scope.launch { + while (isActive) { + remotePresence.refresh() + delay(PRESENCE_REFRESH_MILLIS) + } + } + val bootstrapJob = scope.launch(Dispatchers.IO) { + try { + val installation = FabricShareBootstrap.create( + scope = scope, + dataDirectory = dataDirectory, + minecraftVersion = minecraftVersion, + modVersion = modVersion, + worldAvailable = worldAvailableSnapshot.get(), + friendStore = friendStore, + playerCount = playerCountSnapshot::get, + worldDisplayName = worldNameSnapshot::get, + playerDisplayName = { client.user.name }, + friendActivity = activitySnapshot::get, + compatibilityProfile = { compatibilityProfile }, + friendJoinTarget = joinTargetSnapshot::get, + minecraftThreadFactory = platform.gatewayThreadFactory, + bridgeFactory = { + admission, + admissionScope, + approvedJoins, + gateway, + -> + GatewayMinecraft1201Bridge(gateway) { + FabricLocalLoginAdmissionGate( + admission = FabricLocalLoginAdmission( + admission, + approvedJoins, + ), + scope = admissionScope, + ) + } + }, + screens = { parent, active -> + val parentScreen = parent as Screen + client.execute { + client.setScreen( + if (active) { + ShareStatusScreen(parentScreen) + } else { + ShareSetupScreen(parentScreen) + }, + ) + } + }, + guestScreens = { parent, browser, activity -> + val parentScreen = parent as Screen + client.execute { + client.setScreen( + ShareJoinScreen( + parent = parentScreen, + friends = + ConnectShareClient.friendsViewModel(), + browser = browser, + remotePresence = remotePresence, + friendActivity = activity, + ), + ) + } + }, + ) + browserReference.set(installation.browser) + withContext(clientDispatcher) { + platform.installFriendCardNetworking( + scope = scope, + issuer = installation.friendCardIssuer, + receiver = installation.friendCardReceiver, + approvedJoins = installation.approvedJoins, + ) + ConnectShareClient.install(installation) + installationReference.set(installation) + LOGGER.info( + "Connect Share friend gateway is ready", + ) + } + } catch (cancellation: CancellationException) { + throw cancellation + } catch (failure: Throwable) { + LOGGER.log( + Level.SEVERE, + "Connect Share initialization failed", + failure, + ) + } + } + val admissionNotifications = NewAdmissionTracker() + val socialNotifications = SocialEventTracker() + val admissionToastId = SystemToast.SystemToastIds.PERIODIC_NOTIFICATION + + platform.onEndClientTick { minecraft -> + val installation = + installationReference.get() + ?: return@onEndClientTick + val server = minecraft.singleplayerServer + val worldAvailable = server != null && minecraft.connection != null + worldAvailableSnapshot.set(worldAvailable) + playerCountSnapshot.set( + server?.playerList?.playerCount ?: 0, + ) + worldNameSnapshot.set( + server?.worldData?.levelName ?: "Minecraft world", + ) + val currentServer = minecraft.currentServer + val externalServer = currentServer + ?.takeIf { !worldAvailable } + joinTargetSnapshot.set(externalServer?.ip) + val activityIdentity: Any? = when { + externalServer != null -> "server:${externalServer.ip}" + worldAvailable -> server + else -> null + } + if (activityIdentitySnapshot.getAndSet(activityIdentity) != + activityIdentity + ) { + activityEpochSnapshot.set( + activityIdentity?.let { UUID.randomUUID().toString() }, + ) + } + activitySnapshot.set( + FriendActivityResolver.resolve( + worldAvailable = worldAvailable, + worldSharingActive = installation.viewModel.state.value + .shareState is ShareState.Sharing, + worldName = worldNameSnapshot.get(), + externalServerName = externalServer?.name, + sessionEpoch = activityEpochSnapshot.get(), + compatibility = compatibilityProfile, + ), + ) + ConnectShareClient.integratedWorldChanged( + worldAvailable, + server, + ) + ConnectShareClient.guestConnectionChanged( + minecraft.connection != null, + ) + admissionNotifications.update( + installation.viewModel.state.value.pendingAdmissions, + ).firstOrNull()?.let { request -> + SystemToast.add( + minecraft.toasts, + admissionToastId, + Component.translatable( + if (request.purpose == AdmissionPurpose.FRIEND) { + "connect_share.notification.friend_request" + } else { + "connect_share.notification.join_request" + }, + ), + Component.translatable( + if (request.purpose == AdmissionPurpose.FRIEND) { + "connect_share.notification.friend_request_detail" + } else { + "connect_share.notification.join_request_detail" + }, + request.identity.name, + ), + ) + } + val friends = installation.friendsViewModel + friends.updateIncoming( + installation.viewModel.state.value.pendingAdmissions, + ) + friends.updateRemotePresence(remotePresence.state.value) + friends.updateActivities(installation.friendActivity.state.value) + processFollowActions(minecraft, installation, scope) + socialNotifications.update(friends.state.value).forEach { event -> + SystemToast.add( + minecraft.toasts, + SystemToast.SystemToastIds.PERIODIC_NOTIFICATION, + event.title(), + event.detail(), + ) + } + } + platform.onClientStopping { + scope.launch(Dispatchers.IO) { + bootstrapJob.cancelAndJoin() + try { + if (installationReference.get() != null) { + ConnectShareClient.shutdown() + } + } finally { + scope.cancel() + } + } + } + } + + private companion object { + const val PRESENCE_REFRESH_MILLIS = 10_000L + val LOGGER: Logger = Logger.getLogger("Connect") + } + + private fun processFollowActions( + minecraft: Minecraft, + installation: ConnectShareInstallation, + scope: CoroutineScope, + ) { + installation.friendsViewModel.followActions( + activeGameplay = minecraft.level != null, + ).forEach { action -> + when (action) { + is FollowAction.RequestJoin -> { + followToast( + minecraft, + "connect_share.notification.follow_waiting", + "connect_share.notification.follow_waiting_detail", + action.displayName, + ) + scope.launch(Dispatchers.IO) { + installation.friendJoinOrchestrator.request( + action.peerId, + FriendJoinRequest( + requestId = UUID.randomUUID(), + playerName = minecraft.user.name, + playerUuid = checkNotNull(minecraft.user.profileId), + ), + ).fold( + ifLeft = { failure -> + minecraft.execute { + followToast( + minecraft, + "connect_share.notification.follow_failed", + null, + failure.uiMessage().component(), + ) + } + }, + ifRight = { target -> + minecraft.execute { + if (minecraft.level != null) { + target.close() + followToast( + minecraft, + "connect_share.notification.follow_ready", + "connect_share.notification.follow_ready_detail", + action.displayName, + ) + } else { + connectFollow( + minecraft, + installation, + action, + target, + ) + } + } + }, + ) + } + } + + is FollowAction.OfferJoinNow -> followToast( + minecraft, + "connect_share.notification.follow_ready", + "connect_share.notification.follow_ready_detail", + action.displayName, + ) + + is FollowAction.Expired, + is FollowAction.Cancelled -> + checkNotNull(action.terminalNotification()).let { + followToast( + minecraft, + it.titleKey, + it.detailKey, + it.displayName, + ) + } + } + } + } + + private fun connectFollow( + minecraft: Minecraft, + installation: ConnectShareInstallation, + action: FollowAction.RequestJoin, + target: GuestJoinTarget, + ) { + val address = when (target) { + is GuestJoinTarget.Connect -> + ServerAddress.parseString(target.publicAddress) + is GuestJoinTarget.Direct -> ServerAddress( + target.localAddress.hostString, + target.localAddress.port, + ) + } + if (target is GuestJoinTarget.Direct) { + ConnectShareClient.holdGuestDirect(target) + } + installation.friendsViewModel.completeFollow(action.peerId) + ConnectScreen.startConnecting( + minecraft.screen ?: TitleScreen(), + minecraft, + address, + ServerData(action.displayName, address.toString(), false), + false, + ) + } + + private fun followToast( + minecraft: Minecraft, + titleKey: String, + detailKey: String?, + value: String, + ) { + followToast(minecraft, titleKey, detailKey, Component.literal(value)) + } + + private fun followToast( + minecraft: Minecraft, + titleKey: String, + detailKey: String?, + value: Component, + ) { + SystemToast.add( + minecraft.toasts, + SystemToast.SystemToastIds.PERIODIC_NOTIFICATION, + Component.translatable(titleKey, value), + detailKey?.let { Component.translatable(it, value) } + ?: value, + ) + } + + private fun ShareUiMessage.component() = + Component.translatable(translationKey, *arguments.toTypedArray()) + + private fun SocialEvent.title(): Component = Component.translatable( + when (this) { + is SocialEvent.FriendAccepted -> + "connect_share.notification.friend_accepted" + is SocialEvent.FriendRemoved -> + "connect_share.notification.friend_removed" + is SocialEvent.PlayingServer -> + "connect_share.notification.friend_playing" + is SocialEvent.WorldReady -> + "connect_share.notification.friend_online" + }, + ) + + private fun SocialEvent.detail(): Component = when (this) { + is SocialEvent.FriendAccepted -> Component.translatable( + "connect_share.notification.friend_accepted_detail", + displayName, + ) + is SocialEvent.FriendRemoved -> Component.translatable( + "connect_share.notification.friend_removed_detail", + displayName, + ) + is SocialEvent.PlayingServer -> Component.translatable( + "connect_share.notification.friend_playing_detail", + displayName, + serverName, + ) + is SocialEvent.WorldReady -> Component.translatable( + "connect_share.notification.friend_online_detail", + displayName, + worldName ?: "Minecraft world", + ) + } +} + +interface ConnectShare1201Platform { + val modVersion: String + val loader: ModLoader + val loadedMods: List + val configDirectory: Path + val gatewayThreadFactory: ThreadFactory? + get() = null + + fun onEndClientTick(callback: (Minecraft) -> Unit) + + fun onClientStopping(callback: () -> Unit) + + fun installFriendCardNetworking( + scope: CoroutineScope, + issuer: com.minekube.connect.share.fabric.FriendCardIssuer, + receiver: FriendCardReceiver, + approvedJoins: com.minekube.connect.share.fabric.ApprovedJoinTracker, + ) +} diff --git a/share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/EndpointIdentityScreen.kt b/share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/EndpointIdentityScreen.kt new file mode 100644 index 000000000..5232e5aa3 --- /dev/null +++ b/share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/EndpointIdentityScreen.kt @@ -0,0 +1,255 @@ +package com.minekube.connect.share.fabric.v1_20_1 + +import com.minekube.connect.share.fabric.ConnectShareClient +import com.minekube.connect.share.fabric.ui.ShareUiMessage +import com.minekube.connect.share.fabric.ui.AdaptiveShareLayout +import com.minekube.connect.share.identity.CredentialSource +import java.nio.file.Path +import net.minecraft.ChatFormatting +import net.minecraft.client.gui.components.Button +import net.minecraft.client.gui.components.EditBox +import net.minecraft.client.gui.components.MultiLineTextWidget +import net.minecraft.client.gui.components.StringWidget +import net.minecraft.client.gui.screens.ConfirmScreen +import net.minecraft.client.gui.screens.Screen +import net.minecraft.network.chat.CommonComponents +import net.minecraft.network.chat.Component +import net.minecraft.network.chat.Style +import net.minecraft.util.FormattedCharSequence +import org.lwjgl.util.tinyfd.TinyFileDialogs + +class EndpointIdentityScreen( + private val parent: Screen, +) : Screen(Component.translatable("connect_share.identity.title")) { + private val viewModel = ConnectShareClient.viewModel() + private var fingerprint = 0 + private var endpointBox: EditBox? = null + private var tokenBox: EditBox? = null + + override fun init() { + val state = viewModel.state.value + fingerprint = state.copy( + importDraft = state.importDraft.copy(token = ""), + ).hashCode() + val layout = AdaptiveShareLayout.form(width, height, 2) + + addRenderableWidget( + centered(title.copy().withStyle(ChatFormatting.BOLD), layout.headerY), + ) + addRenderableWidget( + MultiLineTextWidget( + layout.contentX, + layout.subtitleY, + Component.translatable("connect_share.identity.description"), + font, + ).setMaxWidth(layout.contentWidth).setCentered(true), + ) + + addRenderableWidget( + StringWidget( + layout.contentX, + layout.bodyTop, + layout.contentWidth, + 11, + Component.translatable("connect_share.identity.endpoint"), + font, + ), + ) + endpointBox = EditBox( + font, + layout.contentX, + layout.bodyTop + 12, + layout.contentWidth, + 20, + Component.translatable("connect_share.identity.endpoint"), + ).also { box -> + box.value = state.importDraft.endpoint + box.setHint(Component.translatable("connect_share.identity.endpoint_hint")) + box.setResponder(viewModel::setImportEndpoint) + box.setEditable(state.importDraft.endpointEditable) + addRenderableWidget(box) + } + + addRenderableWidget( + StringWidget( + layout.contentX, + layout.bodyTop + 38, + layout.contentWidth, + 11, + Component.translatable("connect_share.identity.token"), + font, + ), + ) + tokenBox = EditBox( + font, + layout.contentX, + layout.bodyTop + 50, + layout.contentWidth, + 20, + Component.translatable("connect_share.identity.token"), + ).also { box -> + box.value = state.importDraft.token + box.setHint(Component.translatable("connect_share.identity.token_hint")) + box.setResponder(viewModel::setImportToken) + box.setFormatter { text, _ -> + FormattedCharSequence.forward( + "•".repeat(text.length), + Style.EMPTY, + ) + } + box.setEditable(state.importDraft.tokenEditable) + addRenderableWidget(box) + } + + val identity = state.identity + addRenderableWidget( + MultiLineTextWidget( + layout.contentX, + layout.bodyTop + 78, + Component.translatable( + "connect_share.identity.sources", + identity?.endpointSource?.displayName() ?: "…", + identity?.tokenSource?.displayName() ?: "…", + ).withStyle(ChatFormatting.GRAY), + font, + ).setMaxWidth(layout.contentWidth).setCentered(true), + ) + state.safeMessage?.let { safeMessage -> + addRenderableWidget( + MultiLineTextWidget( + layout.contentX, + layout.bodyTop + 104, + safeMessage.component() + .withStyle(ChatFormatting.YELLOW), + font, + ).setMaxWidth(layout.contentWidth).setCentered(true), + ) + } + + val save = addRenderableWidget( + Button.builder( + Component.translatable("connect_share.identity.save"), + ) { + viewModel.importIdentity() + }.bounds( + layout.contentX, + layout.footerTop, + layout.halfButtonWidth, + 20, + ).build(), + ) + val choose = addRenderableWidget( + Button.builder( + Component.translatable("connect_share.identity.choose_file"), + ) { + chooseTokenFile()?.let(viewModel::importTokenFile) + }.bounds( + layout.contentX + layout.halfButtonWidth + 6, + layout.footerTop, + layout.halfButtonWidth, + 20, + ).build(), + ) + save.active = state.importDraft.endpointEditable && + state.importDraft.tokenEditable && + !state.operationInProgress + choose.active = state.importDraft.endpointEditable && + state.importDraft.tokenEditable && + !state.operationInProgress + + val reset = addRenderableWidget( + Button.builder( + Component.translatable("connect_share.identity.reset"), + ) { + confirmReset() + }.bounds( + layout.contentX, + layout.footerTop + 24, + layout.halfButtonWidth, + 20, + ).build(), + ) + reset.active = state.importDraft.endpointEditable && + state.importDraft.tokenEditable && + !state.operationInProgress + addRenderableWidget( + Button.builder(CommonComponents.GUI_BACK) { onClose() } + .bounds( + layout.contentX + layout.halfButtonWidth + 6, + layout.footerTop + 24, + layout.halfButtonWidth, + 20, + ).build(), + ) + } + + override fun tick() { + super.tick() + val state = viewModel.state.value + val next = state.copy( + importDraft = state.importDraft.copy(token = ""), + ).hashCode() + if ( + next != fingerprint || + state.importDraft.token.isEmpty() && + tokenBox?.value?.isNotEmpty() == true + ) { + rebuildWidgets() + } + } + + override fun onClose() { + viewModel.setImportToken("") + minecraft!!.setScreen(parent) + } + + private fun confirmReset() { + minecraft!!.setScreen( + ConfirmScreen( + { confirmed -> + if (confirmed) { + viewModel.resetIdentity() + } + minecraft!!.setScreen(this) + }, + Component.translatable( + "connect_share.identity.reset_confirm.title", + ), + Component.translatable( + "connect_share.identity.reset_confirm.message", + ), + ), + ) + } + + private fun chooseTokenFile(): Path? { + val selected = TinyFileDialogs.tinyfd_openFileDialog( + Component.translatable("connect_share.identity.choose_file").string, + null, + null, + "token.json", + false, + ) ?: return null + return Path.of(selected) + } + + private fun centered(message: Component, y: Int): StringWidget { + val textWidth = font.width(message) + return StringWidget( + width / 2 - textWidth / 2, + y, + textWidth, + 11, + message, + font, + ) + } +} + +private fun ShareUiMessage.component() = + Component.translatable(translationKey, *arguments.toTypedArray()) + +private fun CredentialSource.displayName(): Component = + Component.translatable( + "connect_share.identity.source.${name.lowercase()}", + ) diff --git a/share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/FabricConnectShare1201Client.kt b/share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/FabricConnectShare1201Client.kt new file mode 100644 index 000000000..9ff74b8bf --- /dev/null +++ b/share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/FabricConnectShare1201Client.kt @@ -0,0 +1,68 @@ +package com.minekube.connect.share.fabric.v1_20_1 + +import com.minekube.connect.share.fabric.ApprovedJoinTracker +import com.minekube.connect.share.fabric.FriendCardIssuer +import com.minekube.connect.share.fabric.FriendCardReceiver +import com.minekube.connect.share.fabric.LoadedMod +import com.minekube.connect.share.fabric.ModSide +import com.minekube.connect.share.friend.ModLoader +import java.nio.file.Path +import kotlinx.coroutines.CoroutineScope +import net.fabricmc.api.ClientModInitializer +import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientLifecycleEvents +import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents +import net.fabricmc.loader.api.FabricLoader +import net.minecraft.client.Minecraft + +class FabricConnectShare1201Client : ClientModInitializer { + override fun onInitializeClient() { + ConnectShare1201Runtime(FabricPlatform).initialize() + } + + private object FabricPlatform : ConnectShare1201Platform { + private val loaderInstance = FabricLoader.getInstance() + + override val modVersion: String = loaderInstance + .getModContainer("connect-share") + .orElseThrow() + .metadata.version.friendlyString + override val loader = ModLoader.FABRIC + override val loadedMods: List = + loaderInstance.allMods.map { container -> + val metadata = container.metadata + LoadedMod( + id = metadata.id, + version = metadata.version.friendlyString, + side = when (metadata.environment.name) { + "CLIENT" -> ModSide.CLIENT + "SERVER" -> ModSide.SERVER + else -> ModSide.UNIVERSAL + }, + builtIn = metadata.type == "builtin", + ) + } + override val configDirectory: Path = loaderInstance.configDir + + override fun onEndClientTick(callback: (Minecraft) -> Unit) { + ClientTickEvents.END_CLIENT_TICK.register(callback) + } + + override fun onClientStopping(callback: () -> Unit) { + ClientLifecycleEvents.CLIENT_STOPPING.register { callback() } + } + + override fun installFriendCardNetworking( + scope: CoroutineScope, + issuer: FriendCardIssuer, + receiver: FriendCardReceiver, + approvedJoins: ApprovedJoinTracker, + ) { + FriendCardNetworking.install( + scope, + issuer, + receiver, + approvedJoins, + ) + } + } +} diff --git a/share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/FriendCardNetworking.kt b/share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/FriendCardNetworking.kt new file mode 100644 index 000000000..1e534b4f8 --- /dev/null +++ b/share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/FriendCardNetworking.kt @@ -0,0 +1,87 @@ +package com.minekube.connect.share.fabric.v1_20_1 + +import com.minekube.connect.share.fabric.ApprovedJoinTracker +import com.minekube.connect.share.fabric.ConnectShareClient +import com.minekube.connect.share.fabric.FriendCardIssuer +import com.minekube.connect.share.fabric.FriendCardReceiver +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking +import net.fabricmc.fabric.api.networking.v1.PacketByteBufs +import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents +import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking + +object FriendCardNetworking { + fun install( + scope: CoroutineScope, + issuer: FriendCardIssuer, + receiver: FriendCardReceiver, + approvedJoins: ApprovedJoinTracker, + ) { + ServerPlayNetworking.registerGlobalReceiver( + FriendCardChannels.CARD, + ) { server, player, _, buffer, _ -> + val payload = runCatching { + FriendCardCodec.decode(buffer) + }.getOrNull() ?: return@registerGlobalReceiver + server.execute { + val proof = approvedJoins.consume( + player.gameProfile.name, + player.uuid, + payload.invitation, + ) ?: return@execute + receiver.receive( + invitation = payload.invitation, + displayName = player.gameProfile.name, + authenticatedMinecraftUuid = proof.authenticatedMinecraftUuid, + allowAutomaticJoin = true, + relationshipId = payload.relationshipId, + ) + } + } + ServerPlayConnectionEvents.JOIN.register( + ServerPlayConnectionEvents.Join { handler, _, _ -> + val player = handler.player + if ( + approvedJoins.hasProof(player.gameProfile.name, player.uuid) && + ServerPlayNetworking.canSend(player, FriendCardChannels.REQUEST) + ) { + ServerPlayNetworking.send( + player, + FriendCardChannels.REQUEST, + PacketByteBufs.empty(), + ) + } + }, + ) + ClientPlayNetworking.registerGlobalReceiver( + FriendCardChannels.REQUEST, + ) { client, _, _, _ -> + val exchange = + ConnectShareClient.consumeFriendCardExchangeConsent() + ?: return@registerGlobalReceiver + scope.launch(Dispatchers.IO) { + issuer.issue().getOrNull()?.let { invitation -> + client.execute { + if ( + client.connection != null && + ClientPlayNetworking.canSend(FriendCardChannels.CARD) + ) { + val buffer = PacketByteBufs.create() + buffer.writeUtf( + invitation, + FriendCardChannels.MAX_CARD_CHARS, + ) + buffer.writeUUID(exchange.relationshipId) + ClientPlayNetworking.send(FriendCardChannels.CARD, buffer) + scope.launch(Dispatchers.IO) { + receiver.confirmOutgoing(exchange.peerId) + } + } + } + } + } + } + } +} diff --git a/share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/FriendCardPayload.kt b/share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/FriendCardPayload.kt new file mode 100644 index 000000000..151a977c3 --- /dev/null +++ b/share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/FriendCardPayload.kt @@ -0,0 +1,43 @@ +package com.minekube.connect.share.fabric.v1_20_1 + +import java.util.UUID +import net.minecraft.network.FriendlyByteBuf +import net.minecraft.resources.ResourceLocation + +data class FriendCardPayload( + val invitation: String, + val relationshipId: UUID = UUID.randomUUID(), +) { + companion object { + val CODEC = FriendCardCodec + } +} + +data object FriendCardRequestPayload { + val CODEC = FriendCardRequestCodec +} + +object FriendCardCodec { + fun encode(buffer: FriendlyByteBuf, payload: FriendCardPayload) { + buffer.writeUtf(payload.invitation, FriendCardChannels.MAX_CARD_CHARS) + buffer.writeUUID(payload.relationshipId) + } + + fun decode(buffer: FriendlyByteBuf): FriendCardPayload = + FriendCardPayload( + invitation = buffer.readUtf(FriendCardChannels.MAX_CARD_CHARS), + relationshipId = buffer.readUUID(), + ) +} + +object FriendCardRequestCodec { + fun encode(buffer: FriendlyByteBuf, payload: FriendCardRequestPayload) = Unit + fun decode(buffer: FriendlyByteBuf): FriendCardRequestPayload = + FriendCardRequestPayload +} + +object FriendCardChannels { + val CARD = ResourceLocation("connect-share", "friend-card") + val REQUEST = ResourceLocation("connect-share", "friend-card-request") + const val MAX_CARD_CHARS = 16_384 +} diff --git a/share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/Minecraft12111Bridge.kt b/share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/Minecraft12111Bridge.kt new file mode 100644 index 000000000..0825100af --- /dev/null +++ b/share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/Minecraft12111Bridge.kt @@ -0,0 +1,62 @@ +package com.minekube.connect.share.fabric.v1_20_1 + +import com.minekube.connect.share.LocalShareChannel as CommonLocalShareChannel +import com.minekube.connect.share.LocalShareChannelBinder as CommonLocalShareChannelBinder +import com.minekube.connect.share.MinecraftVersionTransport +import com.minekube.connect.share.NettyLocalShareChannelBinder +import com.minekube.connect.share.PublishedMinecraftTransport as CommonPublishedMinecraftTransport +import com.minekube.connect.share.ShareConnectionGateway +import com.minekube.connect.share.VersionedMinecraftBridge +import com.minekube.connect.share.CaptureLease as CommonCaptureLease +import com.minekube.connect.share.CapturedServerTransport as CommonCapturedServerTransport +import com.minekube.connect.share.CapturedTransport as CommonCapturedTransport +import com.minekube.connect.share.fabric.FabricLoginAdmissionRegistry +import com.minekube.connect.share.fabric.FabricLocalLoginAdmissionGate + +class Minecraft1201Bridge internal constructor( + transport: Minecraft1201Transport, + localBinder: LocalShareChannelBinder, + loginAdmissionFactory: (() -> FabricLocalLoginAdmissionGate)? = null, +) : VersionedMinecraftBridge( + transport = transport, + localBinder = localBinder, + loginAdmissionAcquire = loginAdmissionFactory?.let { factory -> + { + FabricLoginAdmissionRegistry.install(factory()) + } + }, +) { + constructor() : this( + VanillaMinecraft1201Transport(), + NettyLocalShareChannelBinder(), + ) + + constructor( + loginAdmissionFactory: () -> FabricLocalLoginAdmissionGate, + ) : this( + VanillaMinecraft1201Transport(), + NettyLocalShareChannelBinder(), + loginAdmissionFactory, + ) +} + +internal class GatewayMinecraft1201Bridge( + gateway: ShareConnectionGateway, + loginAdmissionFactory: () -> FabricLocalLoginAdmissionGate, +) : VersionedMinecraftBridge( + transport = VanillaMinecraft1201Transport(), + gateway = gateway, + loginAdmissionAcquire = { + FabricLoginAdmissionRegistry.install( + loginAdmissionFactory(), + ) + }, +) + +internal typealias Minecraft1201Transport = MinecraftVersionTransport +internal typealias PublishedMinecraftTransport = CommonPublishedMinecraftTransport +internal typealias LocalShareChannelBinder = CommonLocalShareChannelBinder +internal typealias LocalShareChannel = CommonLocalShareChannel +internal typealias CapturedServerTransport = CommonCapturedServerTransport +internal typealias CaptureLease = CommonCaptureLease +internal typealias CapturedTransport = CommonCapturedTransport diff --git a/share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/Minecraft12111LoginBridge.kt b/share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/Minecraft12111LoginBridge.kt new file mode 100644 index 000000000..6780ef045 --- /dev/null +++ b/share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/Minecraft12111LoginBridge.kt @@ -0,0 +1,199 @@ +package com.minekube.connect.share.fabric.v1_20_1 + +import com.mojang.authlib.GameProfile +import com.minekube.connect.api.ConnectAttributes +import com.minekube.connect.network.netty.LocalSession +import com.minekube.connect.share.admission.AdmissionAnswer +import com.minekube.connect.share.admission.Ingress +import com.minekube.connect.share.direct.DirectSessionAttributes +import com.minekube.connect.share.fabric.DirectMinecraftAuthentication +import com.minekube.connect.share.fabric.FabricDirectAuthenticationPolicy +import com.minekube.connect.share.fabric.FabricLoginAdmissionRegistry +import com.minekube.connect.share.fabric.ui.ShareLoginMessages +import com.minekube.connect.tunnel.p2p.DirectP2pRoute +import com.minekube.connect.tunnel.p2p.DirectP2pSession +import com.minekube.connect.share.fabric.v1_20_1.mixin.ConnectionAccessor +import java.util.function.Consumer +import net.minecraft.network.Connection +import net.minecraft.network.chat.Component +import net.minecraft.server.MinecraftServer +import net.minecraft.core.UUIDUtil + +object Minecraft1201LoginBridge { + private val IMMEDIATE_LOGIN_CONTINUATION = + Minecraft1201LoginContinuation { _, accept -> accept.run() } + + @Volatile + private var loginContinuation = IMMEDIATE_LOGIN_CONTINUATION + + @JvmStatic + fun hasConnectIdentity(connection: Connection): Boolean = + channel(connection).attr(ConnectAttributes.CONNECT_PLAYER).get() != null + + @JvmStatic + fun authenticatedProfile( + connection: Connection, + requestedName: String, + ): GameProfile? { + val player = channel(connection) + .attr(ConnectAttributes.CONNECT_PLAYER) + .get() + ?: return null + val profile = ConnectGameProfileMapper + .toMinecraft(player.gameProfile) + .getOrNull() + ?: return null + return profile.takeIf { + requestedName.equals(profile.name, ignoreCase = true) + } + } + + @JvmStatic + fun isPassthroughConnect(connection: Connection): Boolean = + LocalSession.context(channel(connection)) + .map { it.player.auth.isPassthrough } + .orElse(false) + + @JvmStatic + fun hasDirectSession(connection: Connection): Boolean = + directSession(connection) != null + + @JvmStatic + fun shouldUseOfflineDirectProfile(connection: Connection): Boolean = + directSession(connection)?.let { + FabricDirectAuthenticationPolicy.minecraftAuthentication( + it.authMode(), + ) == DirectMinecraftAuthentication.OFFLINE_PROFILE + } == true + + @JvmStatic + fun offlineProfile(requestedName: String): GameProfile? = + requestedName.takeIf(String::isValidPlayerName) + ?.let { GameProfile(UUIDUtil.createOfflinePlayerUUID(it), it) } + + @JvmStatic + fun installLoginContinuation(continuation: Minecraft1201LoginContinuation) { + loginContinuation = continuation + } + + @JvmStatic + fun continueApprovedLogin(listener: Any, accept: Runnable) { + loginContinuation.continueLogin(listener, accept) + } + + internal fun resetLoginContinuationForTests() { + loginContinuation = IMMEDIATE_LOGIN_CONTINUATION + } + + @JvmStatic + fun requestPassthroughAdmission( + connection: Connection, + server: MinecraftServer, + profile: GameProfile, + allow: Runnable, + deny: Consumer, + ) { + val channel = channel(connection) + val context = LocalSession.context(channel).orElse(null) + if (context == null || !context.player.auth.isPassthrough) { + server.execute(allow) + return + } + val decision = FabricLoginAdmissionRegistry.request( + name = profile.name, + uuid = profile.id, + connectionId = context.player.sessionId, + minecraftAuthenticated = + server.usesAuthentication() && !connection.isMemoryConnection, + ingress = Ingress.CONNECT, + ).toCompletableFuture() + channel.closeFuture().addListener { + decision.cancel(false) + } + decision.whenComplete { answer, failure -> + server.execute { + if (!connection.isConnected) { + return@execute + } + if (failure == null && answer == AdmissionAnswer.ALLOW) { + allow.run() + } else { + deny.accept(denialReason(answer)) + } + } + } + } + + @JvmStatic + fun requestDirectAdmission( + connection: Connection, + server: MinecraftServer, + profile: GameProfile, + allow: Runnable, + deny: Consumer, + ) { + val channel = channel(connection) + val session = directSession(connection) + if (session == null) { + server.execute(allow) + return + } + val minecraftAuthenticated = + server.usesAuthentication() && !connection.isMemoryConnection + FabricDirectAuthenticationPolicy.validate( + session.authMode(), + minecraftAuthenticated, + ).onLeft { + server.execute { + deny.accept( + Component.literal( + ShareLoginMessages.AUTHENTICATION_REQUIRED.fallback, + ), + ) + } + return + } + val decision = FabricLoginAdmissionRegistry.request( + name = profile.name, + uuid = profile.id, + connectionId = session.connectionId(), + minecraftAuthenticated = minecraftAuthenticated, + ingress = session.route().toIngress(), + directPeerId = session.peerId(), + ).toCompletableFuture() + channel.closeFuture().addListener { + decision.cancel(false) + } + decision.whenComplete { answer, failure -> + server.execute { + if (!connection.isConnected) { + return@execute + } + if (failure == null && answer == AdmissionAnswer.ALLOW) { + allow.run() + } else { + deny.accept(denialReason(answer)) + } + } + } + } + + private fun channel(connection: Connection) = + (connection as ConnectionAccessor).connectShareChannel + + private fun directSession(connection: Connection): DirectP2pSession? = + channel(connection).attr(DirectSessionAttributes.SESSION).get() + + private fun DirectP2pRoute.toIngress(): Ingress = when (this) { + DirectP2pRoute.LAN -> Ingress.DIRECT_LAN + DirectP2pRoute.INTERNET -> Ingress.DIRECT_INTERNET + } + + private fun denialReason(answer: AdmissionAnswer?): Component = + Component.literal(ShareLoginMessages.denial(answer).fallback) + +} + +fun interface Minecraft1201LoginContinuation { + fun continueLogin(listener: Any, accept: Runnable) +} diff --git a/share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/ObservableCheckbox.kt b/share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/ObservableCheckbox.kt new file mode 100644 index 000000000..658d6a0e8 --- /dev/null +++ b/share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/ObservableCheckbox.kt @@ -0,0 +1,19 @@ +package com.minekube.connect.share.fabric.v1_20_1 + +import net.minecraft.client.gui.components.Checkbox +import net.minecraft.network.chat.Component + +internal class ObservableCheckbox( + x: Int, + y: Int, + width: Int, + height: Int, + message: Component, + selected: Boolean, + private val changed: (Boolean) -> Unit = {}, +) : Checkbox(x, y, width, height, message, selected) { + override fun onPress() { + super.onPress() + changed(selected()) + } +} diff --git a/share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/RecoveryScreen.kt b/share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/RecoveryScreen.kt new file mode 100644 index 000000000..0ba46d0a6 --- /dev/null +++ b/share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/RecoveryScreen.kt @@ -0,0 +1,275 @@ +package com.minekube.connect.share.fabric.v1_20_1 + +import com.minekube.connect.share.fabric.ConnectShareClient +import com.minekube.connect.share.fabric.ui.AdaptiveShareLayout +import com.minekube.connect.share.fabric.ui.ShareUiMessage +import java.nio.file.Path +import net.minecraft.ChatFormatting +import net.minecraft.client.gui.components.Button +import net.minecraft.client.gui.components.EditBox +import net.minecraft.client.gui.components.MultiLineTextWidget +import net.minecraft.client.gui.components.StringWidget +import net.minecraft.client.gui.screens.Screen +import net.minecraft.network.chat.CommonComponents +import net.minecraft.network.chat.Component +import net.minecraft.network.chat.Style +import net.minecraft.util.FormattedCharSequence +import org.lwjgl.util.tinyfd.TinyFileDialogs + +class RecoveryScreen( + private val parent: Screen, +) : Screen(Component.translatable("connect_share.recovery.title")) { + private val viewModel = ConnectShareClient.recoveryViewModel() + private var fingerprint = 0 + private var passphraseValue = "" + private var confirmationValue = "" + + override fun init() { + val state = viewModel.state.value + fingerprint = state.hashCode() + val layout = AdaptiveShareLayout.form(width, height, 2) + + addRenderableWidget( + centered(title.copy().withStyle(ChatFormatting.BOLD), layout.headerY), + ) + addRenderableWidget( + MultiLineTextWidget( + layout.contentX, + layout.subtitleY, + Component.translatable("connect_share.recovery.description"), + font, + ).setMaxWidth(layout.contentWidth).setCentered(true), + ) + + addRenderableWidget( + StringWidget( + layout.contentX, + layout.bodyTop, + layout.contentWidth, + 11, + Component.translatable("connect_share.recovery.secret"), + font, + ), + ) + secretBox( + value = passphraseValue, + y = layout.bodyTop + 12, + label = "connect_share.recovery.secret", + ) { passphraseValue = it } + + addRenderableWidget( + StringWidget( + layout.contentX, + layout.bodyTop + 38, + layout.contentWidth, + 11, + Component.translatable("connect_share.recovery.secret_confirm"), + font, + ), + ) + secretBox( + value = confirmationValue, + y = layout.bodyTop + 50, + label = "connect_share.recovery.secret_confirm", + ) { confirmationValue = it } + + val status = state.safeMessage?.component() + ?: state.summary?.let { summary -> + Component.translatable( + "connect_share.recovery.summary", + summary.entryCount, + Component.translatable( + if (summary.includesPreferences) { + "connect_share.recovery.included" + } else { + "connect_share.recovery.not_included" + }, + ), + Component.translatable( + if (summary.includesEndpointIdentity) { + "connect_share.recovery.included" + } else { + "connect_share.recovery.not_included" + }, + ), + ) + } + status?.let { + addRenderableWidget( + MultiLineTextWidget( + layout.contentX, + layout.bodyTop + 78, + it.withStyle( + if (state.safeMessage == null) { + ChatFormatting.GREEN + } else { + ChatFormatting.YELLOW + }, + ), + font, + ).setMaxWidth(layout.contentWidth).setCentered(true), + ) + } + addRenderableWidget( + MultiLineTextWidget( + layout.contentX, + layout.bodyTop + 106, + Component.translatable("connect_share.recovery.warning") + .withStyle(ChatFormatting.GRAY), + font, + ).setMaxWidth(layout.contentWidth).setCentered(true), + ) + + if (state.importConfirmationRequired) { + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.recovery.restore_confirm"), + ) { + viewModel.confirmImport() + }.bounds( + layout.contentX, + layout.footerTop, + layout.halfButtonWidth, + 20, + ).build(), + ).active = !state.operationInProgress + addRenderableWidget( + Button.builder(CommonComponents.GUI_CANCEL) { + viewModel.cancelImport() + }.bounds( + layout.contentX + layout.halfButtonWidth + 6, + layout.footerTop, + layout.halfButtonWidth, + 20, + ).build(), + ).active = !state.operationInProgress + } else { + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.recovery.export"), + ) { + chooseBackupDestination()?.let { target -> + val passphrase = passphraseValue.toCharArray() + val confirmation = confirmationValue.toCharArray() + clearInputs() + viewModel.export(target, passphrase, confirmation) + } + }.bounds( + layout.contentX, + layout.footerTop, + layout.halfButtonWidth, + 20, + ).build(), + ).active = !state.operationInProgress + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.recovery.import"), + ) { + chooseBackupSource()?.let { source -> + val passphrase = passphraseValue.toCharArray() + clearInputs() + viewModel.previewImport(source, passphrase) + } + }.bounds( + layout.contentX + layout.halfButtonWidth + 6, + layout.footerTop, + layout.halfButtonWidth, + 20, + ).build(), + ).active = !state.operationInProgress + } + + addRenderableWidget( + Button.builder(CommonComponents.GUI_BACK) { onClose() } + .bounds( + layout.contentX, + layout.footerTop + 24, + layout.contentWidth, + 20, + ).build(), + ) + } + + override fun tick() { + super.tick() + if (viewModel.state.value.hashCode() != fingerprint) { + rebuildWidgets() + } + } + + override fun onClose() { + clearInputs() + viewModel.close() + minecraft!!.setScreen(parent) + } + + private fun secretBox( + value: String, + y: Int, + label: String, + changed: (String) -> Unit, + ) { + val layout = AdaptiveShareLayout.form(width, height, 2) + addRenderableWidget( + EditBox( + font, + layout.contentX, + y, + layout.contentWidth, + 20, + Component.translatable(label), + ).also { box -> + box.value = value + box.setMaxLength(256) + box.setResponder(changed) + box.setFormatter { text, _ -> + FormattedCharSequence.forward( + "•".repeat(text.length), + Style.EMPTY, + ) + } + }, + ) + } + + private fun clearInputs() { + passphraseValue = "" + confirmationValue = "" + } + + private fun chooseBackupDestination(): Path? { + val selected = TinyFileDialogs.tinyfd_saveFileDialog( + Component.translatable("connect_share.recovery.export").string, + "connect-share-friends.backup", + null, + "Connect Share backup", + ) ?: return null + return Path.of(selected) + } + + private fun chooseBackupSource(): Path? { + val selected = TinyFileDialogs.tinyfd_openFileDialog( + Component.translatable("connect_share.recovery.import").string, + null, + null, + "Connect Share backup", + false, + ) ?: return null + return Path.of(selected) + } + + private fun centered(message: Component, y: Int): StringWidget { + val textWidth = font.width(message) + return StringWidget( + width / 2 - textWidth / 2, + y, + textWidth, + 11, + message, + font, + ) + } +} + +private fun ShareUiMessage.component() = + Component.translatable(translationKey, *arguments.toTypedArray()) diff --git a/share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/ShareJoinScreen.kt b/share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/ShareJoinScreen.kt new file mode 100644 index 000000000..9bcf25bf7 --- /dev/null +++ b/share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/ShareJoinScreen.kt @@ -0,0 +1,1507 @@ +package com.minekube.connect.share.fabric.v1_20_1 + +import com.minekube.connect.share.fabric.ConnectShareClient +import com.minekube.connect.share.fabric.FabricShareBrowser +import com.minekube.connect.share.fabric.FriendCardExchangeConsent +import com.minekube.connect.share.fabric.FriendJoinAttemptFailure +import com.minekube.connect.share.fabric.FriendPresenceMonitor +import com.minekube.connect.share.fabric.FriendActivityMonitor +import com.minekube.connect.share.fabric.GuestJoinTarget +import com.minekube.connect.share.admission.Ingress +import com.minekube.connect.share.friend.FriendControlRequest +import com.minekube.connect.share.friend.FriendJoinRequest +import com.minekube.connect.share.friend.FriendActivityKind +import com.minekube.connect.share.fabric.ui.FriendSummary +import com.minekube.connect.share.fabric.ui.AdaptiveShareLayout +import com.minekube.connect.share.fabric.ui.FriendFormDraft +import com.minekube.connect.share.fabric.ui.FriendPresenceTone +import com.minekube.connect.share.fabric.ui.FriendPrimaryAction +import com.minekube.connect.share.fabric.ui.FriendsOverview +import com.minekube.connect.share.fabric.ui.FriendsSummaryTone +import com.minekube.connect.share.fabric.ui.FriendsScreenLayout +import com.minekube.connect.share.fabric.ui.IncomingFriendRequestSummary +import com.minekube.connect.share.fabric.ui.OutgoingFriendRequestSummary +import com.minekube.connect.share.fabric.ui.FriendsViewModel +import com.minekube.connect.share.fabric.ui.ShareUiMessage +import com.minekube.connect.share.fabric.ui.uiMessage +import com.minekube.connect.share.fabric.ui.overview +import com.minekube.connect.share.fabric.ui.page +import com.minekube.connect.share.fabric.ui.presentation +import com.minekube.connect.share.fabric.ui.summary +import com.minekube.connect.share.friend.FriendPermissions +import com.minekube.connect.share.friend.FriendAccessPolicy +import com.minekube.connect.tunnel.p2p.DirectP2pAuthMode +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.cancel +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import net.minecraft.client.gui.components.Button +import net.minecraft.client.gui.components.Checkbox +import net.minecraft.client.gui.components.CycleButton +import net.minecraft.client.gui.components.EditBox +import net.minecraft.client.gui.components.MultiLineTextWidget +import net.minecraft.client.gui.components.StringWidget +import net.minecraft.client.gui.components.Tooltip +import net.minecraft.client.gui.screens.ConnectScreen +import net.minecraft.client.gui.screens.Screen +import net.minecraft.client.multiplayer.ServerData +import net.minecraft.client.multiplayer.resolver.ServerAddress +import net.minecraft.network.chat.CommonComponents +import net.minecraft.ChatFormatting +import net.minecraft.network.chat.Component +import java.util.UUID + +class ShareJoinScreen( + private val parent: Screen, + private val friends: FriendsViewModel, + private val browser: FabricShareBrowser, + private val remotePresence: FriendPresenceMonitor, + private val friendActivity: FriendActivityMonitor, +) : Screen(Component.translatable("connect_share.friends.title")) { + private var scope: CoroutineScope? = null + private var mode = Mode.FRIENDS + private var selectedPeerId: String? = null + private var nameValue = "" + private var invitationValue = "" + private var offlineSelected = false + private var internetSelected = false + private var nameBox: EditBox? = null + private var invitationBox: EditBox? = null + private var offlineMode: Checkbox? = null + private var internetDirect: Checkbox? = null + private var primaryButton: Button? = null + private var secondaryButton: Button? = null + private var safeMessage: ShareUiMessage? = null + private var fingerprint = 0 + private var joining = false + private var joiningPeerId: String? = null + private var reciprocalPairing = false + private var removeConfirmation = false + private var friendLinkState = FriendLinkState.IDLE + private var requestOperationInProgress = false + private var relationshipOffset = 0 + private val requestJobs = mutableMapOf() + private val requestStates = + mutableMapOf() + + override fun init() { + if (scope == null) { + scope = CoroutineScope( + SupervisorJob() + minecraft!!.asCoroutineDispatcher(), + ) + browser.start().onLeft { safeMessage = it.uiMessage() } + } + friends.updatePresence(browser.discovered.value) + friends.updateRemotePresence(remotePresence.state.value) + friends.updateActivities(friendActivity.state.value) + friends.updateIncoming( + ConnectShareClient.viewModel().state.value.pendingAdmissions, + ) + fingerprint = currentFingerprint() + nameBox = null + invitationBox = null + primaryButton = null + secondaryButton = null + + when (mode) { + Mode.FRIENDS -> buildFriends() + Mode.ADD -> buildAddFriend() + Mode.CONNECTION_OPTIONS -> buildConnectionOptions() + Mode.MANAGE -> buildManageFriend() + } + } + + override fun tick() { + super.tick() + friends.updatePresence(browser.discovered.value) + friends.updateRemotePresence(remotePresence.state.value) + friends.updateActivities(friendActivity.state.value) + friends.updateIncoming( + ConnectShareClient.viewModel().state.value.pendingAdmissions, + ) + val next = currentFingerprint() + if (next != fingerprint) { + rebuildWidgets() + } else { + refresh() + } + } + + override fun onClose() { + if (mode == Mode.MANAGE && removeConfirmation) { + removeConfirmation = false + rebuildWidgets() + return + } + when (mode) { + Mode.FRIENDS -> minecraft!!.setScreen(parent) + Mode.ADD, + Mode.CONNECTION_OPTIONS, + Mode.MANAGE, + -> { + val returningToAdd = mode == Mode.CONNECTION_OPTIONS + mode = if (returningToAdd) { + Mode.ADD + } else { + Mode.FRIENDS + } + if (!returningToAdd) resetFriendFormDraft() + selectedPeerId = null + safeMessage = null + rebuildWidgets() + } + } + } + + override fun removed() { + scope?.cancel() + scope = null + super.removed() + } + + private fun buildFriends() { + val layout = AdaptiveShareLayout.friends(width, height) + val state = friends.state.value + val overview = state.overview() + addRenderableWidget( + centered( + Component.translatable("connect_share.friends.title") + .withStyle(ChatFormatting.BOLD), + layout.headerY, + ), + ) + addRenderableWidget( + centered( + friendsSummary(overview), + layout.subtitleY, + ), + ) + + val relationships = buildList { + state.incomingRequests.forEach { + add(RelationshipRow.Incoming(it)) + } + state.outgoingRequests.forEach { + add(RelationshipRow.Outgoing(it)) + } + state.friends.forEach { add(RelationshipRow.Friend(it)) } + } + val page = relationships.page( + offset = relationshipOffset, + size = layout.visibleRows, + ) + relationshipOffset = page.offset + if (relationships.isEmpty()) { + addRenderableWidget( + centeredWrapped( + Component.translatable("connect_share.friends.empty"), + layout.rowsTop + 24, + layout.contentWidth, + ), + ) + } + page.items.forEachIndexed { index, relationship -> + val y = layout.rowY(index) + when (relationship) { + is RelationshipRow.Incoming -> + addIncomingRow(relationship.request, y, layout) + + is RelationshipRow.Outgoing -> + addOutgoingRow(relationship.request, y, layout) + + is RelationshipRow.Friend -> + addFriendRow(relationship.friend, y, layout) + } + } + if (page.pageCount > 1) { + val previousTooltip = Tooltip.create( + Component.translatable( + "connect_share.page.previous_tooltip", + page.pageNumber, + page.pageCount, + ), + ) + val nextTooltip = Tooltip.create( + Component.translatable( + "connect_share.page.next_tooltip", + page.pageNumber, + page.pageCount, + ), + ) + val previous = addRenderableWidget( + Button.builder(Component.literal("‹")) { + relationshipOffset = page.previousOffset ?: 0 + rebuildWidgets() + }.bounds(layout.contentX, layout.headerY, 24, 20) + .createNarration { + Component.translatable("connect_share.page.previous") + } + .tooltip(previousTooltip) + .build(), + ) + previous.active = page.hasPrevious + val next = addRenderableWidget( + Button.builder(Component.literal("›")) { + relationshipOffset = page.nextOffset ?: page.offset + rebuildWidgets() + }.bounds( + layout.contentX + layout.contentWidth - 24, + layout.headerY, + 24, + 20, + ) + .createNarration { + Component.translatable("connect_share.page.next") + } + .tooltip(nextTooltip) + .build(), + ) + next.active = page.hasNext + } + + safeMessage().let { message -> + if (message != null) { + addRenderableWidget( + centeredWrapped( + message.component() + .withStyle(ChatFormatting.YELLOW), + layout.messageY, + layout.contentWidth, + ), + ) + } + } + primaryButton = addRenderableWidget( + Button.builder( + Component.translatable(friendLinkState.translationKey), + ) { + copyMyFriendLink() + }.bounds( + layout.contentX, + layout.footerTop, + layout.halfButtonWidth, + 20, + ) + .tooltip( + Tooltip.create( + Component.translatable( + "connect_share.friends.copy_my_link.tooltip", + ), + ), + ) + .build(), + ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.friends.add"), + ) { + resetFriendFormDraft() + mode = Mode.ADD + safeMessage = null + rebuildWidgets() + }.bounds( + layout.contentX + layout.halfButtonWidth + 6, + layout.footerTop, + layout.halfButtonWidth, + 20, + ).build(), + ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.privacy.title"), + ) { + minecraft!!.setScreen(SharePrivacyScreen(this)) + }.bounds( + layout.contentX, + layout.footerTop + 24, + layout.halfButtonWidth, + 20, + ) + .build(), + ) + addRenderableWidget( + Button.builder(CommonComponents.GUI_BACK) { onClose() } + .bounds( + layout.contentX + layout.halfButtonWidth + 6, + layout.footerTop + 24, + layout.halfButtonWidth, + 20, + ) + .build(), + ) + } + + private fun addIncomingRow( + request: IncomingFriendRequestSummary, + y: Int, + layout: FriendsScreenLayout, + ) { + val buttonWidth = 58 + val textWidth = layout.contentWidth - buttonWidth * 2 - 12 + addRenderableWidget( + StringWidget( + layout.contentX, + y, + textWidth, + 20, + Component.translatable( + if (request.purpose == com.minekube.connect.share.admission.AdmissionPurpose.FRIEND) { + "connect_share.friends.incoming_request" + } else { + "connect_share.friends.incoming_join_request" + }, + request.displayName, + friendlyIngress(request.ingress), + ), + font, + ), + ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.status.allow"), + ) { + ConnectShareClient.viewModel().allow(request.requestId) + }.bounds( + layout.contentX + layout.contentWidth - buttonWidth * 2 - 4, + y + 2, + buttonWidth, + 20, + ).build(), + ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.status.deny"), + ) { + ConnectShareClient.viewModel().deny(request.requestId) + }.bounds( + layout.contentX + layout.contentWidth - buttonWidth, + y + 2, + buttonWidth, + 20, + ).build(), + ) + } + + private fun addOutgoingRow( + request: OutgoingFriendRequestSummary, + y: Int, + layout: FriendsScreenLayout, + ) { + val deliveryState = requestStates[request.peerId] + val buttonWidth = 58 + val textWidth = layout.contentWidth - buttonWidth * 2 - 12 + addRenderableWidget( + StringWidget( + layout.contentX, + y, + textWidth, + 20, + outgoingRequestLabel(request.displayName, deliveryState), + font, + ), + ) + addRenderableWidget( + Button.builder( + Component.translatable( + deliveryState?.translationKey + ?: "connect_share.friends.retry_request", + ), + ) { + deliverOutgoing(request.peerId) + }.bounds( + layout.contentX + layout.contentWidth - buttonWidth * 2 - 4, + y + 2, + buttonWidth, + 20, + ).build().apply { + active = deliveryState == null || + deliveryState == RequestDeliveryState.FAILED + }, + ) + addRenderableWidget( + Button.builder( + Component.translatable( + "connect_share.friends.cancel_request", + ), + ) { + cancelOutgoing(request.peerId) + }.bounds( + layout.contentX + layout.contentWidth - buttonWidth, + y + 2, + buttonWidth, + 20, + ).build(), + ) + } + + private fun addFriendRow( + friend: FriendSummary, + y: Int, + layout: FriendsScreenLayout, + ) { + val actionWidth = 104 + val manageWidth = 28 + val textWidth = layout.contentWidth - actionWidth - manageWidth - 12 + val presentation = friend.presentation() + addRenderableWidget( + StringWidget( + layout.contentX, + y, + textWidth, + 11, + Component.literal(friend.displayName) + .withStyle(ChatFormatting.WHITE), + font, + ), + ) + addRenderableWidget( + StringWidget( + layout.contentX, + y + 11, + textWidth, + 11, + friendStatus(friend).copy() + .withStyle(presentation.tone.color()), + font, + ), + ) + addRenderableWidget( + Button.builder( + Component.translatable(presentation.action.translationKey), + ) { + when (presentation.action) { + FriendPrimaryAction.ASK_TO_JOIN -> + requestToJoin(friend.peerId) + FriendPrimaryAction.JOIN_NOW -> joinSaved(friend.peerId) + FriendPrimaryAction.CANCEL_FOLLOW -> + friends.cancelFollow(friend.peerId) + FriendPrimaryAction.JOIN_WHEN_READY -> + friends.follow(friend.peerId) + } + rebuildWidgets() + }.bounds( + layout.contentX + layout.contentWidth - actionWidth - manageWidth - 4, + y + 2, + actionWidth, + 20, + ).build(), + ) + addRenderableWidget( + Button.builder( + Component.literal("…"), + ) { + selectedPeerId = friend.peerId + applyFriendFormDraft( + FriendFormDraft.forManage(friend.displayName), + ) + mode = Mode.MANAGE + safeMessage = null + rebuildWidgets() + }.bounds( + layout.contentX + layout.contentWidth - manageWidth, + y + 2, + manageWidth, + 20, + ).tooltip( + Tooltip.create( + Component.translatable( + "connect_share.friends.manage_named", + friend.displayName, + ), + ), + ).createNarration { + Component.translatable( + "connect_share.friends.manage_named", + friend.displayName, + ) + }.build(), + ) + } + + private fun buildAddFriend() { + val layout = AdaptiveShareLayout.form(width, height, 4) + addRenderableWidget( + centered( + Component.translatable("connect_share.friends.add") + .withStyle(ChatFormatting.BOLD), + layout.headerY, + ), + ) + addRenderableWidget( + centeredWrapped( + Component.translatable("connect_share.friends.add_description"), + layout.subtitleY, + layout.contentWidth, + ), + ) + addRenderableWidget( + StringWidget( + layout.contentX, + layout.bodyTop, + layout.contentWidth, + 11, + Component.translatable("connect_share.friends.link"), + font, + ), + ) + invitationBox = addRenderableWidget( + EditBox( + font, + layout.contentX, + layout.bodyTop + 12, + layout.contentWidth, + 20, + Component.translatable("connect_share.join.invitation"), + ).apply { + setMaxLength(MAX_INVITATION_LENGTH) + setHint(Component.translatable("connect_share.join.invitation_hint")) + setValue(invitationValue) + setResponder { + invitationValue = it + friends.suggestedDisplayName(it).getOrNull() + ?.let { suggested -> + nameValue = suggested + nameBox?.setValue(suggested) + } + refresh() + } + }, + ) + addRenderableWidget( + StringWidget( + layout.contentX, + layout.bodyTop + 38, + layout.contentWidth, + 11, + Component.translatable("connect_share.friends.name"), + font, + ), + ) + nameBox = addRenderableWidget( + EditBox( + font, + layout.contentX, + layout.bodyTop + 50, + layout.contentWidth, + 20, + Component.translatable("connect_share.friends.name"), + ).apply { + setMaxLength(64) + setHint(Component.translatable("connect_share.friends.name_hint")) + setValue(nameValue) + setResponder { + nameValue = it + refresh() + } + }, + ) + addRenderableWidget( + Button.builder( + Component.translatable( + "connect_share.friends.connection_options.show", + ), + ) { + mode = Mode.CONNECTION_OPTIONS + rebuildWidgets() + }.bounds( + layout.contentX, + layout.bodyTop + 76, + layout.contentWidth, + 20, + ).build(), + ) + safeMessage().let { message -> + if (message != null) { + addRenderableWidget( + centeredWrapped( + message.component().withStyle(ChatFormatting.YELLOW), + layout.footerTop - 16, + layout.contentWidth, + ), + ) + } + } + primaryButton = addRenderableWidget( + Button.builder( + Component.translatable( + "connect_share.friends.send_request", + ), + ) { + createFriendRequest() + }.bounds( + layout.contentX, + layout.footerTop, + layout.contentWidth, + 20, + ).build(), + ) + secondaryButton = addRenderableWidget( + Button.builder( + Component.translatable("connect_share.friends.join_once"), + ) { + joinInvitation() + }.bounds( + layout.contentX, + layout.footerTop + 24, + layout.halfButtonWidth, + 20, + ).build(), + ) + addRenderableWidget( + Button.builder(CommonComponents.GUI_BACK) { onClose() } + .bounds( + layout.contentX + layout.halfButtonWidth + 6, + layout.footerTop + 24, + layout.halfButtonWidth, + 20, + ) + .build(), + ) + refresh() + } + + private fun buildConnectionOptions() { + val layout = AdaptiveShareLayout.form(width, height, 2) + addRenderableWidget( + centered( + Component.translatable( + "connect_share.friends.connection_options.title", + ).withStyle(ChatFormatting.BOLD), + layout.headerY, + ), + ) + addRenderableWidget( + centeredWrapped( + Component.translatable( + "connect_share.friends.connection_options.description", + ), + layout.subtitleY, + layout.contentWidth, + ), + ) + offlineMode = addRenderableWidget( + ObservableCheckbox( + layout.contentX, + layout.bodyTop, + layout.contentWidth, + 20, + Component.translatable("connect_share.join.offline"), + offlineSelected, + ) { selected -> offlineSelected = selected }.apply { + setTooltip( + Tooltip.create( + Component.translatable("connect_share.join.offline.tooltip"), + ), + ) + }, + ) + internetDirect = addRenderableWidget( + ObservableCheckbox( + layout.contentX, + layout.bodyTop + 28, + layout.contentWidth, + 20, + Component.translatable("connect_share.join.internet"), + internetSelected, + ) { selected -> internetSelected = selected }.apply { + setTooltip( + Tooltip.create( + Component.translatable("connect_share.join.internet.tooltip"), + ), + ) + }, + ) + addRenderableWidget( + centeredWrapped( + Component.translatable( + "connect_share.friends.connection_options.fallback", + ).withStyle(ChatFormatting.GRAY), + layout.bodyTop + 64, + layout.contentWidth, + ), + ) + addRenderableWidget( + Button.builder(CommonComponents.GUI_DONE) { onClose() } + .bounds( + layout.contentX, + layout.footerTop + 24, + layout.contentWidth, + 20, + ).build(), + ) + } + + private fun buildManageFriend() { + val friend = selectedFriend() + if (friend == null) { + mode = Mode.FRIENDS + rebuildWidgets() + return + } + if (removeConfirmation) { + buildRemoveFriendConfirmation(friend) + return + } + val layout = AdaptiveShareLayout.form(width, height, 5) + val form = AdaptiveShareLayout.manageFriendForm(layout.bodyTop) + var internetDirectSelected = friend.internetDirectGuestOptIn + addRenderableWidget( + centered( + Component.translatable( + "connect_share.friends.manage_title", + friend.displayName, + ).withStyle(ChatFormatting.BOLD), + layout.headerY, + ), + ) + addRenderableWidget( + centeredWrapped( + safeMessage()?.let { + it.component().withStyle(ChatFormatting.YELLOW) + } ?: friendStatus(friend).copy().withStyle( + friend.presentation().tone.color(), + ), + layout.subtitleY, + layout.contentWidth, + ), + ) + addRenderableWidget( + StringWidget( + layout.contentX, + form.nameLabelY, + layout.contentWidth, + 11, + Component.translatable("connect_share.friends.name"), + font, + ), + ) + nameBox = addRenderableWidget( + EditBox( + font, + layout.contentX, + form.nameInputY, + layout.contentWidth, + 20, + Component.translatable("connect_share.friends.name"), + ).apply { + setMaxLength(64) + setValue(nameValue.ifBlank { friend.displayName }) + setResponder { + nameValue = it + refresh() + } + }, + ) + val notify = addRenderableWidget( + ObservableCheckbox( + layout.contentX, + form.notifyY, + layout.contentWidth, + 20, + Component.translatable("connect_share.friends.notify"), + friend.permissions.notifyWhenOnline, + ), + ) + var accessPolicy = friend.permissions.accessPolicy + addRenderableWidget( + CycleButton.builder( + { policy: FriendAccessPolicy -> + Component.translatable( + "connect_share.friends.access.${policy.name.lowercase()}", + ) + }, + ).withInitialValue(accessPolicy) + .withValues(FriendAccessPolicy.entries) + .create( + layout.contentX, + form.accessPolicyY, + layout.contentWidth, + 20, + Component.translatable("connect_share.friends.access"), + ) { _, selected -> accessPolicy = selected }, + ) + val shareWorlds = addRenderableWidget( + ObservableCheckbox( + layout.contentX, + form.shareWorldsY, + layout.contentWidth, + 20, + Component.translatable("connect_share.friends.share_worlds"), + friend.permissions.canSeeMyWorlds, + ), + ) + addRenderableWidget( + CycleButton.onOffBuilder(friend.internetDirectGuestOptIn) + .create( + layout.contentX, + form.internetDirectY, + layout.contentWidth, + 20, + Component.translatable( + "connect_share.friends.internet_direct_short", + ), + ) { _, selected -> internetDirectSelected = selected } + .apply { + setTooltip( + Tooltip.create( + Component.translatable( + "connect_share.friends.internet_direct.tooltip", + ), + ), + ) + }, + ) + primaryButton = addRenderableWidget( + Button.builder( + Component.translatable("connect_share.friends.save_changes"), + ) { + val activeScope = scope ?: return@builder + requestOperationInProgress = true + refresh() + activeScope.launch { + withContext(Dispatchers.IO) { + friends.rename(friend.peerId, nameValue) + friends.updateInternetDirectGuestOptIn( + friend.peerId, + internetDirectSelected, + ) + friends.updatePermissions( + friend.peerId, + FriendPermissions( + notifyWhenOnline = notify.selected(), + canSeeMyWorlds = shareWorlds.selected(), + accessPolicy = accessPolicy, + ), + ) + } + requestOperationInProgress = false + mode = Mode.FRIENDS + selectedPeerId = null + resetFriendFormDraft() + rebuildWidgets() + } + }.bounds( + layout.contentX, + layout.footerTop, + layout.halfButtonWidth, + 20, + ).build(), + ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.friends.remove"), + ) { + removeConfirmation = true + rebuildWidgets() + }.bounds( + layout.contentX + layout.halfButtonWidth + 6, + layout.footerTop, + layout.halfButtonWidth, + 20, + ).build(), + ) + addRenderableWidget( + Button.builder(CommonComponents.GUI_BACK) { onClose() } + .bounds( + layout.contentX, + layout.footerTop + 24, + layout.contentWidth, + 20, + ) + .build(), + ) + refresh() + } + + private fun buildRemoveFriendConfirmation(friend: FriendSummary) { + val layout = AdaptiveShareLayout.form(width, height, 1) + addRenderableWidget( + centered( + Component.translatable( + "connect_share.friends.remove_confirm.title", + friend.displayName, + ).withStyle(ChatFormatting.BOLD), + layout.headerY + 12, + ), + ) + addRenderableWidget( + centeredWrapped( + Component.translatable( + "connect_share.friends.remove_confirm.message", + ), + layout.bodyTop, + layout.contentWidth, + ), + ) + addRenderableWidget( + Button.builder( + Component.translatable( + "connect_share.friends.remove_confirm.confirm", + ), + ) { + val activeScope = scope ?: return@builder + requestOperationInProgress = true + refresh() + activeScope.launch { + withContext(Dispatchers.IO) { + friends.remove(friend.peerId) + } + requestOperationInProgress = false + removeConfirmation = false + mode = Mode.FRIENDS + selectedPeerId = null + resetFriendFormDraft() + rebuildWidgets() + } + }.bounds( + layout.contentX, + layout.footerTop + 24, + (layout.contentWidth - 12) / 3, + 20, + ).build(), + ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.friends.block"), + ) { + val activeScope = scope ?: return@builder + requestOperationInProgress = true + refresh() + activeScope.launch { + withContext(Dispatchers.IO) { + friends.block(friend.peerId) + } + requestOperationInProgress = false + removeConfirmation = false + mode = Mode.FRIENDS + selectedPeerId = null + resetFriendFormDraft() + rebuildWidgets() + } + }.bounds( + layout.contentX + (layout.contentWidth - 12) / 3 + 6, + layout.footerTop + 24, + (layout.contentWidth - 12) / 3, + 20, + ).build(), + ) + addRenderableWidget( + Button.builder(CommonComponents.GUI_CANCEL) { + removeConfirmation = false + rebuildWidgets() + }.bounds( + layout.contentX + 2 * ((layout.contentWidth - 12) / 3 + 6), + layout.footerTop + 24, + (layout.contentWidth - 12) / 3, + 20, + ).build(), + ) + } + + private fun copyMyFriendLink() { + val activeScope = scope ?: return + if (friendLinkState == FriendLinkState.COPYING) { + return + } + friendLinkState = FriendLinkState.COPYING + rebuildWidgets() + activeScope.launch { + val invitation = withContext(Dispatchers.IO) { + ConnectShareClient.friendCardIssuer().issue() + } + invitation.fold( + ifLeft = { + friendLinkState = FriendLinkState.FAILED + }, + ifRight = { link -> + minecraft!!.keyboardHandler.setClipboard(link) + friendLinkState = FriendLinkState.COPIED + }, + ) + rebuildWidgets() + } + } + + private fun joinSaved(peerId: String) { + if (joining) return + joining = true + joiningPeerId = peerId + reciprocalPairing = true + safeMessage = null + refresh() + scope?.launch { + friends.join( + peerId = peerId, + browser = browser, + authMode = authMode(), + ownConnectAddress = + ConnectShareClient.connectPublicAddress(), + ).fold( + ifLeft = ::joinFailed, + ifRight = ::connect, + ) + } + } + + private fun requestToJoin( + peerId: String, + allowModMismatch: Boolean = false, + ) { + if (joining) return + joining = true + joiningPeerId = peerId + reciprocalPairing = false + safeMessage = null + refresh() + scope?.launch { + ConnectShareClient.friendJoinOrchestrator().request( + peerId, + FriendJoinRequest( + requestId = UUID.randomUUID(), + playerName = minecraft!!.user.name, + playerUuid = checkNotNull(minecraft!!.user.profileId), + ), + allowModMismatch = allowModMismatch, + ).fold( + ifLeft = { failure -> + joining = false + if (failure is FriendJoinAttemptFailure.Compatibility) { + minecraft!!.setScreen( + CompatibilityMismatchScreen( + parent = this@ShareJoinScreen, + failure = failure, + tryAnyway = { + requestToJoin(peerId, true) + }, + ), + ) + } else { + safeMessage = failure.uiMessage() + rebuildWidgets() + } + }, + ifRight = ::connect, + ) + } + } + + private fun createFriendRequest() { + val activeScope = scope ?: return + if ( + requestOperationInProgress || + invitationValue.isBlank() || + nameValue.isBlank() + ) { + return + } + requestOperationInProgress = true + safeMessage = null + refresh() + activeScope.launch { + val peerId = withContext(Dispatchers.IO) { + friends.sendRequest( + invitationValue, + nameValue, + internetDirectGuestOptIn = internetSelected, + ) + } + requestOperationInProgress = false + if (peerId == null) { + rebuildWidgets() + return@launch + } + mode = Mode.FRIENDS + resetFriendFormDraft() + rebuildWidgets() + deliverOutgoing(peerId) + } + } + + private fun deliverOutgoing(peerId: String) { + val activeScope = scope ?: return + if (requestJobs[peerId]?.isActive == true) { + return + } + safeMessage = null + requestStates[peerId] = RequestDeliveryState.SENDING + rebuildWidgets() + val job = activeScope.launch { + val senderCard = withContext(Dispatchers.IO) { + ConnectShareClient.friendCardIssuer().issue().getOrNull() + } + if (senderCard == null) { + requestFailed( + peerId, + ShareUiMessage("connect_share.friends.request_failed"), + ) + return@launch + } + val targetResult = friends.routeOutgoing( + peerId = peerId, + browser = browser, + authMode = DirectP2pAuthMode.OFFLINE, + ) + val target = targetResult.getOrNull() + if (target == null) { + requestFailed( + peerId, + targetResult.leftOrNull()?.uiMessage() + ?: ShareUiMessage( + "connect_share.friends.request_failed", + ), + ) + return@launch + } + val displayName = friends.state.value.outgoingRequests + .firstOrNull { it.peerId == peerId } + ?.displayName + ?: peerId + val result = ConnectShareClient.friendRequestClient().exchange( + target = target, + request = FriendControlRequest( + requestId = UUID.randomUUID(), + relationshipId = friends.state.value.outgoingRequests + .firstOrNull { it.peerId == peerId } + ?.relationshipId + ?: run { + target.close() + requestFailed( + peerId, + ShareUiMessage( + "connect_share.friends.request_failed", + ), + ) + return@launch + }, + displayName = minecraft!!.user.name, + invitation = senderCard, + ), + onReceived = { + minecraft!!.execute { + requestStates[peerId] = + RequestDeliveryState.WAITING + rebuildWidgets() + } + }, + ) + val hostCard = result.getOrNull() + if (hostCard == null) { + requestFailed( + peerId, + result.leftOrNull()?.uiMessage() + ?: ShareUiMessage( + "connect_share.friends.request_failed", + ), + ) + return@launch + } + val accepted = withContext(Dispatchers.IO) { + ConnectShareClient.friendCardReceiver().receive( + invitation = hostCard, + displayName = displayName, + authenticatedMinecraftUuid = null, + relationshipId = friends.state.value.outgoingRequests + .firstOrNull { it.peerId == peerId } + ?.relationshipId, + ) + } + if (accepted.isLeft()) { + requestFailed( + peerId, + ShareUiMessage("connect_share.friends.request_failed"), + ) + return@launch + } + requestStates.remove(peerId) + friends.reload() + safeMessage = ShareUiMessage( + "connect_share.friends.request_accepted", + listOf(displayName), + ) + rebuildWidgets() + } + requestJobs[peerId] = job + job.invokeOnCompletion { + minecraft!!.execute { + requestJobs.remove(peerId, job) + } + } + } + + private fun cancelOutgoing(peerId: String) { + val activeScope = scope ?: return + requestJobs.remove(peerId)?.cancel() + requestStates[peerId] = RequestDeliveryState.CANCELLING + rebuildWidgets() + activeScope.launch { + withContext(Dispatchers.IO) { + friends.remove(peerId) + } + requestStates.remove(peerId) + rebuildWidgets() + } + } + + private fun requestFailed( + peerId: String, + message: ShareUiMessage, + ) { + requestStates[peerId] = RequestDeliveryState.FAILED + safeMessage = message + rebuildWidgets() + } + + private fun joinInvitation() { + if (joining || invitationValue.isBlank()) return + joining = true + joiningPeerId = null + reciprocalPairing = false + safeMessage = null + refresh() + scope?.launch { + browser.join( + invitationUri = invitationValue, + lanAddress = null, + internetOptIn = internetSelected, + authMode = authMode(), + ).fold( + ifLeft = ::joinFailed, + ifRight = ::connect, + ) + } + } + + private fun joinFailed(failure: com.minekube.connect.share.fabric.GuestJoinFailure) { + joining = false + joiningPeerId = null + reciprocalPairing = false + safeMessage = failure.uiMessage() + rebuildWidgets() + } + + private fun connect(target: GuestJoinTarget) { + val client = checkNotNull(minecraft) + val address = when (target) { + is GuestJoinTarget.Connect -> + ServerAddress.parseString(target.publicAddress) + + is GuestJoinTarget.Direct -> + ServerAddress( + target.localAddress.hostString, + target.localAddress.port, + ) + } + if (target is GuestJoinTarget.Direct) { + ConnectShareClient.holdGuestDirect(target) + } + val state = friends.state.value + val joiningFriend = state.friends.firstOrNull { + it.peerId == joiningPeerId + } + val data = ServerData( + joiningFriend?.displayName + ?: "Connect Share", + address.toString(), + false, + ) + val exchangeFriendCard = FriendCardExchangeConsent.shouldArm( + savedFriendJoin = reciprocalPairing, + canSeeMyWorlds = + joiningFriend?.permissions?.canSeeMyWorlds == true, + ) + if (exchangeFriendCard && joiningPeerId != null) { + ConnectShareClient.armFriendCardExchange( + checkNotNull(joiningPeerId), + ) + } + ConnectScreen.startConnecting( + parent, + client, + address, + data, + false, + ) + } + + private fun refresh() { + val inputReady = invitationValue.isNotBlank() + primaryButton?.active = + !joining && !requestOperationInProgress && + friendLinkState != FriendLinkState.COPYING && + when (mode) { + Mode.ADD -> inputReady && nameValue.isNotBlank() + Mode.CONNECTION_OPTIONS -> true + Mode.MANAGE -> nameValue.isNotBlank() + Mode.FRIENDS -> true + } + secondaryButton?.active = !joining && inputReady + invitationBox?.setEditable(!joining) + nameBox?.setEditable(!joining) + } + + private fun resetFriendFormDraft() { + applyFriendFormDraft(currentFriendFormDraft().newRequest()) + } + + private fun currentFriendFormDraft(): FriendFormDraft = + FriendFormDraft( + displayName = nameValue, + invitation = invitationValue, + offlineMode = offlineSelected, + internetDirect = internetSelected, + ) + + private fun applyFriendFormDraft(draft: FriendFormDraft) { + nameValue = draft.displayName + invitationValue = draft.invitation + offlineSelected = draft.offlineMode + internetSelected = draft.internetDirect + } + + private fun friendsSummary(overview: FriendsOverview): Component = + overview.summary().let { presentation -> + Component.translatable( + presentation.translationKey, + *listOfNotNull(presentation.count).toTypedArray(), + ).withStyle( + when (presentation.tone) { + FriendsSummaryTone.ATTENTION -> ChatFormatting.YELLOW + FriendsSummaryTone.READY -> ChatFormatting.GREEN + FriendsSummaryTone.ONLINE -> ChatFormatting.AQUA + FriendsSummaryTone.MUTED -> ChatFormatting.GRAY + }, + ) + } + + private fun friendStatus(friend: FriendSummary): Component = + friend.presentation().let { presentation -> + Component.translatable( + presentation.statusKey, + *presentation.statusArguments.toTypedArray(), + ) + } + + private fun FriendPresenceTone.color(): ChatFormatting = when (this) { + FriendPresenceTone.JOINABLE -> ChatFormatting.GREEN + FriendPresenceTone.ONLINE -> ChatFormatting.AQUA + FriendPresenceTone.SAVED -> ChatFormatting.GRAY + FriendPresenceTone.OFFLINE -> ChatFormatting.DARK_GRAY + } + + private fun friendlyIngress(ingress: Ingress): Component = + Component.translatable( + when (ingress) { + Ingress.CONNECT -> "connect_share.ingress.online" + Ingress.DIRECT_LAN -> "connect_share.ingress.nearby" + Ingress.DIRECT_INTERNET -> "connect_share.ingress.direct" + }, + ) + + private fun outgoingRequestLabel( + displayName: String, + deliveryState: RequestDeliveryState?, + ): Component = Component.translatable( + if (deliveryState == null) { + "connect_share.friends.outgoing_request" + } else { + "connect_share.friends.outgoing_request_active" + }, + displayName, + ) + + private fun selectedFriend(): FriendSummary? = + friends.state.value.friends.firstOrNull { + it.peerId == selectedPeerId + } + + private fun safeMessage(): ShareUiMessage? = + safeMessage ?: friends.state.value.safeMessage + + private fun ShareUiMessage.component() = + Component.translatable(translationKey, *arguments.toTypedArray()) + + private fun authMode(): DirectP2pAuthMode = + if (offlineSelected) { + DirectP2pAuthMode.OFFLINE + } else { + DirectP2pAuthMode.ONLINE + } + + private fun currentFingerprint(): Int = + 31 * browser.discovered.value.hashCode() + + 31 * friends.state.value.hashCode() + + mode.hashCode() + + private fun centered(message: Component, y: Int): StringWidget { + val textWidth = font.width(message) + return StringWidget( + width / 2 - textWidth / 2, + y, + textWidth, + 9, + message, + font, + ) + } + + private fun centeredWrapped( + message: Component, + y: Int, + contentWidth: Int = CONTENT_WIDTH, + ): MultiLineTextWidget = + MultiLineTextWidget( + width / 2 - contentWidth / 2, + y, + message, + font, + ).setMaxWidth(contentWidth).setCentered(true) + + private enum class Mode { + FRIENDS, + ADD, + CONNECTION_OPTIONS, + MANAGE, + } + + private sealed interface RelationshipRow { + data class Incoming( + val request: IncomingFriendRequestSummary, + ) : RelationshipRow + + data class Outgoing( + val request: OutgoingFriendRequestSummary, + ) : RelationshipRow + + data class Friend( + val friend: FriendSummary, + ) : RelationshipRow + } + + private enum class FriendLinkState( + val translationKey: String, + ) { + IDLE("connect_share.friends.invite"), + COPYING("connect_share.friends.copying_my_link"), + COPIED("connect_share.friends.my_link_copied"), + FAILED("connect_share.friends.copy_my_link_failed"), + } + + private enum class RequestDeliveryState( + val translationKey: String, + ) { + SENDING("connect_share.friends.request_sending"), + WAITING("connect_share.friends.request_waiting"), + CANCELLING("connect_share.friends.request_cancelling"), + FAILED("connect_share.friends.retry_request"), + } + + private companion object { + const val MAX_INVITATION_LENGTH = 32_768 + const val MAX_VISIBLE_RELATIONSHIPS = 5 + const val CONTENT_WIDTH = 310 + } +} diff --git a/share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/SharePrivacyScreen.kt b/share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/SharePrivacyScreen.kt new file mode 100644 index 000000000..d921fd2ae --- /dev/null +++ b/share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/SharePrivacyScreen.kt @@ -0,0 +1,176 @@ +package com.minekube.connect.share.fabric.v1_20_1 + +import com.minekube.connect.share.fabric.ConnectShareClient +import com.minekube.connect.share.fabric.ui.AdaptiveShareLayout +import net.minecraft.ChatFormatting +import net.minecraft.client.gui.components.Button +import net.minecraft.client.gui.components.Checkbox +import net.minecraft.client.gui.components.MultiLineTextWidget +import net.minecraft.client.gui.components.StringWidget +import net.minecraft.client.gui.screens.Screen +import net.minecraft.network.chat.CommonComponents +import net.minecraft.network.chat.Component + +class SharePrivacyScreen( + private val parent: Screen, +) : Screen(Component.translatable("connect_share.privacy.title")) { + private val viewModel = ConnectShareClient.viewModel() + private var diagnosticsCopied = false + + override fun init() { + val layout = AdaptiveShareLayout.form(width, height, 4) + addRenderableWidget( + centered(title.copy().withStyle(ChatFormatting.BOLD), layout.headerY), + ) + addRenderableWidget( + MultiLineTextWidget( + layout.contentX, + layout.subtitleY, + Component.translatable("connect_share.privacy.description"), + font, + ).setMaxWidth(layout.contentWidth).setCentered(true), + ) + + val privacy = viewModel.state.value.presencePrivacy + privacyToggle("online", layout.bodyTop, privacy.showOnline) { value -> + viewModel.setPresencePrivacy( + viewModel.state.value.presencePrivacy.copy(showOnline = value), + ) + } + privacyToggle( + "playing", + layout.bodyTop + 24, + privacy.showPlaying, + ) { value -> + viewModel.setPresencePrivacy( + viewModel.state.value.presencePrivacy.copy(showPlaying = value), + ) + } + privacyToggle( + "current_server", + layout.bodyTop + 48, + privacy.showCurrentServer, + ) { value -> + viewModel.setPresencePrivacy( + viewModel.state.value.presencePrivacy.copy( + showCurrentServer = value, + ), + ) + } + privacyToggle( + "joinable", + layout.bodyTop + 72, + privacy.showJoinable, + ) { value -> + viewModel.setPresencePrivacy( + viewModel.state.value.presencePrivacy.copy(showJoinable = value), + ) + } + addRenderableWidget( + MultiLineTextWidget( + layout.contentX, + layout.bodyTop + 104, + Component.translatable("connect_share.privacy.confirmed_only") + .withStyle(ChatFormatting.GRAY), + font, + ).setMaxWidth(layout.contentWidth).setCentered(true), + ) + + val third = (layout.contentWidth - 12) / 3 + addRenderableWidget( + Button.builder( + Component.translatable( + if (diagnosticsCopied) { + "connect_share.diagnostics.copied" + } else { + "connect_share.diagnostics.copy" + }, + ), + ) { + minecraft!!.keyboardHandler.setClipboard( + ConnectShareClient.diagnosticBundle(), + ) + diagnosticsCopied = true + rebuildWidgets() + }.bounds( + layout.contentX, + layout.footerTop, + third, + 20, + ).build(), + ) + addRenderableWidget( + Button.builder( + Component.translatable( + "connect_share.privacy.blocked", + ConnectShareClient.friendsViewModel() + .state.value.blocked.size, + ), + ) { + minecraft!!.setScreen(BlockedFriendsScreen(this)) + }.bounds( + layout.contentX + third + 6, + layout.footerTop, + third, + 20, + ).build(), + ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.recovery.menu"), + ) { + minecraft!!.setScreen(RecoveryScreen(this)) + }.bounds( + layout.contentX + (third + 6) * 2, + layout.footerTop, + third, + 20, + ).build(), + ) + addRenderableWidget( + Button.builder(CommonComponents.GUI_DONE) { onClose() } + .bounds( + layout.contentX, + layout.footerTop + 24, + layout.contentWidth, + 20, + ).build(), + ) + } + + override fun onClose() { + minecraft!!.setScreen(parent) + } + + private fun privacyToggle( + key: String, + y: Int, + selected: Boolean, + changed: (Boolean) -> Unit, + ) { + val layout = AdaptiveShareLayout.form(width, height, 4) + addRenderableWidget( + ObservableCheckbox( + layout.contentX, + y, + layout.contentWidth, + 20, + Component.translatable("connect_share.privacy.$key"), + selected, + changed, + ), + ) + } + + private fun centered(message: Component, y: Int): StringWidget { + val textWidth = font.width(message) + return StringWidget( + width / 2 - textWidth / 2, + y, + textWidth, + 11, + message, + font, + ) + } +} diff --git a/share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/ShareSetupScreen.kt b/share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/ShareSetupScreen.kt new file mode 100644 index 000000000..3156e7ac4 --- /dev/null +++ b/share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/ShareSetupScreen.kt @@ -0,0 +1,226 @@ +package com.minekube.connect.share.fabric.v1_20_1 + +import com.minekube.connect.share.ShareGameMode +import com.minekube.connect.share.fabric.ConnectShareClient +import com.minekube.connect.share.fabric.ui.AdaptiveShareLayout +import com.minekube.connect.share.fabric.ui.FormScreenLayout +import com.minekube.connect.share.fabric.ui.ShareUiState +import net.minecraft.ChatFormatting +import net.minecraft.client.gui.components.Button +import net.minecraft.client.gui.components.Checkbox +import net.minecraft.client.gui.components.CycleButton +import net.minecraft.client.gui.components.MultiLineTextWidget +import net.minecraft.client.gui.components.StringWidget +import net.minecraft.client.gui.components.Tooltip +import net.minecraft.client.gui.screens.Screen +import net.minecraft.network.chat.CommonComponents +import net.minecraft.network.chat.Component + +class ShareSetupScreen( + private val parent: Screen, +) : Screen(Component.translatable("connect_share.setup.title")) { + private val viewModel = ConnectShareClient.viewModel() + private var startButton: Button? = null + private var defaultsLoaded = false + private var optionsExpanded = false + + override fun init() { + if (!defaultsLoaded) { + minecraft!!.singleplayerServer?.let { server -> + viewModel.setGameMode(server.defaultGameType.toShareGameMode()) + viewModel.setAllowCheats(server.worldData.allowCommands) + } + defaultsLoaded = true + } + val current = viewModel.state.value + val layout = AdaptiveShareLayout.form(width, height, 4) + + addRenderableWidget( + centered(title.copy().withStyle(ChatFormatting.BOLD), layout.headerY), + ) + addRenderableWidget( + MultiLineTextWidget( + layout.contentX, + layout.subtitleY, + Component.translatable("connect_share.setup.description"), + font, + ).setMaxWidth(layout.contentWidth).setCentered(true), + ) + addRenderableWidget( + centered( + Component.translatable("connect_share.setup.friends_only") + .withStyle(ChatFormatting.GREEN), + layout.bodyTop, + ), + ) + + if (optionsExpanded) { + addOptions(layout, current) + } else { + addRenderableWidget( + MultiLineTextWidget( + layout.contentX, + layout.bodyTop + 28, + Component.translatable("connect_share.setup.persistence") + .withStyle(ChatFormatting.GRAY), + font, + ).setMaxWidth(layout.contentWidth).setCentered(true), + ) + } + + startButton = addRenderableWidget( + Button.builder( + Component.translatable("connect_share.setup.start"), + ) { + viewModel.start() + minecraft!!.setScreen(ShareStatusScreen(parent)) + }.bounds( + layout.contentX, + layout.footerTop, + layout.contentWidth, + 20, + ).build(), + ) + + val third = (layout.contentWidth - 12) / 3 + addRenderableWidget( + Button.builder( + Component.translatable( + if (optionsExpanded) { + "connect_share.setup.options.hide" + } else { + "connect_share.setup.options.show" + }, + ), + ) { + optionsExpanded = !optionsExpanded + rebuildWidgets() + }.bounds( + layout.contentX, + layout.footerTop + 24, + third, + 20, + ).build(), + ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.privacy.title"), + ) { + minecraft!!.setScreen(SharePrivacyScreen(this)) + }.bounds( + layout.contentX + third + 6, + layout.footerTop + 24, + third, + 20, + ).build(), + ) + addRenderableWidget( + Button.builder(CommonComponents.GUI_CANCEL) { onClose() } + .bounds( + layout.contentX + (third + 6) * 2, + layout.footerTop + 24, + third, + 20, + ).build(), + ) + refresh() + } + + private fun addOptions( + layout: FormScreenLayout, + current: ShareUiState, + ) { + addRenderableWidget( + CycleButton.builder( + { mode: ShareGameMode -> + Component.translatable( + "connect_share.game_mode.${mode.name.lowercase()}", + ) + }, + ).withInitialValue(current.options.gameMode) + .withValues(ShareGameMode.entries) + .create( + layout.contentX, + layout.bodyTop + 24, + layout.halfButtonWidth, + 20, + Component.translatable("selectWorld.gameMode"), + ) { _, mode -> viewModel.setGameMode(mode) }, + ) + addRenderableWidget( + CycleButton.onOffBuilder(current.options.allowCheats) + .create( + layout.contentX + layout.halfButtonWidth + 6, + layout.bodyTop + 24, + layout.halfButtonWidth, + 20, + Component.translatable("selectWorld.allowCommands"), + ) { _, allowed -> viewModel.setAllowCheats(allowed) }, + ) + addRenderableWidget( + CycleButton.builder( + { guests: Int -> Component.literal(guests.toString()) }, + ).withInitialValue(current.options.maxGuests) + .withValues((1..16).toList()) + .create( + layout.contentX, + layout.bodyTop + 48, + layout.contentWidth, + 20, + Component.translatable("connect_share.setup.max_guests"), + ) { _, guests -> viewModel.setMaxGuests(guests) }, + ) + addRenderableWidget( + ObservableCheckbox( + layout.contentX, + layout.bodyTop + 74, + layout.contentWidth, + 20, + Component.translatable("connect_share.setup.internet"), + current.options.allowInternetDirect, + ) { allowed -> + viewModel.setAllowInternetDirect(allowed) + }.apply { + setTooltip( + Tooltip.create( + Component.translatable( + "connect_share.setup.internet.tooltip", + ), + ), + ) + }, + ) + } + + override fun tick() { + super.tick() + refresh() + } + + override fun onClose() { + minecraft!!.setScreen(parent) + } + + private fun refresh() { + startButton?.active = viewModel.state.value.startEnabled + } + + private fun centered(message: Component, y: Int): StringWidget { + val textWidth = font.width(message) + return StringWidget( + width / 2 - textWidth / 2, + y, + textWidth, + 11, + message, + font, + ) + } +} + +private fun net.minecraft.world.level.GameType.toShareGameMode(): ShareGameMode = when (this) { + net.minecraft.world.level.GameType.SURVIVAL -> ShareGameMode.SURVIVAL + net.minecraft.world.level.GameType.CREATIVE -> ShareGameMode.CREATIVE + net.minecraft.world.level.GameType.ADVENTURE -> ShareGameMode.ADVENTURE + net.minecraft.world.level.GameType.SPECTATOR -> ShareGameMode.SPECTATOR +} diff --git a/share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/ShareStatusScreen.kt b/share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/ShareStatusScreen.kt new file mode 100644 index 000000000..5ba936a9c --- /dev/null +++ b/share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/ShareStatusScreen.kt @@ -0,0 +1,342 @@ +package com.minekube.connect.share.fabric.v1_20_1 + +import com.minekube.connect.share.ShareState +import com.minekube.connect.share.admission.AdmissionIdentity +import com.minekube.connect.share.admission.AdmissionPurpose +import com.minekube.connect.share.admission.Ingress +import com.minekube.connect.share.fabric.ConnectShareClient +import com.minekube.connect.share.fabric.ui.AdaptiveShareLayout +import com.minekube.connect.share.fabric.ui.FormScreenLayout +import net.minecraft.ChatFormatting +import net.minecraft.client.gui.components.Button +import net.minecraft.client.gui.components.MultiLineTextWidget +import net.minecraft.client.gui.components.StringWidget +import net.minecraft.client.gui.screens.Screen +import net.minecraft.network.chat.CommonComponents +import net.minecraft.network.chat.Component + +class ShareStatusScreen( + private val parent: Screen, +) : Screen(Component.translatable("connect_share.status.title")) { + private val viewModel = ConnectShareClient.viewModel() + private var fingerprint: Int = 0 + private var linkCopied = false + private var showConnectionDetails = false + + override fun init() { + val state = viewModel.state.value + fingerprint = state.hashCode() + val layout = AdaptiveShareLayout.form(width, height, 3) + val sharing = state.shareState as? ShareState.Sharing + + addRenderableWidget( + centered( + title.copy().withStyle( + if (sharing != null) { + ChatFormatting.GREEN + } else { + ChatFormatting.YELLOW + }, + ), + layout.headerY, + ), + ) + addRenderableWidget( + centeredWrapped( + Component.translatable(statusKey(state.shareState)) + .withStyle(ChatFormatting.GRAY), + layout.subtitleY, + layout.contentWidth, + ), + ) + + var requestHeadingY = layout.bodyTop + 54 + if (sharing != null) { + val invitation = viewModel.currentInvitation() + addRenderableWidget( + Button.builder( + Component.translatable( + if (linkCopied) { + "connect_share.status.friend_link_copied" + } else { + "connect_share.status.copy_friend_link" + }, + ), + ) { + invitation?.let { + minecraft!!.keyboardHandler.setClipboard(it) + linkCopied = true + rebuildWidgets() + } + }.bounds( + layout.contentX, + layout.bodyTop, + layout.contentWidth, + 20, + ).build().apply { active = invitation != null }, + ) + addRenderableWidget( + Button.builder( + Component.translatable( + if (showConnectionDetails) { + "connect_share.status.connection_details.hide" + } else { + "connect_share.status.connection_details.show" + }, + ), + ) { + showConnectionDetails = !showConnectionDetails + rebuildWidgets() + }.bounds( + layout.contentX, + layout.bodyTop + 24, + layout.contentWidth, + 20, + ).build(), + ) + if (showConnectionDetails) { + addConnectionDetails(layout, sharing) + requestHeadingY = layout.bodyTop + 102 + } + } + + addRenderableWidget( + centered( + Component.translatable("connect_share.status.requests") + .withStyle(ChatFormatting.BOLD), + requestHeadingY, + ), + ) + val pending = state.pendingAdmissions + val rowsTop = requestHeadingY + 14 + val visibleRows = ((layout.footerTop - rowsTop) / 24).coerceIn(1, 3) + pending.take(visibleRows).forEachIndexed { index, request -> + val y = rowsTop + index * 24 + val buttonWidth = 54 + val labelWidth = layout.contentWidth - buttonWidth * 2 - 10 + val identity = request.identity + val ingress = when (identity) { + is AdmissionIdentity.Authenticated -> + identity.ingress + is AdmissionIdentity.UnverifiedOffline -> + identity.ingress + } + val label = Component.translatable( + if (request.purpose == AdmissionPurpose.FRIEND) { + "connect_share.status.friend_request" + } else { + "connect_share.status.request" + }, + identity.name, + friendlyIngress(ingress), + ) + addRenderableWidget( + StringWidget( + layout.contentX, + y, + labelWidth, + 20, + label, + font, + ), + ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.status.allow"), + ) { + viewModel.allow(request.requestId) + }.bounds( + layout.contentX + layout.contentWidth - + buttonWidth * 2 - 4, + y, + buttonWidth, + 20, + ).build(), + ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.status.deny"), + ) { + viewModel.deny(request.requestId) + }.bounds( + layout.contentX + layout.contentWidth - buttonWidth, + y, + buttonWidth, + 20, + ).build(), + ) + } + if (pending.size > visibleRows) { + addRenderableWidget( + centered( + Component.translatable( + "connect_share.status.more", + pending.size - visibleRows, + ).withStyle(ChatFormatting.GRAY), + rowsTop + visibleRows * 24, + ), + ) + } else if (pending.isEmpty()) { + addRenderableWidget( + centered( + Component.translatable("connect_share.status.waiting") + .withStyle(ChatFormatting.GRAY), + rowsTop + 4, + ), + ) + } + + addFooter(layout) + } + + private fun addConnectionDetails( + layout: FormScreenLayout, + sharing: ShareState.Sharing, + ) { + val address = sharing.address + addRenderableWidget( + Button.builder( + Component.translatable( + if (address == null) { + "connect_share.status.address_unavailable" + } else { + "connect_share.status.copy_address" + }, + ), + ) { + address?.let(minecraft!!.keyboardHandler::setClipboard) + }.bounds( + layout.contentX, + layout.bodyTop + 48, + layout.contentWidth, + 20, + ).build().apply { active = address != null }, + ) + addRenderableWidget( + centeredWrapped( + Component.translatable( + "connect_share.status.route_summary", + routeLabel(sharing), + ).withStyle(ChatFormatting.GRAY), + layout.bodyTop + 74, + layout.contentWidth, + ), + ) + } + + private fun addFooter(layout: FormScreenLayout) { + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.privacy.title"), + ) { + minecraft!!.setScreen(SharePrivacyScreen(this)) + }.bounds( + layout.contentX, + layout.footerTop, + layout.halfButtonWidth, + 20, + ).build(), + ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.identity.manage"), + ) { + minecraft!!.setScreen(EndpointIdentityScreen(this)) + }.bounds( + layout.contentX + layout.halfButtonWidth + 6, + layout.footerTop, + layout.halfButtonWidth, + 20, + ).build(), + ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.status.stop"), + ) { + viewModel.stop() + minecraft!!.setScreen(parent) + }.bounds( + layout.contentX, + layout.footerTop + 24, + layout.halfButtonWidth, + 20, + ).build(), + ) + addRenderableWidget( + Button.builder(CommonComponents.GUI_BACK) { onClose() } + .bounds( + layout.contentX + layout.halfButtonWidth + 6, + layout.footerTop + 24, + layout.halfButtonWidth, + 20, + ).build(), + ) + } + + override fun tick() { + super.tick() + val next = viewModel.state.value.hashCode() + if (next != fingerprint) { + rebuildWidgets() + } + } + + override fun onClose() { + minecraft!!.setScreen(parent) + } + + private fun routeLabel(sharing: ShareState.Sharing): Component = + Component.translatable( + when { + sharing.internetDirectAvailable -> + "connect_share.status.route.internet" + sharing.lanDirectAvailable && sharing.connectAvailable -> + "connect_share.status.route.lan_connect" + sharing.lanDirectAvailable -> + "connect_share.status.route.lan" + sharing.connectAvailable -> + "connect_share.status.route.connect" + else -> "connect_share.status.route.starting" + }, + ) + + private fun friendlyIngress(ingress: Ingress): Component = + Component.translatable( + when (ingress) { + Ingress.CONNECT -> "connect_share.ingress.online" + Ingress.DIRECT_LAN -> "connect_share.ingress.nearby" + Ingress.DIRECT_INTERNET -> "connect_share.ingress.direct" + }, + ) + + private fun statusKey(state: ShareState): String = when (state) { + ShareState.Idle -> "connect_share.status.idle" + ShareState.Starting -> "connect_share.status.starting" + is ShareState.Sharing -> "connect_share.status.ready" + ShareState.Stopping -> "connect_share.status.stopping" + is ShareState.Failed -> "connect_share.status.failed" + } + + private fun centered(message: Component, y: Int): StringWidget { + val textWidth = font.width(message) + return StringWidget( + width / 2 - textWidth / 2, + y, + textWidth, + 11, + message, + font, + ) + } + + private fun centeredWrapped( + message: Component, + y: Int, + maxWidth: Int, + ): MultiLineTextWidget = MultiLineTextWidget( + width / 2 - maxWidth / 2, + y, + message, + font, + ).setMaxWidth(maxWidth).setCentered(true) +} diff --git a/share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/VanillaMinecraft12111Transport.kt b/share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/VanillaMinecraft12111Transport.kt new file mode 100644 index 000000000..f4d93caab --- /dev/null +++ b/share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/VanillaMinecraft12111Transport.kt @@ -0,0 +1,152 @@ +package com.minekube.connect.share.fabric.v1_20_1 + +import com.minekube.connect.share.ShareGameMode +import com.minekube.connect.share.ShareOptions +import com.minekube.connect.share.fabric.v1_20_1.mixin.IntegratedServerAccessor +import com.minekube.connect.share.fabric.v1_20_1.mixin.LanServerPingerAccessor +import com.minekube.connect.share.fabric.v1_20_1.mixin.ServerConnectionListenerAccessor +import io.netty.channel.Channel +import io.netty.channel.ChannelFuture +import io.netty.channel.ChannelInitializer +import io.netty.channel.EventLoopGroup +import java.net.InetSocketAddress +import net.minecraft.client.Minecraft +import net.minecraft.client.server.IntegratedServer +import net.minecraft.util.HttpUtil +import net.minecraft.world.level.GameType + +internal class VanillaMinecraft1201Transport( + private val serverProvider: () -> IntegratedServer? = { + Minecraft.getInstance().singleplayerServer + }, +) : Minecraft1201Transport { + override fun publish(options: ShareOptions): PublishedMinecraftTransport { + val server = checkNotNull(serverProvider()) { + "Connect Share requires an active singleplayer world" + } + check(!server.isPublished) { + "This singleplayer world is already published" + } + val connection = server.connection + val channels = (connection as ServerConnectionListenerAccessor) + .connectShareChannels + val before = synchronized(channels) { channels.toSet() } + val captureLease = CapturedServerTransport.arm() + var published = false + try { + published = server.publishServer( + options.gameMode.toMinecraft(), + options.allowCheats, + HttpUtil.getAvailablePort(), + ) + check(published) { "Minecraft could not start its private Share listener" } + val captured = captureLease.complete().fold( + ifLeft = { + throw IllegalStateException( + "Minecraft did not expose its Share channel initializer", + ) + }, + ifRight = { it }, + ) + val added = synchronized(channels) { + channels.filterNot(before::contains) + } + check(added.size == 1) { + "Minecraft created ${added.size} listeners for one Share start" + } + val loopback = added.single() + val address = loopback.channel().localAddress() as? InetSocketAddress + ?: throw IllegalStateException( + "Minecraft Share did not create a TCP listener", + ) + check(address.address.isLoopbackAddress) { + "Minecraft Share listener escaped loopback" + } + suppressLanAdvertisement(server) + return PublishedVanillaTransport( + server = server, + channels = channels, + loopback = loopback, + address = address, + childInitializer = captured.childInitializer, + eventLoopGroup = captured.eventLoopGroup, + ) + } catch (failure: Throwable) { + captureLease.close() + rollbackNewListeners(server, channels, before) + if (published) { + suppressLanAdvertisement(server) + } + throw failure + } + } + + private fun rollbackNewListeners( + server: IntegratedServer, + channels: MutableList, + before: Set, + ) { + val added = synchronized(channels) { + channels.filterNot(before::contains).also(channels::removeAll) + } + added.forEach(ChannelFuture::closeChannel) + (server as IntegratedServerAccessor).setConnectSharePublishedPort(-1) + } + + private fun suppressLanAdvertisement(server: IntegratedServer) { + val accessor = server as IntegratedServerAccessor + accessor.connectShareLanPinger?.let { pinger -> + pinger.interrupt() + (pinger as LanServerPingerAccessor).connectShareSocket.close() + } + accessor.connectShareLanPinger = null + } + + private fun ShareGameMode.toMinecraft(): GameType = when (this) { + ShareGameMode.SURVIVAL -> GameType.SURVIVAL + ShareGameMode.CREATIVE -> GameType.CREATIVE + ShareGameMode.ADVENTURE -> GameType.ADVENTURE + ShareGameMode.SPECTATOR -> GameType.SPECTATOR + } +} + +private class PublishedVanillaTransport( + private val server: IntegratedServer, + private val channels: MutableList, + private val loopback: ChannelFuture, + override val address: InetSocketAddress, + override val childInitializer: ChannelInitializer, + override val eventLoopGroup: EventLoopGroup, +) : PublishedMinecraftTransport { + override fun addLocalListener(listener: LocalShareChannel) { + val future = checkNotNull(listener.future) { + "Connect Share local channel did not expose its bound future" + } + synchronized(channels) { + check(channels.add(future)) { + "Minecraft already tracks the Connect Share local listener" + } + } + } + + override fun removeLocalListener(listener: LocalShareChannel) { + val future = listener.future ?: return + synchronized(channels) { + channels.remove(future) + } + } + + override fun close() { + synchronized(channels) { + channels.remove(loopback) + } + loopback.closeChannel() + (server as IntegratedServerAccessor).setConnectSharePublishedPort(-1) + } +} + +private fun ChannelFuture.closeChannel() { + if (channel().isOpen) { + channel().close().syncUninterruptibly() + } +} diff --git a/share/fabric-1.20.1/src/main/resources/assets/connect-share/lang/de_de.json b/share/fabric-1.20.1/src/main/resources/assets/connect-share/lang/de_de.json new file mode 100644 index 000000000..bf44f3379 --- /dev/null +++ b/share/fabric-1.20.1/src/main/resources/assets/connect-share/lang/de_de.json @@ -0,0 +1,286 @@ +{ + "connect_share.menu.share": "Welt teilen", + "connect_share.menu.active": "Welt geteilt", + "connect_share.menu.join": "Freunde", + "connect_share.setup.title": "Diese Welt mit Freunden spielen", + "connect_share.setup.description": "Mache diese Welt jetzt betretbar. Gespeicherte Freunde sehen sie automatisch.", + "connect_share.setup.max_guests": "Maximale Gäste", + "connect_share.setup.internet": "Schnellere direkte Internetverbindungen erlauben", + "connect_share.setup.internet.tooltip": "Optional. Zeigt eingeladenen Gästen mit Mod deine öffentliche IP-Adresse. Connect bleibt der Relay-Fallback.", + "connect_share.setup.persistence": "Beim Wechseln oder erneuten Öffnen einer Welt wird das Teilen automatisch fortgesetzt.", + "connect_share.setup.start": "Teilen starten", + "connect_share.game_mode.survival": "Überleben", + "connect_share.game_mode.creative": "Kreativ", + "connect_share.game_mode.adventure": "Abenteuer", + "connect_share.game_mode.spectator": "Zuschauer", + "connect_share.status.title": "Deine Welt ist bereit", + "connect_share.status.address": "Über Connect bereit · %s", + "connect_share.status.direct_only": "Für Freunde in der Nähe bereit", + "connect_share.status.copy_invitation": "Freundeslink kopieren", + "connect_share.status.copy_address": "Serveradresse kopieren", + "connect_share.status.link_help": "Sende den Freundeslink einmal. Danach erscheinen zukünftige Welten automatisch.", + "connect_share.status.connection_help": "Die beste Verbindung wird automatisch gewählt: direkt, wenn möglich; sonst Connect.", + "connect_share.status.requests": "Anfragen, die auf dich warten", + "connect_share.status.idle": "Nicht geteilt", + "connect_share.status.starting": "Wird gestartet…", + "connect_share.status.active": "Aktiv", + "connect_share.status.stopping": "Wird beendet…", + "connect_share.status.failed": "Start fehlgeschlagen", + "connect_share.status.request": "%s möchte sich verbinden · %s", + "connect_share.status.friend_request": "%s hat eine Freundschaftsanfrage gesendet · %s", + "connect_share.status.allow": "Annehmen", + "connect_share.status.deny": "Ablehnen", + "connect_share.status.more": "%s weitere Anfragen", + "connect_share.status.waiting": "Niemand wartet auf eine Antwort.", + "connect_share.status.stop": "Teilen mit Freunden beenden", + "connect_share.join.title": "Connect Share beitreten", + "connect_share.join.description": "Wähle eine Welt in der Nähe oder füge ihre signierte Einladung ein.", + "connect_share.join.invitation": "Connect-Share-Einladung", + "connect_share.join.invitation_hint": "minekube://share/…", + "connect_share.join.scanning": "Suche nach Connect-Share-Welten in der Nähe…", + "connect_share.join.discovered": "In der Nähe: %s", + "connect_share.join.offline": "Offline-Identität verwenden (ungeprüft)", + "connect_share.join.offline.tooltip": "Der Host sieht diese Identität als ungeprüft und muss sie freigeben.", + "connect_share.join.internet": "Direkte Internetverbindung versuchen", + "connect_share.join.internet.tooltip": "Optional. Zeigt dem Host deine öffentliche IP-Adresse. Connect bleibt der Relay-Fallback.", + "connect_share.join.join": "Beitreten", + "connect_share.friends.title": "Freunde", + "connect_share.friends.page": "Seite %s von %s", + "connect_share.friends.description": "Spielbereite Freunde erscheinen hier.", + "connect_share.friends.copy_my_link": "Meinen Freundeslink kopieren", + "connect_share.friends.copy_my_link.tooltip": "Enthält eine direkte Route, damit diese Person dich erreicht. Sende ihn nur an vertraute Personen.", + "connect_share.friends.copying_my_link": "Freundeslink wird erstellt…", + "connect_share.friends.my_link_copied": "Freundeslink kopiert", + "connect_share.friends.copy_my_link_failed": "Freundeslink konnte nicht kopiert werden", + "connect_share.friends.empty": "Noch keine Freunde oder gesendeten Anfragen. Füge einen Freundeslink ein, um eine zu senden.", + "connect_share.friends.outgoing_request": "Anfrage an %s", + "connect_share.friends.incoming_request": "Anfrage von %s · %s", + "connect_share.friends.incoming_join_request": "%s möchte beitreten · %s", + "connect_share.friends.outgoing_request_active": "%s · Anfrage läuft", + "connect_share.friends.retry_request": "Erneut", + "connect_share.friends.request_sending": "Wird gesendet…", + "connect_share.friends.request_waiting": "Warten…", + "connect_share.friends.request_cancelling": "Wird abgebrochen…", + "connect_share.friends.request_failed": "Die Anfrage konnte nicht zugestellt werden. Dein Freund ist möglicherweise offline oder noch nicht direkt erreichbar.", + "connect_share.friends.request_accepted": "%s hat deine Freundschaftsanfrage angenommen.", + "connect_share.friends.cancel_request": "Abbrechen", + "connect_share.friends.manage": "Verwalten", + "connect_share.friends.request_join": "Anfragen", + "connect_share.friends.follow": "Beitreten, sobald bereit", + "connect_share.friends.cancel_follow": "Folgen abbrechen", + "connect_share.notification.follow_waiting": "%s wird gefolgt", + "connect_share.notification.follow_waiting_detail": "Sobald die nächste Welt bereit ist, wird eine Beitrittsanfrage gesendet. Abbruch unter Freunde.", + "connect_share.notification.follow_ready": "%s ist bereit", + "connect_share.notification.follow_ready_detail": "Öffne Freunde, um jetzt beizutreten; dein aktives Spiel wurde nicht unterbrochen.", + "connect_share.notification.follow_expired": "Folgen abgelaufen", + "connect_share.notification.follow_expired_detail": "%s war nicht rechtzeitig beitrittsbereit.", + "connect_share.notification.follow_cancelled": "Folgen beendet", + "connect_share.notification.follow_cancelled_detail": "%s kann nicht mehr gefolgt werden, da sich Freundschaft oder Zugriff geändert haben.", + "connect_share.notification.follow_failed": "Freund konnte nicht gefolgt werden", + "connect_share.friends.playing_server": "%s · spielt auf %s", + "connect_share.friends.hosting_world": "%s · spielt %s", + "connect_share.friends.online": "%s · online", + "connect_share.friends.friend_unreachable": "Dein Freund ist gerade nicht über libp2p erreichbar.", + "connect_share.friends.add": "Freund hinzufügen", + "connect_share.friends.add_description": "Füge den Link deines Freundes ein und sende eine Anfrage. Die Person kann sie annehmen, solange ihr Spiel geöffnet ist.", + "connect_share.friends.name": "Name des Freundes", + "connect_share.friends.name_hint": "Name der Person, die den Link gesendet hat", + "connect_share.friends.save": "Freund speichern", + "connect_share.friends.send_request": "Anfrage senden", + "connect_share.friends.connecting_request": "Freundschaftsanfrage an %s", + "connect_share.friends.join_once": "Einmal beitreten", + "connect_share.friends.manage_title": "%s verwalten", + "connect_share.friends.notify": "Benachrichtigen, wenn die Welt bereit ist", + "connect_share.friends.share_worlds": "Meine Welten mit diesem Freund teilen", + "connect_share.friends.auto_join": "Diesen Freund automatisch beitreten lassen", + "connect_share.friends.auto_join.tooltip": "Überspringt die Freigabe nur, wenn die Mod die gespeicherte libp2p-Identität nachweist.", + "connect_share.friends.access": "Wenn diese Person beitreten möchte", + "connect_share.friends.access.ask_every_time": "Jedes Mal nachfragen", + "connect_share.friends.access.auto_accept": "Automatisch beitreten lassen", + "connect_share.friends.access.never_allow": "Beitritt nie erlauben", + "connect_share.privacy.title": "Privatsphäre", + "connect_share.privacy.description": "Lege fest, was bestätigte Freunde sehen. Andere Personen erhalten keine Informationen.", + "connect_share.privacy.online": "Anzeigen, dass ich online bin", + "connect_share.privacy.playing": "Anzeigen, wenn ich spiele", + "connect_share.privacy.current_server": "Server- oder Weltnamen anzeigen", + "connect_share.privacy.joinable": "Freunden Beitrittsanfragen erlauben", + "connect_share.privacy.confirmed_only": "Freundschaft, Entfernen und Blockieren werden vor Präsenz und Beitritt berücksichtigt.", + "connect_share.friends.save_changes": "Änderungen speichern", + "connect_share.friends.remove": "Freund entfernen", + "connect_share.friends.remove_confirm.title": "%s entfernen?", + "connect_share.friends.remove_confirm.message": "Diese Person wird nicht mehr automatisch vertraut. Du kannst ihren Freundeslink später erneut hinzufügen.", + "connect_share.friends.remove_confirm.confirm": "Freund entfernen", + "connect_share.friends.block": "Blockieren", + "connect_share.privacy.blocked": "Blockierte Identitäten (%s)", + "connect_share.privacy.blocked_title": "Blockierte Identitäten", + "connect_share.privacy.blocked_empty": "Du hast niemanden blockiert.", + "connect_share.privacy.unblock": "Entsperren", + "connect_share.friends.ready_lan": "%s · %s · direkt im LAN", + "connect_share.friends.ready_connect": "%s · %s · online über Connect", + "connect_share.friends.saved_connect": "%s · gespeichert · Connect-Fallback", + "connect_share.friends.saved_offline": "%s · keine Route verfügbar", + "connect_share.notification.join_request": "Beitrittsanfrage", + "connect_share.notification.join_request_detail": "%s möchte beitreten. Öffne Connect Share zum Annehmen oder Ablehnen.", + "connect_share.notification.friend_request": "Freundschaftsanfrage", + "connect_share.notification.friend_request_detail": "%s hat eine Freundschaftsanfrage gesendet. Öffne Connect Share zum Annehmen oder Ablehnen.", + "connect_share.notification.friend_online": "Welt eines Freundes ist bereit", + "connect_share.notification.friend_online_detail": "%s spielt %s. Öffne Freunde, um Beitritt anzufragen.", + "connect_share.notification.friend_accepted": "Freund hinzugefügt", + "connect_share.notification.friend_accepted_detail": "%s hat deine Freundschaftsanfrage angenommen.", + "connect_share.notification.friend_removed": "Freund entfernt", + "connect_share.notification.friend_removed_detail": "%s ist nicht mehr in deiner Freundesliste.", + "connect_share.notification.friend_playing": "Freund spielt", + "connect_share.notification.friend_playing_detail": "%s spielt auf %s. Öffne Freunde, um den Beitritt anzufragen.", + "connect_share.identity.manage": "Erweiterte Einstellungen…", + "connect_share.identity.title": "Connect-Endpunkt-Identität", + "connect_share.identity.current": "Endpunkt: %s", + "connect_share.identity.sources": "Endpunkt: %s · Zugang: %s", + "connect_share.identity.endpoint": "Vorhandener Endpunkt", + "connect_share.identity.token": "Endpunkt-Token", + "connect_share.identity.save": "Prüfen und speichern", + "connect_share.identity.choose_file": "token.json importieren…", + "connect_share.identity.reset": "Endpunkt-Identität zurücksetzen…", + "connect_share.identity.reset_confirm.title": "Connect-Identität zurücksetzen?", + "connect_share.identity.reset_confirm.message": "Die alte Beitrittsadresse und das Token werden nicht mehr verwendet. Dies kann nicht rückgängig gemacht werden.", + "connect_share.compatibility.title": "Andere Spieleinrichtung", + "connect_share.compatibility.copy_pack": "Modpack-Link des Hosts kopieren", + "connect_share.compatibility.pack_copied": "Modpack-Link kopiert", + "connect_share.compatibility.try_anyway": "Trotzdem versuchen", + "connect_share.diagnostics.copy": "Sichere Diagnose kopieren", + "connect_share.diagnostics.copied": "Diagnose kopiert", + "connect_share.friends.internet_direct": "Direkte Internetverbindungen für diesen Freund erlauben", + "connect_share.setup.friends_only": "Nur Freunde, die du bestätigst, können beitreten.", + "connect_share.setup.options.show": "Spieloptionen…", + "connect_share.setup.options.hide": "Spieloptionen ausblenden", + "connect_share.status.ready": "Freunde können diese Welt jetzt sehen und um Beitritt bitten.", + "connect_share.status.copy_friend_link": "Einladung für neuen Freund kopieren", + "connect_share.status.friend_link_copied": "Freundeseinladung kopiert", + "connect_share.status.connection_details.show": "Verbindungsdetails…", + "connect_share.status.connection_details.hide": "Verbindungsdetails ausblenden", + "connect_share.status.address_unavailable": "Serveradresse wird noch vorbereitet", + "connect_share.status.route_summary": "Verbindung: %s", + "connect_share.status.route.internet": "direkt, mit Connect als Ausweichroute", + "connect_share.status.route.lan_connect": "in der Nähe, mit Connect als Ausweichroute", + "connect_share.status.route.lan": "in der Nähe", + "connect_share.status.route.connect": "über Connect", + "connect_share.status.route.starting": "wird vorbereitet", + "connect_share.privacy.blocked_description": "Blockierte Personen können weder Anfragen senden noch deine Aktivität sehen.", + "connect_share.compatibility.description": "Eure Spieleinstellungen unterscheiden sich. Gleiche diese Punkte für einen zuverlässigen Beitritt ab.", + "connect_share.compatibility.more": "%s weitere Unterschiede", + "connect_share.identity.description": "Erweitert: Verwende einen Endpunkt aus dem Minekube-Dashboard. Dein Token bleibt auf diesem Gerät verborgen.", + "connect_share.identity.endpoint_hint": "dein-name.play.minekube.net", + "connect_share.identity.token_hint": "Endpunkt-Token einfügen", + "connect_share.page.previous": "Zurück", + "connect_share.page.next": "Weiter", + "connect_share.friends.summary.requests.one": "1 Anfrage wartet auf dich", + "connect_share.friends.summary.requests.many": "%s Anfragen warten auf dich", + "connect_share.friends.summary.welcome": "Spielt zusammen, ohne einen Server einzurichten.", + "connect_share.friends.summary.joinable.one": "1 Freund ist spielbereit", + "connect_share.friends.summary.joinable.many": "%s Freunde sind spielbereit", + "connect_share.friends.summary.online.one": "1 Freund ist online", + "connect_share.friends.summary.online.many": "%s Freunde sind online", + "connect_share.friends.summary.saved.one": "1 gespeicherter Freund", + "connect_share.friends.summary.saved.many": "%s gespeicherte Freunde", + "connect_share.friends.invite": "Freund einladen", + "connect_share.friends.manage_named": "%s verwalten", + "connect_share.friends.action.join_now": "Jetzt beitreten", + "connect_share.friends.action.ask_to_join": "Um Beitritt bitten", + "connect_share.friends.action.cancel_follow": "Abbrechen", + "connect_share.friends.action.join_when_ready": "Beitreten, sobald bereit", + "connect_share.friends.status.world": "Spielt %s", + "connect_share.friends.status.server": "Auf %s", + "connect_share.friends.status.ready": "Bereit zum Beitreten", + "connect_share.friends.status.online": "Online", + "connect_share.friends.status.saved": "Gespeicherter Freund", + "connect_share.friends.status.offline": "Offline", + "connect_share.friends.connection_options.show": "Verbindungsoptionen…", + "connect_share.friends.connection_options.hide": "Verbindungsoptionen ausblenden", + "connect_share.friends.internet_direct_short": "Schnellere Direktverbindungen", + "connect_share.friends.internet_direct.tooltip": "Optional. Teilt deine öffentliche IP-Adresse nur mit diesem Freund. Connect bleibt die Ausweichroute.", + "connect_share.ingress.online": "online", + "connect_share.ingress.nearby": "in der Nähe", + "connect_share.ingress.direct": "direkt", + "connect_share.compatibility.minecraft": "Minecraft: deins %s · Freund %s", + "connect_share.compatibility.loader": "Mod-Loader: deiner %s · Freund %s", + "connect_share.compatibility.install": "Installiere %s %s", + "connect_share.compatibility.host_missing": "Dein Freund benötigt %s %s", + "connect_share.compatibility.mod_version": "%s: deins %s · Freund %s", + "connect_share.menu.requests": "Freunde (%s)", + "connect_share.menu.ready": "Freunde (%s)", + "connect_share.friends.link": "Freundeslink", + "connect_share.friends.connection_options.title": "Verbindungsoptionen", + "connect_share.friends.connection_options.description": "Optionale Einstellungen für ungewöhnliche Konten oder schnellere Direktverbindungen.", + "connect_share.friends.connection_options.fallback": "Connect bleibt die Ausweichroute, wenn keine Direktverbindung verfügbar ist.", + "connect_share.identity.source.generated": "Auf diesem Gerät erstellt", + "connect_share.identity.source.imported": "Importiert", + "connect_share.identity.source.environment": "Vom Launcher verwaltet", + "connect_share.error.generic": "Connect Share konnte nicht aktualisiert werden", + "connect_share.error.identity_invalid": "Die Connect-Zugangsdaten sind ungültig", + "connect_share.error.identity_rejected": "Connect hat diese Zugangsdaten abgelehnt", + "connect_share.error.identity_network": "Connect konnte die Zugangsdaten nicht prüfen", + "connect_share.error.identity_managed": "Die Connect-Zugangsdaten werden von der Umgebung verwaltet", + "connect_share.error.identity_active": "Beende das Teilen, bevor du Connect-Zugangsdaten änderst", + "connect_share.error.preferences_save": "Die Connect-Share-Privatsphäre konnte nicht gespeichert werden", + "connect_share.error.share_already_active": "Connect Share ist bereits aktiv", + "connect_share.error.share_start_failed": "Connect Share konnte nicht gestartet werden", + "connect_share.error.share_stop_failed": "Connect Share wurde mit Bereinigungsfehlern beendet", + "connect_share.error.invalid_invitation": "Diese Connect-Share-Einladung ist ungültig", + "connect_share.error.invitation_malformed": "Diese Connect-Share-Einladung ist unvollständig oder fehlerhaft", + "connect_share.error.invitation_unsupported_version": "Diese Einladung verwendet das nicht unterstützte Share-Format %s", + "connect_share.error.invitation_expired": "Diese Connect-Share-Einladung ist abgelaufen — bitte um eine neue", + "connect_share.error.invitation_invalid_signature": "Diese Einladung konnte nicht verifiziert werden", + "connect_share.error.invitation_relay_forbidden": "Eine direkte Share-Einladung darf keine Relay-Route enthalten", + "connect_share.error.invitation_peer_mismatch": "Diese Einladung passt nicht zu dem Freund, der sie gesendet hat", + "connect_share.login.authentication_required": "Diese direkte Verbindung erfordert die Minecraft-Online-Authentifizierung", + "connect_share.login.approval_timed_out": "Der Host hat die Beitrittsanfrage nicht rechtzeitig beantwortet", + "connect_share.login.share_full": "Diese geteilte Welt ist voll", + "connect_share.login.sharing_stopped": "Der Host teilt diese Welt nicht mehr", + "connect_share.login.host_denied": "Der Host hat diese Verbindung abgelehnt", + "connect_share.error.invalid_friend_name": "Der Freundesname muss zwischen 1 und 64 Zeichen lang sein", + "connect_share.error.friend_identity_conflict": "Die Identität stimmt nicht mit dem gespeicherten Schlüssel überein", + "connect_share.error.friend_not_saved": "Dieser Freund ist nicht mehr gespeichert", + "connect_share.error.friend_blocked": "Diese Identität ist blockiert", + "connect_share.error.friends_load": "Gespeicherte Connect-Share-Freunde konnten nicht geladen werden", + "connect_share.error.friend_remove": "Dieser Connect-Share-Freund konnte nicht entfernt werden", + "connect_share.error.friend_block": "Diese Connect-Share-Identität konnte nicht blockiert werden", + "connect_share.error.friend_unblock": "Diese Connect-Share-Identität konnte nicht entsperrt werden", + "connect_share.error.join_peer_mismatch": "Der gefundene Host passt nicht zu dieser Einladung", + "connect_share.error.join_discovery_unavailable": "Automatische LAN-Suche ist nicht verfügbar", + "connect_share.error.join_no_route": "Keine Route zu dieser Connect-Share-Welt verfügbar", + "connect_share.error.identity_endpoint_conflict": "Dieses Profil verwendet denselben Connect-Endpunkt wie dein Freund", + "connect_share.error.friend_unreachable": "Dein Freund ist gerade nicht erreichbar", + "connect_share.error.friend_declined": "Dein Freund hat diese Anfrage abgelehnt", + "connect_share.error.friend_timed_out": "Dein Freund hat nicht rechtzeitig geantwortet", + "connect_share.error.friend_invalid_response": "Die Antwort auf die Freundschaftsanfrage war ungültig", + "connect_share.error.minecraft_version": "Eure Minecraft-Versionen stimmen nicht überein", + "connect_share.error.mod_loader": "Eure Mod-Loader stimmen nicht überein", + "connect_share.error.required_mods": "Die benötigten Mods stimmen nicht überein", + "connect_share.page.previous_tooltip": "Vorherige Seite · Seite %s von %s", + "connect_share.page.next_tooltip": "Nächste Seite · Seite %s von %s", + "connect_share.recovery.title": "Sichern & wiederherstellen", + "connect_share.recovery.menu": "Sicherung", + "connect_share.recovery.description": "Übertrage Freunde und deine Share-Identität auf ein anderes Gerät. Die verschlüsselte Sicherung bleibt offline.", + "connect_share.recovery.secret": "Wiederherstellungspasswort", + "connect_share.recovery.secret_confirm": "Passwort bestätigen", + "connect_share.recovery.warning": "Beim Wiederherstellen werden Freunde und Share-Identität dieses Geräts ersetzt. Beende zuerst das Teilen und starte Minecraft danach neu.", + "connect_share.recovery.export": "Freunde sichern", + "connect_share.recovery.import": "Sicherung wiederherstellen", + "connect_share.recovery.restore_confirm": "Ersetzen und wiederherstellen", + "connect_share.recovery.summary": "%s Einträge · Einstellungen: %s · Connect-Endpunkt: %s", + "connect_share.recovery.included": "enthalten", + "connect_share.recovery.not_included": "nicht enthalten", + "connect_share.recovery.exported": "Verschlüsselte Sicherung gespeichert. Bewahre das Passwort sicher auf.", + "connect_share.recovery.restored_restart": "Wiederherstellung abgeschlossen. Starte Minecraft vor dem Teilen oder Beitreten neu.", + "connect_share.recovery.error.secret_mismatch": "Die Wiederherstellungspasswörter stimmen nicht überein.", + "connect_share.recovery.error.weak_secret": "Verwende ein Wiederherstellungspasswort mit mindestens 12 Zeichen.", + "connect_share.recovery.error.authentication": "Falsches Passwort oder beschädigte Sicherung. Es wurde nichts geändert.", + "connect_share.recovery.error.unsupported": "Diese Sicherungsversion wird nicht unterstützt.", + "connect_share.recovery.error.too_large": "Diese Sicherung ist zu groß.", + "connect_share.recovery.error.invalid": "Dies ist keine gültige Connect-Share-Sicherung.", + "connect_share.recovery.error.nothing_to_export": "Es gibt noch keine vollständigen Freundesidentitäten zum Sichern.", + "connect_share.recovery.error.unsafe_files": "Die Sicherung wurde wegen einer unsicheren Share-Datei beendet.", + "connect_share.recovery.error.read": "Die ausgewählte Sicherung konnte nicht gelesen werden.", + "connect_share.recovery.error.write": "Die verschlüsselte Sicherung konnte nicht gespeichert werden.", + "connect_share.recovery.error.restore": "Die Wiederherstellung ist fehlgeschlagen; die vorherigen Daten dieses Geräts wurden wiederhergestellt.", + "connect_share.recovery.error.stop_sharing": "Beende das Teilen, bevor du eine Sicherung wiederherstellst." +} diff --git a/share/fabric-1.20.1/src/main/resources/assets/connect-share/lang/en_us.json b/share/fabric-1.20.1/src/main/resources/assets/connect-share/lang/en_us.json new file mode 100644 index 000000000..66fb81293 --- /dev/null +++ b/share/fabric-1.20.1/src/main/resources/assets/connect-share/lang/en_us.json @@ -0,0 +1,286 @@ +{ + "connect_share.menu.share": "Share world", + "connect_share.menu.active": "World shared", + "connect_share.menu.join": "Friends", + "connect_share.setup.title": "Play this world with friends", + "connect_share.setup.description": "Make this world joinable now. Saved friends will see it automatically.", + "connect_share.setup.max_guests": "Maximum guests", + "connect_share.setup.internet": "Allow faster direct internet connections", + "connect_share.setup.internet.tooltip": "Optional. Reveals your public IP address to invited modded guests. Connect remains the relay fallback.", + "connect_share.setup.persistence": "Sharing resumes automatically when you switch or reopen a world.", + "connect_share.setup.start": "Start sharing", + "connect_share.game_mode.survival": "Survival", + "connect_share.game_mode.creative": "Creative", + "connect_share.game_mode.adventure": "Adventure", + "connect_share.game_mode.spectator": "Spectator", + "connect_share.status.title": "Your world is ready", + "connect_share.status.address": "Ready through Connect · %s", + "connect_share.status.direct_only": "Ready for nearby friends", + "connect_share.status.copy_invitation": "Copy friend link", + "connect_share.status.copy_address": "Copy server address", + "connect_share.status.link_help": "Send the friend link once. After saving it, future worlds appear automatically.", + "connect_share.status.connection_help": "The best connection is chosen automatically: direct when possible, Connect otherwise.", + "connect_share.status.requests": "Requests waiting for you", + "connect_share.status.idle": "Not sharing", + "connect_share.status.starting": "Starting…", + "connect_share.status.active": "Active", + "connect_share.status.stopping": "Stopping…", + "connect_share.status.failed": "Could not start", + "connect_share.status.request": "%s wants to connect · %s", + "connect_share.status.friend_request": "%s sent a friend request · %s", + "connect_share.status.allow": "Accept", + "connect_share.status.deny": "Decline", + "connect_share.status.more": "%s more requests", + "connect_share.status.waiting": "No one is waiting for a response.", + "connect_share.status.stop": "Stop sharing with friends", + "connect_share.join.title": "Join Connect Share", + "connect_share.join.description": "Choose a nearby world or paste its signed invitation.", + "connect_share.join.invitation": "Connect Share invitation", + "connect_share.join.invitation_hint": "minekube://share/…", + "connect_share.join.scanning": "Scanning for nearby Connect Share worlds…", + "connect_share.join.discovered": "Nearby: %s", + "connect_share.join.offline": "Use an offline identity (unverified)", + "connect_share.join.offline.tooltip": "The host will see this identity as unverified and must approve it.", + "connect_share.join.internet": "Try a direct internet connection", + "connect_share.join.internet.tooltip": "Optional. Reveals your public IP address to the host. Connect remains the relay fallback.", + "connect_share.join.join": "Join", + "connect_share.friends.title": "Friends", + "connect_share.friends.page": "Page %s of %s", + "connect_share.friends.description": "See who is around and join them when they are ready.", + "connect_share.friends.summary.welcome": "Play together without setting up a server.", + "connect_share.friends.invite": "Invite a friend", + "connect_share.friends.copy_my_link": "Copy my friend link", + "connect_share.friends.copy_my_link.tooltip": "Includes a direct route so this person can reach you. Only send it to someone you trust.", + "connect_share.friends.copying_my_link": "Creating friend link…", + "connect_share.friends.my_link_copied": "Friend link copied", + "connect_share.friends.copy_my_link_failed": "Could not copy friend link", + "connect_share.friends.empty": "Invite someone or add their link to start playing together.", + "connect_share.friends.outgoing_request": "Waiting for %s", + "connect_share.friends.incoming_request": "%s wants to be friends · %s", + "connect_share.friends.incoming_join_request": "%s wants you to join · %s", + "connect_share.friends.outgoing_request_active": "Contacting %s…", + "connect_share.friends.retry_request": "Retry", + "connect_share.friends.request_sending": "Sending…", + "connect_share.friends.request_waiting": "Waiting…", + "connect_share.friends.request_cancelling": "Canceling…", + "connect_share.friends.request_failed": "Request could not be delivered. Your friend may be offline or not reachable directly yet.", + "connect_share.friends.request_accepted": "%s accepted your friend request.", + "connect_share.friends.cancel_request": "Cancel", + "connect_share.friends.manage": "Manage", + "connect_share.friends.manage_named": "Manage %s", + "connect_share.friends.action.join_now": "Join now", + "connect_share.friends.action.ask_to_join": "Ask to join", + "connect_share.friends.action.cancel_follow": "Cancel", + "connect_share.friends.action.join_when_ready": "Join when ready", + "connect_share.friends.status.world": "Playing %s", + "connect_share.friends.status.server": "On %s", + "connect_share.friends.status.ready": "Ready to join", + "connect_share.friends.status.online": "Online", + "connect_share.friends.status.saved": "Saved friend", + "connect_share.friends.status.offline": "Offline", + "connect_share.friends.request_join": "Request", + "connect_share.friends.follow": "Join when ready", + "connect_share.friends.cancel_follow": "Cancel follow", + "connect_share.notification.follow_waiting": "Following %s", + "connect_share.notification.follow_waiting_detail": "A join request will be sent when their next world is ready. Cancel from Friends.", + "connect_share.notification.follow_ready": "%s is ready", + "connect_share.notification.follow_ready_detail": "Open Friends to join now; active gameplay was not interrupted.", + "connect_share.notification.follow_expired": "Follow expired", + "connect_share.notification.follow_expired_detail": "%s did not become joinable in time.", + "connect_share.notification.follow_cancelled": "Follow cancelled", + "connect_share.notification.follow_cancelled_detail": "%s can no longer be followed because the friendship or access changed.", + "connect_share.notification.follow_failed": "Could not follow friend", + "connect_share.friends.playing_server": "%s · playing on %s", + "connect_share.friends.hosting_world": "%s · playing %s", + "connect_share.friends.online": "%s · online", + "connect_share.friends.friend_unreachable": "Your friend is not reachable over libp2p right now.", + "connect_share.friends.add": "Add from link", + "connect_share.friends.add_description": "Paste your friend's Connect Share link. Their name fills in automatically.", + "connect_share.friends.name": "Friend's name", + "connect_share.friends.name_hint": "Friend's name (filled from link)", + "connect_share.friends.save": "Save friend", + "connect_share.friends.send_request": "Send request", + "connect_share.friends.connecting_request": "Friend request to %s", + "connect_share.friends.join_once": "Join without adding", + "connect_share.friends.connection_options.show": "Connection options…", + "connect_share.friends.connection_options.hide": "Hide connection options", + "connect_share.friends.manage_title": "Manage %s", + "connect_share.friends.notify": "Notify me when their world is ready", + "connect_share.friends.share_worlds": "Share my worlds with this friend", + "connect_share.friends.auto_join": "Let this friend join automatically", + "connect_share.friends.auto_join.tooltip": "Skips the approval prompt only when their mod proves the saved libp2p identity.", + "connect_share.friends.access": "When they ask to join", + "connect_share.friends.access.ask_every_time": "Ask me every time", + "connect_share.friends.access.auto_accept": "Let them join automatically", + "connect_share.friends.access.never_allow": "Never allow joining", + "connect_share.privacy.title": "Privacy", + "connect_share.privacy.description": "Choose what confirmed friends can see. People who are not your friends receive nothing.", + "connect_share.privacy.online": "Show that I am online", + "connect_share.privacy.playing": "Show when I am playing", + "connect_share.privacy.current_server": "Show the server or world name", + "connect_share.privacy.joinable": "Let friends request to join", + "connect_share.privacy.confirmed_only": "Friend requests, removals, and blocks take effect before presence or joining.", + "connect_share.friends.save_changes": "Save changes", + "connect_share.friends.remove": "Remove friend", + "connect_share.friends.remove_confirm.title": "Remove %s?", + "connect_share.friends.remove_confirm.message": "They will no longer be trusted automatically. You can add their friend link again later.", + "connect_share.friends.remove_confirm.confirm": "Remove friend", + "connect_share.friends.block": "Block", + "connect_share.privacy.blocked": "Blocked identities (%s)", + "connect_share.privacy.blocked_title": "Blocked identities", + "connect_share.privacy.blocked_empty": "You have not blocked anyone.", + "connect_share.privacy.unblock": "Unblock", + "connect_share.friends.ready_lan": "%s · %s · direct LAN", + "connect_share.friends.ready_connect": "%s · %s · online via Connect", + "connect_share.friends.saved_connect": "%s · saved · Connect fallback", + "connect_share.friends.saved_offline": "%s · no route available", + "connect_share.notification.join_request": "Join request", + "connect_share.notification.join_request_detail": "%s wants to join. Open Connect Share to accept or decline.", + "connect_share.notification.friend_request": "Friend request", + "connect_share.notification.friend_request_detail": "%s sent a friend request. Open Connect Share to accept or decline.", + "connect_share.notification.friend_online": "Friend's world is ready", + "connect_share.notification.friend_online_detail": "%s is playing %s. Open Friends to request to join.", + "connect_share.notification.friend_accepted": "Friend added", + "connect_share.notification.friend_accepted_detail": "%s accepted your friend request.", + "connect_share.notification.friend_removed": "Friend removed", + "connect_share.notification.friend_removed_detail": "%s is no longer in your friends list.", + "connect_share.notification.friend_playing": "Friend is playing", + "connect_share.notification.friend_playing_detail": "%s is on %s. Open Friends to request to join.", + "connect_share.identity.manage": "Advanced settings…", + "connect_share.identity.title": "Connect endpoint identity", + "connect_share.identity.current": "Endpoint: %s", + "connect_share.identity.sources": "Endpoint: %s · Credential: %s", + "connect_share.identity.endpoint": "Existing endpoint", + "connect_share.identity.token": "Endpoint token", + "connect_share.identity.save": "Validate and save", + "connect_share.identity.choose_file": "Import token.json…", + "connect_share.identity.reset": "Reset endpoint identity…", + "connect_share.identity.reset_confirm.title": "Reset Connect identity?", + "connect_share.identity.reset_confirm.message": "The old join address and token will stop being used. This cannot be undone.", + "connect_share.compatibility.title": "Different game setup", + "connect_share.compatibility.copy_pack": "Copy the host's modpack link", + "connect_share.compatibility.pack_copied": "Modpack link copied", + "connect_share.compatibility.try_anyway": "Try anyway", + "connect_share.diagnostics.copy": "Copy safe diagnostics", + "connect_share.diagnostics.copied": "Diagnostics copied", + "connect_share.friends.internet_direct": "Allow direct internet routes for this friend", + "connect_share.friends.internet_direct_short": "Faster direct connections", + "connect_share.friends.internet_direct.tooltip": "Optional. Shares your public IP address only with this friend. Connect remains the fallback.", + "connect_share.ingress.online": "online", + "connect_share.ingress.nearby": "nearby", + "connect_share.ingress.direct": "direct", + "connect_share.compatibility.minecraft": "Minecraft: yours %s · friend's %s", + "connect_share.compatibility.loader": "Loader: yours %s · friend's %s", + "connect_share.compatibility.install": "Install %s %s", + "connect_share.compatibility.host_missing": "Your friend needs %s %s", + "connect_share.compatibility.mod_version": "%s: yours %s · friend's %s", + "connect_share.setup.friends_only": "Only friends you approve can join.", + "connect_share.setup.options.show": "Game options…", + "connect_share.setup.options.hide": "Hide game options", + "connect_share.status.ready": "Friends can now see this world and ask to join.", + "connect_share.status.copy_friend_link": "Copy invite for a new friend", + "connect_share.status.friend_link_copied": "Friend invite copied", + "connect_share.status.connection_details.show": "Connection details…", + "connect_share.status.connection_details.hide": "Hide connection details", + "connect_share.status.address_unavailable": "Server address is still starting", + "connect_share.status.route_summary": "Connection: %s", + "connect_share.status.route.internet": "direct, with Connect as fallback", + "connect_share.status.route.lan_connect": "nearby, with Connect as fallback", + "connect_share.status.route.lan": "nearby", + "connect_share.status.route.connect": "through Connect", + "connect_share.status.route.starting": "getting ready", + "connect_share.privacy.blocked_description": "Blocked people cannot send requests or see your activity.", + "connect_share.compatibility.description": "Your game setups differ. Match these items for the most reliable join.", + "connect_share.compatibility.more": "%s more differences", + "connect_share.identity.description": "Advanced: reuse an endpoint from the Minekube dashboard. Your token stays hidden on this device.", + "connect_share.identity.endpoint_hint": "your-name.play.minekube.net", + "connect_share.identity.token_hint": "Paste endpoint token", + "connect_share.page.previous": "Previous", + "connect_share.page.next": "Next", + "connect_share.friends.summary.requests.one": "1 request waiting for you", + "connect_share.friends.summary.requests.many": "%s requests waiting for you", + "connect_share.friends.summary.joinable.one": "1 friend ready to play", + "connect_share.friends.summary.joinable.many": "%s friends ready to play", + "connect_share.friends.summary.online.one": "1 friend online", + "connect_share.friends.summary.online.many": "%s friends online", + "connect_share.friends.summary.saved.one": "1 saved friend", + "connect_share.friends.summary.saved.many": "%s saved friends", + "connect_share.menu.requests": "Friends (%s)", + "connect_share.menu.ready": "Friends (%s)", + "connect_share.friends.link": "Friend link", + "connect_share.friends.connection_options.title": "Connection options", + "connect_share.friends.connection_options.description": "Optional choices for unusual accounts or faster direct routes.", + "connect_share.friends.connection_options.fallback": "Connect remains the fallback when a direct route is unavailable.", + "connect_share.identity.source.generated": "Generated on this device", + "connect_share.identity.source.imported": "Imported", + "connect_share.identity.source.environment": "Managed by launcher", + "connect_share.error.generic": "Could not update Connect Share", + "connect_share.error.identity_invalid": "Connect credentials are invalid", + "connect_share.error.identity_rejected": "Connect rejected these credentials", + "connect_share.error.identity_network": "Could not reach Connect to validate credentials", + "connect_share.error.identity_managed": "Connect credentials are managed by the environment", + "connect_share.error.identity_active": "Stop sharing before changing Connect credentials", + "connect_share.error.preferences_save": "Connect Share privacy settings could not be saved", + "connect_share.error.share_already_active": "Connect Share is already active", + "connect_share.error.share_start_failed": "Could not start Connect Share", + "connect_share.error.share_stop_failed": "Connect Share stopped with cleanup errors", + "connect_share.error.invalid_invitation": "This Connect Share invitation is invalid", + "connect_share.error.invitation_malformed": "This Connect Share invitation is incomplete or malformed", + "connect_share.error.invitation_unsupported_version": "This invitation uses unsupported Share format %s", + "connect_share.error.invitation_expired": "This Connect Share invitation has expired — ask for a new one", + "connect_share.error.invitation_invalid_signature": "This invitation could not be verified", + "connect_share.error.invitation_relay_forbidden": "A direct Share invitation cannot contain a relay route", + "connect_share.error.invitation_peer_mismatch": "This invitation does not match the friend who sent it", + "connect_share.login.authentication_required": "This direct connection requires Minecraft online authentication", + "connect_share.login.approval_timed_out": "The host did not answer the join request in time", + "connect_share.login.share_full": "This shared world is full", + "connect_share.login.sharing_stopped": "The host stopped sharing this world", + "connect_share.login.host_denied": "The host declined this connection", + "connect_share.error.invalid_friend_name": "Friend name must be between 1 and 64 characters", + "connect_share.error.friend_identity_conflict": "This friend identity does not match the saved key", + "connect_share.error.friend_not_saved": "This friend is no longer saved", + "connect_share.error.friend_blocked": "This identity is blocked", + "connect_share.error.friends_load": "Saved Connect Share friends could not be loaded", + "connect_share.error.friend_remove": "This Connect Share friend could not be removed", + "connect_share.error.friend_block": "This Connect Share identity could not be blocked", + "connect_share.error.friend_unblock": "This Connect Share identity could not be unblocked", + "connect_share.error.join_peer_mismatch": "The discovered host does not match this invitation", + "connect_share.error.join_discovery_unavailable": "Automatic LAN discovery is unavailable", + "connect_share.error.join_no_route": "No route to this Connect Share world is available", + "connect_share.error.identity_endpoint_conflict": "This profile uses the same Connect endpoint as your friend", + "connect_share.error.friend_unreachable": "Your friend is not reachable right now", + "connect_share.error.friend_declined": "Your friend declined this request", + "connect_share.error.friend_timed_out": "Your friend did not answer in time", + "connect_share.error.friend_invalid_response": "The friend request response was invalid", + "connect_share.error.minecraft_version": "Your Minecraft versions do not match", + "connect_share.error.mod_loader": "Your mod loaders do not match", + "connect_share.error.required_mods": "Your required mods do not match", + "connect_share.page.previous_tooltip": "Previous page · Page %s of %s", + "connect_share.page.next_tooltip": "Next page · Page %s of %s", + "connect_share.recovery.title": "Backup & restore", + "connect_share.recovery.menu": "Backup", + "connect_share.recovery.description": "Move your friends and Share identity to another device. The encrypted backup stays offline.", + "connect_share.recovery.secret": "Recovery password", + "connect_share.recovery.secret_confirm": "Confirm recovery password", + "connect_share.recovery.warning": "Restoring replaces this device’s friends and Share identity. Stop sharing first, then restart Minecraft after restoring.", + "connect_share.recovery.export": "Back up friends", + "connect_share.recovery.import": "Restore backup", + "connect_share.recovery.restore_confirm": "Replace and restore", + "connect_share.recovery.summary": "%s items · Preferences: %s · Connect endpoint: %s", + "connect_share.recovery.included": "included", + "connect_share.recovery.not_included": "not included", + "connect_share.recovery.exported": "Encrypted backup saved. Keep its password somewhere safe.", + "connect_share.recovery.restored_restart": "Restore complete. Restart Minecraft before sharing or joining.", + "connect_share.recovery.error.secret_mismatch": "The recovery passwords do not match.", + "connect_share.recovery.error.weak_secret": "Use a recovery password with at least 12 characters.", + "connect_share.recovery.error.authentication": "Wrong password or damaged backup. Nothing was changed.", + "connect_share.recovery.error.unsupported": "This backup version is not supported.", + "connect_share.recovery.error.too_large": "This backup is too large.", + "connect_share.recovery.error.invalid": "This is not a valid Connect Share backup.", + "connect_share.recovery.error.nothing_to_export": "There are no complete friend identities to back up yet.", + "connect_share.recovery.error.unsafe_files": "Backup stopped because a Share data file is unsafe.", + "connect_share.recovery.error.read": "The selected backup could not be read.", + "connect_share.recovery.error.write": "The encrypted backup could not be saved.", + "connect_share.recovery.error.restore": "Restore failed and this device’s previous data was recovered.", + "connect_share.recovery.error.stop_sharing": "Stop sharing before restoring a backup." +} diff --git a/share/fabric-1.20.1/src/main/resources/connect-share-fabric-1.20.1.mixins.json b/share/fabric-1.20.1/src/main/resources/connect-share-fabric-1.20.1.mixins.json new file mode 100644 index 000000000..4e9a9f962 --- /dev/null +++ b/share/fabric-1.20.1/src/main/resources/connect-share-fabric-1.20.1.mixins.json @@ -0,0 +1,22 @@ +{ + "required": true, + "minVersion": "0.8", + "package": "com.minekube.connect.share.fabric.v1_20_1.mixin", + "compatibilityLevel": "JAVA_17", + "mixins": [ + "ConnectionAccessor", + "ServerConnectionListenerAccessor", + "ServerConnectionListenerMixin", + "ServerLoginPacketListenerMixin" + ], + "client": [ + "IntegratedServerAccessor", + "IntegratedServerMixin", + "LanServerPingerAccessor", + "PauseScreenMixin", + "TitleScreenMixin" + ], + "injectors": { + "defaultRequire": 1 + } +} diff --git a/share/fabric-1.20.1/src/main/resources/fabric.mod.json b/share/fabric-1.20.1/src/main/resources/fabric.mod.json new file mode 100644 index 000000000..5e537295e --- /dev/null +++ b/share/fabric-1.20.1/src/main/resources/fabric.mod.json @@ -0,0 +1,26 @@ +{ + "schemaVersion": 1, + "id": "connect-share", + "version": "${version}", + "name": "Connect Share", + "description": "Share a private Minecraft world through Minekube Connect.", + "environment": "client", + "entrypoints": { + "client": [ + { + "adapter": "kotlin", + "value": "com.minekube.connect.share.fabric.v1_20_1.FabricConnectShare1201Client" + } + ] + }, + "mixins": [ + "connect-share-fabric-1.20.1.mixins.json" + ], + "depends": { + "fabricloader": ">=0.19.3", + "fabric-api": "*", + "fabric-language-kotlin": ">=1.13.13", + "minecraft": "1.20.1", + "java": ">=17" + } +} diff --git a/share/fabric-1.20.1/src/test/kotlin/com/minekube/connect/share/fabric/v1_20_1/CapturedServerTransportTest.kt b/share/fabric-1.20.1/src/test/kotlin/com/minekube/connect/share/fabric/v1_20_1/CapturedServerTransportTest.kt new file mode 100644 index 000000000..a8ef44a0b --- /dev/null +++ b/share/fabric-1.20.1/src/test/kotlin/com/minekube/connect/share/fabric/v1_20_1/CapturedServerTransportTest.kt @@ -0,0 +1,58 @@ +package com.minekube.connect.share.fabric.v1_20_1 + +import com.minekube.connect.share.CaptureFailure +import io.netty.channel.Channel +import io.netty.channel.ChannelInitializer +import io.netty.channel.DefaultEventLoopGroup +import kotlin.concurrent.thread +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotSame +import kotlin.test.assertSame +import kotlin.test.assertTrue + +class CapturedServerTransportTest { + @Test + fun `captures the tagged vanilla initializer and group only for the armed thread`() { + val initializer = NoopInitializer + val group = DefaultEventLoopGroup(1) + try { + val lease = CapturedServerTransport.arm() + + assertTrue(CapturedServerTransport.isShareStartArmed()) + val taggedInitializer = + CapturedServerTransport.captureChildInitializer(initializer) + assertNotSame(initializer, taggedInitializer) + assertSame(group, CapturedServerTransport.captureEventLoopGroup(group)) + + var otherThreadArmed = true + thread { + otherThreadArmed = CapturedServerTransport.isShareStartArmed() + }.join() + + val captured = lease.complete().getOrNull() + requireNotNull(captured) + assertSame(taggedInitializer, captured.childInitializer) + assertSame(group, captured.eventLoopGroup) + assertFalse(otherThreadArmed) + assertFalse(CapturedServerTransport.isShareStartArmed()) + } finally { + group.shutdownGracefully().syncUninterruptibly() + } + } + + @Test + fun `incomplete capture is typed and always disarms`() { + val lease = CapturedServerTransport.arm() + + val failure = lease.complete().leftOrNull() + + assertEquals(CaptureFailure.Incomplete, failure) + assertFalse(CapturedServerTransport.isShareStartArmed()) + } + + private object NoopInitializer : ChannelInitializer() { + override fun initChannel(channel: Channel) = Unit + } +} diff --git a/share/fabric-1.20.1/src/test/kotlin/com/minekube/connect/share/fabric/v1_20_1/ConnectGameProfileMapperTest.kt b/share/fabric-1.20.1/src/test/kotlin/com/minekube/connect/share/fabric/v1_20_1/ConnectGameProfileMapperTest.kt new file mode 100644 index 000000000..17ca44f16 --- /dev/null +++ b/share/fabric-1.20.1/src/test/kotlin/com/minekube/connect/share/fabric/v1_20_1/ConnectGameProfileMapperTest.kt @@ -0,0 +1,47 @@ +package com.minekube.connect.share.fabric.v1_20_1 + +import com.minekube.connect.api.player.GameProfile as ConnectGameProfile +import java.util.UUID +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ConnectGameProfileMapperTest { + @Test + fun `preserves identity and signed profile properties`() { + val id = UUID.randomUUID() + val mapped = ConnectGameProfileMapper.toMinecraft( + ConnectGameProfile( + "Robin", + id, + listOf( + ConnectGameProfile.Property("textures", "signed-value", "signature"), + ConnectGameProfile.Property("badge", "unsigned-value", ""), + ), + ), + ).getOrNull() + requireNotNull(mapped) + + assertEquals(id, mapped.id) + assertEquals("Robin", mapped.name) + val texture = mapped.properties["textures"].single() + val badge = mapped.properties["badge"].single() + assertTrue(texture.hasSignature()) + assertEquals("signature", texture.signature) + assertFalse(badge.hasSignature()) + } + + @Test + fun `rejects malformed Connect profiles as a typed outcome`() { + val result = ConnectGameProfileMapper.toMinecraft( + ConnectGameProfile( + "", + UUID.randomUUID(), + emptyList(), + ), + ) + + assertEquals(ProfileMappingFailure.InvalidName, result.leftOrNull()) + } +} diff --git a/share/fabric-1.20.1/src/test/kotlin/com/minekube/connect/share/fabric/v1_20_1/Fabric12111ArtifactTest.kt b/share/fabric-1.20.1/src/test/kotlin/com/minekube/connect/share/fabric/v1_20_1/Fabric12111ArtifactTest.kt new file mode 100644 index 000000000..23aa3bf1b --- /dev/null +++ b/share/fabric-1.20.1/src/test/kotlin/com/minekube/connect/share/fabric/v1_20_1/Fabric12111ArtifactTest.kt @@ -0,0 +1,320 @@ +package com.minekube.connect.share.fabric.v1_20_1 + +import com.minekube.connect.share.ShareCoordinator +import com.minekube.connect.share.fabric.ConnectShareClient +import com.minekube.connect.tunnel.p2p.DirectP2pNode +import com.minekube.connect.tunnel.p2p.Libp2pEndpoint +import com.minekube.connect.tunnel.p2p.Libp2pTunnelTransport +import java.nio.file.Files +import java.nio.file.Path +import java.net.URLClassLoader +import java.util.jar.JarInputStream +import java.util.jar.JarFile +import kotlin.io.path.name +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class Fabric1201ArtifactTest { + @Test + fun `artifact runs on the standard Minecraft 1201 Java runtime`() { + JarFile(artifact().toFile()).use { jar -> + val metadata = jar.getInputStream( + jar.getJarEntry("fabric.mod.json"), + ).bufferedReader().readText() + assertTrue("\"java\": \">=17\"" in metadata) + val mixins = jar.getInputStream( + jar.getJarEntry( + "connect-share-fabric-1.20.1.mixins.json", + ), + ).bufferedReader().readText() + assertTrue("\"compatibilityLevel\": \"JAVA_17\"" in mixins) + + listOf( + "com/minekube/connect/share/ShareCoordinator.class", + "com/minekube/connect/share/fabric/FabricShareBootstrap.class", + "com/minekube/connect/share/fabric/v1_20_1/" + + "FabricConnectShare1201Client.class", + ).forEach { name -> + val bytes = jar.getInputStream(jar.getJarEntry(name)).readNBytes(8) + val major = + (bytes[6].toInt() and 0xff) shl 8 or + (bytes[7].toInt() and 0xff) + assertTrue( + major <= JAVA_17_CLASS_MAJOR, + "$name requires class version $major", + ) + } + } + } + @Test + fun `artifact uses a friends first sharing vocabulary`() { + JarFile(artifact().toFile()).use { jar -> + val language = jar.getInputStream( + jar.getJarEntry( + "assets/connect-share/lang/en_us.json", + ), + ).bufferedReader().use { it.readText() } + + assertTrue( + "\"connect_share.setup.title\": \"Play this world with friends\"" in + language, + ) + assertTrue( + "\"connect_share.status.copy_friend_link\": " + + "\"Copy invite for a new friend\"" in language, + ) + assertTrue( + "\"connect_share.friends.copy_my_link\": " + + "\"Copy my friend link\"" in language, + ) + assertTrue( + "\"connect_share.friends.send_request\": " + + "\"Send request\"" in language, + ) + assertTrue( + "\"connect_share.friends.outgoing_request\": " + + "\"Waiting for %s\"" in language, + ) + assertTrue( + "\"connect_share.friends.incoming_request\": " + + "\"%s wants to be friends · %s\"" in language, + ) + assertTrue( + "\"connect_share.friends.retry_request\": \"Retry\"" in + language, + ) + assertTrue( + "\"connect_share.friends.cancel_request\": \"Cancel\"" in + language, + ) + assertTrue( + "\"connect_share.status.allow\": \"Accept\"" in language, + ) + assertTrue( + "\"connect_share.status.deny\": \"Decline\"" in language, + ) + assertFalse("connect_share.friends.accept_request" in language) + } + } + + @Test + fun `friend removal confirmation stays inside the friends screen`() { + JarFile(artifact().toFile()).use { jar -> + val bytecode = jar.entries().asSequence() + .filter { + it.name.startsWith( + "com/minekube/connect/share/fabric/v1_20_1/" + + "ShareJoinScreen", + ) && it.name.endsWith(".class") + } + .joinToString { + jar.getInputStream(it).use { stream -> + stream.readBytes().toString(Charsets.ISO_8859_1) + } + } + + assertFalse("net/minecraft/class_410" in bytecode) + assertTrue( + "connect_share.friends.remove_confirm.confirm" in bytecode, + ) + assertTrue("sendRequest" in bytecode) + assertTrue("suggestedDisplayName" in bytecode) + assertTrue("FriendRequestClient" in bytecode) + assertTrue("getIncomingRequests" in bytecode) + assertTrue("connect_share.status.allow" in bytecode) + assertTrue("connect_share.status.deny" in bytecode) + assertTrue("joinOutgoing" !in bytecode) + assertFalse("connect_share.friends.accept_request" in bytecode) + } + } + + @Test + fun `approved card exchange promotes an outgoing request`() { + JarFile(artifact().toFile()).use { jar -> + val bytecode = jar.entries().asSequence() + .filter { + it.name.startsWith( + "com/minekube/connect/share/fabric/v1_20_1/" + + "FriendCardNetworking", + ) && it.name.endsWith(".class") + } + .joinToString { + jar.getInputStream(it).use { input -> + input.readBytes().toString(Charsets.ISO_8859_1) + } + } + + assertTrue("confirmOutgoing" in bytecode) + } + } + + @Test + fun `remapped artifact is self contained and isolates networking runtime`() { + JarFile(artifact().toFile()).use { jar -> + val entries = jar.entries().asSequence().map { it.name }.toSet() + + assertTrue("fabric.mod.json" in entries) + assertTrue("LICENSE" in entries) + assertTrue("connect-share-fabric-1.20.1.mixins.json" in entries) + assertTrue( + "com/minekube/connect/share/fabric/v1_20_1/" + + "FriendCardNetworking.class" in entries, + ) + assertTrue( + entries.any { + it.startsWith("com/minekube/connect/share/") && + it.endsWith(".class") + }, + ) + assertTrue("META-INF/connect/libp2p-runtime.jar" in entries) + assertFalse(entries.any { it.startsWith("io/libp2p/") }) + assertFalse(entries.any { it.startsWith("io/netty/") }) + assertFalse(entries.any { it.startsWith("it/unimi/dsi/fastutil/") }) + assertFalse(entries.any { it.startsWith("kotlin/") }) + assertTrue( + entries.any { + it.startsWith( + "com/minekube/connect/shadow/it/unimi/dsi/fastutil/", + ) + }, + ) + + val payload = jar.getJarEntry("META-INF/connect/libp2p-runtime.jar") + val payloadEntries = JarInputStream(jar.getInputStream(payload)).use { nested -> + generateSequence(nested::getNextJarEntry).map { it.name }.toSet() + } + assertTrue(payloadEntries.any { it.startsWith("io/libp2p/") }) + assertTrue(payloadEntries.any { it.startsWith("io/netty/") }) + assertTrue(payloadEntries.any { it.startsWith("kotlin/") }) + assertTrue( + "com/minekube/connect/tunnel/p2p/DirectP2pNodeRuntime.class" in + payloadEntries, + ) + } + } + + @Test + fun `minecraft profile mapper uses the legacy mutable Mojang property map ABI`() { + JarFile(artifact().toFile()).use { jar -> + val factory = jar.getJarEntry( + "com/minekube/connect/share/fabric/v1_20_1/" + + "MinecraftGameProfileFactory.class", + ) + assertNotNull(factory) + + val bytecode = jar.getInputStream(factory).use { + it.readBytes().toString(Charsets.ISO_8859_1) + } + assertTrue("getProperties" in bytecode) + assertTrue("com/mojang/authlib/properties/PropertyMap" in bytecode) + assertFalse( + "com/minekube/connect/shadow/com/google/common" in bytecode, + ) + } + } + + @Test + fun `packaged loader reads libp2p only from child payload`() { + URLClassLoader( + arrayOf(artifact().toUri().toURL()), + ClassLoader.getPlatformClassLoader(), + ).use { artifactLoader -> + val loaderType = Class.forName( + "com.minekube.connect.tunnel.p2p.Libp2pRuntimeLoader", + true, + artifactLoader, + ) + val loaderMethod = loaderType.getDeclaredMethod("classLoader") + .apply { isAccessible = true } + val runtimeLoader = loaderMethod.invoke(null) as ClassLoader + try { + val host = Class.forName( + "io.libp2p.core.Host", + false, + runtimeLoader, + ) + val directRuntime = Class.forName( + "com.minekube.connect.tunnel.p2p.DirectP2pNodeRuntime", + false, + runtimeLoader, + ) + assertTrue(host.classLoader === runtimeLoader) + assertTrue(directRuntime.classLoader === runtimeLoader) + val directCodeSource = + directRuntime.protectionDomain.codeSource.location + assertNotNull(directCodeSource) + assertTrue( + directCodeSource.toString().contains("libp2p-runtime-"), + ) + val nodeType = Class.forName( + "com.minekube.connect.tunnel.p2p.DirectP2pNode", + true, + artifactLoader, + ) + val node = nodeType.getDeclaredConstructor().newInstance() + nodeType.getMethod("close").invoke(node) + } finally { + loaderType.getDeclaredMethod("close") + .apply { isAccessible = true } + .invoke(null) + } + } + } + + @Test + fun `parent facing APIs do not expose isolated runtime types`() { + listOf( + ConnectShareClient::class.java, + ShareCoordinator::class.java, + DirectP2pNode::class.java, + Libp2pEndpoint::class.java, + Libp2pTunnelTransport::class.java, + ).forEach(::assertParentFacingTypes) + } + + private fun assertParentFacingTypes(type: Class<*>) { + val exposed = buildList { + type.declaredFields.forEach { add(it.type.name) } + type.declaredConstructors.forEach { constructor -> + constructor.parameterTypes.forEach { add(it.name) } + } + type.declaredMethods.forEach { method -> + add(method.returnType.name) + method.parameterTypes.forEach { add(it.name) } + } + } + val invalid = exposed.filter { name -> + FORBIDDEN_TYPE_PREFIXES.any(name::startsWith) + } + assertTrue( + invalid.isEmpty(), + "${type.name} exposes isolated runtime types: $invalid", + ) + } + + private fun artifact(): Path { + val explicit = System.getProperty("connectShareArtifact") + if (explicit != null) { + return Path.of(explicit) + } + val directory = Path.of("build", "libs") + return Files.list(directory).use { paths -> + paths.filter { + it.name.startsWith("connect-share-fabric-1.20.1-") && + it.name.endsWith(".jar") && + !it.name.contains("sources") && + !it.name.contains("dev") + }.findFirst().orElse(null) + }.also(::assertNotNull) + } + + private companion object { + const val JAVA_17_CLASS_MAJOR = 61 + val FORBIDDEN_TYPE_PREFIXES = listOf( + "io.libp2p.", + "io.netty.", + ) + } +} diff --git a/share/fabric-1.20.1/src/test/kotlin/com/minekube/connect/share/fabric/v1_20_1/FriendCardPayloadTest.kt b/share/fabric-1.20.1/src/test/kotlin/com/minekube/connect/share/fabric/v1_20_1/FriendCardPayloadTest.kt new file mode 100644 index 000000000..d88fb1ba5 --- /dev/null +++ b/share/fabric-1.20.1/src/test/kotlin/com/minekube/connect/share/fabric/v1_20_1/FriendCardPayloadTest.kt @@ -0,0 +1,42 @@ +package com.minekube.connect.share.fabric.v1_20_1 + +import io.netty.buffer.Unpooled +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertSame +import net.minecraft.network.FriendlyByteBuf +import java.util.UUID + +class FriendCardPayloadTest { + @Test + fun `signed friend card survives the network payload codec`() { + val invitation = "minekube://share/" + "signed-card".repeat(500) + val relationshipId = UUID.randomUUID() + val buffer = FriendlyByteBuf(Unpooled.buffer()) + + FriendCardPayload.CODEC.encode( + buffer, + FriendCardPayload(invitation, relationshipId), + ) + + val decoded = FriendCardPayload.CODEC.decode(buffer) + assertEquals(invitation, decoded.invitation) + assertEquals(relationshipId, decoded.relationshipId) + } + + @Test + fun `friend card request has a zero data payload`() { + val buffer = FriendlyByteBuf(Unpooled.buffer()) + + FriendCardRequestPayload.CODEC.encode( + buffer, + FriendCardRequestPayload, + ) + + assertEquals(0, buffer.readableBytes()) + assertSame( + FriendCardRequestPayload, + FriendCardRequestPayload.CODEC.decode(buffer), + ) + } +} diff --git a/share/fabric-1.20.1/src/test/kotlin/com/minekube/connect/share/fabric/v1_20_1/Minecraft1201LoginContinuationTest.kt b/share/fabric-1.20.1/src/test/kotlin/com/minekube/connect/share/fabric/v1_20_1/Minecraft1201LoginContinuationTest.kt new file mode 100644 index 000000000..0fcc6d604 --- /dev/null +++ b/share/fabric-1.20.1/src/test/kotlin/com/minekube/connect/share/fabric/v1_20_1/Minecraft1201LoginContinuationTest.kt @@ -0,0 +1,48 @@ +package com.minekube.connect.share.fabric.v1_20_1 + +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertSame +import kotlin.test.assertTrue + +class Minecraft1201LoginContinuationTest { + @AfterTest + fun resetContinuation() { + Minecraft1201LoginBridge.resetLoginContinuationForTests() + } + + @Test + fun `fabric accepts an approved login immediately`() { + var accepted = false + + Minecraft1201LoginBridge.continueApprovedLogin( + listener = Any(), + accept = Runnable { accepted = true }, + ) + + assertTrue(accepted) + } + + @Test + fun `loader continuation can finish native negotiation first`() { + val listener = Any() + var accepted = false + var observedListener: Any? = null + var deferredAccept: Runnable? = null + Minecraft1201LoginBridge.installLoginContinuation { actual, accept -> + observedListener = actual + deferredAccept = accept + } + + Minecraft1201LoginBridge.continueApprovedLogin( + listener = listener, + accept = Runnable { accepted = true }, + ) + + assertSame(listener, observedListener) + assertFalse(accepted) + deferredAccept!!.run() + assertTrue(accepted) + } +} diff --git a/share/fabric-1.20.1/src/test/kotlin/com/minekube/connect/share/fabric/v1_20_1/Minecraft12111BridgeTest.kt b/share/fabric-1.20.1/src/test/kotlin/com/minekube/connect/share/fabric/v1_20_1/Minecraft12111BridgeTest.kt new file mode 100644 index 000000000..8ed24c4f1 --- /dev/null +++ b/share/fabric-1.20.1/src/test/kotlin/com/minekube/connect/share/fabric/v1_20_1/Minecraft12111BridgeTest.kt @@ -0,0 +1,130 @@ +package com.minekube.connect.share.fabric.v1_20_1 + +import com.minekube.connect.share.ShareGameMode +import com.minekube.connect.share.ShareOptions +import io.netty.channel.Channel +import io.netty.channel.ChannelInitializer +import io.netty.channel.DefaultEventLoopGroup +import io.netty.channel.EventLoopGroup +import io.netty.channel.local.LocalAddress +import java.net.InetAddress +import java.net.InetSocketAddress +import java.net.SocketAddress +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.test.assertTrue +import kotlinx.coroutines.runBlocking + +class Minecraft1201BridgeTest { + @Test + fun `publishes only on loopback and releases every listener`() = runBlocking { + val transport = FakeMinecraftTransport() + val bridge = Minecraft1201Bridge(transport, FakeLocalChannelBinder()) + + val target = bridge.open(shareOptions) + + assertTrue(transport.boundAddress.address.isLoopbackAddress) + assertIs(target.address) + assertEquals(2, transport.listenerCount) + assertEquals(25565, transport.publishedPort) + + target.close() + + assertEquals(-1, transport.publishedPort) + assertEquals(0, transport.listenerCount) + } + + @Test + fun `can open again after close but never installs a second active listener`() = runBlocking { + val transport = FakeMinecraftTransport() + val bridge = Minecraft1201Bridge(transport, FakeLocalChannelBinder()) + + val first = bridge.open(shareOptions) + assertFailsWith { + bridge.open(shareOptions) + } + assertEquals(2, transport.listenerCount) + + first.close() + val second = bridge.open(shareOptions) + + assertEquals(2, transport.listenerCount) + second.close() + assertEquals(0, transport.listenerCount) + } + + @Test + fun `failed local bind rolls back the private vanilla listener`() = runBlocking { + val transport = FakeMinecraftTransport() + val bridge = Minecraft1201Bridge( + transport, + LocalShareChannelBinder { _, _ -> + throw IllegalStateException("local bind failed") + }, + ) + + assertFailsWith { + bridge.open(shareOptions) + } + + assertEquals(-1, transport.publishedPort) + assertEquals(0, transport.listenerCount) + } + + private class FakeMinecraftTransport : Minecraft1201Transport { + var publishedPort = -1 + var listenerCount = 0 + lateinit var boundAddress: InetSocketAddress + val eventLoopGroup: EventLoopGroup = DefaultEventLoopGroup(1) + + override fun publish(options: ShareOptions): PublishedMinecraftTransport { + check(publishedPort == -1) + boundAddress = InetSocketAddress(InetAddress.getLoopbackAddress(), 25565) + publishedPort = boundAddress.port + listenerCount++ + return object : PublishedMinecraftTransport { + override val address: InetSocketAddress = boundAddress + override val childInitializer: ChannelInitializer = NoopInitializer + override val eventLoopGroup = this@FakeMinecraftTransport.eventLoopGroup + + override fun addLocalListener(listener: LocalShareChannel) { + listenerCount++ + } + + override fun removeLocalListener(listener: LocalShareChannel) { + listenerCount-- + } + + override fun close() { + if (publishedPort != -1) { + listenerCount-- + publishedPort = -1 + } + } + } + } + } + + private class FakeLocalChannelBinder : LocalShareChannelBinder { + override fun bind( + childInitializer: ChannelInitializer, + eventLoopGroup: EventLoopGroup, + ): LocalShareChannel = object : LocalShareChannel { + override val address: SocketAddress = LocalAddress("connect-share-test") + override fun close() = Unit + } + } + + private object NoopInitializer : ChannelInitializer() { + override fun initChannel(channel: Channel) = Unit + } + + private companion object { + val shareOptions = ShareOptions( + gameMode = ShareGameMode.SURVIVAL, + allowCheats = false, + ) + } +} diff --git a/share/fabric-1.21.1/build.gradle.kts b/share/fabric-1.21.1/build.gradle.kts new file mode 100644 index 000000000..02b516616 --- /dev/null +++ b/share/fabric-1.21.1/build.gradle.kts @@ -0,0 +1,160 @@ +import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar + +plugins { + id("connect.shadow-conventions") + id("net.fabricmc.fabric-loom-remap") + id("org.jetbrains.kotlin.jvm") +} + +base { + archivesName = "connect-share-fabric-1.21.1" +} + +java { + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } +} + +kotlin { + jvmToolchain(21) +} + +repositories { + maven("https://repo.opencollab.dev/maven-releases") { + mavenContent { releasesOnly() } + } + maven("https://repo.opencollab.dev/maven-snapshots") { + mavenContent { snapshotsOnly() } + } + maven("https://dl.cloudsmith.io/public/libp2p/jvm-libp2p/maven/") + maven("https://dl.cloudsmith.io/public/consensys/maven/maven/") + maven("https://jitpack.io") { + content { includeGroupByRegex("com\\.github\\..*") } + } +} + +val connectShareParentRuntime by configurations.creating { + isCanBeConsumed = false + isCanBeResolved = true +} + +dependencies { + minecraft("com.mojang:minecraft:1.21.1") + mappings(loom.officialMojangMappings()) + modImplementation("net.fabricmc:fabric-loader:${Versions.fabricLoaderVersion}") + modImplementation("net.fabricmc.fabric-api:fabric-api:${Versions.fabricApi1211Version}") + modImplementation("net.fabricmc:fabric-language-kotlin:${Versions.fabricLanguageKotlinVersion}") + compileOnly("org.jspecify:jspecify:1.0.0") + + implementation(projects.core) + implementation(projects.share.common) + implementation(projects.share.fabricCommon) + connectShareParentRuntime(projects.share.fabricCommon) { + exclude(group = "io.libp2p") + exclude(group = "io.netty") + exclude(group = "org.jetbrains.kotlin") + exclude(group = "org.jetbrains.kotlinx") + exclude(group = "com.google.errorprone", module = "javac") + } + + testImplementation(kotlin("test")) + testRuntimeOnly("org.junit.platform:junit-platform-launcher") +} + +tasks.test { + useJUnitPlatform() + dependsOn(tasks.remapJar) + systemProperty( + "connectShareArtifact", + tasks.remapJar.flatMap { it.archiveFile }.get().asFile.absolutePath, + ) +} + +tasks.processResources { + inputs.property("version", project.version) + filesMatching("fabric.mod.json") { + expand("version" to project.version) + } +} + +relocate("arrow") +relocate("aopalliance") +relocate("cloud.commandframework") +relocate("com.google.common") +relocate("com.google.gson") +relocate("com.google.inject") +relocate("com.google.protobuf") +relocate("it.unimi.dsi.fastutil") +relocate("io.grpc") +relocate("io.leangen.geantyref") +relocate("jakarta.inject") +relocate("javax.inject") +relocate("okhttp3") +relocate("okio") +relocate("org.bstats") +relocate("org.geysermc.configutils") +relocate("org.yaml.snakeyaml") + +val libp2pRuntimeJar = tasks.named("libp2pRuntimeJar") { + dependsOn(":core:classes") + from(rootProject.project(":core").layout.buildDirectory.dir("classes/java/main")) { + include( + "com/minekube/connect/tunnel/p2p/DirectP2pNodeRuntime*.class", + "com/minekube/connect/tunnel/p2p/Libp2pEndpointRuntime*.class", + "com/minekube/connect/tunnel/p2p/impl/Libp2pTunnelTransportRuntime*.class", + ) + } +} +val minecraftGameProfileFactory = + "com/minekube/connect/share/fabric/v1_21_1/" + + "MinecraftGameProfileFactory.class" +val connectShareShadowJar = tasks.named("shadowJar") { + configurations = listOf(connectShareParentRuntime) + archiveBaseName.set("connect-share-fabric-1.21.1") + archiveVersion.set(project.version.toString()) + archiveClassifier.set("dev-parent-shadow") + mergeServiceFiles() + from(rootProject.file("LICENSE")) + // Authlib's PropertyMap constructor must keep Minecraft's Guava ABI. + exclude(minecraftGameProfileFactory) +} +val connectShareJar = tasks.register("connectShareJar") { + dependsOn(connectShareShadowJar, libp2pRuntimeJar) + archiveBaseName.set("connect-share-fabric-1.21.1") + archiveVersion.set(project.version.toString()) + archiveClassifier.set("dev-shadow") + duplicatesStrategy = DuplicatesStrategy.EXCLUDE + from({ + zipTree(connectShareShadowJar.get().archiveFile.get().asFile) + }) + from(libp2pRuntimeJar) { + into("META-INF/connect") + } + from(sourceSets.main.get().output) { + include(minecraftGameProfileFactory) + } +} + +tasks.remapJar { + dependsOn(connectShareJar) + inputFile.set(connectShareJar.flatMap { it.archiveFile }) + archiveBaseName.set("connect-share-fabric-1.21.1") + archiveVersion.set(project.version.toString()) + archiveClassifier.set("") +} + +val verifyConnectShareArtifactSize = tasks.register("verifyConnectShareArtifactSize") { + dependsOn(tasks.remapJar) + val artifact = tasks.remapJar.flatMap { it.archiveFile } + inputs.file(artifact) + doLast { + val bytes = artifact.get().asFile.length() + val limit = 63L * 1024L * 1024L + logger.lifecycle("Connect Share Fabric 1.21.1 artifact: {} MiB", "%.1f".format(bytes / 1024.0 / 1024.0)) + check(bytes <= limit) { "Connect Share Fabric 1.21.1 exceeds the 63 MiB release budget ($bytes bytes)" } + } +} +tasks.check { dependsOn(verifyConnectShareArtifactSize) } diff --git a/share/fabric-1.21.1/src/main/java/com/minekube/connect/share/fabric/v1_21_1/MinecraftGameProfileFactory.java b/share/fabric-1.21.1/src/main/java/com/minekube/connect/share/fabric/v1_21_1/MinecraftGameProfileFactory.java new file mode 100644 index 000000000..b16e291bf --- /dev/null +++ b/share/fabric-1.21.1/src/main/java/com/minekube/connect/share/fabric/v1_21_1/MinecraftGameProfileFactory.java @@ -0,0 +1,23 @@ +package com.minekube.connect.share.fabric.v1_21_1; + +import com.google.common.collect.ArrayListMultimap; +import com.google.common.collect.Multimap; +import com.mojang.authlib.GameProfile; +import com.mojang.authlib.properties.Property; +import com.mojang.authlib.properties.PropertyMap; +import java.util.List; +import java.util.UUID; + +final class MinecraftGameProfileFactory { + private MinecraftGameProfileFactory() {} + + static GameProfile create(UUID id, String username, List properties) { + Multimap mapped = ArrayListMultimap.create(); + for (Property property : properties) { + mapped.put(property.name(), property); + } + GameProfile profile = new GameProfile(id, username); + profile.getProperties().putAll(mapped); + return profile; + } +} diff --git a/share/fabric-1.21.1/src/main/java/com/minekube/connect/share/fabric/v1_21_1/mixin/ConnectionAccessor.java b/share/fabric-1.21.1/src/main/java/com/minekube/connect/share/fabric/v1_21_1/mixin/ConnectionAccessor.java new file mode 100644 index 000000000..2b12c53ae --- /dev/null +++ b/share/fabric-1.21.1/src/main/java/com/minekube/connect/share/fabric/v1_21_1/mixin/ConnectionAccessor.java @@ -0,0 +1,12 @@ +package com.minekube.connect.share.fabric.v1_21_1.mixin; + +import io.netty.channel.Channel; +import net.minecraft.network.Connection; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.gen.Accessor; + +@Mixin(Connection.class) +public interface ConnectionAccessor { + @Accessor("channel") + Channel getConnectShareChannel(); +} diff --git a/share/fabric-1.21.1/src/main/java/com/minekube/connect/share/fabric/v1_21_1/mixin/IntegratedServerAccessor.java b/share/fabric-1.21.1/src/main/java/com/minekube/connect/share/fabric/v1_21_1/mixin/IntegratedServerAccessor.java new file mode 100644 index 000000000..88aae268f --- /dev/null +++ b/share/fabric-1.21.1/src/main/java/com/minekube/connect/share/fabric/v1_21_1/mixin/IntegratedServerAccessor.java @@ -0,0 +1,19 @@ +package com.minekube.connect.share.fabric.v1_21_1.mixin; + +import net.minecraft.client.server.IntegratedServer; +import net.minecraft.client.server.LanServerPinger; +import org.jspecify.annotations.Nullable; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.gen.Accessor; + +@Mixin(IntegratedServer.class) +public interface IntegratedServerAccessor { + @Accessor("publishedPort") + void setConnectSharePublishedPort(int port); + + @Accessor("lanPinger") + @Nullable LanServerPinger getConnectShareLanPinger(); + + @Accessor("lanPinger") + void setConnectShareLanPinger(@Nullable LanServerPinger pinger); +} diff --git a/share/fabric-1.21.1/src/main/java/com/minekube/connect/share/fabric/v1_21_1/mixin/IntegratedServerMixin.java b/share/fabric-1.21.1/src/main/java/com/minekube/connect/share/fabric/v1_21_1/mixin/IntegratedServerMixin.java new file mode 100644 index 000000000..79c0f0ffc --- /dev/null +++ b/share/fabric-1.21.1/src/main/java/com/minekube/connect/share/fabric/v1_21_1/mixin/IntegratedServerMixin.java @@ -0,0 +1,24 @@ +package com.minekube.connect.share.fabric.v1_21_1.mixin; + +import com.minekube.connect.share.CapturedServerTransport; +import net.minecraft.client.server.IntegratedServer; +import net.minecraft.client.server.LanServerPinger; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Redirect; + +@Mixin(IntegratedServer.class) +public abstract class IntegratedServerMixin { + @Redirect( + method = "publishServer", + at = @At( + value = "INVOKE", + target = "Lnet/minecraft/client/server/LanServerPinger;start()V")) + private void connectShare$suppressLanAdvertisement(LanServerPinger pinger) { + if (CapturedServerTransport.isShareStartArmed()) { + pinger.interrupt(); + } else { + pinger.start(); + } + } +} diff --git a/share/fabric-1.21.1/src/main/java/com/minekube/connect/share/fabric/v1_21_1/mixin/LanServerPingerAccessor.java b/share/fabric-1.21.1/src/main/java/com/minekube/connect/share/fabric/v1_21_1/mixin/LanServerPingerAccessor.java new file mode 100644 index 000000000..c89d16f3a --- /dev/null +++ b/share/fabric-1.21.1/src/main/java/com/minekube/connect/share/fabric/v1_21_1/mixin/LanServerPingerAccessor.java @@ -0,0 +1,12 @@ +package com.minekube.connect.share.fabric.v1_21_1.mixin; + +import java.net.DatagramSocket; +import net.minecraft.client.server.LanServerPinger; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.gen.Accessor; + +@Mixin(LanServerPinger.class) +public interface LanServerPingerAccessor { + @Accessor("socket") + DatagramSocket getConnectShareSocket(); +} diff --git a/share/fabric-1.21.1/src/main/java/com/minekube/connect/share/fabric/v1_21_1/mixin/PauseScreenMixin.java b/share/fabric-1.21.1/src/main/java/com/minekube/connect/share/fabric/v1_21_1/mixin/PauseScreenMixin.java new file mode 100644 index 000000000..2395bb925 --- /dev/null +++ b/share/fabric-1.21.1/src/main/java/com/minekube/connect/share/fabric/v1_21_1/mixin/PauseScreenMixin.java @@ -0,0 +1,85 @@ +package com.minekube.connect.share.fabric.v1_21_1.mixin; + +import com.minekube.connect.share.fabric.ConnectShareClient; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.network.chat.Component; +import org.jspecify.annotations.Nullable; +import org.spongepowered.asm.mixin.Final; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.Unique; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +@Mixin(net.minecraft.client.gui.screens.PauseScreen.class) +abstract class PauseScreenMixin extends Screen { + @Shadow @Final private boolean showPauseMenu; + @Shadow private @Nullable Button disconnectButton; + @Unique private @Nullable Button connectShareButton; + @Unique private @Nullable Button connectShareFriendsButton; + + protected PauseScreenMixin(Component title) { + super(title); + } + + @Inject(method = "init", at = @At("TAIL")) + private void connectShare$addButtons(CallbackInfo ci) { + Minecraft client = Minecraft.getInstance(); + if (!showPauseMenu + || disconnectButton == null + || !ConnectShareClient.isInstalled()) { + return; + } + + int rowX = disconnectButton.getX(); + int rowY = disconnectButton.getY(); + disconnectButton.setY(rowY + 24); + removeWidget(disconnectButton); + if (client.hasSingleplayerServer()) { + connectShareButton = addRenderableWidget( + Button.builder( + Component.translatable( + ConnectShareClient.pauseButtonTranslationKey()), + button -> ConnectShareClient.openPauseScreen(this)) + .bounds(rowX, rowY, 100, 20) + .build()); + connectShareFriendsButton = addRenderableWidget( + Button.builder( + connectShare$friendsLabel(), + button -> ConnectShareClient.openJoinScreen(this)) + .bounds(rowX + 104, rowY, 100, 20) + .build()); + } else { + connectShareFriendsButton = addRenderableWidget( + Button.builder( + connectShare$friendsLabel(), + button -> ConnectShareClient.openJoinScreen(this)) + .bounds(rowX, rowY, 204, 20) + .build()); + } + addRenderableWidget(disconnectButton); + } + + @Inject(method = "tick", at = @At("TAIL")) + private void connectShare$refreshButtons(CallbackInfo ci) { + if (connectShareButton != null) { + connectShareButton.setMessage( + Component.translatable(ConnectShareClient.pauseButtonTranslationKey())); + } + if (connectShareFriendsButton != null) { + connectShareFriendsButton.setMessage(connectShare$friendsLabel()); + } + } + + @Unique + private Component connectShare$friendsLabel() { + int count = ConnectShareClient.friendsButtonCount(); + String key = ConnectShareClient.friendsButtonTranslationKey(); + return count > 0 + ? Component.translatable(key, count) + : Component.translatable(key); + } +} diff --git a/share/fabric-1.21.1/src/main/java/com/minekube/connect/share/fabric/v1_21_1/mixin/ServerConnectionListenerAccessor.java b/share/fabric-1.21.1/src/main/java/com/minekube/connect/share/fabric/v1_21_1/mixin/ServerConnectionListenerAccessor.java new file mode 100644 index 000000000..13f1d35bc --- /dev/null +++ b/share/fabric-1.21.1/src/main/java/com/minekube/connect/share/fabric/v1_21_1/mixin/ServerConnectionListenerAccessor.java @@ -0,0 +1,13 @@ +package com.minekube.connect.share.fabric.v1_21_1.mixin; + +import io.netty.channel.ChannelFuture; +import java.util.List; +import net.minecraft.server.network.ServerConnectionListener; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.gen.Accessor; + +@Mixin(ServerConnectionListener.class) +public interface ServerConnectionListenerAccessor { + @Accessor("channels") + List getConnectShareChannels(); +} diff --git a/share/fabric-1.21.1/src/main/java/com/minekube/connect/share/fabric/v1_21_1/mixin/ServerConnectionListenerMixin.java b/share/fabric-1.21.1/src/main/java/com/minekube/connect/share/fabric/v1_21_1/mixin/ServerConnectionListenerMixin.java new file mode 100644 index 000000000..5e75c4a2e --- /dev/null +++ b/share/fabric-1.21.1/src/main/java/com/minekube/connect/share/fabric/v1_21_1/mixin/ServerConnectionListenerMixin.java @@ -0,0 +1,57 @@ +package com.minekube.connect.share.fabric.v1_21_1.mixin; + +import com.minekube.connect.share.CapturedServerTransport; +import io.netty.channel.Channel; +import io.netty.channel.ChannelHandler; +import io.netty.channel.ChannelInitializer; +import io.netty.channel.EventLoopGroup; +import java.net.InetAddress; +import net.minecraft.server.network.ServerConnectionListener; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.ModifyArg; +import org.spongepowered.asm.mixin.injection.ModifyVariable; + +@Mixin(ServerConnectionListener.class) +public abstract class ServerConnectionListenerMixin { + @ModifyVariable( + method = "startTcpServerListener", + at = @At("HEAD"), + argsOnly = true, + ordinal = 0) + private InetAddress connectShare$forceLoopback(InetAddress requestedAddress) { + return CapturedServerTransport.isShareStartArmed() + ? InetAddress.getLoopbackAddress() + : requestedAddress; + } + + @ModifyArg( + method = "startTcpServerListener", + at = @At( + value = "INVOKE", + target = "Lio/netty/bootstrap/ServerBootstrap;childHandler" + + "(Lio/netty/channel/ChannelHandler;)" + + "Lio/netty/bootstrap/ServerBootstrap;"), + index = 0) + @SuppressWarnings("unchecked") + private ChannelHandler connectShare$captureChildInitializer(ChannelHandler handler) { + if (CapturedServerTransport.isShareStartArmed() + && handler instanceof ChannelInitializer) { + CapturedServerTransport.captureChildInitializer( + (ChannelInitializer) handler); + } + return handler; + } + + @ModifyArg( + method = "startTcpServerListener", + at = @At( + value = "INVOKE", + target = "Lio/netty/bootstrap/ServerBootstrap;group" + + "(Lio/netty/channel/EventLoopGroup;)" + + "Lio/netty/bootstrap/ServerBootstrap;"), + index = 0) + private EventLoopGroup connectShare$captureEventLoopGroup(EventLoopGroup group) { + return CapturedServerTransport.captureEventLoopGroup(group); + } +} diff --git a/share/fabric-1.21.1/src/main/java/com/minekube/connect/share/fabric/v1_21_1/mixin/ServerLoginPacketListenerMixin.java b/share/fabric-1.21.1/src/main/java/com/minekube/connect/share/fabric/v1_21_1/mixin/ServerLoginPacketListenerMixin.java new file mode 100644 index 000000000..753448f1d --- /dev/null +++ b/share/fabric-1.21.1/src/main/java/com/minekube/connect/share/fabric/v1_21_1/mixin/ServerLoginPacketListenerMixin.java @@ -0,0 +1,101 @@ +package com.minekube.connect.share.fabric.v1_21_1.mixin; + +import com.mojang.authlib.GameProfile; +import com.minekube.connect.share.fabric.v1_21_1.Minecraft1211LoginBridge; +import net.minecraft.network.Connection; +import net.minecraft.network.chat.Component; +import net.minecraft.network.protocol.login.ServerboundHelloPacket; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.network.ServerLoginPacketListenerImpl; +import org.jspecify.annotations.Nullable; +import org.spongepowered.asm.mixin.Final; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.Unique; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +@Mixin(ServerLoginPacketListenerImpl.class) +public abstract class ServerLoginPacketListenerMixin { + @Shadow @Final private MinecraftServer server; + @Shadow @Final private Connection connection; + @Shadow @Nullable String requestedUsername; + + @Shadow + abstract void startClientVerification(GameProfile profile); + + @Shadow + public abstract void disconnect(Component reason); + + @Unique private boolean connectShare$admissionStarted; + @Unique private boolean connectShare$admissionAllowed; + + @Inject(method = "handleHello", at = @At("HEAD"), cancellable = true) + private void connectShare$acceptConnectProfile( + ServerboundHelloPacket hello, + CallbackInfo callback) { + if (!Minecraft1211LoginBridge.hasConnectIdentity(connection)) { + if (Minecraft1211LoginBridge.shouldUseOfflineDirectProfile(connection)) { + GameProfile profile = Minecraft1211LoginBridge.offlineProfile(hello.name()); + if (profile == null) { + disconnect(Component.literal("Invalid characters in username")); + } else { + requestedUsername = profile.getName(); + startClientVerification(profile); + } + callback.cancel(); + } + return; + } + + GameProfile profile = Minecraft1211LoginBridge.authenticatedProfile( + connection, hello.name()); + if (profile == null) { + disconnect(Component.literal("Connect identity is invalid")); + callback.cancel(); + return; + } + + requestedUsername = profile.getName(); + startClientVerification(profile); + callback.cancel(); + } + + @Inject( + method = "verifyLoginAndFinishConnectionSetup", + at = @At("HEAD"), + cancellable = true) + private void connectShare$awaitPassthroughAdmission( + GameProfile profile, + CallbackInfo callback) { + boolean direct = Minecraft1211LoginBridge.hasDirectSession(connection); + if (!direct && !Minecraft1211LoginBridge.isPassthroughConnect(connection)) { + return; + } + if (connectShare$admissionAllowed) { + return; + } + + callback.cancel(); + if (connectShare$admissionStarted) { + return; + } + connectShare$admissionStarted = true; + if (direct) { + Minecraft1211LoginBridge.requestDirectAdmission( + connection, + server, + profile, + () -> connectShare$admissionAllowed = true, + this::disconnect); + } else { + Minecraft1211LoginBridge.requestPassthroughAdmission( + connection, + server, + profile, + () -> connectShare$admissionAllowed = true, + this::disconnect); + } + } +} diff --git a/share/fabric-1.21.1/src/main/java/com/minekube/connect/share/fabric/v1_21_1/mixin/TitleScreenMixin.java b/share/fabric-1.21.1/src/main/java/com/minekube/connect/share/fabric/v1_21_1/mixin/TitleScreenMixin.java new file mode 100644 index 000000000..e64bbc930 --- /dev/null +++ b/share/fabric-1.21.1/src/main/java/com/minekube/connect/share/fabric/v1_21_1/mixin/TitleScreenMixin.java @@ -0,0 +1,49 @@ +package com.minekube.connect.share.fabric.v1_21_1.mixin; + +import com.minekube.connect.share.fabric.ConnectShareClient; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.network.chat.Component; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Unique; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +@Mixin(net.minecraft.client.gui.screens.TitleScreen.class) +abstract class TitleScreenMixin extends Screen { + @Unique private Button connectShare$friendsButton; + + protected TitleScreenMixin(Component title) { + super(title); + } + + @Inject(method = "init", at = @At("TAIL")) + private void connectShare$addJoinButton(CallbackInfo ci) { + if (!ConnectShareClient.isInstalled()) { + return; + } + connectShare$friendsButton = addRenderableWidget( + Button.builder( + connectShare$friendsLabel(), + button -> ConnectShareClient.openJoinScreen(this)) + .bounds(width - 74, 4, 70, 20) + .build()); + } + + @Inject(method = "tick", at = @At("TAIL")) + private void connectShare$refreshJoinButton(CallbackInfo ci) { + if (connectShare$friendsButton != null) { + connectShare$friendsButton.setMessage(connectShare$friendsLabel()); + } + } + + @Unique + private Component connectShare$friendsLabel() { + int count = ConnectShareClient.friendsButtonCount(); + String key = ConnectShareClient.friendsButtonTranslationKey(); + return count > 0 + ? Component.translatable(key, count) + : Component.translatable(key); + } +} diff --git a/share/fabric-1.21.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_1/BlockedFriendsScreen.kt b/share/fabric-1.21.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_1/BlockedFriendsScreen.kt new file mode 100644 index 000000000..e5f92d7a6 --- /dev/null +++ b/share/fabric-1.21.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_1/BlockedFriendsScreen.kt @@ -0,0 +1,142 @@ +package com.minekube.connect.share.fabric.v1_21_1 + +import com.minekube.connect.share.fabric.ConnectShareClient +import com.minekube.connect.share.fabric.ui.AdaptiveShareLayout +import com.minekube.connect.share.fabric.ui.page +import net.minecraft.ChatFormatting +import net.minecraft.client.gui.components.Button +import net.minecraft.client.gui.components.MultiLineTextWidget +import net.minecraft.client.gui.components.StringWidget +import net.minecraft.client.gui.screens.Screen +import net.minecraft.network.chat.CommonComponents +import net.minecraft.network.chat.Component + +class BlockedFriendsScreen( + private val parent: Screen, +) : Screen(Component.translatable("connect_share.privacy.blocked_title")) { + private var offset = 0 + + override fun init() { + val friends = ConnectShareClient.friendsViewModel() + val layout = AdaptiveShareLayout.friends(width, height) + val blocked = friends.state.value.blocked + val page = blocked.page(offset, layout.visibleRows) + offset = page.offset + + addRenderableWidget( + centered(title.copy().withStyle(ChatFormatting.BOLD), layout.headerY), + ) + addRenderableWidget( + MultiLineTextWidget( + layout.contentX, + layout.subtitleY, + Component.translatable( + "connect_share.privacy.blocked_description", + ).withStyle(ChatFormatting.GRAY), + font, + ).setMaxWidth(layout.contentWidth).setCentered(true), + ) + + page.items.forEachIndexed { index, identity -> + val y = layout.rowY(index) + val buttonWidth = 88 + addRenderableWidget( + StringWidget( + layout.contentX, + y, + layout.contentWidth - buttonWidth - 6, + 20, + Component.literal(identity.displayName), + font, + ), + ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.privacy.unblock"), + ) { + friends.unblock(identity.peerId) + rebuildWidgets() + }.bounds( + layout.contentX + layout.contentWidth - buttonWidth, + y, + buttonWidth, + 20, + ).build(), + ) + } + + if (blocked.isEmpty()) { + addRenderableWidget( + centered( + Component.translatable("connect_share.privacy.blocked_empty") + .withStyle(ChatFormatting.GRAY), + layout.rowsTop + 18, + ), + ) + } else { + addRenderableWidget( + centered( + Component.translatable( + "connect_share.friends.page", + page.pageNumber, + page.pageCount, + ).withStyle(ChatFormatting.DARK_GRAY), + layout.messageY, + ), + ) + } + + val pageButtonWidth = layout.halfButtonWidth + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.page.previous"), + ) { + offset = page.previousOffset ?: 0 + rebuildWidgets() + }.bounds( + layout.contentX, + layout.footerTop, + pageButtonWidth, + 20, + ).build().apply { active = page.hasPrevious }, + ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.page.next"), + ) { + offset = page.nextOffset ?: page.offset + rebuildWidgets() + }.bounds( + layout.contentX + pageButtonWidth + 6, + layout.footerTop, + pageButtonWidth, + 20, + ).build().apply { active = page.hasNext }, + ) + addRenderableWidget( + Button.builder(CommonComponents.GUI_BACK) { onClose() } + .bounds( + layout.contentX, + layout.footerTop + 24, + layout.contentWidth, + 20, + ).build(), + ) + } + + override fun onClose() { + minecraft!!.setScreen(parent) + } + + private fun centered(message: Component, y: Int): StringWidget { + val textWidth = font.width(message) + return StringWidget( + width / 2 - textWidth / 2, + y, + textWidth, + 11, + message, + font, + ) + } +} diff --git a/share/fabric-1.21.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_1/CompatibilityMismatchScreen.kt b/share/fabric-1.21.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_1/CompatibilityMismatchScreen.kt new file mode 100644 index 000000000..7de1d9ffd --- /dev/null +++ b/share/fabric-1.21.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_1/CompatibilityMismatchScreen.kt @@ -0,0 +1,142 @@ +package com.minekube.connect.share.fabric.v1_21_1 + +import com.minekube.connect.share.fabric.FriendJoinAttemptFailure +import com.minekube.connect.share.fabric.ui.AdaptiveShareLayout +import com.minekube.connect.share.fabric.ui.presentation +import net.minecraft.ChatFormatting +import net.minecraft.client.gui.components.Button +import net.minecraft.client.gui.components.MultiLineTextWidget +import net.minecraft.client.gui.components.StringWidget +import net.minecraft.client.gui.screens.Screen +import net.minecraft.network.chat.CommonComponents +import net.minecraft.network.chat.Component + +class CompatibilityMismatchScreen( + private val parent: Screen, + private val failure: FriendJoinAttemptFailure.Compatibility, + private val tryAnyway: () -> Unit, +) : Screen(Component.translatable("connect_share.compatibility.title")) { + private var packCopied = false + + override fun init() { + val layout = AdaptiveShareLayout.form(width, height, 1) + addRenderableWidget( + centered(title.copy().withStyle(ChatFormatting.YELLOW), layout.headerY), + ) + addRenderableWidget( + MultiLineTextWidget( + layout.contentX, + layout.subtitleY, + Component.translatable("connect_share.compatibility.description"), + font, + ).setMaxWidth(layout.contentWidth).setCentered(true), + ) + + val visibleDifferences = ((layout.availableBodyHeight - 24) / 15) + .coerceIn(1, 5) + failure.report.differences + .take(visibleDifferences) + .forEachIndexed { index, difference -> + val line = difference.presentation() + addRenderableWidget( + StringWidget( + layout.contentX, + layout.bodyTop + 12 + index * 15, + layout.contentWidth, + 11, + Component.translatable( + line.translationKey, + *line.arguments.toTypedArray(), + ), + font, + ), + ) + } + val hidden = failure.report.differences.size - visibleDifferences + if (hidden > 0) { + addRenderableWidget( + centered( + Component.translatable( + "connect_share.compatibility.more", + hidden, + ).withStyle(ChatFormatting.GRAY), + layout.bodyTop + 12 + visibleDifferences * 15, + ), + ) + } + + failure.report.pack?.let { pack -> + addRenderableWidget( + Button.builder( + Component.translatable( + if (packCopied) { + "connect_share.compatibility.pack_copied" + } else { + "connect_share.compatibility.copy_pack" + }, + ), + ) { + minecraft!!.keyboardHandler.setClipboard(pack.url) + packCopied = true + rebuildWidgets() + }.bounds( + layout.contentX, + layout.footerTop, + layout.contentWidth, + 20, + ).build(), + ) + } + val actionY = layout.footerTop + 24 + if (failure.canTryAnyway) { + addRenderableWidget( + Button.builder( + Component.translatable( + "connect_share.compatibility.try_anyway", + ), + ) { + minecraft!!.setScreen(parent) + tryAnyway() + }.bounds( + layout.contentX, + actionY, + layout.halfButtonWidth, + 20, + ).build(), + ) + } + addRenderableWidget( + Button.builder(CommonComponents.GUI_BACK) { onClose() } + .bounds( + if (failure.canTryAnyway) { + layout.contentX + layout.halfButtonWidth + 6 + } else { + layout.contentX + }, + actionY, + if (failure.canTryAnyway) { + layout.halfButtonWidth + } else { + layout.contentWidth + }, + 20, + ).build(), + ) + } + + override fun onClose() { + minecraft!!.setScreen(parent) + } + + private fun centered(message: Component, y: Int): StringWidget { + val textWidth = font.width(message) + return StringWidget( + width / 2 - textWidth / 2, + y, + textWidth, + 11, + message, + font, + ) + } +} diff --git a/share/fabric-1.21.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_1/ConnectGameProfileMapper.kt b/share/fabric-1.21.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_1/ConnectGameProfileMapper.kt new file mode 100644 index 000000000..0400a3cca --- /dev/null +++ b/share/fabric-1.21.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_1/ConnectGameProfileMapper.kt @@ -0,0 +1,48 @@ +package com.minekube.connect.share.fabric.v1_21_1 + +import arrow.core.Either +import arrow.core.raise.either +import arrow.core.raise.ensure +import com.mojang.authlib.GameProfile +import com.mojang.authlib.properties.Property +import com.minekube.connect.api.player.GameProfile as ConnectGameProfile +import net.minecraft.util.StringUtil + +object ConnectGameProfileMapper { + fun toMinecraft( + source: ConnectGameProfile, + ): Either = either { + ensure( + source.username.isNotBlank() && + StringUtil.isValidPlayerName(source.username), + ) { + ProfileMappingFailure.InvalidName + } + val properties = source.properties.map { property -> + ensure(property.name.isNotBlank() && property.value.isNotBlank()) { + ProfileMappingFailure.InvalidProperty + } + val signature = property.signature?.takeIf(String::isNotEmpty) + if (signature == null) { + Property(property.name, property.value) + } else { + Property(property.name, property.value, signature) + } + } + MinecraftGameProfileFactory.create( + source.uniqueId, + source.username, + properties, + ) + } + + @JvmStatic + fun toMinecraftOrNull(source: ConnectGameProfile): GameProfile? = + toMinecraft(source).getOrNull() +} + +sealed interface ProfileMappingFailure { + data object InvalidName : ProfileMappingFailure + + data object InvalidProperty : ProfileMappingFailure +} diff --git a/share/fabric-1.21.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_1/ConnectShare12111Client.kt b/share/fabric-1.21.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_1/ConnectShare12111Client.kt new file mode 100644 index 000000000..92c4755c8 --- /dev/null +++ b/share/fabric-1.21.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_1/ConnectShare12111Client.kt @@ -0,0 +1,499 @@ +package com.minekube.connect.share.fabric.v1_21_1 + +import com.minekube.connect.share.admission.NewAdmissionTracker +import com.minekube.connect.share.admission.AdmissionPurpose +import com.minekube.connect.share.fabric.ConnectShareClient +import com.minekube.connect.share.fabric.ConnectShareInstallation +import com.minekube.connect.share.fabric.FabricLocalLoginAdmission +import com.minekube.connect.share.fabric.FabricLocalLoginAdmissionGate +import com.minekube.connect.share.fabric.FabricShareBootstrap +import com.minekube.connect.share.fabric.FabricShareBrowser +import com.minekube.connect.share.fabric.FriendCardReceiver +import com.minekube.connect.share.fabric.FriendActivityResolver +import com.minekube.connect.share.fabric.SocialEvent +import com.minekube.connect.share.fabric.SocialEventTracker +import com.minekube.connect.share.fabric.FriendPresenceMonitor +import com.minekube.connect.share.fabric.FollowAction +import com.minekube.connect.share.fabric.GuestJoinTarget +import com.minekube.connect.share.fabric.MinecraftStatusProbe +import com.minekube.connect.share.fabric.LoadedCompatibilityProfileFactory +import com.minekube.connect.share.fabric.LoadedMod +import com.minekube.connect.share.fabric.ModSide +import com.minekube.connect.share.fabric.ui.ShareUiMessage +import com.minekube.connect.share.fabric.ui.terminalNotification +import com.minekube.connect.share.fabric.ui.uiMessage +import com.minekube.connect.share.ShareState +import com.minekube.connect.share.friend.FriendStore +import com.minekube.connect.share.friend.FriendActivity +import com.minekube.connect.share.friend.FriendActivityKind +import com.minekube.connect.share.friend.FriendJoinRequest +import com.minekube.connect.share.friend.ModLoader +import com.minekube.connect.tunnel.p2p.DirectP2pAuthMode +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicReference +import java.util.UUID +import java.nio.file.Path +import java.util.logging.Level +import java.util.logging.Logger +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.cancel +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import net.minecraft.SharedConstants +import net.minecraft.client.Minecraft +import net.minecraft.client.gui.components.toasts.SystemToast +import net.minecraft.client.gui.screens.Screen +import net.minecraft.client.gui.screens.ConnectScreen +import net.minecraft.client.gui.screens.TitleScreen +import net.minecraft.client.multiplayer.ServerData +import net.minecraft.client.multiplayer.resolver.ServerAddress +import net.minecraft.network.chat.Component + +class ConnectShare1211Runtime( + private val platform: ConnectShare1211Platform, +) { + fun initialize() { + val client = Minecraft.getInstance() + val clientDispatcher = client.asCoroutineDispatcher() + val scope = CoroutineScope( + SupervisorJob() + clientDispatcher, + ) + val installationReference = + AtomicReference() + val worldAvailableSnapshot = + AtomicBoolean(client.hasSingleplayerServer()) + val playerCountSnapshot = AtomicInteger( + client.singleplayerServer?.playerList?.playerCount ?: 0, + ) + val worldNameSnapshot = AtomicReference( + client.singleplayerServer?.worldData?.levelName + ?: "Minecraft world", + ) + val activitySnapshot = AtomicReference( + FriendActivity(FriendActivityKind.ONLINE), + ) + val activityIdentitySnapshot = AtomicReference(null) + val activityEpochSnapshot = AtomicReference(null) + val joinTargetSnapshot = AtomicReference(null) + val minecraftVersion = + SharedConstants.getCurrentVersion().name + val modVersion = platform.modVersion + val compatibilityProfile = LoadedCompatibilityProfileFactory.create( + minecraftVersion = minecraftVersion, + loader = platform.loader, + mods = platform.loadedMods, + packEnvironment = System.getenv(), + ) + val dataDirectory = platform.configDirectory + .resolve("minekube-connect-share") + val friendStore = FriendStore(dataDirectory) + val browserReference = + AtomicReference() + val statusProbe = MinecraftStatusProbe() + val remotePresence = FriendPresenceMonitor( + store = friendStore, + directProbe = { friend -> + browserReference.get()?.probeDirect( + friend = friend, + authMode = DirectP2pAuthMode.OFFLINE, + probe = statusProbe, + ) + }, + ) + scope.launch { + while (isActive) { + remotePresence.refresh() + delay(PRESENCE_REFRESH_MILLIS) + } + } + val bootstrapJob = scope.launch(Dispatchers.IO) { + try { + val installation = FabricShareBootstrap.create( + scope = scope, + dataDirectory = dataDirectory, + minecraftVersion = minecraftVersion, + modVersion = modVersion, + worldAvailable = worldAvailableSnapshot.get(), + friendStore = friendStore, + playerCount = playerCountSnapshot::get, + worldDisplayName = worldNameSnapshot::get, + playerDisplayName = { client.user.name }, + friendActivity = activitySnapshot::get, + compatibilityProfile = { compatibilityProfile }, + friendJoinTarget = joinTargetSnapshot::get, + bridgeFactory = { + admission, + admissionScope, + approvedJoins, + gateway, + -> + GatewayMinecraft1211Bridge(gateway) { + FabricLocalLoginAdmissionGate( + admission = FabricLocalLoginAdmission( + admission, + approvedJoins, + ), + scope = admissionScope, + ) + } + }, + screens = { parent, active -> + val parentScreen = parent as Screen + client.execute { + client.setScreen( + if (active) { + ShareStatusScreen(parentScreen) + } else { + ShareSetupScreen(parentScreen) + }, + ) + } + }, + guestScreens = { parent, browser, activity -> + val parentScreen = parent as Screen + client.execute { + client.setScreen( + ShareJoinScreen( + parent = parentScreen, + friends = + ConnectShareClient.friendsViewModel(), + browser = browser, + remotePresence = remotePresence, + friendActivity = activity, + ), + ) + } + }, + ) + browserReference.set(installation.browser) + withContext(clientDispatcher) { + platform.installFriendCardNetworking( + scope = scope, + issuer = installation.friendCardIssuer, + receiver = installation.friendCardReceiver, + approvedJoins = installation.approvedJoins, + ) + ConnectShareClient.install(installation) + installationReference.set(installation) + LOGGER.info( + "Connect Share friend gateway is ready", + ) + } + } catch (cancellation: CancellationException) { + throw cancellation + } catch (failure: Throwable) { + LOGGER.log( + Level.SEVERE, + "Connect Share initialization failed", + failure, + ) + } + } + val admissionNotifications = NewAdmissionTracker() + val socialNotifications = SocialEventTracker() + val admissionToastId = SystemToast.SystemToastId() + + platform.onEndClientTick { minecraft -> + val installation = + installationReference.get() + ?: return@onEndClientTick + val server = minecraft.singleplayerServer + val worldAvailable = server != null && minecraft.connection != null + worldAvailableSnapshot.set(worldAvailable) + playerCountSnapshot.set( + server?.playerList?.playerCount ?: 0, + ) + worldNameSnapshot.set( + server?.worldData?.levelName ?: "Minecraft world", + ) + val currentServer = minecraft.currentServer + val externalServer = currentServer + ?.takeIf { !worldAvailable } + joinTargetSnapshot.set(externalServer?.ip) + val activityIdentity: Any? = when { + externalServer != null -> "server:${externalServer.ip}" + worldAvailable -> server + else -> null + } + if (activityIdentitySnapshot.getAndSet(activityIdentity) != + activityIdentity + ) { + activityEpochSnapshot.set( + activityIdentity?.let { UUID.randomUUID().toString() }, + ) + } + activitySnapshot.set( + FriendActivityResolver.resolve( + worldAvailable = worldAvailable, + worldSharingActive = installation.viewModel.state.value + .shareState is ShareState.Sharing, + worldName = worldNameSnapshot.get(), + externalServerName = externalServer?.name, + sessionEpoch = activityEpochSnapshot.get(), + compatibility = compatibilityProfile, + ), + ) + ConnectShareClient.integratedWorldChanged( + worldAvailable, + server, + ) + ConnectShareClient.guestConnectionChanged( + minecraft.connection != null, + ) + admissionNotifications.update( + installation.viewModel.state.value.pendingAdmissions, + ).firstOrNull()?.let { request -> + SystemToast.add( + minecraft.toasts, + admissionToastId, + Component.translatable( + if (request.purpose == AdmissionPurpose.FRIEND) { + "connect_share.notification.friend_request" + } else { + "connect_share.notification.join_request" + }, + ), + Component.translatable( + if (request.purpose == AdmissionPurpose.FRIEND) { + "connect_share.notification.friend_request_detail" + } else { + "connect_share.notification.join_request_detail" + }, + request.identity.name, + ), + ) + } + val friends = installation.friendsViewModel + friends.updateIncoming( + installation.viewModel.state.value.pendingAdmissions, + ) + friends.updateRemotePresence(remotePresence.state.value) + friends.updateActivities(installation.friendActivity.state.value) + processFollowActions(minecraft, installation, scope) + socialNotifications.update(friends.state.value).forEach { event -> + SystemToast.add( + minecraft.toasts, + SystemToast.SystemToastId(), + event.title(), + event.detail(), + ) + } + } + platform.onClientStopping { + scope.launch(Dispatchers.IO) { + bootstrapJob.cancelAndJoin() + try { + if (installationReference.get() != null) { + ConnectShareClient.shutdown() + } + } finally { + scope.cancel() + } + } + } + } + + private companion object { + const val PRESENCE_REFRESH_MILLIS = 10_000L + val LOGGER: Logger = Logger.getLogger("Connect") + } + + private fun processFollowActions( + minecraft: Minecraft, + installation: ConnectShareInstallation, + scope: CoroutineScope, + ) { + installation.friendsViewModel.followActions( + activeGameplay = minecraft.level != null, + ).forEach { action -> + when (action) { + is FollowAction.RequestJoin -> { + followToast( + minecraft, + "connect_share.notification.follow_waiting", + "connect_share.notification.follow_waiting_detail", + action.displayName, + ) + scope.launch(Dispatchers.IO) { + installation.friendJoinOrchestrator.request( + action.peerId, + FriendJoinRequest( + requestId = UUID.randomUUID(), + playerName = minecraft.user.name, + playerUuid = minecraft.user.profileId, + ), + ).fold( + ifLeft = { failure -> + minecraft.execute { + followToast( + minecraft, + "connect_share.notification.follow_failed", + null, + failure.uiMessage().component(), + ) + } + }, + ifRight = { target -> + minecraft.execute { + if (minecraft.level != null) { + target.close() + followToast( + minecraft, + "connect_share.notification.follow_ready", + "connect_share.notification.follow_ready_detail", + action.displayName, + ) + } else { + connectFollow( + minecraft, + installation, + action, + target, + ) + } + } + }, + ) + } + } + + is FollowAction.OfferJoinNow -> followToast( + minecraft, + "connect_share.notification.follow_ready", + "connect_share.notification.follow_ready_detail", + action.displayName, + ) + + is FollowAction.Expired, + is FollowAction.Cancelled -> + checkNotNull(action.terminalNotification()).let { + followToast( + minecraft, + it.titleKey, + it.detailKey, + it.displayName, + ) + } + } + } + } + + private fun connectFollow( + minecraft: Minecraft, + installation: ConnectShareInstallation, + action: FollowAction.RequestJoin, + target: GuestJoinTarget, + ) { + val address = when (target) { + is GuestJoinTarget.Connect -> + ServerAddress.parseString(target.publicAddress) + is GuestJoinTarget.Direct -> ServerAddress( + target.localAddress.hostString, + target.localAddress.port, + ) + } + if (target is GuestJoinTarget.Direct) { + ConnectShareClient.holdGuestDirect(target) + } + installation.friendsViewModel.completeFollow(action.peerId) + ConnectScreen.startConnecting( + minecraft.screen ?: TitleScreen(), + minecraft, + address, + ServerData( + action.displayName, + address.toString(), + ServerData.Type.OTHER, + ), + false, + null, + ) + } + + private fun followToast( + minecraft: Minecraft, + titleKey: String, + detailKey: String?, + value: String, + ) { + followToast(minecraft, titleKey, detailKey, Component.literal(value)) + } + + private fun followToast( + minecraft: Minecraft, + titleKey: String, + detailKey: String?, + value: Component, + ) { + SystemToast.add( + minecraft.toasts, + SystemToast.SystemToastId(), + Component.translatable(titleKey, value), + detailKey?.let { Component.translatable(it, value) } + ?: value, + ) + } + + private fun ShareUiMessage.component() = + Component.translatable(translationKey, *arguments.toTypedArray()) + + private fun SocialEvent.title(): Component = Component.translatable( + when (this) { + is SocialEvent.FriendAccepted -> + "connect_share.notification.friend_accepted" + is SocialEvent.FriendRemoved -> + "connect_share.notification.friend_removed" + is SocialEvent.PlayingServer -> + "connect_share.notification.friend_playing" + is SocialEvent.WorldReady -> + "connect_share.notification.friend_online" + }, + ) + + private fun SocialEvent.detail(): Component = when (this) { + is SocialEvent.FriendAccepted -> Component.translatable( + "connect_share.notification.friend_accepted_detail", + displayName, + ) + is SocialEvent.FriendRemoved -> Component.translatable( + "connect_share.notification.friend_removed_detail", + displayName, + ) + is SocialEvent.PlayingServer -> Component.translatable( + "connect_share.notification.friend_playing_detail", + displayName, + serverName, + ) + is SocialEvent.WorldReady -> Component.translatable( + "connect_share.notification.friend_online_detail", + displayName, + worldName ?: "Minecraft world", + ) + } +} + +interface ConnectShare1211Platform { + val modVersion: String + val loader: ModLoader + val loadedMods: List + val configDirectory: Path + + fun onEndClientTick(callback: (Minecraft) -> Unit) + + fun onClientStopping(callback: () -> Unit) + + fun installFriendCardNetworking( + scope: CoroutineScope, + issuer: com.minekube.connect.share.fabric.FriendCardIssuer, + receiver: FriendCardReceiver, + approvedJoins: com.minekube.connect.share.fabric.ApprovedJoinTracker, + ) +} diff --git a/share/fabric-1.21.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_1/EndpointIdentityScreen.kt b/share/fabric-1.21.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_1/EndpointIdentityScreen.kt new file mode 100644 index 000000000..ff5200f30 --- /dev/null +++ b/share/fabric-1.21.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_1/EndpointIdentityScreen.kt @@ -0,0 +1,255 @@ +package com.minekube.connect.share.fabric.v1_21_1 + +import com.minekube.connect.share.fabric.ConnectShareClient +import com.minekube.connect.share.fabric.ui.ShareUiMessage +import com.minekube.connect.share.fabric.ui.AdaptiveShareLayout +import com.minekube.connect.share.identity.CredentialSource +import java.nio.file.Path +import net.minecraft.ChatFormatting +import net.minecraft.client.gui.components.Button +import net.minecraft.client.gui.components.EditBox +import net.minecraft.client.gui.components.MultiLineTextWidget +import net.minecraft.client.gui.components.StringWidget +import net.minecraft.client.gui.screens.ConfirmScreen +import net.minecraft.client.gui.screens.Screen +import net.minecraft.network.chat.CommonComponents +import net.minecraft.network.chat.Component +import net.minecraft.network.chat.Style +import net.minecraft.util.FormattedCharSequence +import org.lwjgl.util.tinyfd.TinyFileDialogs + +class EndpointIdentityScreen( + private val parent: Screen, +) : Screen(Component.translatable("connect_share.identity.title")) { + private val viewModel = ConnectShareClient.viewModel() + private var fingerprint = 0 + private var endpointBox: EditBox? = null + private var tokenBox: EditBox? = null + + override fun init() { + val state = viewModel.state.value + fingerprint = state.copy( + importDraft = state.importDraft.copy(token = ""), + ).hashCode() + val layout = AdaptiveShareLayout.form(width, height, 2) + + addRenderableWidget( + centered(title.copy().withStyle(ChatFormatting.BOLD), layout.headerY), + ) + addRenderableWidget( + MultiLineTextWidget( + layout.contentX, + layout.subtitleY, + Component.translatable("connect_share.identity.description"), + font, + ).setMaxWidth(layout.contentWidth).setCentered(true), + ) + + addRenderableWidget( + StringWidget( + layout.contentX, + layout.bodyTop, + layout.contentWidth, + 11, + Component.translatable("connect_share.identity.endpoint"), + font, + ), + ) + endpointBox = EditBox( + font, + layout.contentX, + layout.bodyTop + 12, + layout.contentWidth, + 20, + Component.translatable("connect_share.identity.endpoint"), + ).also { box -> + box.value = state.importDraft.endpoint + box.setHint(Component.translatable("connect_share.identity.endpoint_hint")) + box.setResponder(viewModel::setImportEndpoint) + box.setEditable(state.importDraft.endpointEditable) + addRenderableWidget(box) + } + + addRenderableWidget( + StringWidget( + layout.contentX, + layout.bodyTop + 38, + layout.contentWidth, + 11, + Component.translatable("connect_share.identity.token"), + font, + ), + ) + tokenBox = EditBox( + font, + layout.contentX, + layout.bodyTop + 50, + layout.contentWidth, + 20, + Component.translatable("connect_share.identity.token"), + ).also { box -> + box.value = state.importDraft.token + box.setHint(Component.translatable("connect_share.identity.token_hint")) + box.setResponder(viewModel::setImportToken) + box.setFormatter { text, _ -> + FormattedCharSequence.forward( + "•".repeat(text.length), + Style.EMPTY, + ) + } + box.setEditable(state.importDraft.tokenEditable) + addRenderableWidget(box) + } + + val identity = state.identity + addRenderableWidget( + MultiLineTextWidget( + layout.contentX, + layout.bodyTop + 78, + Component.translatable( + "connect_share.identity.sources", + identity?.endpointSource?.displayName() ?: "…", + identity?.tokenSource?.displayName() ?: "…", + ).withStyle(ChatFormatting.GRAY), + font, + ).setMaxWidth(layout.contentWidth).setCentered(true), + ) + state.safeMessage?.let { safeMessage -> + addRenderableWidget( + MultiLineTextWidget( + layout.contentX, + layout.bodyTop + 104, + safeMessage.component() + .withStyle(ChatFormatting.YELLOW), + font, + ).setMaxWidth(layout.contentWidth).setCentered(true), + ) + } + + val save = addRenderableWidget( + Button.builder( + Component.translatable("connect_share.identity.save"), + ) { + viewModel.importIdentity() + }.bounds( + layout.contentX, + layout.footerTop, + layout.halfButtonWidth, + 20, + ).build(), + ) + val choose = addRenderableWidget( + Button.builder( + Component.translatable("connect_share.identity.choose_file"), + ) { + chooseTokenFile()?.let(viewModel::importTokenFile) + }.bounds( + layout.contentX + layout.halfButtonWidth + 6, + layout.footerTop, + layout.halfButtonWidth, + 20, + ).build(), + ) + save.active = state.importDraft.endpointEditable && + state.importDraft.tokenEditable && + !state.operationInProgress + choose.active = state.importDraft.endpointEditable && + state.importDraft.tokenEditable && + !state.operationInProgress + + val reset = addRenderableWidget( + Button.builder( + Component.translatable("connect_share.identity.reset"), + ) { + confirmReset() + }.bounds( + layout.contentX, + layout.footerTop + 24, + layout.halfButtonWidth, + 20, + ).build(), + ) + reset.active = state.importDraft.endpointEditable && + state.importDraft.tokenEditable && + !state.operationInProgress + addRenderableWidget( + Button.builder(CommonComponents.GUI_BACK) { onClose() } + .bounds( + layout.contentX + layout.halfButtonWidth + 6, + layout.footerTop + 24, + layout.halfButtonWidth, + 20, + ).build(), + ) + } + + override fun tick() { + super.tick() + val state = viewModel.state.value + val next = state.copy( + importDraft = state.importDraft.copy(token = ""), + ).hashCode() + if ( + next != fingerprint || + state.importDraft.token.isEmpty() && + tokenBox?.value?.isNotEmpty() == true + ) { + rebuildWidgets() + } + } + + override fun onClose() { + viewModel.setImportToken("") + minecraft!!.setScreen(parent) + } + + private fun confirmReset() { + minecraft!!.setScreen( + ConfirmScreen( + { confirmed -> + if (confirmed) { + viewModel.resetIdentity() + } + minecraft!!.setScreen(this) + }, + Component.translatable( + "connect_share.identity.reset_confirm.title", + ), + Component.translatable( + "connect_share.identity.reset_confirm.message", + ), + ), + ) + } + + private fun chooseTokenFile(): Path? { + val selected = TinyFileDialogs.tinyfd_openFileDialog( + Component.translatable("connect_share.identity.choose_file").string, + null, + null, + "token.json", + false, + ) ?: return null + return Path.of(selected) + } + + private fun centered(message: Component, y: Int): StringWidget { + val textWidth = font.width(message) + return StringWidget( + width / 2 - textWidth / 2, + y, + textWidth, + 11, + message, + font, + ) + } +} + +private fun ShareUiMessage.component() = + Component.translatable(translationKey, *arguments.toTypedArray()) + +private fun CredentialSource.displayName(): Component = + Component.translatable( + "connect_share.identity.source.${name.lowercase()}", + ) diff --git a/share/fabric-1.21.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_1/FabricConnectShare1211Client.kt b/share/fabric-1.21.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_1/FabricConnectShare1211Client.kt new file mode 100644 index 000000000..e5511beb2 --- /dev/null +++ b/share/fabric-1.21.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_1/FabricConnectShare1211Client.kt @@ -0,0 +1,68 @@ +package com.minekube.connect.share.fabric.v1_21_1 + +import com.minekube.connect.share.fabric.ApprovedJoinTracker +import com.minekube.connect.share.fabric.FriendCardIssuer +import com.minekube.connect.share.fabric.FriendCardReceiver +import com.minekube.connect.share.fabric.LoadedMod +import com.minekube.connect.share.fabric.ModSide +import com.minekube.connect.share.friend.ModLoader +import java.nio.file.Path +import kotlinx.coroutines.CoroutineScope +import net.fabricmc.api.ClientModInitializer +import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientLifecycleEvents +import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents +import net.fabricmc.loader.api.FabricLoader +import net.minecraft.client.Minecraft + +class FabricConnectShare1211Client : ClientModInitializer { + override fun onInitializeClient() { + ConnectShare1211Runtime(FabricPlatform).initialize() + } + + private object FabricPlatform : ConnectShare1211Platform { + private val loaderInstance = FabricLoader.getInstance() + + override val modVersion: String = loaderInstance + .getModContainer("connect-share") + .orElseThrow() + .metadata.version.friendlyString + override val loader = ModLoader.FABRIC + override val loadedMods: List = + loaderInstance.allMods.map { container -> + val metadata = container.metadata + LoadedMod( + id = metadata.id, + version = metadata.version.friendlyString, + side = when (metadata.environment.name) { + "CLIENT" -> ModSide.CLIENT + "SERVER" -> ModSide.SERVER + else -> ModSide.UNIVERSAL + }, + builtIn = metadata.type == "builtin", + ) + } + override val configDirectory: Path = loaderInstance.configDir + + override fun onEndClientTick(callback: (Minecraft) -> Unit) { + ClientTickEvents.END_CLIENT_TICK.register(callback) + } + + override fun onClientStopping(callback: () -> Unit) { + ClientLifecycleEvents.CLIENT_STOPPING.register { callback() } + } + + override fun installFriendCardNetworking( + scope: CoroutineScope, + issuer: FriendCardIssuer, + receiver: FriendCardReceiver, + approvedJoins: ApprovedJoinTracker, + ) { + FriendCardNetworking.install( + scope, + issuer, + receiver, + approvedJoins, + ) + } + } +} diff --git a/share/fabric-1.21.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_1/FriendCardNetworking.kt b/share/fabric-1.21.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_1/FriendCardNetworking.kt new file mode 100644 index 000000000..e3a3ebff2 --- /dev/null +++ b/share/fabric-1.21.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_1/FriendCardNetworking.kt @@ -0,0 +1,101 @@ +package com.minekube.connect.share.fabric.v1_21_1 + +import com.minekube.connect.share.fabric.ApprovedJoinTracker +import com.minekube.connect.share.fabric.ConnectShareClient +import com.minekube.connect.share.fabric.FriendCardIssuer +import com.minekube.connect.share.fabric.FriendCardReceiver +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking +import net.fabricmc.fabric.api.networking.v1.PayloadTypeRegistry +import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents +import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking + +object FriendCardNetworking { + fun install( + scope: CoroutineScope, + issuer: FriendCardIssuer, + receiver: FriendCardReceiver, + approvedJoins: ApprovedJoinTracker, + ) { + PayloadTypeRegistry.playC2S().register( + FriendCardPayload.TYPE, + FriendCardPayload.CODEC, + ) + PayloadTypeRegistry.playS2C().register( + FriendCardRequestPayload.TYPE, + FriendCardRequestPayload.CODEC, + ) + ServerPlayNetworking.registerGlobalReceiver( + FriendCardPayload.TYPE, + ) { payload, context -> + context.server().execute { + val player = context.player() + val proof = approvedJoins.consume( + player.gameProfile.name, + player.uuid, + payload.invitation, + ) ?: return@execute + receiver.receive( + invitation = payload.invitation, + displayName = player.gameProfile.name, + authenticatedMinecraftUuid = + proof.authenticatedMinecraftUuid, + allowAutomaticJoin = true, + relationshipId = payload.relationshipId, + ) + } + } + ServerPlayConnectionEvents.JOIN.register( + ServerPlayConnectionEvents.Join { handler, _, _ -> + val player = handler.player + if ( + approvedJoins.hasProof( + player.gameProfile.name, + player.uuid, + ) && + ServerPlayNetworking.canSend( + player, + FriendCardRequestPayload.TYPE, + ) + ) { + ServerPlayNetworking.send( + player, + FriendCardRequestPayload, + ) + } + }, + ) + ClientPlayNetworking.registerGlobalReceiver( + FriendCardRequestPayload.TYPE, + ) { _, context -> + val exchange = + ConnectShareClient.consumeFriendCardExchangeConsent() + ?: return@registerGlobalReceiver + val client = context.client() + scope.launch(Dispatchers.IO) { + issuer.issue().getOrNull()?.let { invitation -> + client.execute { + if ( + client.connection != null && + ClientPlayNetworking.canSend( + FriendCardPayload.TYPE, + ) + ) { + ClientPlayNetworking.send( + FriendCardPayload( + invitation, + exchange.relationshipId, + ), + ) + scope.launch(Dispatchers.IO) { + receiver.confirmOutgoing(exchange.peerId) + } + } + } + } + } + } + } +} diff --git a/share/fabric-1.21.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_1/FriendCardPayload.kt b/share/fabric-1.21.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_1/FriendCardPayload.kt new file mode 100644 index 000000000..517b518d7 --- /dev/null +++ b/share/fabric-1.21.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_1/FriendCardPayload.kt @@ -0,0 +1,59 @@ +package com.minekube.connect.share.fabric.v1_21_1 + +import java.util.UUID +import net.minecraft.network.FriendlyByteBuf +import net.minecraft.network.codec.StreamCodec +import net.minecraft.network.protocol.common.custom.CustomPacketPayload +import net.minecraft.resources.ResourceLocation + +data class FriendCardPayload( + val invitation: String, + val relationshipId: UUID = UUID.randomUUID(), +) : CustomPacketPayload { + override fun type(): CustomPacketPayload.Type = TYPE + + companion object { + const val MAX_CARD_CHARS = 16_384 + + val TYPE: CustomPacketPayload.Type = + CustomPacketPayload.Type( + ResourceLocation.fromNamespaceAndPath( + "connect-share", + "friend-card", + ), + ) + + val CODEC: StreamCodec = + CustomPacketPayload.codec( + { payload, buffer -> + buffer.writeUtf( + payload.invitation, + MAX_CARD_CHARS, + ) + buffer.writeUUID(payload.relationshipId) + }, + { buffer -> + FriendCardPayload( + invitation = buffer.readUtf(MAX_CARD_CHARS), + relationshipId = buffer.readUUID(), + ) + }, + ) + } +} + +data object FriendCardRequestPayload : CustomPacketPayload { + override fun type(): CustomPacketPayload.Type = + TYPE + + val TYPE: CustomPacketPayload.Type = + CustomPacketPayload.Type( + ResourceLocation.fromNamespaceAndPath( + "connect-share", + "friend-card-request", + ), + ) + + val CODEC: StreamCodec = + StreamCodec.unit(FriendCardRequestPayload) +} diff --git a/share/fabric-1.21.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_1/Minecraft12111Bridge.kt b/share/fabric-1.21.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_1/Minecraft12111Bridge.kt new file mode 100644 index 000000000..84d2c1a35 --- /dev/null +++ b/share/fabric-1.21.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_1/Minecraft12111Bridge.kt @@ -0,0 +1,62 @@ +package com.minekube.connect.share.fabric.v1_21_1 + +import com.minekube.connect.share.LocalShareChannel as CommonLocalShareChannel +import com.minekube.connect.share.LocalShareChannelBinder as CommonLocalShareChannelBinder +import com.minekube.connect.share.MinecraftVersionTransport +import com.minekube.connect.share.NettyLocalShareChannelBinder +import com.minekube.connect.share.PublishedMinecraftTransport as CommonPublishedMinecraftTransport +import com.minekube.connect.share.ShareConnectionGateway +import com.minekube.connect.share.VersionedMinecraftBridge +import com.minekube.connect.share.CaptureLease as CommonCaptureLease +import com.minekube.connect.share.CapturedServerTransport as CommonCapturedServerTransport +import com.minekube.connect.share.CapturedTransport as CommonCapturedTransport +import com.minekube.connect.share.fabric.FabricLoginAdmissionRegistry +import com.minekube.connect.share.fabric.FabricLocalLoginAdmissionGate + +class Minecraft1211Bridge internal constructor( + transport: Minecraft1211Transport, + localBinder: LocalShareChannelBinder, + loginAdmissionFactory: (() -> FabricLocalLoginAdmissionGate)? = null, +) : VersionedMinecraftBridge( + transport = transport, + localBinder = localBinder, + loginAdmissionAcquire = loginAdmissionFactory?.let { factory -> + { + FabricLoginAdmissionRegistry.install(factory()) + } + }, +) { + constructor() : this( + VanillaMinecraft1211Transport(), + NettyLocalShareChannelBinder(), + ) + + constructor( + loginAdmissionFactory: () -> FabricLocalLoginAdmissionGate, + ) : this( + VanillaMinecraft1211Transport(), + NettyLocalShareChannelBinder(), + loginAdmissionFactory, + ) +} + +internal class GatewayMinecraft1211Bridge( + gateway: ShareConnectionGateway, + loginAdmissionFactory: () -> FabricLocalLoginAdmissionGate, +) : VersionedMinecraftBridge( + transport = VanillaMinecraft1211Transport(), + gateway = gateway, + loginAdmissionAcquire = { + FabricLoginAdmissionRegistry.install( + loginAdmissionFactory(), + ) + }, +) + +internal typealias Minecraft1211Transport = MinecraftVersionTransport +internal typealias PublishedMinecraftTransport = CommonPublishedMinecraftTransport +internal typealias LocalShareChannelBinder = CommonLocalShareChannelBinder +internal typealias LocalShareChannel = CommonLocalShareChannel +internal typealias CapturedServerTransport = CommonCapturedServerTransport +internal typealias CaptureLease = CommonCaptureLease +internal typealias CapturedTransport = CommonCapturedTransport diff --git a/share/fabric-1.21.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_1/Minecraft12111LoginBridge.kt b/share/fabric-1.21.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_1/Minecraft12111LoginBridge.kt new file mode 100644 index 000000000..c21fd54d4 --- /dev/null +++ b/share/fabric-1.21.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_1/Minecraft12111LoginBridge.kt @@ -0,0 +1,175 @@ +package com.minekube.connect.share.fabric.v1_21_1 + +import com.mojang.authlib.GameProfile +import com.minekube.connect.api.ConnectAttributes +import com.minekube.connect.network.netty.LocalSession +import com.minekube.connect.share.admission.AdmissionAnswer +import com.minekube.connect.share.admission.Ingress +import com.minekube.connect.share.direct.DirectSessionAttributes +import com.minekube.connect.share.fabric.DirectMinecraftAuthentication +import com.minekube.connect.share.fabric.FabricDirectAuthenticationPolicy +import com.minekube.connect.share.fabric.FabricLoginAdmissionRegistry +import com.minekube.connect.share.fabric.ui.ShareLoginMessages +import com.minekube.connect.tunnel.p2p.DirectP2pRoute +import com.minekube.connect.tunnel.p2p.DirectP2pSession +import com.minekube.connect.share.fabric.v1_21_1.mixin.ConnectionAccessor +import java.util.function.Consumer +import net.minecraft.network.Connection +import net.minecraft.network.chat.Component +import net.minecraft.server.MinecraftServer +import net.minecraft.core.UUIDUtil +import net.minecraft.util.StringUtil + +object Minecraft1211LoginBridge { + @JvmStatic + fun hasConnectIdentity(connection: Connection): Boolean = + channel(connection).attr(ConnectAttributes.CONNECT_PLAYER).get() != null + + @JvmStatic + fun authenticatedProfile( + connection: Connection, + requestedName: String, + ): GameProfile? { + val player = channel(connection) + .attr(ConnectAttributes.CONNECT_PLAYER) + .get() + ?: return null + val profile = ConnectGameProfileMapper + .toMinecraft(player.gameProfile) + .getOrNull() + ?: return null + return profile.takeIf { + requestedName.equals(profile.name, ignoreCase = true) + } + } + + @JvmStatic + fun isPassthroughConnect(connection: Connection): Boolean = + LocalSession.context(channel(connection)) + .map { it.player.auth.isPassthrough } + .orElse(false) + + @JvmStatic + fun hasDirectSession(connection: Connection): Boolean = + directSession(connection) != null + + @JvmStatic + fun shouldUseOfflineDirectProfile(connection: Connection): Boolean = + directSession(connection)?.let { + FabricDirectAuthenticationPolicy.minecraftAuthentication( + it.authMode(), + ) == DirectMinecraftAuthentication.OFFLINE_PROFILE + } == true + + @JvmStatic + fun offlineProfile(requestedName: String): GameProfile? = + requestedName.takeIf(StringUtil::isValidPlayerName) + ?.let(UUIDUtil::createOfflineProfile) + + @JvmStatic + fun requestPassthroughAdmission( + connection: Connection, + server: MinecraftServer, + profile: GameProfile, + allow: Runnable, + deny: Consumer, + ) { + val channel = channel(connection) + val context = LocalSession.context(channel).orElse(null) + if (context == null || !context.player.auth.isPassthrough) { + server.execute(allow) + return + } + val decision = FabricLoginAdmissionRegistry.request( + name = profile.name, + uuid = profile.id, + connectionId = context.player.sessionId, + minecraftAuthenticated = + server.usesAuthentication() && !connection.isMemoryConnection, + ingress = Ingress.CONNECT, + ).toCompletableFuture() + channel.closeFuture().addListener { + decision.cancel(false) + } + decision.whenComplete { answer, failure -> + server.execute { + if (!connection.isConnected) { + return@execute + } + if (failure == null && answer == AdmissionAnswer.ALLOW) { + allow.run() + } else { + deny.accept(denialReason(answer)) + } + } + } + } + + @JvmStatic + fun requestDirectAdmission( + connection: Connection, + server: MinecraftServer, + profile: GameProfile, + allow: Runnable, + deny: Consumer, + ) { + val channel = channel(connection) + val session = directSession(connection) + if (session == null) { + server.execute(allow) + return + } + val minecraftAuthenticated = + server.usesAuthentication() && !connection.isMemoryConnection + FabricDirectAuthenticationPolicy.validate( + session.authMode(), + minecraftAuthenticated, + ).onLeft { + server.execute { + deny.accept( + Component.literal( + ShareLoginMessages.AUTHENTICATION_REQUIRED.fallback, + ), + ) + } + return + } + val decision = FabricLoginAdmissionRegistry.request( + name = profile.name, + uuid = profile.id, + connectionId = session.connectionId(), + minecraftAuthenticated = minecraftAuthenticated, + ingress = session.route().toIngress(), + directPeerId = session.peerId(), + ).toCompletableFuture() + channel.closeFuture().addListener { + decision.cancel(false) + } + decision.whenComplete { answer, failure -> + server.execute { + if (!connection.isConnected) { + return@execute + } + if (failure == null && answer == AdmissionAnswer.ALLOW) { + allow.run() + } else { + deny.accept(denialReason(answer)) + } + } + } + } + + private fun channel(connection: Connection) = + (connection as ConnectionAccessor).connectShareChannel + + private fun directSession(connection: Connection): DirectP2pSession? = + channel(connection).attr(DirectSessionAttributes.SESSION).get() + + private fun DirectP2pRoute.toIngress(): Ingress = when (this) { + DirectP2pRoute.LAN -> Ingress.DIRECT_LAN + DirectP2pRoute.INTERNET -> Ingress.DIRECT_INTERNET + } + + private fun denialReason(answer: AdmissionAnswer?): Component = + Component.literal(ShareLoginMessages.denial(answer).fallback) +} diff --git a/share/fabric-1.21.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_1/RecoveryScreen.kt b/share/fabric-1.21.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_1/RecoveryScreen.kt new file mode 100644 index 000000000..441f801f0 --- /dev/null +++ b/share/fabric-1.21.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_1/RecoveryScreen.kt @@ -0,0 +1,275 @@ +package com.minekube.connect.share.fabric.v1_21_1 + +import com.minekube.connect.share.fabric.ConnectShareClient +import com.minekube.connect.share.fabric.ui.AdaptiveShareLayout +import com.minekube.connect.share.fabric.ui.ShareUiMessage +import java.nio.file.Path +import net.minecraft.ChatFormatting +import net.minecraft.client.gui.components.Button +import net.minecraft.client.gui.components.EditBox +import net.minecraft.client.gui.components.MultiLineTextWidget +import net.minecraft.client.gui.components.StringWidget +import net.minecraft.client.gui.screens.Screen +import net.minecraft.network.chat.CommonComponents +import net.minecraft.network.chat.Component +import net.minecraft.network.chat.Style +import net.minecraft.util.FormattedCharSequence +import org.lwjgl.util.tinyfd.TinyFileDialogs + +class RecoveryScreen( + private val parent: Screen, +) : Screen(Component.translatable("connect_share.recovery.title")) { + private val viewModel = ConnectShareClient.recoveryViewModel() + private var fingerprint = 0 + private var passphraseValue = "" + private var confirmationValue = "" + + override fun init() { + val state = viewModel.state.value + fingerprint = state.hashCode() + val layout = AdaptiveShareLayout.form(width, height, 2) + + addRenderableWidget( + centered(title.copy().withStyle(ChatFormatting.BOLD), layout.headerY), + ) + addRenderableWidget( + MultiLineTextWidget( + layout.contentX, + layout.subtitleY, + Component.translatable("connect_share.recovery.description"), + font, + ).setMaxWidth(layout.contentWidth).setCentered(true), + ) + + addRenderableWidget( + StringWidget( + layout.contentX, + layout.bodyTop, + layout.contentWidth, + 11, + Component.translatable("connect_share.recovery.secret"), + font, + ), + ) + secretBox( + value = passphraseValue, + y = layout.bodyTop + 12, + label = "connect_share.recovery.secret", + ) { passphraseValue = it } + + addRenderableWidget( + StringWidget( + layout.contentX, + layout.bodyTop + 38, + layout.contentWidth, + 11, + Component.translatable("connect_share.recovery.secret_confirm"), + font, + ), + ) + secretBox( + value = confirmationValue, + y = layout.bodyTop + 50, + label = "connect_share.recovery.secret_confirm", + ) { confirmationValue = it } + + val status = state.safeMessage?.component() + ?: state.summary?.let { summary -> + Component.translatable( + "connect_share.recovery.summary", + summary.entryCount, + Component.translatable( + if (summary.includesPreferences) { + "connect_share.recovery.included" + } else { + "connect_share.recovery.not_included" + }, + ), + Component.translatable( + if (summary.includesEndpointIdentity) { + "connect_share.recovery.included" + } else { + "connect_share.recovery.not_included" + }, + ), + ) + } + status?.let { + addRenderableWidget( + MultiLineTextWidget( + layout.contentX, + layout.bodyTop + 78, + it.withStyle( + if (state.safeMessage == null) { + ChatFormatting.GREEN + } else { + ChatFormatting.YELLOW + }, + ), + font, + ).setMaxWidth(layout.contentWidth).setCentered(true), + ) + } + addRenderableWidget( + MultiLineTextWidget( + layout.contentX, + layout.bodyTop + 106, + Component.translatable("connect_share.recovery.warning") + .withStyle(ChatFormatting.GRAY), + font, + ).setMaxWidth(layout.contentWidth).setCentered(true), + ) + + if (state.importConfirmationRequired) { + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.recovery.restore_confirm"), + ) { + viewModel.confirmImport() + }.bounds( + layout.contentX, + layout.footerTop, + layout.halfButtonWidth, + 20, + ).build(), + ).active = !state.operationInProgress + addRenderableWidget( + Button.builder(CommonComponents.GUI_CANCEL) { + viewModel.cancelImport() + }.bounds( + layout.contentX + layout.halfButtonWidth + 6, + layout.footerTop, + layout.halfButtonWidth, + 20, + ).build(), + ).active = !state.operationInProgress + } else { + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.recovery.export"), + ) { + chooseBackupDestination()?.let { target -> + val passphrase = passphraseValue.toCharArray() + val confirmation = confirmationValue.toCharArray() + clearInputs() + viewModel.export(target, passphrase, confirmation) + } + }.bounds( + layout.contentX, + layout.footerTop, + layout.halfButtonWidth, + 20, + ).build(), + ).active = !state.operationInProgress + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.recovery.import"), + ) { + chooseBackupSource()?.let { source -> + val passphrase = passphraseValue.toCharArray() + clearInputs() + viewModel.previewImport(source, passphrase) + } + }.bounds( + layout.contentX + layout.halfButtonWidth + 6, + layout.footerTop, + layout.halfButtonWidth, + 20, + ).build(), + ).active = !state.operationInProgress + } + + addRenderableWidget( + Button.builder(CommonComponents.GUI_BACK) { onClose() } + .bounds( + layout.contentX, + layout.footerTop + 24, + layout.contentWidth, + 20, + ).build(), + ) + } + + override fun tick() { + super.tick() + if (viewModel.state.value.hashCode() != fingerprint) { + rebuildWidgets() + } + } + + override fun onClose() { + clearInputs() + viewModel.close() + minecraft!!.setScreen(parent) + } + + private fun secretBox( + value: String, + y: Int, + label: String, + changed: (String) -> Unit, + ) { + val layout = AdaptiveShareLayout.form(width, height, 2) + addRenderableWidget( + EditBox( + font, + layout.contentX, + y, + layout.contentWidth, + 20, + Component.translatable(label), + ).also { box -> + box.value = value + box.setMaxLength(256) + box.setResponder(changed) + box.setFormatter { text, _ -> + FormattedCharSequence.forward( + "•".repeat(text.length), + Style.EMPTY, + ) + } + }, + ) + } + + private fun clearInputs() { + passphraseValue = "" + confirmationValue = "" + } + + private fun chooseBackupDestination(): Path? { + val selected = TinyFileDialogs.tinyfd_saveFileDialog( + Component.translatable("connect_share.recovery.export").string, + "connect-share-friends.backup", + null, + "Connect Share backup", + ) ?: return null + return Path.of(selected) + } + + private fun chooseBackupSource(): Path? { + val selected = TinyFileDialogs.tinyfd_openFileDialog( + Component.translatable("connect_share.recovery.import").string, + null, + null, + "Connect Share backup", + false, + ) ?: return null + return Path.of(selected) + } + + private fun centered(message: Component, y: Int): StringWidget { + val textWidth = font.width(message) + return StringWidget( + width / 2 - textWidth / 2, + y, + textWidth, + 11, + message, + font, + ) + } +} + +private fun ShareUiMessage.component() = + Component.translatable(translationKey, *arguments.toTypedArray()) diff --git a/share/fabric-1.21.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_1/ShareJoinScreen.kt b/share/fabric-1.21.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_1/ShareJoinScreen.kt new file mode 100644 index 000000000..f18302189 --- /dev/null +++ b/share/fabric-1.21.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_1/ShareJoinScreen.kt @@ -0,0 +1,1501 @@ +package com.minekube.connect.share.fabric.v1_21_1 + +import com.minekube.connect.share.fabric.ConnectShareClient +import com.minekube.connect.share.fabric.FabricShareBrowser +import com.minekube.connect.share.fabric.FriendCardExchangeConsent +import com.minekube.connect.share.fabric.FriendJoinAttemptFailure +import com.minekube.connect.share.fabric.FriendPresenceMonitor +import com.minekube.connect.share.fabric.FriendActivityMonitor +import com.minekube.connect.share.fabric.GuestJoinTarget +import com.minekube.connect.share.admission.Ingress +import com.minekube.connect.share.friend.FriendControlRequest +import com.minekube.connect.share.friend.FriendJoinRequest +import com.minekube.connect.share.friend.FriendActivityKind +import com.minekube.connect.share.fabric.ui.FriendSummary +import com.minekube.connect.share.fabric.ui.AdaptiveShareLayout +import com.minekube.connect.share.fabric.ui.FriendFormDraft +import com.minekube.connect.share.fabric.ui.FriendPresenceTone +import com.minekube.connect.share.fabric.ui.FriendPrimaryAction +import com.minekube.connect.share.fabric.ui.FriendsOverview +import com.minekube.connect.share.fabric.ui.FriendsSummaryTone +import com.minekube.connect.share.fabric.ui.FriendsScreenLayout +import com.minekube.connect.share.fabric.ui.IncomingFriendRequestSummary +import com.minekube.connect.share.fabric.ui.OutgoingFriendRequestSummary +import com.minekube.connect.share.fabric.ui.FriendsViewModel +import com.minekube.connect.share.fabric.ui.ShareUiMessage +import com.minekube.connect.share.fabric.ui.uiMessage +import com.minekube.connect.share.fabric.ui.overview +import com.minekube.connect.share.fabric.ui.page +import com.minekube.connect.share.fabric.ui.presentation +import com.minekube.connect.share.fabric.ui.summary +import com.minekube.connect.share.friend.FriendPermissions +import com.minekube.connect.share.friend.FriendAccessPolicy +import com.minekube.connect.tunnel.p2p.DirectP2pAuthMode +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.cancel +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import net.minecraft.client.gui.components.Button +import net.minecraft.client.gui.components.Checkbox +import net.minecraft.client.gui.components.CycleButton +import net.minecraft.client.gui.components.EditBox +import net.minecraft.client.gui.components.MultiLineTextWidget +import net.minecraft.client.gui.components.StringWidget +import net.minecraft.client.gui.components.Tooltip +import net.minecraft.client.gui.screens.ConnectScreen +import net.minecraft.client.gui.screens.Screen +import net.minecraft.client.multiplayer.ServerData +import net.minecraft.client.multiplayer.resolver.ServerAddress +import net.minecraft.network.chat.CommonComponents +import net.minecraft.ChatFormatting +import net.minecraft.network.chat.Component +import java.util.UUID + +class ShareJoinScreen( + private val parent: Screen, + private val friends: FriendsViewModel, + private val browser: FabricShareBrowser, + private val remotePresence: FriendPresenceMonitor, + private val friendActivity: FriendActivityMonitor, +) : Screen(Component.translatable("connect_share.friends.title")) { + private var scope: CoroutineScope? = null + private var mode = Mode.FRIENDS + private var selectedPeerId: String? = null + private var nameValue = "" + private var invitationValue = "" + private var offlineSelected = false + private var internetSelected = false + private var nameBox: EditBox? = null + private var invitationBox: EditBox? = null + private var offlineMode: Checkbox? = null + private var internetDirect: Checkbox? = null + private var primaryButton: Button? = null + private var secondaryButton: Button? = null + private var safeMessage: ShareUiMessage? = null + private var fingerprint = 0 + private var joining = false + private var joiningPeerId: String? = null + private var reciprocalPairing = false + private var removeConfirmation = false + private var friendLinkState = FriendLinkState.IDLE + private var requestOperationInProgress = false + private var relationshipOffset = 0 + private val requestJobs = mutableMapOf() + private val requestStates = + mutableMapOf() + + override fun init() { + if (scope == null) { + scope = CoroutineScope( + SupervisorJob() + minecraft!!.asCoroutineDispatcher(), + ) + browser.start().onLeft { safeMessage = it.uiMessage() } + } + friends.updatePresence(browser.discovered.value) + friends.updateRemotePresence(remotePresence.state.value) + friends.updateActivities(friendActivity.state.value) + friends.updateIncoming( + ConnectShareClient.viewModel().state.value.pendingAdmissions, + ) + fingerprint = currentFingerprint() + nameBox = null + invitationBox = null + primaryButton = null + secondaryButton = null + + when (mode) { + Mode.FRIENDS -> buildFriends() + Mode.ADD -> buildAddFriend() + Mode.CONNECTION_OPTIONS -> buildConnectionOptions() + Mode.MANAGE -> buildManageFriend() + } + } + + override fun tick() { + super.tick() + friends.updatePresence(browser.discovered.value) + friends.updateRemotePresence(remotePresence.state.value) + friends.updateActivities(friendActivity.state.value) + friends.updateIncoming( + ConnectShareClient.viewModel().state.value.pendingAdmissions, + ) + val next = currentFingerprint() + if (next != fingerprint) { + rebuildWidgets() + } else { + refresh() + } + } + + override fun onClose() { + if (mode == Mode.MANAGE && removeConfirmation) { + removeConfirmation = false + rebuildWidgets() + return + } + when (mode) { + Mode.FRIENDS -> minecraft!!.setScreen(parent) + Mode.ADD, + Mode.CONNECTION_OPTIONS, + Mode.MANAGE, + -> { + val returningToAdd = mode == Mode.CONNECTION_OPTIONS + mode = if (returningToAdd) { + Mode.ADD + } else { + Mode.FRIENDS + } + if (!returningToAdd) resetFriendFormDraft() + selectedPeerId = null + safeMessage = null + rebuildWidgets() + } + } + } + + override fun removed() { + scope?.cancel() + scope = null + super.removed() + } + + private fun buildFriends() { + val layout = AdaptiveShareLayout.friends(width, height) + val state = friends.state.value + val overview = state.overview() + addRenderableWidget( + centered( + Component.translatable("connect_share.friends.title") + .withStyle(ChatFormatting.BOLD), + layout.headerY, + ), + ) + addRenderableWidget( + centered( + friendsSummary(overview), + layout.subtitleY, + ), + ) + + val relationships = buildList { + state.incomingRequests.forEach { + add(RelationshipRow.Incoming(it)) + } + state.outgoingRequests.forEach { + add(RelationshipRow.Outgoing(it)) + } + state.friends.forEach { add(RelationshipRow.Friend(it)) } + } + val page = relationships.page( + offset = relationshipOffset, + size = layout.visibleRows, + ) + relationshipOffset = page.offset + if (relationships.isEmpty()) { + addRenderableWidget( + centeredWrapped( + Component.translatable("connect_share.friends.empty"), + layout.rowsTop + 24, + layout.contentWidth, + ), + ) + } + page.items.forEachIndexed { index, relationship -> + val y = layout.rowY(index) + when (relationship) { + is RelationshipRow.Incoming -> + addIncomingRow(relationship.request, y, layout) + + is RelationshipRow.Outgoing -> + addOutgoingRow(relationship.request, y, layout) + + is RelationshipRow.Friend -> + addFriendRow(relationship.friend, y, layout) + } + } + if (page.pageCount > 1) { + val previousTooltip = Tooltip.create( + Component.translatable( + "connect_share.page.previous_tooltip", + page.pageNumber, + page.pageCount, + ), + ) + val nextTooltip = Tooltip.create( + Component.translatable( + "connect_share.page.next_tooltip", + page.pageNumber, + page.pageCount, + ), + ) + val previous = addRenderableWidget( + Button.builder(Component.literal("‹")) { + relationshipOffset = page.previousOffset ?: 0 + rebuildWidgets() + }.bounds(layout.contentX, layout.headerY, 24, 20) + .createNarration { + Component.translatable("connect_share.page.previous") + } + .tooltip(previousTooltip) + .build(), + ) + previous.active = page.hasPrevious + val next = addRenderableWidget( + Button.builder(Component.literal("›")) { + relationshipOffset = page.nextOffset ?: page.offset + rebuildWidgets() + }.bounds( + layout.contentX + layout.contentWidth - 24, + layout.headerY, + 24, + 20, + ) + .createNarration { + Component.translatable("connect_share.page.next") + } + .tooltip(nextTooltip) + .build(), + ) + next.active = page.hasNext + } + + safeMessage().let { message -> + if (message != null) { + addRenderableWidget( + centeredWrapped( + message.component() + .withStyle(ChatFormatting.YELLOW), + layout.messageY, + layout.contentWidth, + ), + ) + } + } + primaryButton = addRenderableWidget( + Button.builder( + Component.translatable(friendLinkState.translationKey), + ) { + copyMyFriendLink() + }.bounds( + layout.contentX, + layout.footerTop, + layout.halfButtonWidth, + 20, + ) + .tooltip( + Tooltip.create( + Component.translatable( + "connect_share.friends.copy_my_link.tooltip", + ), + ), + ) + .build(), + ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.friends.add"), + ) { + resetFriendFormDraft() + mode = Mode.ADD + safeMessage = null + rebuildWidgets() + }.bounds( + layout.contentX + layout.halfButtonWidth + 6, + layout.footerTop, + layout.halfButtonWidth, + 20, + ).build(), + ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.privacy.title"), + ) { + minecraft!!.setScreen(SharePrivacyScreen(this)) + }.bounds( + layout.contentX, + layout.footerTop + 24, + layout.halfButtonWidth, + 20, + ) + .build(), + ) + addRenderableWidget( + Button.builder(CommonComponents.GUI_BACK) { onClose() } + .bounds( + layout.contentX + layout.halfButtonWidth + 6, + layout.footerTop + 24, + layout.halfButtonWidth, + 20, + ) + .build(), + ) + } + + private fun addIncomingRow( + request: IncomingFriendRequestSummary, + y: Int, + layout: FriendsScreenLayout, + ) { + val buttonWidth = 58 + val textWidth = layout.contentWidth - buttonWidth * 2 - 12 + addRenderableWidget( + StringWidget( + layout.contentX, + y, + textWidth, + 20, + Component.translatable( + if (request.purpose == com.minekube.connect.share.admission.AdmissionPurpose.FRIEND) { + "connect_share.friends.incoming_request" + } else { + "connect_share.friends.incoming_join_request" + }, + request.displayName, + friendlyIngress(request.ingress), + ), + font, + ), + ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.status.allow"), + ) { + ConnectShareClient.viewModel().allow(request.requestId) + }.bounds( + layout.contentX + layout.contentWidth - buttonWidth * 2 - 4, + y + 2, + buttonWidth, + 20, + ).build(), + ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.status.deny"), + ) { + ConnectShareClient.viewModel().deny(request.requestId) + }.bounds( + layout.contentX + layout.contentWidth - buttonWidth, + y + 2, + buttonWidth, + 20, + ).build(), + ) + } + + private fun addOutgoingRow( + request: OutgoingFriendRequestSummary, + y: Int, + layout: FriendsScreenLayout, + ) { + val deliveryState = requestStates[request.peerId] + val buttonWidth = 58 + val textWidth = layout.contentWidth - buttonWidth * 2 - 12 + addRenderableWidget( + StringWidget( + layout.contentX, + y, + textWidth, + 20, + outgoingRequestLabel(request.displayName, deliveryState), + font, + ), + ) + addRenderableWidget( + Button.builder( + Component.translatable( + deliveryState?.translationKey + ?: "connect_share.friends.retry_request", + ), + ) { + deliverOutgoing(request.peerId) + }.bounds( + layout.contentX + layout.contentWidth - buttonWidth * 2 - 4, + y + 2, + buttonWidth, + 20, + ).build().apply { + active = deliveryState == null || + deliveryState == RequestDeliveryState.FAILED + }, + ) + addRenderableWidget( + Button.builder( + Component.translatable( + "connect_share.friends.cancel_request", + ), + ) { + cancelOutgoing(request.peerId) + }.bounds( + layout.contentX + layout.contentWidth - buttonWidth, + y + 2, + buttonWidth, + 20, + ).build(), + ) + } + + private fun addFriendRow( + friend: FriendSummary, + y: Int, + layout: FriendsScreenLayout, + ) { + val actionWidth = 104 + val manageWidth = 28 + val textWidth = layout.contentWidth - actionWidth - manageWidth - 12 + val presentation = friend.presentation() + addRenderableWidget( + StringWidget( + layout.contentX, + y, + textWidth, + 11, + Component.literal(friend.displayName) + .withStyle(ChatFormatting.WHITE), + font, + ), + ) + addRenderableWidget( + StringWidget( + layout.contentX, + y + 11, + textWidth, + 11, + friendStatus(friend).copy() + .withStyle(presentation.tone.color()), + font, + ), + ) + addRenderableWidget( + Button.builder( + Component.translatable(presentation.action.translationKey), + ) { + when (presentation.action) { + FriendPrimaryAction.ASK_TO_JOIN -> + requestToJoin(friend.peerId) + FriendPrimaryAction.JOIN_NOW -> joinSaved(friend.peerId) + FriendPrimaryAction.CANCEL_FOLLOW -> + friends.cancelFollow(friend.peerId) + FriendPrimaryAction.JOIN_WHEN_READY -> + friends.follow(friend.peerId) + } + rebuildWidgets() + }.bounds( + layout.contentX + layout.contentWidth - actionWidth - manageWidth - 4, + y + 2, + actionWidth, + 20, + ).build(), + ) + addRenderableWidget( + Button.builder( + Component.literal("…"), + ) { + selectedPeerId = friend.peerId + applyFriendFormDraft( + FriendFormDraft.forManage(friend.displayName), + ) + mode = Mode.MANAGE + safeMessage = null + rebuildWidgets() + }.bounds( + layout.contentX + layout.contentWidth - manageWidth, + y + 2, + manageWidth, + 20, + ).tooltip( + Tooltip.create( + Component.translatable( + "connect_share.friends.manage_named", + friend.displayName, + ), + ), + ).createNarration { + Component.translatable( + "connect_share.friends.manage_named", + friend.displayName, + ) + }.build(), + ) + } + + private fun buildAddFriend() { + val layout = AdaptiveShareLayout.form(width, height, 4) + addRenderableWidget( + centered( + Component.translatable("connect_share.friends.add") + .withStyle(ChatFormatting.BOLD), + layout.headerY, + ), + ) + addRenderableWidget( + centeredWrapped( + Component.translatable("connect_share.friends.add_description"), + layout.subtitleY, + layout.contentWidth, + ), + ) + addRenderableWidget( + StringWidget( + layout.contentX, + layout.bodyTop, + layout.contentWidth, + 11, + Component.translatable("connect_share.friends.link"), + font, + ), + ) + invitationBox = addRenderableWidget( + EditBox( + font, + layout.contentX, + layout.bodyTop + 12, + layout.contentWidth, + 20, + Component.translatable("connect_share.join.invitation"), + ).apply { + setMaxLength(MAX_INVITATION_LENGTH) + setHint(Component.translatable("connect_share.join.invitation_hint")) + setValue(invitationValue) + setResponder { + invitationValue = it + friends.suggestedDisplayName(it).getOrNull() + ?.let { suggested -> + nameValue = suggested + nameBox?.setValue(suggested) + } + refresh() + } + }, + ) + addRenderableWidget( + StringWidget( + layout.contentX, + layout.bodyTop + 38, + layout.contentWidth, + 11, + Component.translatable("connect_share.friends.name"), + font, + ), + ) + nameBox = addRenderableWidget( + EditBox( + font, + layout.contentX, + layout.bodyTop + 50, + layout.contentWidth, + 20, + Component.translatable("connect_share.friends.name"), + ).apply { + setMaxLength(64) + setHint(Component.translatable("connect_share.friends.name_hint")) + setValue(nameValue) + setResponder { + nameValue = it + refresh() + } + }, + ) + addRenderableWidget( + Button.builder( + Component.translatable( + "connect_share.friends.connection_options.show", + ), + ) { + mode = Mode.CONNECTION_OPTIONS + rebuildWidgets() + }.bounds( + layout.contentX, + layout.bodyTop + 76, + layout.contentWidth, + 20, + ).build(), + ) + safeMessage().let { message -> + if (message != null) { + addRenderableWidget( + centeredWrapped( + message.component().withStyle(ChatFormatting.YELLOW), + layout.footerTop - 16, + layout.contentWidth, + ), + ) + } + } + primaryButton = addRenderableWidget( + Button.builder( + Component.translatable( + "connect_share.friends.send_request", + ), + ) { + createFriendRequest() + }.bounds( + layout.contentX, + layout.footerTop, + layout.contentWidth, + 20, + ).build(), + ) + secondaryButton = addRenderableWidget( + Button.builder( + Component.translatable("connect_share.friends.join_once"), + ) { + joinInvitation() + }.bounds( + layout.contentX, + layout.footerTop + 24, + layout.halfButtonWidth, + 20, + ).build(), + ) + addRenderableWidget( + Button.builder(CommonComponents.GUI_BACK) { onClose() } + .bounds( + layout.contentX + layout.halfButtonWidth + 6, + layout.footerTop + 24, + layout.halfButtonWidth, + 20, + ) + .build(), + ) + refresh() + } + + private fun buildConnectionOptions() { + val layout = AdaptiveShareLayout.form(width, height, 2) + addRenderableWidget( + centered( + Component.translatable( + "connect_share.friends.connection_options.title", + ).withStyle(ChatFormatting.BOLD), + layout.headerY, + ), + ) + addRenderableWidget( + centeredWrapped( + Component.translatable( + "connect_share.friends.connection_options.description", + ), + layout.subtitleY, + layout.contentWidth, + ), + ) + offlineMode = addRenderableWidget( + Checkbox.builder( + Component.translatable("connect_share.join.offline"), + font, + ).pos(layout.contentX, layout.bodyTop) + .selected(offlineSelected) + .onValueChange { _, selected -> offlineSelected = selected } + .tooltip( + Tooltip.create( + Component.translatable( + "connect_share.join.offline.tooltip", + ), + ), + ).build(), + ) + internetDirect = addRenderableWidget( + Checkbox.builder( + Component.translatable("connect_share.join.internet"), + font, + ).pos(layout.contentX, layout.bodyTop + 28) + .selected(internetSelected) + .onValueChange { _, selected -> internetSelected = selected } + .tooltip( + Tooltip.create( + Component.translatable( + "connect_share.join.internet.tooltip", + ), + ), + ).build(), + ) + addRenderableWidget( + centeredWrapped( + Component.translatable( + "connect_share.friends.connection_options.fallback", + ).withStyle(ChatFormatting.GRAY), + layout.bodyTop + 64, + layout.contentWidth, + ), + ) + addRenderableWidget( + Button.builder(CommonComponents.GUI_DONE) { onClose() } + .bounds( + layout.contentX, + layout.footerTop + 24, + layout.contentWidth, + 20, + ).build(), + ) + } + + private fun buildManageFriend() { + val friend = selectedFriend() + if (friend == null) { + mode = Mode.FRIENDS + rebuildWidgets() + return + } + if (removeConfirmation) { + buildRemoveFriendConfirmation(friend) + return + } + val layout = AdaptiveShareLayout.form(width, height, 5) + val form = AdaptiveShareLayout.manageFriendForm(layout.bodyTop) + var internetDirectSelected = friend.internetDirectGuestOptIn + addRenderableWidget( + centered( + Component.translatable( + "connect_share.friends.manage_title", + friend.displayName, + ).withStyle(ChatFormatting.BOLD), + layout.headerY, + ), + ) + addRenderableWidget( + centeredWrapped( + safeMessage()?.let { + it.component().withStyle(ChatFormatting.YELLOW) + } ?: friendStatus(friend).copy().withStyle( + friend.presentation().tone.color(), + ), + layout.subtitleY, + layout.contentWidth, + ), + ) + addRenderableWidget( + StringWidget( + layout.contentX, + form.nameLabelY, + layout.contentWidth, + 11, + Component.translatable("connect_share.friends.name"), + font, + ), + ) + nameBox = addRenderableWidget( + EditBox( + font, + layout.contentX, + form.nameInputY, + layout.contentWidth, + 20, + Component.translatable("connect_share.friends.name"), + ).apply { + setMaxLength(64) + setValue(nameValue.ifBlank { friend.displayName }) + setResponder { + nameValue = it + refresh() + } + }, + ) + val notify = addRenderableWidget( + Checkbox.builder( + Component.translatable("connect_share.friends.notify"), + font, + ).pos(layout.contentX, form.notifyY) + .selected(friend.permissions.notifyWhenOnline) + .build(), + ) + var accessPolicy = friend.permissions.accessPolicy + addRenderableWidget( + CycleButton.builder( + { policy: FriendAccessPolicy -> + Component.translatable( + "connect_share.friends.access.${policy.name.lowercase()}", + ) + }, + ).withInitialValue(accessPolicy) + .withValues(FriendAccessPolicy.entries) + .create( + layout.contentX, + form.accessPolicyY, + layout.contentWidth, + 20, + Component.translatable("connect_share.friends.access"), + ) { _, selected -> accessPolicy = selected }, + ) + val shareWorlds = addRenderableWidget( + Checkbox.builder( + Component.translatable("connect_share.friends.share_worlds"), + font, + ).pos(layout.contentX, form.shareWorldsY) + .selected(friend.permissions.canSeeMyWorlds) + .build(), + ) + addRenderableWidget( + CycleButton.onOffBuilder(friend.internetDirectGuestOptIn) + .create( + layout.contentX, + form.internetDirectY, + layout.contentWidth, + 20, + Component.translatable( + "connect_share.friends.internet_direct_short", + ), + ) { _, selected -> internetDirectSelected = selected } + .apply { + setTooltip( + Tooltip.create( + Component.translatable( + "connect_share.friends.internet_direct.tooltip", + ), + ), + ) + }, + ) + primaryButton = addRenderableWidget( + Button.builder( + Component.translatable("connect_share.friends.save_changes"), + ) { + val activeScope = scope ?: return@builder + requestOperationInProgress = true + refresh() + activeScope.launch { + withContext(Dispatchers.IO) { + friends.rename(friend.peerId, nameValue) + friends.updateInternetDirectGuestOptIn( + friend.peerId, + internetDirectSelected, + ) + friends.updatePermissions( + friend.peerId, + FriendPermissions( + notifyWhenOnline = notify.selected(), + canSeeMyWorlds = shareWorlds.selected(), + accessPolicy = accessPolicy, + ), + ) + } + requestOperationInProgress = false + mode = Mode.FRIENDS + selectedPeerId = null + resetFriendFormDraft() + rebuildWidgets() + } + }.bounds( + layout.contentX, + layout.footerTop, + layout.halfButtonWidth, + 20, + ).build(), + ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.friends.remove"), + ) { + removeConfirmation = true + rebuildWidgets() + }.bounds( + layout.contentX + layout.halfButtonWidth + 6, + layout.footerTop, + layout.halfButtonWidth, + 20, + ).build(), + ) + addRenderableWidget( + Button.builder(CommonComponents.GUI_BACK) { onClose() } + .bounds( + layout.contentX, + layout.footerTop + 24, + layout.contentWidth, + 20, + ) + .build(), + ) + refresh() + } + + private fun buildRemoveFriendConfirmation(friend: FriendSummary) { + val layout = AdaptiveShareLayout.form(width, height, 1) + addRenderableWidget( + centered( + Component.translatable( + "connect_share.friends.remove_confirm.title", + friend.displayName, + ).withStyle(ChatFormatting.BOLD), + layout.headerY + 12, + ), + ) + addRenderableWidget( + centeredWrapped( + Component.translatable( + "connect_share.friends.remove_confirm.message", + ), + layout.bodyTop, + layout.contentWidth, + ), + ) + addRenderableWidget( + Button.builder( + Component.translatable( + "connect_share.friends.remove_confirm.confirm", + ), + ) { + val activeScope = scope ?: return@builder + requestOperationInProgress = true + refresh() + activeScope.launch { + withContext(Dispatchers.IO) { + friends.remove(friend.peerId) + } + requestOperationInProgress = false + removeConfirmation = false + mode = Mode.FRIENDS + selectedPeerId = null + resetFriendFormDraft() + rebuildWidgets() + } + }.bounds( + layout.contentX, + layout.footerTop + 24, + (layout.contentWidth - 12) / 3, + 20, + ).build(), + ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.friends.block"), + ) { + val activeScope = scope ?: return@builder + requestOperationInProgress = true + refresh() + activeScope.launch { + withContext(Dispatchers.IO) { + friends.block(friend.peerId) + } + requestOperationInProgress = false + removeConfirmation = false + mode = Mode.FRIENDS + selectedPeerId = null + resetFriendFormDraft() + rebuildWidgets() + } + }.bounds( + layout.contentX + (layout.contentWidth - 12) / 3 + 6, + layout.footerTop + 24, + (layout.contentWidth - 12) / 3, + 20, + ).build(), + ) + addRenderableWidget( + Button.builder(CommonComponents.GUI_CANCEL) { + removeConfirmation = false + rebuildWidgets() + }.bounds( + layout.contentX + 2 * ((layout.contentWidth - 12) / 3 + 6), + layout.footerTop + 24, + (layout.contentWidth - 12) / 3, + 20, + ).build(), + ) + } + + private fun copyMyFriendLink() { + val activeScope = scope ?: return + if (friendLinkState == FriendLinkState.COPYING) { + return + } + friendLinkState = FriendLinkState.COPYING + rebuildWidgets() + activeScope.launch { + val invitation = withContext(Dispatchers.IO) { + ConnectShareClient.friendCardIssuer().issue() + } + invitation.fold( + ifLeft = { + friendLinkState = FriendLinkState.FAILED + }, + ifRight = { link -> + minecraft!!.keyboardHandler.setClipboard(link) + friendLinkState = FriendLinkState.COPIED + }, + ) + rebuildWidgets() + } + } + + private fun joinSaved(peerId: String) { + if (joining) return + joining = true + joiningPeerId = peerId + reciprocalPairing = true + safeMessage = null + refresh() + scope?.launch { + friends.join( + peerId = peerId, + browser = browser, + authMode = authMode(), + ownConnectAddress = + ConnectShareClient.connectPublicAddress(), + ).fold( + ifLeft = ::joinFailed, + ifRight = ::connect, + ) + } + } + + private fun requestToJoin( + peerId: String, + allowModMismatch: Boolean = false, + ) { + if (joining) return + joining = true + joiningPeerId = peerId + reciprocalPairing = false + safeMessage = null + refresh() + scope?.launch { + ConnectShareClient.friendJoinOrchestrator().request( + peerId, + FriendJoinRequest( + requestId = UUID.randomUUID(), + playerName = minecraft!!.user.name, + playerUuid = minecraft!!.user.profileId, + ), + allowModMismatch = allowModMismatch, + ).fold( + ifLeft = { failure -> + joining = false + if (failure is FriendJoinAttemptFailure.Compatibility) { + minecraft!!.setScreen( + CompatibilityMismatchScreen( + parent = this@ShareJoinScreen, + failure = failure, + tryAnyway = { + requestToJoin(peerId, true) + }, + ), + ) + } else { + safeMessage = failure.uiMessage() + rebuildWidgets() + } + }, + ifRight = ::connect, + ) + } + } + + private fun createFriendRequest() { + val activeScope = scope ?: return + if ( + requestOperationInProgress || + invitationValue.isBlank() || + nameValue.isBlank() + ) { + return + } + requestOperationInProgress = true + safeMessage = null + refresh() + activeScope.launch { + val peerId = withContext(Dispatchers.IO) { + friends.sendRequest( + invitationValue, + nameValue, + internetDirectGuestOptIn = internetSelected, + ) + } + requestOperationInProgress = false + if (peerId == null) { + rebuildWidgets() + return@launch + } + mode = Mode.FRIENDS + resetFriendFormDraft() + rebuildWidgets() + deliverOutgoing(peerId) + } + } + + private fun deliverOutgoing(peerId: String) { + val activeScope = scope ?: return + if (requestJobs[peerId]?.isActive == true) { + return + } + safeMessage = null + requestStates[peerId] = RequestDeliveryState.SENDING + rebuildWidgets() + val job = activeScope.launch { + val senderCard = withContext(Dispatchers.IO) { + ConnectShareClient.friendCardIssuer().issue().getOrNull() + } + if (senderCard == null) { + requestFailed( + peerId, + ShareUiMessage("connect_share.friends.request_failed"), + ) + return@launch + } + val targetResult = friends.routeOutgoing( + peerId = peerId, + browser = browser, + authMode = DirectP2pAuthMode.OFFLINE, + ) + val target = targetResult.getOrNull() + if (target == null) { + requestFailed( + peerId, + targetResult.leftOrNull()?.uiMessage() + ?: ShareUiMessage( + "connect_share.friends.request_failed", + ), + ) + return@launch + } + val displayName = friends.state.value.outgoingRequests + .firstOrNull { it.peerId == peerId } + ?.displayName + ?: peerId + val result = ConnectShareClient.friendRequestClient().exchange( + target = target, + request = FriendControlRequest( + requestId = UUID.randomUUID(), + relationshipId = friends.state.value.outgoingRequests + .firstOrNull { it.peerId == peerId } + ?.relationshipId + ?: run { + target.close() + requestFailed( + peerId, + ShareUiMessage( + "connect_share.friends.request_failed", + ), + ) + return@launch + }, + displayName = minecraft!!.user.name, + invitation = senderCard, + ), + onReceived = { + minecraft!!.execute { + requestStates[peerId] = + RequestDeliveryState.WAITING + rebuildWidgets() + } + }, + ) + val hostCard = result.getOrNull() + if (hostCard == null) { + requestFailed( + peerId, + result.leftOrNull()?.uiMessage() + ?: ShareUiMessage( + "connect_share.friends.request_failed", + ), + ) + return@launch + } + val accepted = withContext(Dispatchers.IO) { + ConnectShareClient.friendCardReceiver().receive( + invitation = hostCard, + displayName = displayName, + authenticatedMinecraftUuid = null, + relationshipId = friends.state.value.outgoingRequests + .firstOrNull { it.peerId == peerId } + ?.relationshipId, + ) + } + if (accepted.isLeft()) { + requestFailed( + peerId, + ShareUiMessage("connect_share.friends.request_failed"), + ) + return@launch + } + requestStates.remove(peerId) + friends.reload() + safeMessage = ShareUiMessage( + "connect_share.friends.request_accepted", + listOf(displayName), + ) + rebuildWidgets() + } + requestJobs[peerId] = job + job.invokeOnCompletion { + minecraft!!.execute { + requestJobs.remove(peerId, job) + } + } + } + + private fun cancelOutgoing(peerId: String) { + val activeScope = scope ?: return + requestJobs.remove(peerId)?.cancel() + requestStates[peerId] = RequestDeliveryState.CANCELLING + rebuildWidgets() + activeScope.launch { + withContext(Dispatchers.IO) { + friends.remove(peerId) + } + requestStates.remove(peerId) + rebuildWidgets() + } + } + + private fun requestFailed( + peerId: String, + message: ShareUiMessage, + ) { + requestStates[peerId] = RequestDeliveryState.FAILED + safeMessage = message + rebuildWidgets() + } + + private fun joinInvitation() { + if (joining || invitationValue.isBlank()) return + joining = true + joiningPeerId = null + reciprocalPairing = false + safeMessage = null + refresh() + scope?.launch { + browser.join( + invitationUri = invitationValue, + lanAddress = null, + internetOptIn = internetSelected, + authMode = authMode(), + ).fold( + ifLeft = ::joinFailed, + ifRight = ::connect, + ) + } + } + + private fun joinFailed(failure: com.minekube.connect.share.fabric.GuestJoinFailure) { + joining = false + joiningPeerId = null + reciprocalPairing = false + safeMessage = failure.uiMessage() + rebuildWidgets() + } + + private fun connect(target: GuestJoinTarget) { + val address = when (target) { + is GuestJoinTarget.Connect -> + ServerAddress.parseString(target.publicAddress) + + is GuestJoinTarget.Direct -> + ServerAddress( + target.localAddress.hostString, + target.localAddress.port, + ) + } + if (target is GuestJoinTarget.Direct) { + ConnectShareClient.holdGuestDirect(target) + } + val state = friends.state.value + val joiningFriend = state.friends.firstOrNull { + it.peerId == joiningPeerId + } + val data = ServerData( + joiningFriend?.displayName + ?: "Connect Share", + address.toString(), + ServerData.Type.OTHER, + ) + val exchangeFriendCard = FriendCardExchangeConsent.shouldArm( + savedFriendJoin = reciprocalPairing, + canSeeMyWorlds = + joiningFriend?.permissions?.canSeeMyWorlds == true, + ) + if (exchangeFriendCard && joiningPeerId != null) { + ConnectShareClient.armFriendCardExchange( + checkNotNull(joiningPeerId), + ) + } + ConnectScreen.startConnecting( + parent, + checkNotNull(minecraft), + address, + data, + false, + null, + ) + } + + private fun refresh() { + val inputReady = invitationValue.isNotBlank() + primaryButton?.active = + !joining && !requestOperationInProgress && + friendLinkState != FriendLinkState.COPYING && + when (mode) { + Mode.ADD -> inputReady && nameValue.isNotBlank() + Mode.CONNECTION_OPTIONS -> true + Mode.MANAGE -> nameValue.isNotBlank() + Mode.FRIENDS -> true + } + secondaryButton?.active = !joining && inputReady + invitationBox?.setEditable(!joining) + nameBox?.setEditable(!joining) + } + + private fun resetFriendFormDraft() { + applyFriendFormDraft(currentFriendFormDraft().newRequest()) + } + + private fun currentFriendFormDraft(): FriendFormDraft = + FriendFormDraft( + displayName = nameValue, + invitation = invitationValue, + offlineMode = offlineSelected, + internetDirect = internetSelected, + ) + + private fun applyFriendFormDraft(draft: FriendFormDraft) { + nameValue = draft.displayName + invitationValue = draft.invitation + offlineSelected = draft.offlineMode + internetSelected = draft.internetDirect + } + + private fun friendsSummary(overview: FriendsOverview): Component = + overview.summary().let { presentation -> + Component.translatable( + presentation.translationKey, + *listOfNotNull(presentation.count).toTypedArray(), + ).withStyle( + when (presentation.tone) { + FriendsSummaryTone.ATTENTION -> ChatFormatting.YELLOW + FriendsSummaryTone.READY -> ChatFormatting.GREEN + FriendsSummaryTone.ONLINE -> ChatFormatting.AQUA + FriendsSummaryTone.MUTED -> ChatFormatting.GRAY + }, + ) + } + + private fun friendStatus(friend: FriendSummary): Component = + friend.presentation().let { presentation -> + Component.translatable( + presentation.statusKey, + *presentation.statusArguments.toTypedArray(), + ) + } + + private fun FriendPresenceTone.color(): ChatFormatting = when (this) { + FriendPresenceTone.JOINABLE -> ChatFormatting.GREEN + FriendPresenceTone.ONLINE -> ChatFormatting.AQUA + FriendPresenceTone.SAVED -> ChatFormatting.GRAY + FriendPresenceTone.OFFLINE -> ChatFormatting.DARK_GRAY + } + + private fun friendlyIngress(ingress: Ingress): Component = + Component.translatable( + when (ingress) { + Ingress.CONNECT -> "connect_share.ingress.online" + Ingress.DIRECT_LAN -> "connect_share.ingress.nearby" + Ingress.DIRECT_INTERNET -> "connect_share.ingress.direct" + }, + ) + + private fun outgoingRequestLabel( + displayName: String, + deliveryState: RequestDeliveryState?, + ): Component = Component.translatable( + if (deliveryState == null) { + "connect_share.friends.outgoing_request" + } else { + "connect_share.friends.outgoing_request_active" + }, + displayName, + ) + + private fun selectedFriend(): FriendSummary? = + friends.state.value.friends.firstOrNull { + it.peerId == selectedPeerId + } + + private fun safeMessage(): ShareUiMessage? = + safeMessage ?: friends.state.value.safeMessage + + private fun ShareUiMessage.component() = + Component.translatable(translationKey, *arguments.toTypedArray()) + + private fun authMode(): DirectP2pAuthMode = + if (offlineSelected) { + DirectP2pAuthMode.OFFLINE + } else { + DirectP2pAuthMode.ONLINE + } + + private fun currentFingerprint(): Int = + 31 * browser.discovered.value.hashCode() + + 31 * friends.state.value.hashCode() + + mode.hashCode() + + private fun centered(message: Component, y: Int): StringWidget { + val textWidth = font.width(message) + return StringWidget( + width / 2 - textWidth / 2, + y, + textWidth, + 9, + message, + font, + ) + } + + private fun centeredWrapped( + message: Component, + y: Int, + contentWidth: Int = CONTENT_WIDTH, + ): MultiLineTextWidget = + MultiLineTextWidget( + width / 2 - contentWidth / 2, + y, + message, + font, + ).setMaxWidth(contentWidth).setCentered(true) + + private enum class Mode { + FRIENDS, + ADD, + CONNECTION_OPTIONS, + MANAGE, + } + + private sealed interface RelationshipRow { + data class Incoming( + val request: IncomingFriendRequestSummary, + ) : RelationshipRow + + data class Outgoing( + val request: OutgoingFriendRequestSummary, + ) : RelationshipRow + + data class Friend( + val friend: FriendSummary, + ) : RelationshipRow + } + + private enum class FriendLinkState( + val translationKey: String, + ) { + IDLE("connect_share.friends.invite"), + COPYING("connect_share.friends.copying_my_link"), + COPIED("connect_share.friends.my_link_copied"), + FAILED("connect_share.friends.copy_my_link_failed"), + } + + private enum class RequestDeliveryState( + val translationKey: String, + ) { + SENDING("connect_share.friends.request_sending"), + WAITING("connect_share.friends.request_waiting"), + CANCELLING("connect_share.friends.request_cancelling"), + FAILED("connect_share.friends.retry_request"), + } + + private companion object { + const val MAX_INVITATION_LENGTH = 32_768 + const val MAX_VISIBLE_RELATIONSHIPS = 5 + const val CONTENT_WIDTH = 310 + } +} diff --git a/share/fabric-1.21.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_1/SharePrivacyScreen.kt b/share/fabric-1.21.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_1/SharePrivacyScreen.kt new file mode 100644 index 000000000..29264408b --- /dev/null +++ b/share/fabric-1.21.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_1/SharePrivacyScreen.kt @@ -0,0 +1,174 @@ +package com.minekube.connect.share.fabric.v1_21_1 + +import com.minekube.connect.share.fabric.ConnectShareClient +import com.minekube.connect.share.fabric.ui.AdaptiveShareLayout +import net.minecraft.ChatFormatting +import net.minecraft.client.gui.components.Button +import net.minecraft.client.gui.components.Checkbox +import net.minecraft.client.gui.components.MultiLineTextWidget +import net.minecraft.client.gui.components.StringWidget +import net.minecraft.client.gui.screens.Screen +import net.minecraft.network.chat.CommonComponents +import net.minecraft.network.chat.Component + +class SharePrivacyScreen( + private val parent: Screen, +) : Screen(Component.translatable("connect_share.privacy.title")) { + private val viewModel = ConnectShareClient.viewModel() + private var diagnosticsCopied = false + + override fun init() { + val layout = AdaptiveShareLayout.form(width, height, 4) + addRenderableWidget( + centered(title.copy().withStyle(ChatFormatting.BOLD), layout.headerY), + ) + addRenderableWidget( + MultiLineTextWidget( + layout.contentX, + layout.subtitleY, + Component.translatable("connect_share.privacy.description"), + font, + ).setMaxWidth(layout.contentWidth).setCentered(true), + ) + + val privacy = viewModel.state.value.presencePrivacy + privacyToggle("online", layout.bodyTop, privacy.showOnline) { value -> + viewModel.setPresencePrivacy( + viewModel.state.value.presencePrivacy.copy(showOnline = value), + ) + } + privacyToggle( + "playing", + layout.bodyTop + 24, + privacy.showPlaying, + ) { value -> + viewModel.setPresencePrivacy( + viewModel.state.value.presencePrivacy.copy(showPlaying = value), + ) + } + privacyToggle( + "current_server", + layout.bodyTop + 48, + privacy.showCurrentServer, + ) { value -> + viewModel.setPresencePrivacy( + viewModel.state.value.presencePrivacy.copy( + showCurrentServer = value, + ), + ) + } + privacyToggle( + "joinable", + layout.bodyTop + 72, + privacy.showJoinable, + ) { value -> + viewModel.setPresencePrivacy( + viewModel.state.value.presencePrivacy.copy(showJoinable = value), + ) + } + addRenderableWidget( + MultiLineTextWidget( + layout.contentX, + layout.bodyTop + 104, + Component.translatable("connect_share.privacy.confirmed_only") + .withStyle(ChatFormatting.GRAY), + font, + ).setMaxWidth(layout.contentWidth).setCentered(true), + ) + + val third = (layout.contentWidth - 12) / 3 + addRenderableWidget( + Button.builder( + Component.translatable( + if (diagnosticsCopied) { + "connect_share.diagnostics.copied" + } else { + "connect_share.diagnostics.copy" + }, + ), + ) { + minecraft!!.keyboardHandler.setClipboard( + ConnectShareClient.diagnosticBundle(), + ) + diagnosticsCopied = true + rebuildWidgets() + }.bounds( + layout.contentX, + layout.footerTop, + third, + 20, + ).build(), + ) + addRenderableWidget( + Button.builder( + Component.translatable( + "connect_share.privacy.blocked", + ConnectShareClient.friendsViewModel() + .state.value.blocked.size, + ), + ) { + minecraft!!.setScreen(BlockedFriendsScreen(this)) + }.bounds( + layout.contentX + third + 6, + layout.footerTop, + third, + 20, + ).build(), + ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.recovery.menu"), + ) { + minecraft!!.setScreen(RecoveryScreen(this)) + }.bounds( + layout.contentX + (third + 6) * 2, + layout.footerTop, + third, + 20, + ).build(), + ) + addRenderableWidget( + Button.builder(CommonComponents.GUI_DONE) { onClose() } + .bounds( + layout.contentX, + layout.footerTop + 24, + layout.contentWidth, + 20, + ).build(), + ) + } + + override fun onClose() { + minecraft!!.setScreen(parent) + } + + private fun privacyToggle( + key: String, + y: Int, + selected: Boolean, + changed: (Boolean) -> Unit, + ) { + val layout = AdaptiveShareLayout.form(width, height, 4) + addRenderableWidget( + Checkbox.builder( + Component.translatable("connect_share.privacy.$key"), + font, + ).pos(layout.contentX, y) + .selected(selected) + .onValueChange { _, value -> changed(value) } + .build(), + ) + } + + private fun centered(message: Component, y: Int): StringWidget { + val textWidth = font.width(message) + return StringWidget( + width / 2 - textWidth / 2, + y, + textWidth, + 11, + message, + font, + ) + } +} diff --git a/share/fabric-1.21.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_1/ShareSetupScreen.kt b/share/fabric-1.21.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_1/ShareSetupScreen.kt new file mode 100644 index 000000000..a75ef3837 --- /dev/null +++ b/share/fabric-1.21.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_1/ShareSetupScreen.kt @@ -0,0 +1,224 @@ +package com.minekube.connect.share.fabric.v1_21_1 + +import com.minekube.connect.share.ShareGameMode +import com.minekube.connect.share.fabric.ConnectShareClient +import com.minekube.connect.share.fabric.ui.AdaptiveShareLayout +import com.minekube.connect.share.fabric.ui.FormScreenLayout +import com.minekube.connect.share.fabric.ui.ShareUiState +import net.minecraft.ChatFormatting +import net.minecraft.client.gui.components.Button +import net.minecraft.client.gui.components.Checkbox +import net.minecraft.client.gui.components.CycleButton +import net.minecraft.client.gui.components.MultiLineTextWidget +import net.minecraft.client.gui.components.StringWidget +import net.minecraft.client.gui.components.Tooltip +import net.minecraft.client.gui.screens.Screen +import net.minecraft.network.chat.CommonComponents +import net.minecraft.network.chat.Component + +class ShareSetupScreen( + private val parent: Screen, +) : Screen(Component.translatable("connect_share.setup.title")) { + private val viewModel = ConnectShareClient.viewModel() + private var startButton: Button? = null + private var defaultsLoaded = false + private var optionsExpanded = false + + override fun init() { + if (!defaultsLoaded) { + minecraft!!.singleplayerServer?.let { server -> + viewModel.setGameMode(server.defaultGameType.toShareGameMode()) + viewModel.setAllowCheats(server.worldData.isAllowCommands) + } + defaultsLoaded = true + } + val current = viewModel.state.value + val layout = AdaptiveShareLayout.form(width, height, 4) + + addRenderableWidget( + centered(title.copy().withStyle(ChatFormatting.BOLD), layout.headerY), + ) + addRenderableWidget( + MultiLineTextWidget( + layout.contentX, + layout.subtitleY, + Component.translatable("connect_share.setup.description"), + font, + ).setMaxWidth(layout.contentWidth).setCentered(true), + ) + addRenderableWidget( + centered( + Component.translatable("connect_share.setup.friends_only") + .withStyle(ChatFormatting.GREEN), + layout.bodyTop, + ), + ) + + if (optionsExpanded) { + addOptions(layout, current) + } else { + addRenderableWidget( + MultiLineTextWidget( + layout.contentX, + layout.bodyTop + 28, + Component.translatable("connect_share.setup.persistence") + .withStyle(ChatFormatting.GRAY), + font, + ).setMaxWidth(layout.contentWidth).setCentered(true), + ) + } + + startButton = addRenderableWidget( + Button.builder( + Component.translatable("connect_share.setup.start"), + ) { + viewModel.start() + minecraft!!.setScreen(ShareStatusScreen(parent)) + }.bounds( + layout.contentX, + layout.footerTop, + layout.contentWidth, + 20, + ).build(), + ) + + val third = (layout.contentWidth - 12) / 3 + addRenderableWidget( + Button.builder( + Component.translatable( + if (optionsExpanded) { + "connect_share.setup.options.hide" + } else { + "connect_share.setup.options.show" + }, + ), + ) { + optionsExpanded = !optionsExpanded + rebuildWidgets() + }.bounds( + layout.contentX, + layout.footerTop + 24, + third, + 20, + ).build(), + ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.privacy.title"), + ) { + minecraft!!.setScreen(SharePrivacyScreen(this)) + }.bounds( + layout.contentX + third + 6, + layout.footerTop + 24, + third, + 20, + ).build(), + ) + addRenderableWidget( + Button.builder(CommonComponents.GUI_CANCEL) { onClose() } + .bounds( + layout.contentX + (third + 6) * 2, + layout.footerTop + 24, + third, + 20, + ).build(), + ) + refresh() + } + + private fun addOptions( + layout: FormScreenLayout, + current: ShareUiState, + ) { + addRenderableWidget( + CycleButton.builder( + { mode: ShareGameMode -> + Component.translatable( + "connect_share.game_mode.${mode.name.lowercase()}", + ) + }, + ).withInitialValue(current.options.gameMode) + .withValues(ShareGameMode.entries) + .create( + layout.contentX, + layout.bodyTop + 24, + layout.halfButtonWidth, + 20, + Component.translatable("selectWorld.gameMode"), + ) { _, mode -> viewModel.setGameMode(mode) }, + ) + addRenderableWidget( + CycleButton.onOffBuilder(current.options.allowCheats) + .create( + layout.contentX + layout.halfButtonWidth + 6, + layout.bodyTop + 24, + layout.halfButtonWidth, + 20, + Component.translatable("selectWorld.allowCommands"), + ) { _, allowed -> viewModel.setAllowCheats(allowed) }, + ) + addRenderableWidget( + CycleButton.builder( + { guests: Int -> Component.literal(guests.toString()) }, + ).withInitialValue(current.options.maxGuests) + .withValues((1..16).toList()) + .create( + layout.contentX, + layout.bodyTop + 48, + layout.contentWidth, + 20, + Component.translatable("connect_share.setup.max_guests"), + ) { _, guests -> viewModel.setMaxGuests(guests) }, + ) + addRenderableWidget( + Checkbox.builder( + Component.translatable("connect_share.setup.internet"), + font, + ).pos(layout.contentX, layout.bodyTop + 74) + .selected(current.options.allowInternetDirect) + .onValueChange { _, allowed -> + viewModel.setAllowInternetDirect(allowed) + } + .tooltip( + Tooltip.create( + Component.translatable( + "connect_share.setup.internet.tooltip", + ), + ), + ) + .build(), + ) + } + + override fun tick() { + super.tick() + refresh() + } + + override fun onClose() { + minecraft!!.setScreen(parent) + } + + private fun refresh() { + startButton?.active = viewModel.state.value.startEnabled + } + + private fun centered(message: Component, y: Int): StringWidget { + val textWidth = font.width(message) + return StringWidget( + width / 2 - textWidth / 2, + y, + textWidth, + 11, + message, + font, + ) + } +} + +private fun net.minecraft.world.level.GameType.toShareGameMode(): ShareGameMode = when (this) { + net.minecraft.world.level.GameType.SURVIVAL -> ShareGameMode.SURVIVAL + net.minecraft.world.level.GameType.CREATIVE -> ShareGameMode.CREATIVE + net.minecraft.world.level.GameType.ADVENTURE -> ShareGameMode.ADVENTURE + net.minecraft.world.level.GameType.SPECTATOR -> ShareGameMode.SPECTATOR +} diff --git a/share/fabric-1.21.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_1/ShareStatusScreen.kt b/share/fabric-1.21.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_1/ShareStatusScreen.kt new file mode 100644 index 000000000..623f19c21 --- /dev/null +++ b/share/fabric-1.21.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_1/ShareStatusScreen.kt @@ -0,0 +1,342 @@ +package com.minekube.connect.share.fabric.v1_21_1 + +import com.minekube.connect.share.ShareState +import com.minekube.connect.share.admission.AdmissionIdentity +import com.minekube.connect.share.admission.AdmissionPurpose +import com.minekube.connect.share.admission.Ingress +import com.minekube.connect.share.fabric.ConnectShareClient +import com.minekube.connect.share.fabric.ui.AdaptiveShareLayout +import com.minekube.connect.share.fabric.ui.FormScreenLayout +import net.minecraft.ChatFormatting +import net.minecraft.client.gui.components.Button +import net.minecraft.client.gui.components.MultiLineTextWidget +import net.minecraft.client.gui.components.StringWidget +import net.minecraft.client.gui.screens.Screen +import net.minecraft.network.chat.CommonComponents +import net.minecraft.network.chat.Component + +class ShareStatusScreen( + private val parent: Screen, +) : Screen(Component.translatable("connect_share.status.title")) { + private val viewModel = ConnectShareClient.viewModel() + private var fingerprint: Int = 0 + private var linkCopied = false + private var showConnectionDetails = false + + override fun init() { + val state = viewModel.state.value + fingerprint = state.hashCode() + val layout = AdaptiveShareLayout.form(width, height, 3) + val sharing = state.shareState as? ShareState.Sharing + + addRenderableWidget( + centered( + title.copy().withStyle( + if (sharing != null) { + ChatFormatting.GREEN + } else { + ChatFormatting.YELLOW + }, + ), + layout.headerY, + ), + ) + addRenderableWidget( + centeredWrapped( + Component.translatable(statusKey(state.shareState)) + .withStyle(ChatFormatting.GRAY), + layout.subtitleY, + layout.contentWidth, + ), + ) + + var requestHeadingY = layout.bodyTop + 54 + if (sharing != null) { + val invitation = viewModel.currentInvitation() + addRenderableWidget( + Button.builder( + Component.translatable( + if (linkCopied) { + "connect_share.status.friend_link_copied" + } else { + "connect_share.status.copy_friend_link" + }, + ), + ) { + invitation?.let { + minecraft!!.keyboardHandler.setClipboard(it) + linkCopied = true + rebuildWidgets() + } + }.bounds( + layout.contentX, + layout.bodyTop, + layout.contentWidth, + 20, + ).build().apply { active = invitation != null }, + ) + addRenderableWidget( + Button.builder( + Component.translatable( + if (showConnectionDetails) { + "connect_share.status.connection_details.hide" + } else { + "connect_share.status.connection_details.show" + }, + ), + ) { + showConnectionDetails = !showConnectionDetails + rebuildWidgets() + }.bounds( + layout.contentX, + layout.bodyTop + 24, + layout.contentWidth, + 20, + ).build(), + ) + if (showConnectionDetails) { + addConnectionDetails(layout, sharing) + requestHeadingY = layout.bodyTop + 102 + } + } + + addRenderableWidget( + centered( + Component.translatable("connect_share.status.requests") + .withStyle(ChatFormatting.BOLD), + requestHeadingY, + ), + ) + val pending = state.pendingAdmissions + val rowsTop = requestHeadingY + 14 + val visibleRows = ((layout.footerTop - rowsTop) / 24).coerceIn(1, 3) + pending.take(visibleRows).forEachIndexed { index, request -> + val y = rowsTop + index * 24 + val buttonWidth = 54 + val labelWidth = layout.contentWidth - buttonWidth * 2 - 10 + val identity = request.identity + val ingress = when (identity) { + is AdmissionIdentity.Authenticated -> + identity.ingress + is AdmissionIdentity.UnverifiedOffline -> + identity.ingress + } + val label = Component.translatable( + if (request.purpose == AdmissionPurpose.FRIEND) { + "connect_share.status.friend_request" + } else { + "connect_share.status.request" + }, + identity.name, + friendlyIngress(ingress), + ) + addRenderableWidget( + StringWidget( + layout.contentX, + y, + labelWidth, + 20, + label, + font, + ), + ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.status.allow"), + ) { + viewModel.allow(request.requestId) + }.bounds( + layout.contentX + layout.contentWidth - + buttonWidth * 2 - 4, + y, + buttonWidth, + 20, + ).build(), + ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.status.deny"), + ) { + viewModel.deny(request.requestId) + }.bounds( + layout.contentX + layout.contentWidth - buttonWidth, + y, + buttonWidth, + 20, + ).build(), + ) + } + if (pending.size > visibleRows) { + addRenderableWidget( + centered( + Component.translatable( + "connect_share.status.more", + pending.size - visibleRows, + ).withStyle(ChatFormatting.GRAY), + rowsTop + visibleRows * 24, + ), + ) + } else if (pending.isEmpty()) { + addRenderableWidget( + centered( + Component.translatable("connect_share.status.waiting") + .withStyle(ChatFormatting.GRAY), + rowsTop + 4, + ), + ) + } + + addFooter(layout) + } + + private fun addConnectionDetails( + layout: FormScreenLayout, + sharing: ShareState.Sharing, + ) { + val address = sharing.address + addRenderableWidget( + Button.builder( + Component.translatable( + if (address == null) { + "connect_share.status.address_unavailable" + } else { + "connect_share.status.copy_address" + }, + ), + ) { + address?.let(minecraft!!.keyboardHandler::setClipboard) + }.bounds( + layout.contentX, + layout.bodyTop + 48, + layout.contentWidth, + 20, + ).build().apply { active = address != null }, + ) + addRenderableWidget( + centeredWrapped( + Component.translatable( + "connect_share.status.route_summary", + routeLabel(sharing), + ).withStyle(ChatFormatting.GRAY), + layout.bodyTop + 74, + layout.contentWidth, + ), + ) + } + + private fun addFooter(layout: FormScreenLayout) { + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.privacy.title"), + ) { + minecraft!!.setScreen(SharePrivacyScreen(this)) + }.bounds( + layout.contentX, + layout.footerTop, + layout.halfButtonWidth, + 20, + ).build(), + ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.identity.manage"), + ) { + minecraft!!.setScreen(EndpointIdentityScreen(this)) + }.bounds( + layout.contentX + layout.halfButtonWidth + 6, + layout.footerTop, + layout.halfButtonWidth, + 20, + ).build(), + ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.status.stop"), + ) { + viewModel.stop() + minecraft!!.setScreen(parent) + }.bounds( + layout.contentX, + layout.footerTop + 24, + layout.halfButtonWidth, + 20, + ).build(), + ) + addRenderableWidget( + Button.builder(CommonComponents.GUI_BACK) { onClose() } + .bounds( + layout.contentX + layout.halfButtonWidth + 6, + layout.footerTop + 24, + layout.halfButtonWidth, + 20, + ).build(), + ) + } + + override fun tick() { + super.tick() + val next = viewModel.state.value.hashCode() + if (next != fingerprint) { + rebuildWidgets() + } + } + + override fun onClose() { + minecraft!!.setScreen(parent) + } + + private fun routeLabel(sharing: ShareState.Sharing): Component = + Component.translatable( + when { + sharing.internetDirectAvailable -> + "connect_share.status.route.internet" + sharing.lanDirectAvailable && sharing.connectAvailable -> + "connect_share.status.route.lan_connect" + sharing.lanDirectAvailable -> + "connect_share.status.route.lan" + sharing.connectAvailable -> + "connect_share.status.route.connect" + else -> "connect_share.status.route.starting" + }, + ) + + private fun friendlyIngress(ingress: Ingress): Component = + Component.translatable( + when (ingress) { + Ingress.CONNECT -> "connect_share.ingress.online" + Ingress.DIRECT_LAN -> "connect_share.ingress.nearby" + Ingress.DIRECT_INTERNET -> "connect_share.ingress.direct" + }, + ) + + private fun statusKey(state: ShareState): String = when (state) { + ShareState.Idle -> "connect_share.status.idle" + ShareState.Starting -> "connect_share.status.starting" + is ShareState.Sharing -> "connect_share.status.ready" + ShareState.Stopping -> "connect_share.status.stopping" + is ShareState.Failed -> "connect_share.status.failed" + } + + private fun centered(message: Component, y: Int): StringWidget { + val textWidth = font.width(message) + return StringWidget( + width / 2 - textWidth / 2, + y, + textWidth, + 11, + message, + font, + ) + } + + private fun centeredWrapped( + message: Component, + y: Int, + maxWidth: Int, + ): MultiLineTextWidget = MultiLineTextWidget( + width / 2 - maxWidth / 2, + y, + message, + font, + ).setMaxWidth(maxWidth).setCentered(true) +} diff --git a/share/fabric-1.21.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_1/VanillaMinecraft12111Transport.kt b/share/fabric-1.21.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_1/VanillaMinecraft12111Transport.kt new file mode 100644 index 000000000..3ffa59eba --- /dev/null +++ b/share/fabric-1.21.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_1/VanillaMinecraft12111Transport.kt @@ -0,0 +1,152 @@ +package com.minekube.connect.share.fabric.v1_21_1 + +import com.minekube.connect.share.ShareGameMode +import com.minekube.connect.share.ShareOptions +import com.minekube.connect.share.fabric.v1_21_1.mixin.IntegratedServerAccessor +import com.minekube.connect.share.fabric.v1_21_1.mixin.LanServerPingerAccessor +import com.minekube.connect.share.fabric.v1_21_1.mixin.ServerConnectionListenerAccessor +import io.netty.channel.Channel +import io.netty.channel.ChannelFuture +import io.netty.channel.ChannelInitializer +import io.netty.channel.EventLoopGroup +import java.net.InetSocketAddress +import net.minecraft.client.Minecraft +import net.minecraft.client.server.IntegratedServer +import net.minecraft.util.HttpUtil +import net.minecraft.world.level.GameType + +internal class VanillaMinecraft1211Transport( + private val serverProvider: () -> IntegratedServer? = { + Minecraft.getInstance().singleplayerServer + }, +) : Minecraft1211Transport { + override fun publish(options: ShareOptions): PublishedMinecraftTransport { + val server = checkNotNull(serverProvider()) { + "Connect Share requires an active singleplayer world" + } + check(!server.isPublished) { + "This singleplayer world is already published" + } + val connection = server.connection + val channels = (connection as ServerConnectionListenerAccessor) + .connectShareChannels + val before = synchronized(channels) { channels.toSet() } + val captureLease = CapturedServerTransport.arm() + var published = false + try { + published = server.publishServer( + options.gameMode.toMinecraft(), + options.allowCheats, + HttpUtil.getAvailablePort(), + ) + check(published) { "Minecraft could not start its private Share listener" } + val captured = captureLease.complete().fold( + ifLeft = { + throw IllegalStateException( + "Minecraft did not expose its Share channel initializer", + ) + }, + ifRight = { it }, + ) + val added = synchronized(channels) { + channels.filterNot(before::contains) + } + check(added.size == 1) { + "Minecraft created ${added.size} listeners for one Share start" + } + val loopback = added.single() + val address = loopback.channel().localAddress() as? InetSocketAddress + ?: throw IllegalStateException( + "Minecraft Share did not create a TCP listener", + ) + check(address.address.isLoopbackAddress) { + "Minecraft Share listener escaped loopback" + } + suppressLanAdvertisement(server) + return PublishedVanillaTransport( + server = server, + channels = channels, + loopback = loopback, + address = address, + childInitializer = captured.childInitializer, + eventLoopGroup = captured.eventLoopGroup, + ) + } catch (failure: Throwable) { + captureLease.close() + rollbackNewListeners(server, channels, before) + if (published) { + suppressLanAdvertisement(server) + } + throw failure + } + } + + private fun rollbackNewListeners( + server: IntegratedServer, + channels: MutableList, + before: Set, + ) { + val added = synchronized(channels) { + channels.filterNot(before::contains).also(channels::removeAll) + } + added.forEach(ChannelFuture::closeChannel) + (server as IntegratedServerAccessor).setConnectSharePublishedPort(-1) + } + + private fun suppressLanAdvertisement(server: IntegratedServer) { + val accessor = server as IntegratedServerAccessor + accessor.connectShareLanPinger?.let { pinger -> + pinger.interrupt() + (pinger as LanServerPingerAccessor).connectShareSocket.close() + } + accessor.connectShareLanPinger = null + } + + private fun ShareGameMode.toMinecraft(): GameType = when (this) { + ShareGameMode.SURVIVAL -> GameType.SURVIVAL + ShareGameMode.CREATIVE -> GameType.CREATIVE + ShareGameMode.ADVENTURE -> GameType.ADVENTURE + ShareGameMode.SPECTATOR -> GameType.SPECTATOR + } +} + +private class PublishedVanillaTransport( + private val server: IntegratedServer, + private val channels: MutableList, + private val loopback: ChannelFuture, + override val address: InetSocketAddress, + override val childInitializer: ChannelInitializer, + override val eventLoopGroup: EventLoopGroup, +) : PublishedMinecraftTransport { + override fun addLocalListener(listener: LocalShareChannel) { + val future = checkNotNull(listener.future) { + "Connect Share local channel did not expose its bound future" + } + synchronized(channels) { + check(channels.add(future)) { + "Minecraft already tracks the Connect Share local listener" + } + } + } + + override fun removeLocalListener(listener: LocalShareChannel) { + val future = listener.future ?: return + synchronized(channels) { + channels.remove(future) + } + } + + override fun close() { + synchronized(channels) { + channels.remove(loopback) + } + loopback.closeChannel() + (server as IntegratedServerAccessor).setConnectSharePublishedPort(-1) + } +} + +private fun ChannelFuture.closeChannel() { + if (channel().isOpen) { + channel().close().syncUninterruptibly() + } +} diff --git a/share/fabric-1.21.1/src/main/resources/assets/connect-share/lang/de_de.json b/share/fabric-1.21.1/src/main/resources/assets/connect-share/lang/de_de.json new file mode 100644 index 000000000..bf44f3379 --- /dev/null +++ b/share/fabric-1.21.1/src/main/resources/assets/connect-share/lang/de_de.json @@ -0,0 +1,286 @@ +{ + "connect_share.menu.share": "Welt teilen", + "connect_share.menu.active": "Welt geteilt", + "connect_share.menu.join": "Freunde", + "connect_share.setup.title": "Diese Welt mit Freunden spielen", + "connect_share.setup.description": "Mache diese Welt jetzt betretbar. Gespeicherte Freunde sehen sie automatisch.", + "connect_share.setup.max_guests": "Maximale Gäste", + "connect_share.setup.internet": "Schnellere direkte Internetverbindungen erlauben", + "connect_share.setup.internet.tooltip": "Optional. Zeigt eingeladenen Gästen mit Mod deine öffentliche IP-Adresse. Connect bleibt der Relay-Fallback.", + "connect_share.setup.persistence": "Beim Wechseln oder erneuten Öffnen einer Welt wird das Teilen automatisch fortgesetzt.", + "connect_share.setup.start": "Teilen starten", + "connect_share.game_mode.survival": "Überleben", + "connect_share.game_mode.creative": "Kreativ", + "connect_share.game_mode.adventure": "Abenteuer", + "connect_share.game_mode.spectator": "Zuschauer", + "connect_share.status.title": "Deine Welt ist bereit", + "connect_share.status.address": "Über Connect bereit · %s", + "connect_share.status.direct_only": "Für Freunde in der Nähe bereit", + "connect_share.status.copy_invitation": "Freundeslink kopieren", + "connect_share.status.copy_address": "Serveradresse kopieren", + "connect_share.status.link_help": "Sende den Freundeslink einmal. Danach erscheinen zukünftige Welten automatisch.", + "connect_share.status.connection_help": "Die beste Verbindung wird automatisch gewählt: direkt, wenn möglich; sonst Connect.", + "connect_share.status.requests": "Anfragen, die auf dich warten", + "connect_share.status.idle": "Nicht geteilt", + "connect_share.status.starting": "Wird gestartet…", + "connect_share.status.active": "Aktiv", + "connect_share.status.stopping": "Wird beendet…", + "connect_share.status.failed": "Start fehlgeschlagen", + "connect_share.status.request": "%s möchte sich verbinden · %s", + "connect_share.status.friend_request": "%s hat eine Freundschaftsanfrage gesendet · %s", + "connect_share.status.allow": "Annehmen", + "connect_share.status.deny": "Ablehnen", + "connect_share.status.more": "%s weitere Anfragen", + "connect_share.status.waiting": "Niemand wartet auf eine Antwort.", + "connect_share.status.stop": "Teilen mit Freunden beenden", + "connect_share.join.title": "Connect Share beitreten", + "connect_share.join.description": "Wähle eine Welt in der Nähe oder füge ihre signierte Einladung ein.", + "connect_share.join.invitation": "Connect-Share-Einladung", + "connect_share.join.invitation_hint": "minekube://share/…", + "connect_share.join.scanning": "Suche nach Connect-Share-Welten in der Nähe…", + "connect_share.join.discovered": "In der Nähe: %s", + "connect_share.join.offline": "Offline-Identität verwenden (ungeprüft)", + "connect_share.join.offline.tooltip": "Der Host sieht diese Identität als ungeprüft und muss sie freigeben.", + "connect_share.join.internet": "Direkte Internetverbindung versuchen", + "connect_share.join.internet.tooltip": "Optional. Zeigt dem Host deine öffentliche IP-Adresse. Connect bleibt der Relay-Fallback.", + "connect_share.join.join": "Beitreten", + "connect_share.friends.title": "Freunde", + "connect_share.friends.page": "Seite %s von %s", + "connect_share.friends.description": "Spielbereite Freunde erscheinen hier.", + "connect_share.friends.copy_my_link": "Meinen Freundeslink kopieren", + "connect_share.friends.copy_my_link.tooltip": "Enthält eine direkte Route, damit diese Person dich erreicht. Sende ihn nur an vertraute Personen.", + "connect_share.friends.copying_my_link": "Freundeslink wird erstellt…", + "connect_share.friends.my_link_copied": "Freundeslink kopiert", + "connect_share.friends.copy_my_link_failed": "Freundeslink konnte nicht kopiert werden", + "connect_share.friends.empty": "Noch keine Freunde oder gesendeten Anfragen. Füge einen Freundeslink ein, um eine zu senden.", + "connect_share.friends.outgoing_request": "Anfrage an %s", + "connect_share.friends.incoming_request": "Anfrage von %s · %s", + "connect_share.friends.incoming_join_request": "%s möchte beitreten · %s", + "connect_share.friends.outgoing_request_active": "%s · Anfrage läuft", + "connect_share.friends.retry_request": "Erneut", + "connect_share.friends.request_sending": "Wird gesendet…", + "connect_share.friends.request_waiting": "Warten…", + "connect_share.friends.request_cancelling": "Wird abgebrochen…", + "connect_share.friends.request_failed": "Die Anfrage konnte nicht zugestellt werden. Dein Freund ist möglicherweise offline oder noch nicht direkt erreichbar.", + "connect_share.friends.request_accepted": "%s hat deine Freundschaftsanfrage angenommen.", + "connect_share.friends.cancel_request": "Abbrechen", + "connect_share.friends.manage": "Verwalten", + "connect_share.friends.request_join": "Anfragen", + "connect_share.friends.follow": "Beitreten, sobald bereit", + "connect_share.friends.cancel_follow": "Folgen abbrechen", + "connect_share.notification.follow_waiting": "%s wird gefolgt", + "connect_share.notification.follow_waiting_detail": "Sobald die nächste Welt bereit ist, wird eine Beitrittsanfrage gesendet. Abbruch unter Freunde.", + "connect_share.notification.follow_ready": "%s ist bereit", + "connect_share.notification.follow_ready_detail": "Öffne Freunde, um jetzt beizutreten; dein aktives Spiel wurde nicht unterbrochen.", + "connect_share.notification.follow_expired": "Folgen abgelaufen", + "connect_share.notification.follow_expired_detail": "%s war nicht rechtzeitig beitrittsbereit.", + "connect_share.notification.follow_cancelled": "Folgen beendet", + "connect_share.notification.follow_cancelled_detail": "%s kann nicht mehr gefolgt werden, da sich Freundschaft oder Zugriff geändert haben.", + "connect_share.notification.follow_failed": "Freund konnte nicht gefolgt werden", + "connect_share.friends.playing_server": "%s · spielt auf %s", + "connect_share.friends.hosting_world": "%s · spielt %s", + "connect_share.friends.online": "%s · online", + "connect_share.friends.friend_unreachable": "Dein Freund ist gerade nicht über libp2p erreichbar.", + "connect_share.friends.add": "Freund hinzufügen", + "connect_share.friends.add_description": "Füge den Link deines Freundes ein und sende eine Anfrage. Die Person kann sie annehmen, solange ihr Spiel geöffnet ist.", + "connect_share.friends.name": "Name des Freundes", + "connect_share.friends.name_hint": "Name der Person, die den Link gesendet hat", + "connect_share.friends.save": "Freund speichern", + "connect_share.friends.send_request": "Anfrage senden", + "connect_share.friends.connecting_request": "Freundschaftsanfrage an %s", + "connect_share.friends.join_once": "Einmal beitreten", + "connect_share.friends.manage_title": "%s verwalten", + "connect_share.friends.notify": "Benachrichtigen, wenn die Welt bereit ist", + "connect_share.friends.share_worlds": "Meine Welten mit diesem Freund teilen", + "connect_share.friends.auto_join": "Diesen Freund automatisch beitreten lassen", + "connect_share.friends.auto_join.tooltip": "Überspringt die Freigabe nur, wenn die Mod die gespeicherte libp2p-Identität nachweist.", + "connect_share.friends.access": "Wenn diese Person beitreten möchte", + "connect_share.friends.access.ask_every_time": "Jedes Mal nachfragen", + "connect_share.friends.access.auto_accept": "Automatisch beitreten lassen", + "connect_share.friends.access.never_allow": "Beitritt nie erlauben", + "connect_share.privacy.title": "Privatsphäre", + "connect_share.privacy.description": "Lege fest, was bestätigte Freunde sehen. Andere Personen erhalten keine Informationen.", + "connect_share.privacy.online": "Anzeigen, dass ich online bin", + "connect_share.privacy.playing": "Anzeigen, wenn ich spiele", + "connect_share.privacy.current_server": "Server- oder Weltnamen anzeigen", + "connect_share.privacy.joinable": "Freunden Beitrittsanfragen erlauben", + "connect_share.privacy.confirmed_only": "Freundschaft, Entfernen und Blockieren werden vor Präsenz und Beitritt berücksichtigt.", + "connect_share.friends.save_changes": "Änderungen speichern", + "connect_share.friends.remove": "Freund entfernen", + "connect_share.friends.remove_confirm.title": "%s entfernen?", + "connect_share.friends.remove_confirm.message": "Diese Person wird nicht mehr automatisch vertraut. Du kannst ihren Freundeslink später erneut hinzufügen.", + "connect_share.friends.remove_confirm.confirm": "Freund entfernen", + "connect_share.friends.block": "Blockieren", + "connect_share.privacy.blocked": "Blockierte Identitäten (%s)", + "connect_share.privacy.blocked_title": "Blockierte Identitäten", + "connect_share.privacy.blocked_empty": "Du hast niemanden blockiert.", + "connect_share.privacy.unblock": "Entsperren", + "connect_share.friends.ready_lan": "%s · %s · direkt im LAN", + "connect_share.friends.ready_connect": "%s · %s · online über Connect", + "connect_share.friends.saved_connect": "%s · gespeichert · Connect-Fallback", + "connect_share.friends.saved_offline": "%s · keine Route verfügbar", + "connect_share.notification.join_request": "Beitrittsanfrage", + "connect_share.notification.join_request_detail": "%s möchte beitreten. Öffne Connect Share zum Annehmen oder Ablehnen.", + "connect_share.notification.friend_request": "Freundschaftsanfrage", + "connect_share.notification.friend_request_detail": "%s hat eine Freundschaftsanfrage gesendet. Öffne Connect Share zum Annehmen oder Ablehnen.", + "connect_share.notification.friend_online": "Welt eines Freundes ist bereit", + "connect_share.notification.friend_online_detail": "%s spielt %s. Öffne Freunde, um Beitritt anzufragen.", + "connect_share.notification.friend_accepted": "Freund hinzugefügt", + "connect_share.notification.friend_accepted_detail": "%s hat deine Freundschaftsanfrage angenommen.", + "connect_share.notification.friend_removed": "Freund entfernt", + "connect_share.notification.friend_removed_detail": "%s ist nicht mehr in deiner Freundesliste.", + "connect_share.notification.friend_playing": "Freund spielt", + "connect_share.notification.friend_playing_detail": "%s spielt auf %s. Öffne Freunde, um den Beitritt anzufragen.", + "connect_share.identity.manage": "Erweiterte Einstellungen…", + "connect_share.identity.title": "Connect-Endpunkt-Identität", + "connect_share.identity.current": "Endpunkt: %s", + "connect_share.identity.sources": "Endpunkt: %s · Zugang: %s", + "connect_share.identity.endpoint": "Vorhandener Endpunkt", + "connect_share.identity.token": "Endpunkt-Token", + "connect_share.identity.save": "Prüfen und speichern", + "connect_share.identity.choose_file": "token.json importieren…", + "connect_share.identity.reset": "Endpunkt-Identität zurücksetzen…", + "connect_share.identity.reset_confirm.title": "Connect-Identität zurücksetzen?", + "connect_share.identity.reset_confirm.message": "Die alte Beitrittsadresse und das Token werden nicht mehr verwendet. Dies kann nicht rückgängig gemacht werden.", + "connect_share.compatibility.title": "Andere Spieleinrichtung", + "connect_share.compatibility.copy_pack": "Modpack-Link des Hosts kopieren", + "connect_share.compatibility.pack_copied": "Modpack-Link kopiert", + "connect_share.compatibility.try_anyway": "Trotzdem versuchen", + "connect_share.diagnostics.copy": "Sichere Diagnose kopieren", + "connect_share.diagnostics.copied": "Diagnose kopiert", + "connect_share.friends.internet_direct": "Direkte Internetverbindungen für diesen Freund erlauben", + "connect_share.setup.friends_only": "Nur Freunde, die du bestätigst, können beitreten.", + "connect_share.setup.options.show": "Spieloptionen…", + "connect_share.setup.options.hide": "Spieloptionen ausblenden", + "connect_share.status.ready": "Freunde können diese Welt jetzt sehen und um Beitritt bitten.", + "connect_share.status.copy_friend_link": "Einladung für neuen Freund kopieren", + "connect_share.status.friend_link_copied": "Freundeseinladung kopiert", + "connect_share.status.connection_details.show": "Verbindungsdetails…", + "connect_share.status.connection_details.hide": "Verbindungsdetails ausblenden", + "connect_share.status.address_unavailable": "Serveradresse wird noch vorbereitet", + "connect_share.status.route_summary": "Verbindung: %s", + "connect_share.status.route.internet": "direkt, mit Connect als Ausweichroute", + "connect_share.status.route.lan_connect": "in der Nähe, mit Connect als Ausweichroute", + "connect_share.status.route.lan": "in der Nähe", + "connect_share.status.route.connect": "über Connect", + "connect_share.status.route.starting": "wird vorbereitet", + "connect_share.privacy.blocked_description": "Blockierte Personen können weder Anfragen senden noch deine Aktivität sehen.", + "connect_share.compatibility.description": "Eure Spieleinstellungen unterscheiden sich. Gleiche diese Punkte für einen zuverlässigen Beitritt ab.", + "connect_share.compatibility.more": "%s weitere Unterschiede", + "connect_share.identity.description": "Erweitert: Verwende einen Endpunkt aus dem Minekube-Dashboard. Dein Token bleibt auf diesem Gerät verborgen.", + "connect_share.identity.endpoint_hint": "dein-name.play.minekube.net", + "connect_share.identity.token_hint": "Endpunkt-Token einfügen", + "connect_share.page.previous": "Zurück", + "connect_share.page.next": "Weiter", + "connect_share.friends.summary.requests.one": "1 Anfrage wartet auf dich", + "connect_share.friends.summary.requests.many": "%s Anfragen warten auf dich", + "connect_share.friends.summary.welcome": "Spielt zusammen, ohne einen Server einzurichten.", + "connect_share.friends.summary.joinable.one": "1 Freund ist spielbereit", + "connect_share.friends.summary.joinable.many": "%s Freunde sind spielbereit", + "connect_share.friends.summary.online.one": "1 Freund ist online", + "connect_share.friends.summary.online.many": "%s Freunde sind online", + "connect_share.friends.summary.saved.one": "1 gespeicherter Freund", + "connect_share.friends.summary.saved.many": "%s gespeicherte Freunde", + "connect_share.friends.invite": "Freund einladen", + "connect_share.friends.manage_named": "%s verwalten", + "connect_share.friends.action.join_now": "Jetzt beitreten", + "connect_share.friends.action.ask_to_join": "Um Beitritt bitten", + "connect_share.friends.action.cancel_follow": "Abbrechen", + "connect_share.friends.action.join_when_ready": "Beitreten, sobald bereit", + "connect_share.friends.status.world": "Spielt %s", + "connect_share.friends.status.server": "Auf %s", + "connect_share.friends.status.ready": "Bereit zum Beitreten", + "connect_share.friends.status.online": "Online", + "connect_share.friends.status.saved": "Gespeicherter Freund", + "connect_share.friends.status.offline": "Offline", + "connect_share.friends.connection_options.show": "Verbindungsoptionen…", + "connect_share.friends.connection_options.hide": "Verbindungsoptionen ausblenden", + "connect_share.friends.internet_direct_short": "Schnellere Direktverbindungen", + "connect_share.friends.internet_direct.tooltip": "Optional. Teilt deine öffentliche IP-Adresse nur mit diesem Freund. Connect bleibt die Ausweichroute.", + "connect_share.ingress.online": "online", + "connect_share.ingress.nearby": "in der Nähe", + "connect_share.ingress.direct": "direkt", + "connect_share.compatibility.minecraft": "Minecraft: deins %s · Freund %s", + "connect_share.compatibility.loader": "Mod-Loader: deiner %s · Freund %s", + "connect_share.compatibility.install": "Installiere %s %s", + "connect_share.compatibility.host_missing": "Dein Freund benötigt %s %s", + "connect_share.compatibility.mod_version": "%s: deins %s · Freund %s", + "connect_share.menu.requests": "Freunde (%s)", + "connect_share.menu.ready": "Freunde (%s)", + "connect_share.friends.link": "Freundeslink", + "connect_share.friends.connection_options.title": "Verbindungsoptionen", + "connect_share.friends.connection_options.description": "Optionale Einstellungen für ungewöhnliche Konten oder schnellere Direktverbindungen.", + "connect_share.friends.connection_options.fallback": "Connect bleibt die Ausweichroute, wenn keine Direktverbindung verfügbar ist.", + "connect_share.identity.source.generated": "Auf diesem Gerät erstellt", + "connect_share.identity.source.imported": "Importiert", + "connect_share.identity.source.environment": "Vom Launcher verwaltet", + "connect_share.error.generic": "Connect Share konnte nicht aktualisiert werden", + "connect_share.error.identity_invalid": "Die Connect-Zugangsdaten sind ungültig", + "connect_share.error.identity_rejected": "Connect hat diese Zugangsdaten abgelehnt", + "connect_share.error.identity_network": "Connect konnte die Zugangsdaten nicht prüfen", + "connect_share.error.identity_managed": "Die Connect-Zugangsdaten werden von der Umgebung verwaltet", + "connect_share.error.identity_active": "Beende das Teilen, bevor du Connect-Zugangsdaten änderst", + "connect_share.error.preferences_save": "Die Connect-Share-Privatsphäre konnte nicht gespeichert werden", + "connect_share.error.share_already_active": "Connect Share ist bereits aktiv", + "connect_share.error.share_start_failed": "Connect Share konnte nicht gestartet werden", + "connect_share.error.share_stop_failed": "Connect Share wurde mit Bereinigungsfehlern beendet", + "connect_share.error.invalid_invitation": "Diese Connect-Share-Einladung ist ungültig", + "connect_share.error.invitation_malformed": "Diese Connect-Share-Einladung ist unvollständig oder fehlerhaft", + "connect_share.error.invitation_unsupported_version": "Diese Einladung verwendet das nicht unterstützte Share-Format %s", + "connect_share.error.invitation_expired": "Diese Connect-Share-Einladung ist abgelaufen — bitte um eine neue", + "connect_share.error.invitation_invalid_signature": "Diese Einladung konnte nicht verifiziert werden", + "connect_share.error.invitation_relay_forbidden": "Eine direkte Share-Einladung darf keine Relay-Route enthalten", + "connect_share.error.invitation_peer_mismatch": "Diese Einladung passt nicht zu dem Freund, der sie gesendet hat", + "connect_share.login.authentication_required": "Diese direkte Verbindung erfordert die Minecraft-Online-Authentifizierung", + "connect_share.login.approval_timed_out": "Der Host hat die Beitrittsanfrage nicht rechtzeitig beantwortet", + "connect_share.login.share_full": "Diese geteilte Welt ist voll", + "connect_share.login.sharing_stopped": "Der Host teilt diese Welt nicht mehr", + "connect_share.login.host_denied": "Der Host hat diese Verbindung abgelehnt", + "connect_share.error.invalid_friend_name": "Der Freundesname muss zwischen 1 und 64 Zeichen lang sein", + "connect_share.error.friend_identity_conflict": "Die Identität stimmt nicht mit dem gespeicherten Schlüssel überein", + "connect_share.error.friend_not_saved": "Dieser Freund ist nicht mehr gespeichert", + "connect_share.error.friend_blocked": "Diese Identität ist blockiert", + "connect_share.error.friends_load": "Gespeicherte Connect-Share-Freunde konnten nicht geladen werden", + "connect_share.error.friend_remove": "Dieser Connect-Share-Freund konnte nicht entfernt werden", + "connect_share.error.friend_block": "Diese Connect-Share-Identität konnte nicht blockiert werden", + "connect_share.error.friend_unblock": "Diese Connect-Share-Identität konnte nicht entsperrt werden", + "connect_share.error.join_peer_mismatch": "Der gefundene Host passt nicht zu dieser Einladung", + "connect_share.error.join_discovery_unavailable": "Automatische LAN-Suche ist nicht verfügbar", + "connect_share.error.join_no_route": "Keine Route zu dieser Connect-Share-Welt verfügbar", + "connect_share.error.identity_endpoint_conflict": "Dieses Profil verwendet denselben Connect-Endpunkt wie dein Freund", + "connect_share.error.friend_unreachable": "Dein Freund ist gerade nicht erreichbar", + "connect_share.error.friend_declined": "Dein Freund hat diese Anfrage abgelehnt", + "connect_share.error.friend_timed_out": "Dein Freund hat nicht rechtzeitig geantwortet", + "connect_share.error.friend_invalid_response": "Die Antwort auf die Freundschaftsanfrage war ungültig", + "connect_share.error.minecraft_version": "Eure Minecraft-Versionen stimmen nicht überein", + "connect_share.error.mod_loader": "Eure Mod-Loader stimmen nicht überein", + "connect_share.error.required_mods": "Die benötigten Mods stimmen nicht überein", + "connect_share.page.previous_tooltip": "Vorherige Seite · Seite %s von %s", + "connect_share.page.next_tooltip": "Nächste Seite · Seite %s von %s", + "connect_share.recovery.title": "Sichern & wiederherstellen", + "connect_share.recovery.menu": "Sicherung", + "connect_share.recovery.description": "Übertrage Freunde und deine Share-Identität auf ein anderes Gerät. Die verschlüsselte Sicherung bleibt offline.", + "connect_share.recovery.secret": "Wiederherstellungspasswort", + "connect_share.recovery.secret_confirm": "Passwort bestätigen", + "connect_share.recovery.warning": "Beim Wiederherstellen werden Freunde und Share-Identität dieses Geräts ersetzt. Beende zuerst das Teilen und starte Minecraft danach neu.", + "connect_share.recovery.export": "Freunde sichern", + "connect_share.recovery.import": "Sicherung wiederherstellen", + "connect_share.recovery.restore_confirm": "Ersetzen und wiederherstellen", + "connect_share.recovery.summary": "%s Einträge · Einstellungen: %s · Connect-Endpunkt: %s", + "connect_share.recovery.included": "enthalten", + "connect_share.recovery.not_included": "nicht enthalten", + "connect_share.recovery.exported": "Verschlüsselte Sicherung gespeichert. Bewahre das Passwort sicher auf.", + "connect_share.recovery.restored_restart": "Wiederherstellung abgeschlossen. Starte Minecraft vor dem Teilen oder Beitreten neu.", + "connect_share.recovery.error.secret_mismatch": "Die Wiederherstellungspasswörter stimmen nicht überein.", + "connect_share.recovery.error.weak_secret": "Verwende ein Wiederherstellungspasswort mit mindestens 12 Zeichen.", + "connect_share.recovery.error.authentication": "Falsches Passwort oder beschädigte Sicherung. Es wurde nichts geändert.", + "connect_share.recovery.error.unsupported": "Diese Sicherungsversion wird nicht unterstützt.", + "connect_share.recovery.error.too_large": "Diese Sicherung ist zu groß.", + "connect_share.recovery.error.invalid": "Dies ist keine gültige Connect-Share-Sicherung.", + "connect_share.recovery.error.nothing_to_export": "Es gibt noch keine vollständigen Freundesidentitäten zum Sichern.", + "connect_share.recovery.error.unsafe_files": "Die Sicherung wurde wegen einer unsicheren Share-Datei beendet.", + "connect_share.recovery.error.read": "Die ausgewählte Sicherung konnte nicht gelesen werden.", + "connect_share.recovery.error.write": "Die verschlüsselte Sicherung konnte nicht gespeichert werden.", + "connect_share.recovery.error.restore": "Die Wiederherstellung ist fehlgeschlagen; die vorherigen Daten dieses Geräts wurden wiederhergestellt.", + "connect_share.recovery.error.stop_sharing": "Beende das Teilen, bevor du eine Sicherung wiederherstellst." +} diff --git a/share/fabric-1.21.1/src/main/resources/assets/connect-share/lang/en_us.json b/share/fabric-1.21.1/src/main/resources/assets/connect-share/lang/en_us.json new file mode 100644 index 000000000..66fb81293 --- /dev/null +++ b/share/fabric-1.21.1/src/main/resources/assets/connect-share/lang/en_us.json @@ -0,0 +1,286 @@ +{ + "connect_share.menu.share": "Share world", + "connect_share.menu.active": "World shared", + "connect_share.menu.join": "Friends", + "connect_share.setup.title": "Play this world with friends", + "connect_share.setup.description": "Make this world joinable now. Saved friends will see it automatically.", + "connect_share.setup.max_guests": "Maximum guests", + "connect_share.setup.internet": "Allow faster direct internet connections", + "connect_share.setup.internet.tooltip": "Optional. Reveals your public IP address to invited modded guests. Connect remains the relay fallback.", + "connect_share.setup.persistence": "Sharing resumes automatically when you switch or reopen a world.", + "connect_share.setup.start": "Start sharing", + "connect_share.game_mode.survival": "Survival", + "connect_share.game_mode.creative": "Creative", + "connect_share.game_mode.adventure": "Adventure", + "connect_share.game_mode.spectator": "Spectator", + "connect_share.status.title": "Your world is ready", + "connect_share.status.address": "Ready through Connect · %s", + "connect_share.status.direct_only": "Ready for nearby friends", + "connect_share.status.copy_invitation": "Copy friend link", + "connect_share.status.copy_address": "Copy server address", + "connect_share.status.link_help": "Send the friend link once. After saving it, future worlds appear automatically.", + "connect_share.status.connection_help": "The best connection is chosen automatically: direct when possible, Connect otherwise.", + "connect_share.status.requests": "Requests waiting for you", + "connect_share.status.idle": "Not sharing", + "connect_share.status.starting": "Starting…", + "connect_share.status.active": "Active", + "connect_share.status.stopping": "Stopping…", + "connect_share.status.failed": "Could not start", + "connect_share.status.request": "%s wants to connect · %s", + "connect_share.status.friend_request": "%s sent a friend request · %s", + "connect_share.status.allow": "Accept", + "connect_share.status.deny": "Decline", + "connect_share.status.more": "%s more requests", + "connect_share.status.waiting": "No one is waiting for a response.", + "connect_share.status.stop": "Stop sharing with friends", + "connect_share.join.title": "Join Connect Share", + "connect_share.join.description": "Choose a nearby world or paste its signed invitation.", + "connect_share.join.invitation": "Connect Share invitation", + "connect_share.join.invitation_hint": "minekube://share/…", + "connect_share.join.scanning": "Scanning for nearby Connect Share worlds…", + "connect_share.join.discovered": "Nearby: %s", + "connect_share.join.offline": "Use an offline identity (unverified)", + "connect_share.join.offline.tooltip": "The host will see this identity as unverified and must approve it.", + "connect_share.join.internet": "Try a direct internet connection", + "connect_share.join.internet.tooltip": "Optional. Reveals your public IP address to the host. Connect remains the relay fallback.", + "connect_share.join.join": "Join", + "connect_share.friends.title": "Friends", + "connect_share.friends.page": "Page %s of %s", + "connect_share.friends.description": "See who is around and join them when they are ready.", + "connect_share.friends.summary.welcome": "Play together without setting up a server.", + "connect_share.friends.invite": "Invite a friend", + "connect_share.friends.copy_my_link": "Copy my friend link", + "connect_share.friends.copy_my_link.tooltip": "Includes a direct route so this person can reach you. Only send it to someone you trust.", + "connect_share.friends.copying_my_link": "Creating friend link…", + "connect_share.friends.my_link_copied": "Friend link copied", + "connect_share.friends.copy_my_link_failed": "Could not copy friend link", + "connect_share.friends.empty": "Invite someone or add their link to start playing together.", + "connect_share.friends.outgoing_request": "Waiting for %s", + "connect_share.friends.incoming_request": "%s wants to be friends · %s", + "connect_share.friends.incoming_join_request": "%s wants you to join · %s", + "connect_share.friends.outgoing_request_active": "Contacting %s…", + "connect_share.friends.retry_request": "Retry", + "connect_share.friends.request_sending": "Sending…", + "connect_share.friends.request_waiting": "Waiting…", + "connect_share.friends.request_cancelling": "Canceling…", + "connect_share.friends.request_failed": "Request could not be delivered. Your friend may be offline or not reachable directly yet.", + "connect_share.friends.request_accepted": "%s accepted your friend request.", + "connect_share.friends.cancel_request": "Cancel", + "connect_share.friends.manage": "Manage", + "connect_share.friends.manage_named": "Manage %s", + "connect_share.friends.action.join_now": "Join now", + "connect_share.friends.action.ask_to_join": "Ask to join", + "connect_share.friends.action.cancel_follow": "Cancel", + "connect_share.friends.action.join_when_ready": "Join when ready", + "connect_share.friends.status.world": "Playing %s", + "connect_share.friends.status.server": "On %s", + "connect_share.friends.status.ready": "Ready to join", + "connect_share.friends.status.online": "Online", + "connect_share.friends.status.saved": "Saved friend", + "connect_share.friends.status.offline": "Offline", + "connect_share.friends.request_join": "Request", + "connect_share.friends.follow": "Join when ready", + "connect_share.friends.cancel_follow": "Cancel follow", + "connect_share.notification.follow_waiting": "Following %s", + "connect_share.notification.follow_waiting_detail": "A join request will be sent when their next world is ready. Cancel from Friends.", + "connect_share.notification.follow_ready": "%s is ready", + "connect_share.notification.follow_ready_detail": "Open Friends to join now; active gameplay was not interrupted.", + "connect_share.notification.follow_expired": "Follow expired", + "connect_share.notification.follow_expired_detail": "%s did not become joinable in time.", + "connect_share.notification.follow_cancelled": "Follow cancelled", + "connect_share.notification.follow_cancelled_detail": "%s can no longer be followed because the friendship or access changed.", + "connect_share.notification.follow_failed": "Could not follow friend", + "connect_share.friends.playing_server": "%s · playing on %s", + "connect_share.friends.hosting_world": "%s · playing %s", + "connect_share.friends.online": "%s · online", + "connect_share.friends.friend_unreachable": "Your friend is not reachable over libp2p right now.", + "connect_share.friends.add": "Add from link", + "connect_share.friends.add_description": "Paste your friend's Connect Share link. Their name fills in automatically.", + "connect_share.friends.name": "Friend's name", + "connect_share.friends.name_hint": "Friend's name (filled from link)", + "connect_share.friends.save": "Save friend", + "connect_share.friends.send_request": "Send request", + "connect_share.friends.connecting_request": "Friend request to %s", + "connect_share.friends.join_once": "Join without adding", + "connect_share.friends.connection_options.show": "Connection options…", + "connect_share.friends.connection_options.hide": "Hide connection options", + "connect_share.friends.manage_title": "Manage %s", + "connect_share.friends.notify": "Notify me when their world is ready", + "connect_share.friends.share_worlds": "Share my worlds with this friend", + "connect_share.friends.auto_join": "Let this friend join automatically", + "connect_share.friends.auto_join.tooltip": "Skips the approval prompt only when their mod proves the saved libp2p identity.", + "connect_share.friends.access": "When they ask to join", + "connect_share.friends.access.ask_every_time": "Ask me every time", + "connect_share.friends.access.auto_accept": "Let them join automatically", + "connect_share.friends.access.never_allow": "Never allow joining", + "connect_share.privacy.title": "Privacy", + "connect_share.privacy.description": "Choose what confirmed friends can see. People who are not your friends receive nothing.", + "connect_share.privacy.online": "Show that I am online", + "connect_share.privacy.playing": "Show when I am playing", + "connect_share.privacy.current_server": "Show the server or world name", + "connect_share.privacy.joinable": "Let friends request to join", + "connect_share.privacy.confirmed_only": "Friend requests, removals, and blocks take effect before presence or joining.", + "connect_share.friends.save_changes": "Save changes", + "connect_share.friends.remove": "Remove friend", + "connect_share.friends.remove_confirm.title": "Remove %s?", + "connect_share.friends.remove_confirm.message": "They will no longer be trusted automatically. You can add their friend link again later.", + "connect_share.friends.remove_confirm.confirm": "Remove friend", + "connect_share.friends.block": "Block", + "connect_share.privacy.blocked": "Blocked identities (%s)", + "connect_share.privacy.blocked_title": "Blocked identities", + "connect_share.privacy.blocked_empty": "You have not blocked anyone.", + "connect_share.privacy.unblock": "Unblock", + "connect_share.friends.ready_lan": "%s · %s · direct LAN", + "connect_share.friends.ready_connect": "%s · %s · online via Connect", + "connect_share.friends.saved_connect": "%s · saved · Connect fallback", + "connect_share.friends.saved_offline": "%s · no route available", + "connect_share.notification.join_request": "Join request", + "connect_share.notification.join_request_detail": "%s wants to join. Open Connect Share to accept or decline.", + "connect_share.notification.friend_request": "Friend request", + "connect_share.notification.friend_request_detail": "%s sent a friend request. Open Connect Share to accept or decline.", + "connect_share.notification.friend_online": "Friend's world is ready", + "connect_share.notification.friend_online_detail": "%s is playing %s. Open Friends to request to join.", + "connect_share.notification.friend_accepted": "Friend added", + "connect_share.notification.friend_accepted_detail": "%s accepted your friend request.", + "connect_share.notification.friend_removed": "Friend removed", + "connect_share.notification.friend_removed_detail": "%s is no longer in your friends list.", + "connect_share.notification.friend_playing": "Friend is playing", + "connect_share.notification.friend_playing_detail": "%s is on %s. Open Friends to request to join.", + "connect_share.identity.manage": "Advanced settings…", + "connect_share.identity.title": "Connect endpoint identity", + "connect_share.identity.current": "Endpoint: %s", + "connect_share.identity.sources": "Endpoint: %s · Credential: %s", + "connect_share.identity.endpoint": "Existing endpoint", + "connect_share.identity.token": "Endpoint token", + "connect_share.identity.save": "Validate and save", + "connect_share.identity.choose_file": "Import token.json…", + "connect_share.identity.reset": "Reset endpoint identity…", + "connect_share.identity.reset_confirm.title": "Reset Connect identity?", + "connect_share.identity.reset_confirm.message": "The old join address and token will stop being used. This cannot be undone.", + "connect_share.compatibility.title": "Different game setup", + "connect_share.compatibility.copy_pack": "Copy the host's modpack link", + "connect_share.compatibility.pack_copied": "Modpack link copied", + "connect_share.compatibility.try_anyway": "Try anyway", + "connect_share.diagnostics.copy": "Copy safe diagnostics", + "connect_share.diagnostics.copied": "Diagnostics copied", + "connect_share.friends.internet_direct": "Allow direct internet routes for this friend", + "connect_share.friends.internet_direct_short": "Faster direct connections", + "connect_share.friends.internet_direct.tooltip": "Optional. Shares your public IP address only with this friend. Connect remains the fallback.", + "connect_share.ingress.online": "online", + "connect_share.ingress.nearby": "nearby", + "connect_share.ingress.direct": "direct", + "connect_share.compatibility.minecraft": "Minecraft: yours %s · friend's %s", + "connect_share.compatibility.loader": "Loader: yours %s · friend's %s", + "connect_share.compatibility.install": "Install %s %s", + "connect_share.compatibility.host_missing": "Your friend needs %s %s", + "connect_share.compatibility.mod_version": "%s: yours %s · friend's %s", + "connect_share.setup.friends_only": "Only friends you approve can join.", + "connect_share.setup.options.show": "Game options…", + "connect_share.setup.options.hide": "Hide game options", + "connect_share.status.ready": "Friends can now see this world and ask to join.", + "connect_share.status.copy_friend_link": "Copy invite for a new friend", + "connect_share.status.friend_link_copied": "Friend invite copied", + "connect_share.status.connection_details.show": "Connection details…", + "connect_share.status.connection_details.hide": "Hide connection details", + "connect_share.status.address_unavailable": "Server address is still starting", + "connect_share.status.route_summary": "Connection: %s", + "connect_share.status.route.internet": "direct, with Connect as fallback", + "connect_share.status.route.lan_connect": "nearby, with Connect as fallback", + "connect_share.status.route.lan": "nearby", + "connect_share.status.route.connect": "through Connect", + "connect_share.status.route.starting": "getting ready", + "connect_share.privacy.blocked_description": "Blocked people cannot send requests or see your activity.", + "connect_share.compatibility.description": "Your game setups differ. Match these items for the most reliable join.", + "connect_share.compatibility.more": "%s more differences", + "connect_share.identity.description": "Advanced: reuse an endpoint from the Minekube dashboard. Your token stays hidden on this device.", + "connect_share.identity.endpoint_hint": "your-name.play.minekube.net", + "connect_share.identity.token_hint": "Paste endpoint token", + "connect_share.page.previous": "Previous", + "connect_share.page.next": "Next", + "connect_share.friends.summary.requests.one": "1 request waiting for you", + "connect_share.friends.summary.requests.many": "%s requests waiting for you", + "connect_share.friends.summary.joinable.one": "1 friend ready to play", + "connect_share.friends.summary.joinable.many": "%s friends ready to play", + "connect_share.friends.summary.online.one": "1 friend online", + "connect_share.friends.summary.online.many": "%s friends online", + "connect_share.friends.summary.saved.one": "1 saved friend", + "connect_share.friends.summary.saved.many": "%s saved friends", + "connect_share.menu.requests": "Friends (%s)", + "connect_share.menu.ready": "Friends (%s)", + "connect_share.friends.link": "Friend link", + "connect_share.friends.connection_options.title": "Connection options", + "connect_share.friends.connection_options.description": "Optional choices for unusual accounts or faster direct routes.", + "connect_share.friends.connection_options.fallback": "Connect remains the fallback when a direct route is unavailable.", + "connect_share.identity.source.generated": "Generated on this device", + "connect_share.identity.source.imported": "Imported", + "connect_share.identity.source.environment": "Managed by launcher", + "connect_share.error.generic": "Could not update Connect Share", + "connect_share.error.identity_invalid": "Connect credentials are invalid", + "connect_share.error.identity_rejected": "Connect rejected these credentials", + "connect_share.error.identity_network": "Could not reach Connect to validate credentials", + "connect_share.error.identity_managed": "Connect credentials are managed by the environment", + "connect_share.error.identity_active": "Stop sharing before changing Connect credentials", + "connect_share.error.preferences_save": "Connect Share privacy settings could not be saved", + "connect_share.error.share_already_active": "Connect Share is already active", + "connect_share.error.share_start_failed": "Could not start Connect Share", + "connect_share.error.share_stop_failed": "Connect Share stopped with cleanup errors", + "connect_share.error.invalid_invitation": "This Connect Share invitation is invalid", + "connect_share.error.invitation_malformed": "This Connect Share invitation is incomplete or malformed", + "connect_share.error.invitation_unsupported_version": "This invitation uses unsupported Share format %s", + "connect_share.error.invitation_expired": "This Connect Share invitation has expired — ask for a new one", + "connect_share.error.invitation_invalid_signature": "This invitation could not be verified", + "connect_share.error.invitation_relay_forbidden": "A direct Share invitation cannot contain a relay route", + "connect_share.error.invitation_peer_mismatch": "This invitation does not match the friend who sent it", + "connect_share.login.authentication_required": "This direct connection requires Minecraft online authentication", + "connect_share.login.approval_timed_out": "The host did not answer the join request in time", + "connect_share.login.share_full": "This shared world is full", + "connect_share.login.sharing_stopped": "The host stopped sharing this world", + "connect_share.login.host_denied": "The host declined this connection", + "connect_share.error.invalid_friend_name": "Friend name must be between 1 and 64 characters", + "connect_share.error.friend_identity_conflict": "This friend identity does not match the saved key", + "connect_share.error.friend_not_saved": "This friend is no longer saved", + "connect_share.error.friend_blocked": "This identity is blocked", + "connect_share.error.friends_load": "Saved Connect Share friends could not be loaded", + "connect_share.error.friend_remove": "This Connect Share friend could not be removed", + "connect_share.error.friend_block": "This Connect Share identity could not be blocked", + "connect_share.error.friend_unblock": "This Connect Share identity could not be unblocked", + "connect_share.error.join_peer_mismatch": "The discovered host does not match this invitation", + "connect_share.error.join_discovery_unavailable": "Automatic LAN discovery is unavailable", + "connect_share.error.join_no_route": "No route to this Connect Share world is available", + "connect_share.error.identity_endpoint_conflict": "This profile uses the same Connect endpoint as your friend", + "connect_share.error.friend_unreachable": "Your friend is not reachable right now", + "connect_share.error.friend_declined": "Your friend declined this request", + "connect_share.error.friend_timed_out": "Your friend did not answer in time", + "connect_share.error.friend_invalid_response": "The friend request response was invalid", + "connect_share.error.minecraft_version": "Your Minecraft versions do not match", + "connect_share.error.mod_loader": "Your mod loaders do not match", + "connect_share.error.required_mods": "Your required mods do not match", + "connect_share.page.previous_tooltip": "Previous page · Page %s of %s", + "connect_share.page.next_tooltip": "Next page · Page %s of %s", + "connect_share.recovery.title": "Backup & restore", + "connect_share.recovery.menu": "Backup", + "connect_share.recovery.description": "Move your friends and Share identity to another device. The encrypted backup stays offline.", + "connect_share.recovery.secret": "Recovery password", + "connect_share.recovery.secret_confirm": "Confirm recovery password", + "connect_share.recovery.warning": "Restoring replaces this device’s friends and Share identity. Stop sharing first, then restart Minecraft after restoring.", + "connect_share.recovery.export": "Back up friends", + "connect_share.recovery.import": "Restore backup", + "connect_share.recovery.restore_confirm": "Replace and restore", + "connect_share.recovery.summary": "%s items · Preferences: %s · Connect endpoint: %s", + "connect_share.recovery.included": "included", + "connect_share.recovery.not_included": "not included", + "connect_share.recovery.exported": "Encrypted backup saved. Keep its password somewhere safe.", + "connect_share.recovery.restored_restart": "Restore complete. Restart Minecraft before sharing or joining.", + "connect_share.recovery.error.secret_mismatch": "The recovery passwords do not match.", + "connect_share.recovery.error.weak_secret": "Use a recovery password with at least 12 characters.", + "connect_share.recovery.error.authentication": "Wrong password or damaged backup. Nothing was changed.", + "connect_share.recovery.error.unsupported": "This backup version is not supported.", + "connect_share.recovery.error.too_large": "This backup is too large.", + "connect_share.recovery.error.invalid": "This is not a valid Connect Share backup.", + "connect_share.recovery.error.nothing_to_export": "There are no complete friend identities to back up yet.", + "connect_share.recovery.error.unsafe_files": "Backup stopped because a Share data file is unsafe.", + "connect_share.recovery.error.read": "The selected backup could not be read.", + "connect_share.recovery.error.write": "The encrypted backup could not be saved.", + "connect_share.recovery.error.restore": "Restore failed and this device’s previous data was recovered.", + "connect_share.recovery.error.stop_sharing": "Stop sharing before restoring a backup." +} diff --git a/share/fabric-1.21.1/src/main/resources/connect-share-fabric-1.21.1.mixins.json b/share/fabric-1.21.1/src/main/resources/connect-share-fabric-1.21.1.mixins.json new file mode 100644 index 000000000..04259385d --- /dev/null +++ b/share/fabric-1.21.1/src/main/resources/connect-share-fabric-1.21.1.mixins.json @@ -0,0 +1,22 @@ +{ + "required": true, + "minVersion": "0.8", + "package": "com.minekube.connect.share.fabric.v1_21_1.mixin", + "compatibilityLevel": "JAVA_21", + "mixins": [ + "ConnectionAccessor", + "ServerConnectionListenerAccessor", + "ServerConnectionListenerMixin", + "ServerLoginPacketListenerMixin" + ], + "client": [ + "IntegratedServerAccessor", + "IntegratedServerMixin", + "LanServerPingerAccessor", + "PauseScreenMixin", + "TitleScreenMixin" + ], + "injectors": { + "defaultRequire": 1 + } +} diff --git a/share/fabric-1.21.1/src/main/resources/fabric.mod.json b/share/fabric-1.21.1/src/main/resources/fabric.mod.json new file mode 100644 index 000000000..f1c62f0d6 --- /dev/null +++ b/share/fabric-1.21.1/src/main/resources/fabric.mod.json @@ -0,0 +1,26 @@ +{ + "schemaVersion": 1, + "id": "connect-share", + "version": "${version}", + "name": "Connect Share", + "description": "Share a private Minecraft world through Minekube Connect.", + "environment": "client", + "entrypoints": { + "client": [ + { + "adapter": "kotlin", + "value": "com.minekube.connect.share.fabric.v1_21_1.FabricConnectShare1211Client" + } + ] + }, + "mixins": [ + "connect-share-fabric-1.21.1.mixins.json" + ], + "depends": { + "fabricloader": ">=0.19.3", + "fabric-api": "*", + "fabric-language-kotlin": ">=1.13.13", + "minecraft": "1.21.1", + "java": ">=21" + } +} diff --git a/share/fabric-1.21.1/src/test/kotlin/com/minekube/connect/share/fabric/v1_21_1/CapturedServerTransportTest.kt b/share/fabric-1.21.1/src/test/kotlin/com/minekube/connect/share/fabric/v1_21_1/CapturedServerTransportTest.kt new file mode 100644 index 000000000..5b3a60d65 --- /dev/null +++ b/share/fabric-1.21.1/src/test/kotlin/com/minekube/connect/share/fabric/v1_21_1/CapturedServerTransportTest.kt @@ -0,0 +1,58 @@ +package com.minekube.connect.share.fabric.v1_21_1 + +import com.minekube.connect.share.CaptureFailure +import io.netty.channel.Channel +import io.netty.channel.ChannelInitializer +import io.netty.channel.DefaultEventLoopGroup +import kotlin.concurrent.thread +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotSame +import kotlin.test.assertSame +import kotlin.test.assertTrue + +class CapturedServerTransportTest { + @Test + fun `captures the tagged vanilla initializer and group only for the armed thread`() { + val initializer = NoopInitializer + val group = DefaultEventLoopGroup(1) + try { + val lease = CapturedServerTransport.arm() + + assertTrue(CapturedServerTransport.isShareStartArmed()) + val taggedInitializer = + CapturedServerTransport.captureChildInitializer(initializer) + assertNotSame(initializer, taggedInitializer) + assertSame(group, CapturedServerTransport.captureEventLoopGroup(group)) + + var otherThreadArmed = true + thread { + otherThreadArmed = CapturedServerTransport.isShareStartArmed() + }.join() + + val captured = lease.complete().getOrNull() + requireNotNull(captured) + assertSame(taggedInitializer, captured.childInitializer) + assertSame(group, captured.eventLoopGroup) + assertFalse(otherThreadArmed) + assertFalse(CapturedServerTransport.isShareStartArmed()) + } finally { + group.shutdownGracefully().syncUninterruptibly() + } + } + + @Test + fun `incomplete capture is typed and always disarms`() { + val lease = CapturedServerTransport.arm() + + val failure = lease.complete().leftOrNull() + + assertEquals(CaptureFailure.Incomplete, failure) + assertFalse(CapturedServerTransport.isShareStartArmed()) + } + + private object NoopInitializer : ChannelInitializer() { + override fun initChannel(channel: Channel) = Unit + } +} diff --git a/share/fabric-1.21.1/src/test/kotlin/com/minekube/connect/share/fabric/v1_21_1/ConnectGameProfileMapperTest.kt b/share/fabric-1.21.1/src/test/kotlin/com/minekube/connect/share/fabric/v1_21_1/ConnectGameProfileMapperTest.kt new file mode 100644 index 000000000..9844b5104 --- /dev/null +++ b/share/fabric-1.21.1/src/test/kotlin/com/minekube/connect/share/fabric/v1_21_1/ConnectGameProfileMapperTest.kt @@ -0,0 +1,47 @@ +package com.minekube.connect.share.fabric.v1_21_1 + +import com.minekube.connect.api.player.GameProfile as ConnectGameProfile +import java.util.UUID +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ConnectGameProfileMapperTest { + @Test + fun `preserves identity and signed profile properties`() { + val id = UUID.randomUUID() + val mapped = ConnectGameProfileMapper.toMinecraft( + ConnectGameProfile( + "Robin", + id, + listOf( + ConnectGameProfile.Property("textures", "signed-value", "signature"), + ConnectGameProfile.Property("badge", "unsigned-value", ""), + ), + ), + ).getOrNull() + requireNotNull(mapped) + + assertEquals(id, mapped.id) + assertEquals("Robin", mapped.name) + val texture = mapped.properties["textures"].single() + val badge = mapped.properties["badge"].single() + assertTrue(texture.hasSignature()) + assertEquals("signature", texture.signature) + assertFalse(badge.hasSignature()) + } + + @Test + fun `rejects malformed Connect profiles as a typed outcome`() { + val result = ConnectGameProfileMapper.toMinecraft( + ConnectGameProfile( + "", + UUID.randomUUID(), + emptyList(), + ), + ) + + assertEquals(ProfileMappingFailure.InvalidName, result.leftOrNull()) + } +} diff --git a/share/fabric-1.21.1/src/test/kotlin/com/minekube/connect/share/fabric/v1_21_1/Fabric12111ArtifactTest.kt b/share/fabric-1.21.1/src/test/kotlin/com/minekube/connect/share/fabric/v1_21_1/Fabric12111ArtifactTest.kt new file mode 100644 index 000000000..a1181d1d9 --- /dev/null +++ b/share/fabric-1.21.1/src/test/kotlin/com/minekube/connect/share/fabric/v1_21_1/Fabric12111ArtifactTest.kt @@ -0,0 +1,288 @@ +package com.minekube.connect.share.fabric.v1_21_1 + +import com.minekube.connect.share.ShareCoordinator +import com.minekube.connect.share.fabric.ConnectShareClient +import com.minekube.connect.tunnel.p2p.DirectP2pNode +import com.minekube.connect.tunnel.p2p.Libp2pEndpoint +import com.minekube.connect.tunnel.p2p.Libp2pTunnelTransport +import java.nio.file.Files +import java.nio.file.Path +import java.net.URLClassLoader +import java.util.jar.JarInputStream +import java.util.jar.JarFile +import kotlin.io.path.name +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class Fabric1211ArtifactTest { + @Test + fun `artifact uses a friends first sharing vocabulary`() { + JarFile(artifact().toFile()).use { jar -> + val language = jar.getInputStream( + jar.getJarEntry( + "assets/connect-share/lang/en_us.json", + ), + ).bufferedReader().use { it.readText() } + + assertTrue( + "\"connect_share.setup.title\": \"Play this world with friends\"" in + language, + ) + assertTrue( + "\"connect_share.status.copy_friend_link\": " + + "\"Copy invite for a new friend\"" in language, + ) + assertTrue( + "\"connect_share.friends.copy_my_link\": " + + "\"Copy my friend link\"" in language, + ) + assertTrue( + "\"connect_share.friends.send_request\": " + + "\"Send request\"" in language, + ) + assertTrue( + "\"connect_share.friends.outgoing_request\": " + + "\"Waiting for %s\"" in language, + ) + assertTrue( + "\"connect_share.friends.incoming_request\": " + + "\"%s wants to be friends · %s\"" in language, + ) + assertTrue( + "\"connect_share.friends.retry_request\": \"Retry\"" in + language, + ) + assertTrue( + "\"connect_share.friends.cancel_request\": \"Cancel\"" in + language, + ) + assertTrue( + "\"connect_share.status.allow\": \"Accept\"" in language, + ) + assertTrue( + "\"connect_share.status.deny\": \"Decline\"" in language, + ) + assertFalse("connect_share.friends.accept_request" in language) + } + } + + @Test + fun `friend removal confirmation stays inside the friends screen`() { + JarFile(artifact().toFile()).use { jar -> + val bytecode = jar.entries().asSequence() + .filter { + it.name.startsWith( + "com/minekube/connect/share/fabric/v1_21_1/" + + "ShareJoinScreen", + ) && it.name.endsWith(".class") + } + .joinToString { + jar.getInputStream(it).use { stream -> + stream.readBytes().toString(Charsets.ISO_8859_1) + } + } + + assertFalse("net/minecraft/class_410" in bytecode) + assertTrue( + "connect_share.friends.remove_confirm.confirm" in bytecode, + ) + assertTrue("sendRequest" in bytecode) + assertTrue("suggestedDisplayName" in bytecode) + assertTrue("FriendRequestClient" in bytecode) + assertTrue("getIncomingRequests" in bytecode) + assertTrue("connect_share.status.allow" in bytecode) + assertTrue("connect_share.status.deny" in bytecode) + assertTrue("joinOutgoing" !in bytecode) + assertFalse("connect_share.friends.accept_request" in bytecode) + } + } + + @Test + fun `approved card exchange promotes an outgoing request`() { + JarFile(artifact().toFile()).use { jar -> + val bytecode = jar.entries().asSequence() + .filter { + it.name.startsWith( + "com/minekube/connect/share/fabric/v1_21_1/" + + "FriendCardNetworking", + ) && it.name.endsWith(".class") + } + .joinToString { + jar.getInputStream(it).use { input -> + input.readBytes().toString(Charsets.ISO_8859_1) + } + } + + assertTrue("confirmOutgoing" in bytecode) + } + } + + @Test + fun `remapped artifact is self contained and isolates networking runtime`() { + JarFile(artifact().toFile()).use { jar -> + val entries = jar.entries().asSequence().map { it.name }.toSet() + + assertTrue("fabric.mod.json" in entries) + assertTrue("LICENSE" in entries) + assertTrue("connect-share-fabric-1.21.1.mixins.json" in entries) + assertTrue( + "com/minekube/connect/share/fabric/v1_21_1/" + + "FriendCardNetworking.class" in entries, + ) + assertTrue( + entries.any { + it.startsWith("com/minekube/connect/share/") && + it.endsWith(".class") + }, + ) + assertTrue("META-INF/connect/libp2p-runtime.jar" in entries) + assertFalse(entries.any { it.startsWith("io/libp2p/") }) + assertFalse(entries.any { it.startsWith("io/netty/") }) + assertFalse(entries.any { it.startsWith("it/unimi/dsi/fastutil/") }) + assertFalse(entries.any { it.startsWith("kotlin/") }) + assertTrue( + entries.any { + it.startsWith( + "com/minekube/connect/shadow/it/unimi/dsi/fastutil/", + ) + }, + ) + + val payload = jar.getJarEntry("META-INF/connect/libp2p-runtime.jar") + val payloadEntries = JarInputStream(jar.getInputStream(payload)).use { nested -> + generateSequence(nested::getNextJarEntry).map { it.name }.toSet() + } + assertTrue(payloadEntries.any { it.startsWith("io/libp2p/") }) + assertTrue(payloadEntries.any { it.startsWith("io/netty/") }) + assertTrue(payloadEntries.any { it.startsWith("kotlin/") }) + assertTrue( + "com/minekube/connect/tunnel/p2p/DirectP2pNodeRuntime.class" in + payloadEntries, + ) + } + } + + @Test + fun `minecraft profile mapper preserves the mutable Mojang Guava ABI`() { + JarFile(artifact().toFile()).use { jar -> + val factory = jar.getJarEntry( + "com/minekube/connect/share/fabric/v1_21_1/" + + "MinecraftGameProfileFactory.class", + ) + assertNotNull(factory) + + val bytecode = jar.getInputStream(factory).use { + it.readBytes().toString(Charsets.ISO_8859_1) + } + assertTrue("com/google/common/collect/Multimap" in bytecode) + assertTrue("getProperties" in bytecode) + assertFalse( + "com/minekube/connect/shadow/com/google/common" in bytecode, + ) + } + } + + @Test + fun `packaged loader reads libp2p only from child payload`() { + URLClassLoader( + arrayOf(artifact().toUri().toURL()), + ClassLoader.getPlatformClassLoader(), + ).use { artifactLoader -> + val loaderType = Class.forName( + "com.minekube.connect.tunnel.p2p.Libp2pRuntimeLoader", + true, + artifactLoader, + ) + val loaderMethod = loaderType.getDeclaredMethod("classLoader") + .apply { isAccessible = true } + val runtimeLoader = loaderMethod.invoke(null) as ClassLoader + try { + val host = Class.forName( + "io.libp2p.core.Host", + false, + runtimeLoader, + ) + val directRuntime = Class.forName( + "com.minekube.connect.tunnel.p2p.DirectP2pNodeRuntime", + false, + runtimeLoader, + ) + assertTrue(host.classLoader === runtimeLoader) + assertTrue(directRuntime.classLoader === runtimeLoader) + val directCodeSource = + directRuntime.protectionDomain.codeSource.location + assertNotNull(directCodeSource) + assertTrue( + directCodeSource.toString().contains("libp2p-runtime-"), + ) + val nodeType = Class.forName( + "com.minekube.connect.tunnel.p2p.DirectP2pNode", + true, + artifactLoader, + ) + val node = nodeType.getDeclaredConstructor().newInstance() + nodeType.getMethod("close").invoke(node) + } finally { + loaderType.getDeclaredMethod("close") + .apply { isAccessible = true } + .invoke(null) + } + } + } + + @Test + fun `parent facing APIs do not expose isolated runtime types`() { + listOf( + ConnectShareClient::class.java, + ShareCoordinator::class.java, + DirectP2pNode::class.java, + Libp2pEndpoint::class.java, + Libp2pTunnelTransport::class.java, + ).forEach(::assertParentFacingTypes) + } + + private fun assertParentFacingTypes(type: Class<*>) { + val exposed = buildList { + type.declaredFields.forEach { add(it.type.name) } + type.declaredConstructors.forEach { constructor -> + constructor.parameterTypes.forEach { add(it.name) } + } + type.declaredMethods.forEach { method -> + add(method.returnType.name) + method.parameterTypes.forEach { add(it.name) } + } + } + val invalid = exposed.filter { name -> + FORBIDDEN_TYPE_PREFIXES.any(name::startsWith) + } + assertTrue( + invalid.isEmpty(), + "${type.name} exposes isolated runtime types: $invalid", + ) + } + + private fun artifact(): Path { + val explicit = System.getProperty("connectShareArtifact") + if (explicit != null) { + return Path.of(explicit) + } + val directory = Path.of("build", "libs") + return Files.list(directory).use { paths -> + paths.filter { + it.name.startsWith("connect-share-fabric-1.21.1-") && + it.name.endsWith(".jar") && + !it.name.contains("sources") && + !it.name.contains("dev") + }.findFirst().orElse(null) + }.also(::assertNotNull) + } + + private companion object { + val FORBIDDEN_TYPE_PREFIXES = listOf( + "io.libp2p.", + "io.netty.", + ) + } +} diff --git a/share/fabric-1.21.1/src/test/kotlin/com/minekube/connect/share/fabric/v1_21_1/FriendCardPayloadTest.kt b/share/fabric-1.21.1/src/test/kotlin/com/minekube/connect/share/fabric/v1_21_1/FriendCardPayloadTest.kt new file mode 100644 index 000000000..e5a1163d6 --- /dev/null +++ b/share/fabric-1.21.1/src/test/kotlin/com/minekube/connect/share/fabric/v1_21_1/FriendCardPayloadTest.kt @@ -0,0 +1,42 @@ +package com.minekube.connect.share.fabric.v1_21_1 + +import io.netty.buffer.Unpooled +import java.util.UUID +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertSame +import net.minecraft.network.FriendlyByteBuf + +class FriendCardPayloadTest { + @Test + fun `signed friend card survives the network payload codec`() { + val invitation = "minekube://share/" + "signed-card".repeat(500) + val relationshipId = UUID.randomUUID() + val buffer = FriendlyByteBuf(Unpooled.buffer()) + + FriendCardPayload.CODEC.encode( + buffer, + FriendCardPayload(invitation, relationshipId), + ) + + val decoded = FriendCardPayload.CODEC.decode(buffer) + assertEquals(invitation, decoded.invitation) + assertEquals(relationshipId, decoded.relationshipId) + } + + @Test + fun `friend card request has a zero data payload`() { + val buffer = FriendlyByteBuf(Unpooled.buffer()) + + FriendCardRequestPayload.CODEC.encode( + buffer, + FriendCardRequestPayload, + ) + + assertEquals(0, buffer.readableBytes()) + assertSame( + FriendCardRequestPayload, + FriendCardRequestPayload.CODEC.decode(buffer), + ) + } +} diff --git a/share/fabric-1.21.1/src/test/kotlin/com/minekube/connect/share/fabric/v1_21_1/Minecraft12111BridgeTest.kt b/share/fabric-1.21.1/src/test/kotlin/com/minekube/connect/share/fabric/v1_21_1/Minecraft12111BridgeTest.kt new file mode 100644 index 000000000..a7640877e --- /dev/null +++ b/share/fabric-1.21.1/src/test/kotlin/com/minekube/connect/share/fabric/v1_21_1/Minecraft12111BridgeTest.kt @@ -0,0 +1,130 @@ +package com.minekube.connect.share.fabric.v1_21_1 + +import com.minekube.connect.share.ShareGameMode +import com.minekube.connect.share.ShareOptions +import io.netty.channel.Channel +import io.netty.channel.ChannelInitializer +import io.netty.channel.DefaultEventLoopGroup +import io.netty.channel.EventLoopGroup +import io.netty.channel.local.LocalAddress +import java.net.InetAddress +import java.net.InetSocketAddress +import java.net.SocketAddress +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.test.assertTrue +import kotlinx.coroutines.runBlocking + +class Minecraft1211BridgeTest { + @Test + fun `publishes only on loopback and releases every listener`() = runBlocking { + val transport = FakeMinecraftTransport() + val bridge = Minecraft1211Bridge(transport, FakeLocalChannelBinder()) + + val target = bridge.open(shareOptions) + + assertTrue(transport.boundAddress.address.isLoopbackAddress) + assertIs(target.address) + assertEquals(2, transport.listenerCount) + assertEquals(25565, transport.publishedPort) + + target.close() + + assertEquals(-1, transport.publishedPort) + assertEquals(0, transport.listenerCount) + } + + @Test + fun `can open again after close but never installs a second active listener`() = runBlocking { + val transport = FakeMinecraftTransport() + val bridge = Minecraft1211Bridge(transport, FakeLocalChannelBinder()) + + val first = bridge.open(shareOptions) + assertFailsWith { + bridge.open(shareOptions) + } + assertEquals(2, transport.listenerCount) + + first.close() + val second = bridge.open(shareOptions) + + assertEquals(2, transport.listenerCount) + second.close() + assertEquals(0, transport.listenerCount) + } + + @Test + fun `failed local bind rolls back the private vanilla listener`() = runBlocking { + val transport = FakeMinecraftTransport() + val bridge = Minecraft1211Bridge( + transport, + LocalShareChannelBinder { _, _ -> + throw IllegalStateException("local bind failed") + }, + ) + + assertFailsWith { + bridge.open(shareOptions) + } + + assertEquals(-1, transport.publishedPort) + assertEquals(0, transport.listenerCount) + } + + private class FakeMinecraftTransport : Minecraft1211Transport { + var publishedPort = -1 + var listenerCount = 0 + lateinit var boundAddress: InetSocketAddress + val eventLoopGroup: EventLoopGroup = DefaultEventLoopGroup(1) + + override fun publish(options: ShareOptions): PublishedMinecraftTransport { + check(publishedPort == -1) + boundAddress = InetSocketAddress(InetAddress.getLoopbackAddress(), 25565) + publishedPort = boundAddress.port + listenerCount++ + return object : PublishedMinecraftTransport { + override val address: InetSocketAddress = boundAddress + override val childInitializer: ChannelInitializer = NoopInitializer + override val eventLoopGroup = this@FakeMinecraftTransport.eventLoopGroup + + override fun addLocalListener(listener: LocalShareChannel) { + listenerCount++ + } + + override fun removeLocalListener(listener: LocalShareChannel) { + listenerCount-- + } + + override fun close() { + if (publishedPort != -1) { + listenerCount-- + publishedPort = -1 + } + } + } + } + } + + private class FakeLocalChannelBinder : LocalShareChannelBinder { + override fun bind( + childInitializer: ChannelInitializer, + eventLoopGroup: EventLoopGroup, + ): LocalShareChannel = object : LocalShareChannel { + override val address: SocketAddress = LocalAddress("connect-share-test") + override fun close() = Unit + } + } + + private object NoopInitializer : ChannelInitializer() { + override fun initChannel(channel: Channel) = Unit + } + + private companion object { + val shareOptions = ShareOptions( + gameMode = ShareGameMode.SURVIVAL, + allowCheats = false, + ) + } +} diff --git a/share/fabric-1.21.11/build.gradle.kts b/share/fabric-1.21.11/build.gradle.kts new file mode 100644 index 000000000..21e627bd6 --- /dev/null +++ b/share/fabric-1.21.11/build.gradle.kts @@ -0,0 +1,159 @@ +import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar + +plugins { + id("connect.shadow-conventions") + id("net.fabricmc.fabric-loom-remap") + id("org.jetbrains.kotlin.jvm") +} + +base { + archivesName = "connect-share-fabric-1.21.11" +} + +java { + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } +} + +kotlin { + jvmToolchain(21) +} + +repositories { + maven("https://repo.opencollab.dev/maven-releases") { + mavenContent { releasesOnly() } + } + maven("https://repo.opencollab.dev/maven-snapshots") { + mavenContent { snapshotsOnly() } + } + maven("https://dl.cloudsmith.io/public/libp2p/jvm-libp2p/maven/") + maven("https://dl.cloudsmith.io/public/consensys/maven/maven/") + maven("https://jitpack.io") { + content { includeGroupByRegex("com\\.github\\..*") } + } +} + +val connectShareParentRuntime by configurations.creating { + isCanBeConsumed = false + isCanBeResolved = true +} + +dependencies { + minecraft("com.mojang:minecraft:1.21.11") + mappings(loom.officialMojangMappings()) + modImplementation("net.fabricmc:fabric-loader:${Versions.fabricLoaderVersion}") + modImplementation("net.fabricmc.fabric-api:fabric-api:${Versions.fabricApi12111Version}") + modImplementation("net.fabricmc:fabric-language-kotlin:${Versions.fabricLanguageKotlinVersion}") + + implementation(projects.core) + implementation(projects.share.common) + implementation(projects.share.fabricCommon) + connectShareParentRuntime(projects.share.fabricCommon) { + exclude(group = "io.libp2p") + exclude(group = "io.netty") + exclude(group = "org.jetbrains.kotlin") + exclude(group = "org.jetbrains.kotlinx") + exclude(group = "com.google.errorprone", module = "javac") + } + + testImplementation(kotlin("test")) + testRuntimeOnly("org.junit.platform:junit-platform-launcher") +} + +tasks.test { + useJUnitPlatform() + dependsOn(tasks.remapJar) + systemProperty( + "connectShareArtifact", + tasks.remapJar.flatMap { it.archiveFile }.get().asFile.absolutePath, + ) +} + +tasks.processResources { + inputs.property("version", project.version) + filesMatching("fabric.mod.json") { + expand("version" to project.version) + } +} + +relocate("arrow") +relocate("aopalliance") +relocate("cloud.commandframework") +relocate("com.google.common") +relocate("com.google.gson") +relocate("com.google.inject") +relocate("com.google.protobuf") +relocate("it.unimi.dsi.fastutil") +relocate("io.grpc") +relocate("io.leangen.geantyref") +relocate("jakarta.inject") +relocate("javax.inject") +relocate("okhttp3") +relocate("okio") +relocate("org.bstats") +relocate("org.geysermc.configutils") +relocate("org.yaml.snakeyaml") + +val libp2pRuntimeJar = tasks.named("libp2pRuntimeJar") { + dependsOn(":core:classes") + from(rootProject.project(":core").layout.buildDirectory.dir("classes/java/main")) { + include( + "com/minekube/connect/tunnel/p2p/DirectP2pNodeRuntime*.class", + "com/minekube/connect/tunnel/p2p/Libp2pEndpointRuntime*.class", + "com/minekube/connect/tunnel/p2p/impl/Libp2pTunnelTransportRuntime*.class", + ) + } +} +val minecraftGameProfileFactory = + "com/minekube/connect/share/fabric/v1_21_11/" + + "MinecraftGameProfileFactory.class" +val connectShareShadowJar = tasks.named("shadowJar") { + configurations = listOf(connectShareParentRuntime) + archiveBaseName.set("connect-share-fabric-1.21.11") + archiveVersion.set(project.version.toString()) + archiveClassifier.set("dev-parent-shadow") + mergeServiceFiles() + from(rootProject.file("LICENSE")) + // Authlib's PropertyMap constructor must keep Minecraft's Guava ABI. + exclude(minecraftGameProfileFactory) +} +val connectShareJar = tasks.register("connectShareJar") { + dependsOn(connectShareShadowJar, libp2pRuntimeJar) + archiveBaseName.set("connect-share-fabric-1.21.11") + archiveVersion.set(project.version.toString()) + archiveClassifier.set("dev-shadow") + duplicatesStrategy = DuplicatesStrategy.EXCLUDE + from({ + zipTree(connectShareShadowJar.get().archiveFile.get().asFile) + }) + from(libp2pRuntimeJar) { + into("META-INF/connect") + } + from(sourceSets.main.get().output) { + include(minecraftGameProfileFactory) + } +} + +tasks.remapJar { + dependsOn(connectShareJar) + inputFile.set(connectShareJar.flatMap { it.archiveFile }) + archiveBaseName.set("connect-share-fabric-1.21.11") + archiveVersion.set(project.version.toString()) + archiveClassifier.set("") +} + +val verifyConnectShareArtifactSize = tasks.register("verifyConnectShareArtifactSize") { + dependsOn(tasks.remapJar) + val artifact = tasks.remapJar.flatMap { it.archiveFile } + inputs.file(artifact) + doLast { + val bytes = artifact.get().asFile.length() + val limit = 63L * 1024L * 1024L + logger.lifecycle("Connect Share Fabric 1.21.11 artifact: {} MiB", "%.1f".format(bytes / 1024.0 / 1024.0)) + check(bytes <= limit) { "Connect Share Fabric 1.21.11 exceeds the 63 MiB release budget ($bytes bytes)" } + } +} +tasks.check { dependsOn(verifyConnectShareArtifactSize) } diff --git a/share/fabric-1.21.11/src/main/java/com/minekube/connect/share/fabric/v1_21_11/MinecraftGameProfileFactory.java b/share/fabric-1.21.11/src/main/java/com/minekube/connect/share/fabric/v1_21_11/MinecraftGameProfileFactory.java new file mode 100644 index 000000000..6a7a4cf12 --- /dev/null +++ b/share/fabric-1.21.11/src/main/java/com/minekube/connect/share/fabric/v1_21_11/MinecraftGameProfileFactory.java @@ -0,0 +1,21 @@ +package com.minekube.connect.share.fabric.v1_21_11; + +import com.google.common.collect.ArrayListMultimap; +import com.google.common.collect.Multimap; +import com.mojang.authlib.GameProfile; +import com.mojang.authlib.properties.Property; +import com.mojang.authlib.properties.PropertyMap; +import java.util.List; +import java.util.UUID; + +final class MinecraftGameProfileFactory { + private MinecraftGameProfileFactory() {} + + static GameProfile create(UUID id, String username, List properties) { + Multimap mapped = ArrayListMultimap.create(); + for (Property property : properties) { + mapped.put(property.name(), property); + } + return new GameProfile(id, username, new PropertyMap(mapped)); + } +} diff --git a/share/fabric-1.21.11/src/main/java/com/minekube/connect/share/fabric/v1_21_11/mixin/ConnectionAccessor.java b/share/fabric-1.21.11/src/main/java/com/minekube/connect/share/fabric/v1_21_11/mixin/ConnectionAccessor.java new file mode 100644 index 000000000..8c744dfcd --- /dev/null +++ b/share/fabric-1.21.11/src/main/java/com/minekube/connect/share/fabric/v1_21_11/mixin/ConnectionAccessor.java @@ -0,0 +1,12 @@ +package com.minekube.connect.share.fabric.v1_21_11.mixin; + +import io.netty.channel.Channel; +import net.minecraft.network.Connection; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.gen.Accessor; + +@Mixin(Connection.class) +public interface ConnectionAccessor { + @Accessor("channel") + Channel getConnectShareChannel(); +} diff --git a/share/fabric-1.21.11/src/main/java/com/minekube/connect/share/fabric/v1_21_11/mixin/IntegratedServerAccessor.java b/share/fabric-1.21.11/src/main/java/com/minekube/connect/share/fabric/v1_21_11/mixin/IntegratedServerAccessor.java new file mode 100644 index 000000000..231b52e05 --- /dev/null +++ b/share/fabric-1.21.11/src/main/java/com/minekube/connect/share/fabric/v1_21_11/mixin/IntegratedServerAccessor.java @@ -0,0 +1,19 @@ +package com.minekube.connect.share.fabric.v1_21_11.mixin; + +import net.minecraft.client.server.IntegratedServer; +import net.minecraft.client.server.LanServerPinger; +import org.jspecify.annotations.Nullable; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.gen.Accessor; + +@Mixin(IntegratedServer.class) +public interface IntegratedServerAccessor { + @Accessor("publishedPort") + void setConnectSharePublishedPort(int port); + + @Accessor("lanPinger") + @Nullable LanServerPinger getConnectShareLanPinger(); + + @Accessor("lanPinger") + void setConnectShareLanPinger(@Nullable LanServerPinger pinger); +} diff --git a/share/fabric-1.21.11/src/main/java/com/minekube/connect/share/fabric/v1_21_11/mixin/IntegratedServerMixin.java b/share/fabric-1.21.11/src/main/java/com/minekube/connect/share/fabric/v1_21_11/mixin/IntegratedServerMixin.java new file mode 100644 index 000000000..f001cd246 --- /dev/null +++ b/share/fabric-1.21.11/src/main/java/com/minekube/connect/share/fabric/v1_21_11/mixin/IntegratedServerMixin.java @@ -0,0 +1,24 @@ +package com.minekube.connect.share.fabric.v1_21_11.mixin; + +import com.minekube.connect.share.CapturedServerTransport; +import net.minecraft.client.server.IntegratedServer; +import net.minecraft.client.server.LanServerPinger; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Redirect; + +@Mixin(IntegratedServer.class) +public abstract class IntegratedServerMixin { + @Redirect( + method = "publishServer", + at = @At( + value = "INVOKE", + target = "Lnet/minecraft/client/server/LanServerPinger;start()V")) + private void connectShare$suppressLanAdvertisement(LanServerPinger pinger) { + if (CapturedServerTransport.isShareStartArmed()) { + pinger.interrupt(); + } else { + pinger.start(); + } + } +} diff --git a/share/fabric-1.21.11/src/main/java/com/minekube/connect/share/fabric/v1_21_11/mixin/LanServerPingerAccessor.java b/share/fabric-1.21.11/src/main/java/com/minekube/connect/share/fabric/v1_21_11/mixin/LanServerPingerAccessor.java new file mode 100644 index 000000000..392f45e0c --- /dev/null +++ b/share/fabric-1.21.11/src/main/java/com/minekube/connect/share/fabric/v1_21_11/mixin/LanServerPingerAccessor.java @@ -0,0 +1,12 @@ +package com.minekube.connect.share.fabric.v1_21_11.mixin; + +import java.net.DatagramSocket; +import net.minecraft.client.server.LanServerPinger; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.gen.Accessor; + +@Mixin(LanServerPinger.class) +public interface LanServerPingerAccessor { + @Accessor("socket") + DatagramSocket getConnectShareSocket(); +} diff --git a/share/fabric-1.21.11/src/main/java/com/minekube/connect/share/fabric/v1_21_11/mixin/PauseScreenMixin.java b/share/fabric-1.21.11/src/main/java/com/minekube/connect/share/fabric/v1_21_11/mixin/PauseScreenMixin.java new file mode 100644 index 000000000..24cca4833 --- /dev/null +++ b/share/fabric-1.21.11/src/main/java/com/minekube/connect/share/fabric/v1_21_11/mixin/PauseScreenMixin.java @@ -0,0 +1,85 @@ +package com.minekube.connect.share.fabric.v1_21_11.mixin; + +import com.minekube.connect.share.fabric.ConnectShareClient; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.network.chat.Component; +import org.jspecify.annotations.Nullable; +import org.spongepowered.asm.mixin.Final; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.Unique; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +@Mixin(net.minecraft.client.gui.screens.PauseScreen.class) +abstract class PauseScreenMixin extends Screen { + @Shadow @Final private boolean showPauseMenu; + @Shadow private @Nullable Button disconnectButton; + @Unique private @Nullable Button connectShareButton; + @Unique private @Nullable Button connectShareFriendsButton; + + protected PauseScreenMixin(Component title) { + super(title); + } + + @Inject(method = "init", at = @At("TAIL")) + private void connectShare$addButtons(CallbackInfo ci) { + Minecraft client = Minecraft.getInstance(); + if (!showPauseMenu + || disconnectButton == null + || !ConnectShareClient.isInstalled()) { + return; + } + + int rowX = disconnectButton.getX(); + int rowY = disconnectButton.getY(); + disconnectButton.setY(rowY + 24); + removeWidget(disconnectButton); + if (client.hasSingleplayerServer()) { + connectShareButton = addRenderableWidget( + Button.builder( + Component.translatable( + ConnectShareClient.pauseButtonTranslationKey()), + button -> ConnectShareClient.openPauseScreen(this)) + .bounds(rowX, rowY, 100, 20) + .build()); + connectShareFriendsButton = addRenderableWidget( + Button.builder( + connectShare$friendsLabel(), + button -> ConnectShareClient.openJoinScreen(this)) + .bounds(rowX + 104, rowY, 100, 20) + .build()); + } else { + connectShareFriendsButton = addRenderableWidget( + Button.builder( + connectShare$friendsLabel(), + button -> ConnectShareClient.openJoinScreen(this)) + .bounds(rowX, rowY, 204, 20) + .build()); + } + addRenderableWidget(disconnectButton); + } + + @Inject(method = "tick", at = @At("TAIL")) + private void connectShare$refreshButtons(CallbackInfo ci) { + if (connectShareButton != null) { + connectShareButton.setMessage( + Component.translatable(ConnectShareClient.pauseButtonTranslationKey())); + } + if (connectShareFriendsButton != null) { + connectShareFriendsButton.setMessage(connectShare$friendsLabel()); + } + } + + @Unique + private Component connectShare$friendsLabel() { + int count = ConnectShareClient.friendsButtonCount(); + String key = ConnectShareClient.friendsButtonTranslationKey(); + return count > 0 + ? Component.translatable(key, count) + : Component.translatable(key); + } +} diff --git a/share/fabric-1.21.11/src/main/java/com/minekube/connect/share/fabric/v1_21_11/mixin/ServerConnectionListenerAccessor.java b/share/fabric-1.21.11/src/main/java/com/minekube/connect/share/fabric/v1_21_11/mixin/ServerConnectionListenerAccessor.java new file mode 100644 index 000000000..15287d399 --- /dev/null +++ b/share/fabric-1.21.11/src/main/java/com/minekube/connect/share/fabric/v1_21_11/mixin/ServerConnectionListenerAccessor.java @@ -0,0 +1,13 @@ +package com.minekube.connect.share.fabric.v1_21_11.mixin; + +import io.netty.channel.ChannelFuture; +import java.util.List; +import net.minecraft.server.network.ServerConnectionListener; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.gen.Accessor; + +@Mixin(ServerConnectionListener.class) +public interface ServerConnectionListenerAccessor { + @Accessor("channels") + List getConnectShareChannels(); +} diff --git a/share/fabric-1.21.11/src/main/java/com/minekube/connect/share/fabric/v1_21_11/mixin/ServerConnectionListenerMixin.java b/share/fabric-1.21.11/src/main/java/com/minekube/connect/share/fabric/v1_21_11/mixin/ServerConnectionListenerMixin.java new file mode 100644 index 000000000..8f2912143 --- /dev/null +++ b/share/fabric-1.21.11/src/main/java/com/minekube/connect/share/fabric/v1_21_11/mixin/ServerConnectionListenerMixin.java @@ -0,0 +1,57 @@ +package com.minekube.connect.share.fabric.v1_21_11.mixin; + +import com.minekube.connect.share.CapturedServerTransport; +import io.netty.channel.Channel; +import io.netty.channel.ChannelHandler; +import io.netty.channel.ChannelInitializer; +import io.netty.channel.EventLoopGroup; +import java.net.InetAddress; +import net.minecraft.server.network.ServerConnectionListener; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.ModifyArg; +import org.spongepowered.asm.mixin.injection.ModifyVariable; + +@Mixin(ServerConnectionListener.class) +public abstract class ServerConnectionListenerMixin { + @ModifyVariable( + method = "startTcpServerListener", + at = @At("HEAD"), + argsOnly = true, + ordinal = 0) + private InetAddress connectShare$forceLoopback(InetAddress requestedAddress) { + return CapturedServerTransport.isShareStartArmed() + ? InetAddress.getLoopbackAddress() + : requestedAddress; + } + + @ModifyArg( + method = "startTcpServerListener", + at = @At( + value = "INVOKE", + target = "Lio/netty/bootstrap/ServerBootstrap;childHandler" + + "(Lio/netty/channel/ChannelHandler;)" + + "Lio/netty/bootstrap/ServerBootstrap;"), + index = 0) + @SuppressWarnings("unchecked") + private ChannelHandler connectShare$captureChildInitializer(ChannelHandler handler) { + if (CapturedServerTransport.isShareStartArmed() + && handler instanceof ChannelInitializer) { + CapturedServerTransport.captureChildInitializer( + (ChannelInitializer) handler); + } + return handler; + } + + @ModifyArg( + method = "startTcpServerListener", + at = @At( + value = "INVOKE", + target = "Lio/netty/bootstrap/ServerBootstrap;group" + + "(Lio/netty/channel/EventLoopGroup;)" + + "Lio/netty/bootstrap/ServerBootstrap;"), + index = 0) + private EventLoopGroup connectShare$captureEventLoopGroup(EventLoopGroup group) { + return CapturedServerTransport.captureEventLoopGroup(group); + } +} diff --git a/share/fabric-1.21.11/src/main/java/com/minekube/connect/share/fabric/v1_21_11/mixin/ServerLoginPacketListenerMixin.java b/share/fabric-1.21.11/src/main/java/com/minekube/connect/share/fabric/v1_21_11/mixin/ServerLoginPacketListenerMixin.java new file mode 100644 index 000000000..d546be367 --- /dev/null +++ b/share/fabric-1.21.11/src/main/java/com/minekube/connect/share/fabric/v1_21_11/mixin/ServerLoginPacketListenerMixin.java @@ -0,0 +1,101 @@ +package com.minekube.connect.share.fabric.v1_21_11.mixin; + +import com.mojang.authlib.GameProfile; +import com.minekube.connect.share.fabric.v1_21_11.Minecraft12111LoginBridge; +import net.minecraft.network.Connection; +import net.minecraft.network.chat.Component; +import net.minecraft.network.protocol.login.ServerboundHelloPacket; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.network.ServerLoginPacketListenerImpl; +import org.jspecify.annotations.Nullable; +import org.spongepowered.asm.mixin.Final; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.Unique; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +@Mixin(ServerLoginPacketListenerImpl.class) +public abstract class ServerLoginPacketListenerMixin { + @Shadow @Final private MinecraftServer server; + @Shadow @Final private Connection connection; + @Shadow @Nullable String requestedUsername; + + @Shadow + abstract void startClientVerification(GameProfile profile); + + @Shadow + public abstract void disconnect(Component reason); + + @Unique private boolean connectShare$admissionStarted; + @Unique private boolean connectShare$admissionAllowed; + + @Inject(method = "handleHello", at = @At("HEAD"), cancellable = true) + private void connectShare$acceptConnectProfile( + ServerboundHelloPacket hello, + CallbackInfo callback) { + if (!Minecraft12111LoginBridge.hasConnectIdentity(connection)) { + if (Minecraft12111LoginBridge.shouldUseOfflineDirectProfile(connection)) { + GameProfile profile = Minecraft12111LoginBridge.offlineProfile(hello.name()); + if (profile == null) { + disconnect(Component.literal("Invalid characters in username")); + } else { + requestedUsername = profile.name(); + startClientVerification(profile); + } + callback.cancel(); + } + return; + } + + GameProfile profile = Minecraft12111LoginBridge.authenticatedProfile( + connection, hello.name()); + if (profile == null) { + disconnect(Component.literal("Connect identity is invalid")); + callback.cancel(); + return; + } + + requestedUsername = profile.name(); + startClientVerification(profile); + callback.cancel(); + } + + @Inject( + method = "verifyLoginAndFinishConnectionSetup", + at = @At("HEAD"), + cancellable = true) + private void connectShare$awaitPassthroughAdmission( + GameProfile profile, + CallbackInfo callback) { + boolean direct = Minecraft12111LoginBridge.hasDirectSession(connection); + if (!direct && !Minecraft12111LoginBridge.isPassthroughConnect(connection)) { + return; + } + if (connectShare$admissionAllowed) { + return; + } + + callback.cancel(); + if (connectShare$admissionStarted) { + return; + } + connectShare$admissionStarted = true; + if (direct) { + Minecraft12111LoginBridge.requestDirectAdmission( + connection, + server, + profile, + () -> connectShare$admissionAllowed = true, + this::disconnect); + } else { + Minecraft12111LoginBridge.requestPassthroughAdmission( + connection, + server, + profile, + () -> connectShare$admissionAllowed = true, + this::disconnect); + } + } +} diff --git a/share/fabric-1.21.11/src/main/java/com/minekube/connect/share/fabric/v1_21_11/mixin/TitleScreenMixin.java b/share/fabric-1.21.11/src/main/java/com/minekube/connect/share/fabric/v1_21_11/mixin/TitleScreenMixin.java new file mode 100644 index 000000000..dd6842a68 --- /dev/null +++ b/share/fabric-1.21.11/src/main/java/com/minekube/connect/share/fabric/v1_21_11/mixin/TitleScreenMixin.java @@ -0,0 +1,49 @@ +package com.minekube.connect.share.fabric.v1_21_11.mixin; + +import com.minekube.connect.share.fabric.ConnectShareClient; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.network.chat.Component; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Unique; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +@Mixin(net.minecraft.client.gui.screens.TitleScreen.class) +abstract class TitleScreenMixin extends Screen { + @Unique private Button connectShare$friendsButton; + + protected TitleScreenMixin(Component title) { + super(title); + } + + @Inject(method = "init", at = @At("TAIL")) + private void connectShare$addJoinButton(CallbackInfo ci) { + if (!ConnectShareClient.isInstalled()) { + return; + } + connectShare$friendsButton = addRenderableWidget( + Button.builder( + connectShare$friendsLabel(), + button -> ConnectShareClient.openJoinScreen(this)) + .bounds(width - 74, 4, 70, 20) + .build()); + } + + @Inject(method = "tick", at = @At("TAIL")) + private void connectShare$refreshJoinButton(CallbackInfo ci) { + if (connectShare$friendsButton != null) { + connectShare$friendsButton.setMessage(connectShare$friendsLabel()); + } + } + + @Unique + private Component connectShare$friendsLabel() { + int count = ConnectShareClient.friendsButtonCount(); + String key = ConnectShareClient.friendsButtonTranslationKey(); + return count > 0 + ? Component.translatable(key, count) + : Component.translatable(key); + } +} diff --git a/share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/BlockedFriendsScreen.kt b/share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/BlockedFriendsScreen.kt new file mode 100644 index 000000000..ca424d4e5 --- /dev/null +++ b/share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/BlockedFriendsScreen.kt @@ -0,0 +1,142 @@ +package com.minekube.connect.share.fabric.v1_21_11 + +import com.minekube.connect.share.fabric.ConnectShareClient +import com.minekube.connect.share.fabric.ui.AdaptiveShareLayout +import com.minekube.connect.share.fabric.ui.page +import net.minecraft.ChatFormatting +import net.minecraft.client.gui.components.Button +import net.minecraft.client.gui.components.MultiLineTextWidget +import net.minecraft.client.gui.components.StringWidget +import net.minecraft.client.gui.screens.Screen +import net.minecraft.network.chat.CommonComponents +import net.minecraft.network.chat.Component + +class BlockedFriendsScreen( + private val parent: Screen, +) : Screen(Component.translatable("connect_share.privacy.blocked_title")) { + private var offset = 0 + + override fun init() { + val friends = ConnectShareClient.friendsViewModel() + val layout = AdaptiveShareLayout.friends(width, height) + val blocked = friends.state.value.blocked + val page = blocked.page(offset, layout.visibleRows) + offset = page.offset + + addRenderableWidget( + centered(title.copy().withStyle(ChatFormatting.BOLD), layout.headerY), + ) + addRenderableWidget( + MultiLineTextWidget( + layout.contentX, + layout.subtitleY, + Component.translatable( + "connect_share.privacy.blocked_description", + ).withStyle(ChatFormatting.GRAY), + font, + ).setMaxWidth(layout.contentWidth).setCentered(true), + ) + + page.items.forEachIndexed { index, identity -> + val y = layout.rowY(index) + val buttonWidth = 88 + addRenderableWidget( + StringWidget( + layout.contentX, + y, + layout.contentWidth - buttonWidth - 6, + 20, + Component.literal(identity.displayName), + font, + ).setMaxWidth(layout.contentWidth - buttonWidth - 6), + ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.privacy.unblock"), + ) { + friends.unblock(identity.peerId) + rebuildWidgets() + }.bounds( + layout.contentX + layout.contentWidth - buttonWidth, + y, + buttonWidth, + 20, + ).build(), + ) + } + + if (blocked.isEmpty()) { + addRenderableWidget( + centered( + Component.translatable("connect_share.privacy.blocked_empty") + .withStyle(ChatFormatting.GRAY), + layout.rowsTop + 18, + ), + ) + } else { + addRenderableWidget( + centered( + Component.translatable( + "connect_share.friends.page", + page.pageNumber, + page.pageCount, + ).withStyle(ChatFormatting.DARK_GRAY), + layout.messageY, + ), + ) + } + + val pageButtonWidth = layout.halfButtonWidth + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.page.previous"), + ) { + offset = page.previousOffset ?: 0 + rebuildWidgets() + }.bounds( + layout.contentX, + layout.footerTop, + pageButtonWidth, + 20, + ).build().apply { active = page.hasPrevious }, + ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.page.next"), + ) { + offset = page.nextOffset ?: page.offset + rebuildWidgets() + }.bounds( + layout.contentX + pageButtonWidth + 6, + layout.footerTop, + pageButtonWidth, + 20, + ).build().apply { active = page.hasNext }, + ) + addRenderableWidget( + Button.builder(CommonComponents.GUI_BACK) { onClose() } + .bounds( + layout.contentX, + layout.footerTop + 24, + layout.contentWidth, + 20, + ).build(), + ) + } + + override fun onClose() { + minecraft.setScreen(parent) + } + + private fun centered(message: Component, y: Int): StringWidget { + val textWidth = font.width(message) + return StringWidget( + width / 2 - textWidth / 2, + y, + textWidth, + 11, + message, + font, + ) + } +} diff --git a/share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/CompatibilityMismatchScreen.kt b/share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/CompatibilityMismatchScreen.kt new file mode 100644 index 000000000..dcc971a29 --- /dev/null +++ b/share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/CompatibilityMismatchScreen.kt @@ -0,0 +1,142 @@ +package com.minekube.connect.share.fabric.v1_21_11 + +import com.minekube.connect.share.fabric.FriendJoinAttemptFailure +import com.minekube.connect.share.fabric.ui.AdaptiveShareLayout +import com.minekube.connect.share.fabric.ui.presentation +import net.minecraft.ChatFormatting +import net.minecraft.client.gui.components.Button +import net.minecraft.client.gui.components.MultiLineTextWidget +import net.minecraft.client.gui.components.StringWidget +import net.minecraft.client.gui.screens.Screen +import net.minecraft.network.chat.CommonComponents +import net.minecraft.network.chat.Component + +class CompatibilityMismatchScreen( + private val parent: Screen, + private val failure: FriendJoinAttemptFailure.Compatibility, + private val tryAnyway: () -> Unit, +) : Screen(Component.translatable("connect_share.compatibility.title")) { + private var packCopied = false + + override fun init() { + val layout = AdaptiveShareLayout.form(width, height, 1) + addRenderableWidget( + centered(title.copy().withStyle(ChatFormatting.YELLOW), layout.headerY), + ) + addRenderableWidget( + MultiLineTextWidget( + layout.contentX, + layout.subtitleY, + Component.translatable("connect_share.compatibility.description"), + font, + ).setMaxWidth(layout.contentWidth).setCentered(true), + ) + + val visibleDifferences = ((layout.availableBodyHeight - 24) / 15) + .coerceIn(1, 5) + failure.report.differences + .take(visibleDifferences) + .forEachIndexed { index, difference -> + val line = difference.presentation() + addRenderableWidget( + StringWidget( + layout.contentX, + layout.bodyTop + 12 + index * 15, + layout.contentWidth, + 11, + Component.translatable( + line.translationKey, + *line.arguments.toTypedArray(), + ), + font, + ).setMaxWidth(layout.contentWidth), + ) + } + val hidden = failure.report.differences.size - visibleDifferences + if (hidden > 0) { + addRenderableWidget( + centered( + Component.translatable( + "connect_share.compatibility.more", + hidden, + ).withStyle(ChatFormatting.GRAY), + layout.bodyTop + 12 + visibleDifferences * 15, + ), + ) + } + + failure.report.pack?.let { pack -> + addRenderableWidget( + Button.builder( + Component.translatable( + if (packCopied) { + "connect_share.compatibility.pack_copied" + } else { + "connect_share.compatibility.copy_pack" + }, + ), + ) { + minecraft.keyboardHandler.setClipboard(pack.url) + packCopied = true + rebuildWidgets() + }.bounds( + layout.contentX, + layout.footerTop, + layout.contentWidth, + 20, + ).build(), + ) + } + val actionY = layout.footerTop + 24 + if (failure.canTryAnyway) { + addRenderableWidget( + Button.builder( + Component.translatable( + "connect_share.compatibility.try_anyway", + ), + ) { + minecraft.setScreen(parent) + tryAnyway() + }.bounds( + layout.contentX, + actionY, + layout.halfButtonWidth, + 20, + ).build(), + ) + } + addRenderableWidget( + Button.builder(CommonComponents.GUI_BACK) { onClose() } + .bounds( + if (failure.canTryAnyway) { + layout.contentX + layout.halfButtonWidth + 6 + } else { + layout.contentX + }, + actionY, + if (failure.canTryAnyway) { + layout.halfButtonWidth + } else { + layout.contentWidth + }, + 20, + ).build(), + ) + } + + override fun onClose() { + minecraft.setScreen(parent) + } + + private fun centered(message: Component, y: Int): StringWidget { + val textWidth = font.width(message) + return StringWidget( + width / 2 - textWidth / 2, + y, + textWidth, + 11, + message, + font, + ) + } +} diff --git a/share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/ConnectGameProfileMapper.kt b/share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/ConnectGameProfileMapper.kt new file mode 100644 index 000000000..0787ece34 --- /dev/null +++ b/share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/ConnectGameProfileMapper.kt @@ -0,0 +1,48 @@ +package com.minekube.connect.share.fabric.v1_21_11 + +import arrow.core.Either +import arrow.core.raise.either +import arrow.core.raise.ensure +import com.mojang.authlib.GameProfile +import com.mojang.authlib.properties.Property +import com.minekube.connect.api.player.GameProfile as ConnectGameProfile +import net.minecraft.util.StringUtil + +object ConnectGameProfileMapper { + fun toMinecraft( + source: ConnectGameProfile, + ): Either = either { + ensure( + source.username.isNotBlank() && + StringUtil.isValidPlayerName(source.username), + ) { + ProfileMappingFailure.InvalidName + } + val properties = source.properties.map { property -> + ensure(property.name.isNotBlank() && property.value.isNotBlank()) { + ProfileMappingFailure.InvalidProperty + } + val signature = property.signature?.takeIf(String::isNotEmpty) + if (signature == null) { + Property(property.name, property.value) + } else { + Property(property.name, property.value, signature) + } + } + MinecraftGameProfileFactory.create( + source.uniqueId, + source.username, + properties, + ) + } + + @JvmStatic + fun toMinecraftOrNull(source: ConnectGameProfile): GameProfile? = + toMinecraft(source).getOrNull() +} + +sealed interface ProfileMappingFailure { + data object InvalidName : ProfileMappingFailure + + data object InvalidProperty : ProfileMappingFailure +} diff --git a/share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/ConnectShare12111Client.kt b/share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/ConnectShare12111Client.kt new file mode 100644 index 000000000..999123815 --- /dev/null +++ b/share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/ConnectShare12111Client.kt @@ -0,0 +1,497 @@ +package com.minekube.connect.share.fabric.v1_21_11 + +import com.minekube.connect.share.admission.NewAdmissionTracker +import com.minekube.connect.share.admission.AdmissionPurpose +import com.minekube.connect.share.fabric.ConnectShareClient +import com.minekube.connect.share.fabric.ConnectShareInstallation +import com.minekube.connect.share.fabric.FabricLocalLoginAdmission +import com.minekube.connect.share.fabric.FabricLocalLoginAdmissionGate +import com.minekube.connect.share.fabric.FabricShareBootstrap +import com.minekube.connect.share.fabric.FabricShareBrowser +import com.minekube.connect.share.fabric.FriendCardReceiver +import com.minekube.connect.share.fabric.FriendActivityResolver +import com.minekube.connect.share.fabric.SocialEvent +import com.minekube.connect.share.fabric.SocialEventTracker +import com.minekube.connect.share.fabric.FriendPresenceMonitor +import com.minekube.connect.share.fabric.FollowAction +import com.minekube.connect.share.fabric.GuestJoinTarget +import com.minekube.connect.share.fabric.MinecraftStatusProbe +import com.minekube.connect.share.fabric.LoadedCompatibilityProfileFactory +import com.minekube.connect.share.fabric.LoadedMod +import com.minekube.connect.share.fabric.ModSide +import com.minekube.connect.share.fabric.ui.ShareUiMessage +import com.minekube.connect.share.fabric.ui.terminalNotification +import com.minekube.connect.share.fabric.ui.uiMessage +import com.minekube.connect.share.ShareState +import com.minekube.connect.share.friend.FriendStore +import com.minekube.connect.share.friend.FriendActivity +import com.minekube.connect.share.friend.FriendActivityKind +import com.minekube.connect.share.friend.FriendJoinRequest +import com.minekube.connect.share.friend.ModLoader +import com.minekube.connect.tunnel.p2p.DirectP2pAuthMode +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicReference +import java.util.UUID +import java.util.logging.Level +import java.util.logging.Logger +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.cancel +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import net.fabricmc.api.ClientModInitializer +import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientLifecycleEvents +import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents +import net.fabricmc.loader.api.FabricLoader +import net.minecraft.SharedConstants +import net.minecraft.client.Minecraft +import net.minecraft.client.gui.components.toasts.SystemToast +import net.minecraft.client.gui.screens.Screen +import net.minecraft.client.gui.screens.ConnectScreen +import net.minecraft.client.gui.screens.TitleScreen +import net.minecraft.client.multiplayer.ServerData +import net.minecraft.client.multiplayer.resolver.ServerAddress +import net.minecraft.network.chat.Component + +class ConnectShare12111Client : ClientModInitializer { + override fun onInitializeClient() { + val client = Minecraft.getInstance() + val clientDispatcher = client.asCoroutineDispatcher() + val scope = CoroutineScope( + SupervisorJob() + clientDispatcher, + ) + val installationReference = + AtomicReference() + val worldAvailableSnapshot = + AtomicBoolean(client.hasSingleplayerServer()) + val playerCountSnapshot = AtomicInteger( + client.singleplayerServer?.playerList?.playerCount ?: 0, + ) + val worldNameSnapshot = AtomicReference( + client.singleplayerServer?.worldData?.levelName + ?: "Minecraft world", + ) + val activitySnapshot = AtomicReference( + FriendActivity(FriendActivityKind.ONLINE), + ) + val activityIdentitySnapshot = AtomicReference(null) + val activityEpochSnapshot = AtomicReference(null) + val joinTargetSnapshot = AtomicReference(null) + val minecraftVersion = + SharedConstants.getCurrentVersion().name() + val modVersion = FabricLoader.getInstance() + .getModContainer("connect-share") + .orElseThrow() + .metadata.version.friendlyString + val compatibilityProfile = LoadedCompatibilityProfileFactory.create( + minecraftVersion = minecraftVersion, + loader = ModLoader.FABRIC, + mods = FabricLoader.getInstance().allMods.map { container -> + val metadata = container.metadata + LoadedMod( + id = metadata.id, + version = metadata.version.friendlyString, + side = when (metadata.environment.name) { + "CLIENT" -> ModSide.CLIENT + "SERVER" -> ModSide.SERVER + else -> ModSide.UNIVERSAL + }, + builtIn = metadata.type == "builtin", + ) + }, + packEnvironment = System.getenv(), + ) + val dataDirectory = FabricLoader.getInstance().configDir + .resolve("minekube-connect-share") + val friendStore = FriendStore(dataDirectory) + val browserReference = + AtomicReference() + val statusProbe = MinecraftStatusProbe() + val remotePresence = FriendPresenceMonitor( + store = friendStore, + directProbe = { friend -> + browserReference.get()?.probeDirect( + friend = friend, + authMode = DirectP2pAuthMode.OFFLINE, + probe = statusProbe, + ) + }, + ) + scope.launch { + while (isActive) { + remotePresence.refresh() + delay(PRESENCE_REFRESH_MILLIS) + } + } + val bootstrapJob = scope.launch(Dispatchers.IO) { + try { + val installation = FabricShareBootstrap.create( + scope = scope, + dataDirectory = dataDirectory, + minecraftVersion = minecraftVersion, + modVersion = modVersion, + worldAvailable = worldAvailableSnapshot.get(), + friendStore = friendStore, + playerCount = playerCountSnapshot::get, + worldDisplayName = worldNameSnapshot::get, + playerDisplayName = { client.user.name }, + friendActivity = activitySnapshot::get, + compatibilityProfile = { compatibilityProfile }, + friendJoinTarget = joinTargetSnapshot::get, + bridgeFactory = { + admission, + admissionScope, + approvedJoins, + gateway, + -> + GatewayMinecraft12111Bridge(gateway) { + FabricLocalLoginAdmissionGate( + admission = FabricLocalLoginAdmission( + admission, + approvedJoins, + ), + scope = admissionScope, + ) + } + }, + screens = { parent, active -> + val parentScreen = parent as Screen + client.execute { + client.setScreen( + if (active) { + ShareStatusScreen(parentScreen) + } else { + ShareSetupScreen(parentScreen) + }, + ) + } + }, + guestScreens = { parent, browser, activity -> + val parentScreen = parent as Screen + client.execute { + client.setScreen( + ShareJoinScreen( + parent = parentScreen, + friends = + ConnectShareClient.friendsViewModel(), + browser = browser, + remotePresence = remotePresence, + friendActivity = activity, + ), + ) + } + }, + ) + browserReference.set(installation.browser) + withContext(clientDispatcher) { + FriendCardNetworking.install( + scope = scope, + issuer = installation.friendCardIssuer, + receiver = installation.friendCardReceiver, + approvedJoins = installation.approvedJoins, + ) + ConnectShareClient.install(installation) + installationReference.set(installation) + LOGGER.info( + "Connect Share friend gateway is ready", + ) + } + } catch (cancellation: CancellationException) { + throw cancellation + } catch (failure: Throwable) { + LOGGER.log( + Level.SEVERE, + "Connect Share initialization failed", + failure, + ) + } + } + val admissionNotifications = NewAdmissionTracker() + val socialNotifications = SocialEventTracker() + val admissionToastId = SystemToast.SystemToastId() + + ClientTickEvents.END_CLIENT_TICK.register { minecraft -> + val installation = + installationReference.get() + ?: return@register + val server = minecraft.singleplayerServer + val worldAvailable = server != null && minecraft.connection != null + worldAvailableSnapshot.set(worldAvailable) + playerCountSnapshot.set( + server?.playerList?.playerCount ?: 0, + ) + worldNameSnapshot.set( + server?.worldData?.levelName ?: "Minecraft world", + ) + val currentServer = minecraft.currentServer + val externalServer = currentServer + ?.takeIf { !worldAvailable } + joinTargetSnapshot.set(externalServer?.ip) + val activityIdentity: Any? = when { + externalServer != null -> "server:${externalServer.ip}" + worldAvailable -> server + else -> null + } + if (activityIdentitySnapshot.getAndSet(activityIdentity) != + activityIdentity + ) { + activityEpochSnapshot.set( + activityIdentity?.let { UUID.randomUUID().toString() }, + ) + } + activitySnapshot.set( + FriendActivityResolver.resolve( + worldAvailable = worldAvailable, + worldSharingActive = installation.viewModel.state.value + .shareState is ShareState.Sharing, + worldName = worldNameSnapshot.get(), + externalServerName = externalServer?.name, + sessionEpoch = activityEpochSnapshot.get(), + compatibility = compatibilityProfile, + ), + ) + ConnectShareClient.integratedWorldChanged( + worldAvailable, + server, + ) + ConnectShareClient.guestConnectionChanged( + minecraft.connection != null, + ) + admissionNotifications.update( + installation.viewModel.state.value.pendingAdmissions, + ).firstOrNull()?.let { request -> + SystemToast.add( + minecraft.toastManager, + admissionToastId, + Component.translatable( + if (request.purpose == AdmissionPurpose.FRIEND) { + "connect_share.notification.friend_request" + } else { + "connect_share.notification.join_request" + }, + ), + Component.translatable( + if (request.purpose == AdmissionPurpose.FRIEND) { + "connect_share.notification.friend_request_detail" + } else { + "connect_share.notification.join_request_detail" + }, + request.identity.name, + ), + ) + } + val friends = installation.friendsViewModel + friends.updateIncoming( + installation.viewModel.state.value.pendingAdmissions, + ) + friends.updateRemotePresence(remotePresence.state.value) + friends.updateActivities(installation.friendActivity.state.value) + processFollowActions(minecraft, installation, scope) + socialNotifications.update(friends.state.value).forEach { event -> + SystemToast.add( + minecraft.toastManager, + SystemToast.SystemToastId(), + event.title(), + event.detail(), + ) + } + } + ClientLifecycleEvents.CLIENT_STOPPING.register { + scope.launch(Dispatchers.IO) { + bootstrapJob.cancelAndJoin() + try { + if (installationReference.get() != null) { + ConnectShareClient.shutdown() + } + } finally { + scope.cancel() + } + } + } + } + + private companion object { + const val PRESENCE_REFRESH_MILLIS = 10_000L + val LOGGER: Logger = Logger.getLogger("Connect") + } + + private fun processFollowActions( + minecraft: Minecraft, + installation: ConnectShareInstallation, + scope: CoroutineScope, + ) { + installation.friendsViewModel.followActions( + activeGameplay = minecraft.level != null, + ).forEach { action -> + when (action) { + is FollowAction.RequestJoin -> { + followToast( + minecraft, + "connect_share.notification.follow_waiting", + "connect_share.notification.follow_waiting_detail", + action.displayName, + ) + scope.launch(Dispatchers.IO) { + installation.friendJoinOrchestrator.request( + action.peerId, + FriendJoinRequest( + requestId = UUID.randomUUID(), + playerName = minecraft.user.name, + playerUuid = minecraft.user.profileId, + ), + ).fold( + ifLeft = { failure -> + minecraft.execute { + followToast( + minecraft, + "connect_share.notification.follow_failed", + null, + failure.uiMessage().component(), + ) + } + }, + ifRight = { target -> + minecraft.execute { + if (minecraft.level != null) { + target.close() + followToast( + minecraft, + "connect_share.notification.follow_ready", + "connect_share.notification.follow_ready_detail", + action.displayName, + ) + } else { + connectFollow( + minecraft, + installation, + action, + target, + ) + } + } + }, + ) + } + } + + is FollowAction.OfferJoinNow -> followToast( + minecraft, + "connect_share.notification.follow_ready", + "connect_share.notification.follow_ready_detail", + action.displayName, + ) + + is FollowAction.Expired, + is FollowAction.Cancelled -> + checkNotNull(action.terminalNotification()).let { + followToast( + minecraft, + it.titleKey, + it.detailKey, + it.displayName, + ) + } + } + } + } + + private fun connectFollow( + minecraft: Minecraft, + installation: ConnectShareInstallation, + action: FollowAction.RequestJoin, + target: GuestJoinTarget, + ) { + val address = when (target) { + is GuestJoinTarget.Connect -> + ServerAddress.parseString(target.publicAddress) + is GuestJoinTarget.Direct -> ServerAddress( + target.localAddress.hostString, + target.localAddress.port, + ) + } + if (target is GuestJoinTarget.Direct) { + ConnectShareClient.holdGuestDirect(target) + } + installation.friendsViewModel.completeFollow(action.peerId) + ConnectScreen.startConnecting( + minecraft.screen ?: TitleScreen(), + minecraft, + address, + ServerData( + action.displayName, + address.toString(), + ServerData.Type.OTHER, + ), + false, + null, + ) + } + + private fun followToast( + minecraft: Minecraft, + titleKey: String, + detailKey: String?, + value: String, + ) { + followToast(minecraft, titleKey, detailKey, Component.literal(value)) + } + + private fun followToast( + minecraft: Minecraft, + titleKey: String, + detailKey: String?, + value: Component, + ) { + SystemToast.add( + minecraft.toastManager, + SystemToast.SystemToastId(), + Component.translatable(titleKey, value), + detailKey?.let { Component.translatable(it, value) } + ?: value, + ) + } + + private fun ShareUiMessage.component() = + Component.translatable(translationKey, *arguments.toTypedArray()) + + private fun SocialEvent.title(): Component = Component.translatable( + when (this) { + is SocialEvent.FriendAccepted -> + "connect_share.notification.friend_accepted" + is SocialEvent.FriendRemoved -> + "connect_share.notification.friend_removed" + is SocialEvent.PlayingServer -> + "connect_share.notification.friend_playing" + is SocialEvent.WorldReady -> + "connect_share.notification.friend_online" + }, + ) + + private fun SocialEvent.detail(): Component = when (this) { + is SocialEvent.FriendAccepted -> Component.translatable( + "connect_share.notification.friend_accepted_detail", + displayName, + ) + is SocialEvent.FriendRemoved -> Component.translatable( + "connect_share.notification.friend_removed_detail", + displayName, + ) + is SocialEvent.PlayingServer -> Component.translatable( + "connect_share.notification.friend_playing_detail", + displayName, + serverName, + ) + is SocialEvent.WorldReady -> Component.translatable( + "connect_share.notification.friend_online_detail", + displayName, + worldName ?: "Minecraft world", + ) + } +} diff --git a/share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/EndpointIdentityScreen.kt b/share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/EndpointIdentityScreen.kt new file mode 100644 index 000000000..eaaa99b40 --- /dev/null +++ b/share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/EndpointIdentityScreen.kt @@ -0,0 +1,255 @@ +package com.minekube.connect.share.fabric.v1_21_11 + +import com.minekube.connect.share.fabric.ConnectShareClient +import com.minekube.connect.share.fabric.ui.ShareUiMessage +import com.minekube.connect.share.fabric.ui.AdaptiveShareLayout +import com.minekube.connect.share.identity.CredentialSource +import java.nio.file.Path +import net.minecraft.ChatFormatting +import net.minecraft.client.gui.components.Button +import net.minecraft.client.gui.components.EditBox +import net.minecraft.client.gui.components.MultiLineTextWidget +import net.minecraft.client.gui.components.StringWidget +import net.minecraft.client.gui.screens.ConfirmScreen +import net.minecraft.client.gui.screens.Screen +import net.minecraft.network.chat.CommonComponents +import net.minecraft.network.chat.Component +import net.minecraft.network.chat.Style +import net.minecraft.util.FormattedCharSequence +import org.lwjgl.util.tinyfd.TinyFileDialogs + +class EndpointIdentityScreen( + private val parent: Screen, +) : Screen(Component.translatable("connect_share.identity.title")) { + private val viewModel = ConnectShareClient.viewModel() + private var fingerprint = 0 + private var endpointBox: EditBox? = null + private var tokenBox: EditBox? = null + + override fun init() { + val state = viewModel.state.value + fingerprint = state.copy( + importDraft = state.importDraft.copy(token = ""), + ).hashCode() + val layout = AdaptiveShareLayout.form(width, height, 2) + + addRenderableWidget( + centered(title.copy().withStyle(ChatFormatting.BOLD), layout.headerY), + ) + addRenderableWidget( + MultiLineTextWidget( + layout.contentX, + layout.subtitleY, + Component.translatable("connect_share.identity.description"), + font, + ).setMaxWidth(layout.contentWidth).setCentered(true), + ) + + addRenderableWidget( + StringWidget( + layout.contentX, + layout.bodyTop, + layout.contentWidth, + 11, + Component.translatable("connect_share.identity.endpoint"), + font, + ), + ) + endpointBox = EditBox( + font, + layout.contentX, + layout.bodyTop + 12, + layout.contentWidth, + 20, + Component.translatable("connect_share.identity.endpoint"), + ).also { box -> + box.value = state.importDraft.endpoint + box.setHint(Component.translatable("connect_share.identity.endpoint_hint")) + box.setResponder(viewModel::setImportEndpoint) + box.setEditable(state.importDraft.endpointEditable) + addRenderableWidget(box) + } + + addRenderableWidget( + StringWidget( + layout.contentX, + layout.bodyTop + 38, + layout.contentWidth, + 11, + Component.translatable("connect_share.identity.token"), + font, + ), + ) + tokenBox = EditBox( + font, + layout.contentX, + layout.bodyTop + 50, + layout.contentWidth, + 20, + Component.translatable("connect_share.identity.token"), + ).also { box -> + box.value = state.importDraft.token + box.setHint(Component.translatable("connect_share.identity.token_hint")) + box.setResponder(viewModel::setImportToken) + box.addFormatter { text, _ -> + FormattedCharSequence.forward( + "•".repeat(text.length), + Style.EMPTY, + ) + } + box.setEditable(state.importDraft.tokenEditable) + addRenderableWidget(box) + } + + val identity = state.identity + addRenderableWidget( + MultiLineTextWidget( + layout.contentX, + layout.bodyTop + 78, + Component.translatable( + "connect_share.identity.sources", + identity?.endpointSource?.displayName() ?: "…", + identity?.tokenSource?.displayName() ?: "…", + ).withStyle(ChatFormatting.GRAY), + font, + ).setMaxWidth(layout.contentWidth).setCentered(true), + ) + state.safeMessage?.let { safeMessage -> + addRenderableWidget( + MultiLineTextWidget( + layout.contentX, + layout.bodyTop + 104, + safeMessage.component() + .withStyle(ChatFormatting.YELLOW), + font, + ).setMaxWidth(layout.contentWidth).setCentered(true), + ) + } + + val save = addRenderableWidget( + Button.builder( + Component.translatable("connect_share.identity.save"), + ) { + viewModel.importIdentity() + }.bounds( + layout.contentX, + layout.footerTop, + layout.halfButtonWidth, + 20, + ).build(), + ) + val choose = addRenderableWidget( + Button.builder( + Component.translatable("connect_share.identity.choose_file"), + ) { + chooseTokenFile()?.let(viewModel::importTokenFile) + }.bounds( + layout.contentX + layout.halfButtonWidth + 6, + layout.footerTop, + layout.halfButtonWidth, + 20, + ).build(), + ) + save.active = state.importDraft.endpointEditable && + state.importDraft.tokenEditable && + !state.operationInProgress + choose.active = state.importDraft.endpointEditable && + state.importDraft.tokenEditable && + !state.operationInProgress + + val reset = addRenderableWidget( + Button.builder( + Component.translatable("connect_share.identity.reset"), + ) { + confirmReset() + }.bounds( + layout.contentX, + layout.footerTop + 24, + layout.halfButtonWidth, + 20, + ).build(), + ) + reset.active = state.importDraft.endpointEditable && + state.importDraft.tokenEditable && + !state.operationInProgress + addRenderableWidget( + Button.builder(CommonComponents.GUI_BACK) { onClose() } + .bounds( + layout.contentX + layout.halfButtonWidth + 6, + layout.footerTop + 24, + layout.halfButtonWidth, + 20, + ).build(), + ) + } + + override fun tick() { + super.tick() + val state = viewModel.state.value + val next = state.copy( + importDraft = state.importDraft.copy(token = ""), + ).hashCode() + if ( + next != fingerprint || + state.importDraft.token.isEmpty() && + tokenBox?.value?.isNotEmpty() == true + ) { + rebuildWidgets() + } + } + + override fun onClose() { + viewModel.setImportToken("") + minecraft.setScreen(parent) + } + + private fun confirmReset() { + minecraft.setScreen( + ConfirmScreen( + { confirmed -> + if (confirmed) { + viewModel.resetIdentity() + } + minecraft.setScreen(this) + }, + Component.translatable( + "connect_share.identity.reset_confirm.title", + ), + Component.translatable( + "connect_share.identity.reset_confirm.message", + ), + ), + ) + } + + private fun chooseTokenFile(): Path? { + val selected = TinyFileDialogs.tinyfd_openFileDialog( + Component.translatable("connect_share.identity.choose_file").string, + null, + null, + "token.json", + false, + ) ?: return null + return Path.of(selected) + } + + private fun centered(message: Component, y: Int): StringWidget { + val textWidth = font.width(message) + return StringWidget( + width / 2 - textWidth / 2, + y, + textWidth, + 11, + message, + font, + ) + } +} + +private fun ShareUiMessage.component() = + Component.translatable(translationKey, *arguments.toTypedArray()) + +private fun CredentialSource.displayName(): Component = + Component.translatable( + "connect_share.identity.source.${name.lowercase()}", + ) diff --git a/share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/FriendCardNetworking.kt b/share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/FriendCardNetworking.kt new file mode 100644 index 000000000..566857c3a --- /dev/null +++ b/share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/FriendCardNetworking.kt @@ -0,0 +1,101 @@ +package com.minekube.connect.share.fabric.v1_21_11 + +import com.minekube.connect.share.fabric.ApprovedJoinTracker +import com.minekube.connect.share.fabric.ConnectShareClient +import com.minekube.connect.share.fabric.FriendCardIssuer +import com.minekube.connect.share.fabric.FriendCardReceiver +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking +import net.fabricmc.fabric.api.networking.v1.PayloadTypeRegistry +import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents +import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking + +object FriendCardNetworking { + fun install( + scope: CoroutineScope, + issuer: FriendCardIssuer, + receiver: FriendCardReceiver, + approvedJoins: ApprovedJoinTracker, + ) { + PayloadTypeRegistry.playC2S().register( + FriendCardPayload.TYPE, + FriendCardPayload.CODEC, + ) + PayloadTypeRegistry.playS2C().register( + FriendCardRequestPayload.TYPE, + FriendCardRequestPayload.CODEC, + ) + ServerPlayNetworking.registerGlobalReceiver( + FriendCardPayload.TYPE, + ) { payload, context -> + context.server().execute { + val player = context.player() + val proof = approvedJoins.consume( + player.gameProfile.name(), + player.uuid, + payload.invitation, + ) ?: return@execute + receiver.receive( + invitation = payload.invitation, + displayName = player.gameProfile.name(), + authenticatedMinecraftUuid = + proof.authenticatedMinecraftUuid, + allowAutomaticJoin = true, + relationshipId = payload.relationshipId, + ) + } + } + ServerPlayConnectionEvents.JOIN.register( + ServerPlayConnectionEvents.Join { handler, _, _ -> + val player = handler.player + if ( + approvedJoins.hasProof( + player.gameProfile.name(), + player.uuid, + ) && + ServerPlayNetworking.canSend( + player, + FriendCardRequestPayload.TYPE, + ) + ) { + ServerPlayNetworking.send( + player, + FriendCardRequestPayload, + ) + } + }, + ) + ClientPlayNetworking.registerGlobalReceiver( + FriendCardRequestPayload.TYPE, + ) { _, context -> + val exchange = + ConnectShareClient.consumeFriendCardExchangeConsent() + ?: return@registerGlobalReceiver + val client = context.client() + scope.launch(Dispatchers.IO) { + issuer.issue().getOrNull()?.let { invitation -> + client.execute { + if ( + client.connection != null && + ClientPlayNetworking.canSend( + FriendCardPayload.TYPE, + ) + ) { + ClientPlayNetworking.send( + FriendCardPayload( + invitation, + exchange.relationshipId, + ), + ) + scope.launch(Dispatchers.IO) { + receiver.confirmOutgoing(exchange.peerId) + } + } + } + } + } + } + } +} diff --git a/share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/FriendCardPayload.kt b/share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/FriendCardPayload.kt new file mode 100644 index 000000000..e4a7bed57 --- /dev/null +++ b/share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/FriendCardPayload.kt @@ -0,0 +1,59 @@ +package com.minekube.connect.share.fabric.v1_21_11 + +import java.util.UUID +import net.minecraft.network.FriendlyByteBuf +import net.minecraft.network.codec.StreamCodec +import net.minecraft.network.protocol.common.custom.CustomPacketPayload +import net.minecraft.resources.Identifier + +data class FriendCardPayload( + val invitation: String, + val relationshipId: UUID = UUID.randomUUID(), +) : CustomPacketPayload { + override fun type(): CustomPacketPayload.Type = TYPE + + companion object { + const val MAX_CARD_CHARS = 16_384 + + val TYPE: CustomPacketPayload.Type = + CustomPacketPayload.Type( + Identifier.fromNamespaceAndPath( + "connect-share", + "friend-card", + ), + ) + + val CODEC: StreamCodec = + CustomPacketPayload.codec( + { payload, buffer -> + buffer.writeUtf( + payload.invitation, + MAX_CARD_CHARS, + ) + buffer.writeUUID(payload.relationshipId) + }, + { buffer -> + FriendCardPayload( + invitation = buffer.readUtf(MAX_CARD_CHARS), + relationshipId = buffer.readUUID(), + ) + }, + ) + } +} + +data object FriendCardRequestPayload : CustomPacketPayload { + override fun type(): CustomPacketPayload.Type = + TYPE + + val TYPE: CustomPacketPayload.Type = + CustomPacketPayload.Type( + Identifier.fromNamespaceAndPath( + "connect-share", + "friend-card-request", + ), + ) + + val CODEC: StreamCodec = + StreamCodec.unit(FriendCardRequestPayload) +} diff --git a/share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/Minecraft12111Bridge.kt b/share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/Minecraft12111Bridge.kt new file mode 100644 index 000000000..49efb333c --- /dev/null +++ b/share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/Minecraft12111Bridge.kt @@ -0,0 +1,62 @@ +package com.minekube.connect.share.fabric.v1_21_11 + +import com.minekube.connect.share.LocalShareChannel as CommonLocalShareChannel +import com.minekube.connect.share.LocalShareChannelBinder as CommonLocalShareChannelBinder +import com.minekube.connect.share.MinecraftVersionTransport +import com.minekube.connect.share.NettyLocalShareChannelBinder +import com.minekube.connect.share.PublishedMinecraftTransport as CommonPublishedMinecraftTransport +import com.minekube.connect.share.ShareConnectionGateway +import com.minekube.connect.share.VersionedMinecraftBridge +import com.minekube.connect.share.CaptureLease as CommonCaptureLease +import com.minekube.connect.share.CapturedServerTransport as CommonCapturedServerTransport +import com.minekube.connect.share.CapturedTransport as CommonCapturedTransport +import com.minekube.connect.share.fabric.FabricLoginAdmissionRegistry +import com.minekube.connect.share.fabric.FabricLocalLoginAdmissionGate + +class Minecraft12111Bridge internal constructor( + transport: Minecraft12111Transport, + localBinder: LocalShareChannelBinder, + loginAdmissionFactory: (() -> FabricLocalLoginAdmissionGate)? = null, +) : VersionedMinecraftBridge( + transport = transport, + localBinder = localBinder, + loginAdmissionAcquire = loginAdmissionFactory?.let { factory -> + { + FabricLoginAdmissionRegistry.install(factory()) + } + }, +) { + constructor() : this( + VanillaMinecraft12111Transport(), + NettyLocalShareChannelBinder(), + ) + + constructor( + loginAdmissionFactory: () -> FabricLocalLoginAdmissionGate, + ) : this( + VanillaMinecraft12111Transport(), + NettyLocalShareChannelBinder(), + loginAdmissionFactory, + ) +} + +internal class GatewayMinecraft12111Bridge( + gateway: ShareConnectionGateway, + loginAdmissionFactory: () -> FabricLocalLoginAdmissionGate, +) : VersionedMinecraftBridge( + transport = VanillaMinecraft12111Transport(), + gateway = gateway, + loginAdmissionAcquire = { + FabricLoginAdmissionRegistry.install( + loginAdmissionFactory(), + ) + }, +) + +internal typealias Minecraft12111Transport = MinecraftVersionTransport +internal typealias PublishedMinecraftTransport = CommonPublishedMinecraftTransport +internal typealias LocalShareChannelBinder = CommonLocalShareChannelBinder +internal typealias LocalShareChannel = CommonLocalShareChannel +internal typealias CapturedServerTransport = CommonCapturedServerTransport +internal typealias CaptureLease = CommonCaptureLease +internal typealias CapturedTransport = CommonCapturedTransport diff --git a/share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/Minecraft12111LoginBridge.kt b/share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/Minecraft12111LoginBridge.kt new file mode 100644 index 000000000..a2ac1224d --- /dev/null +++ b/share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/Minecraft12111LoginBridge.kt @@ -0,0 +1,175 @@ +package com.minekube.connect.share.fabric.v1_21_11 + +import com.mojang.authlib.GameProfile +import com.minekube.connect.api.ConnectAttributes +import com.minekube.connect.network.netty.LocalSession +import com.minekube.connect.share.admission.AdmissionAnswer +import com.minekube.connect.share.admission.Ingress +import com.minekube.connect.share.direct.DirectSessionAttributes +import com.minekube.connect.share.fabric.DirectMinecraftAuthentication +import com.minekube.connect.share.fabric.FabricDirectAuthenticationPolicy +import com.minekube.connect.share.fabric.FabricLoginAdmissionRegistry +import com.minekube.connect.share.fabric.ui.ShareLoginMessages +import com.minekube.connect.tunnel.p2p.DirectP2pRoute +import com.minekube.connect.tunnel.p2p.DirectP2pSession +import com.minekube.connect.share.fabric.v1_21_11.mixin.ConnectionAccessor +import java.util.function.Consumer +import net.minecraft.network.Connection +import net.minecraft.network.chat.Component +import net.minecraft.server.MinecraftServer +import net.minecraft.core.UUIDUtil +import net.minecraft.util.StringUtil + +object Minecraft12111LoginBridge { + @JvmStatic + fun hasConnectIdentity(connection: Connection): Boolean = + channel(connection).attr(ConnectAttributes.CONNECT_PLAYER).get() != null + + @JvmStatic + fun authenticatedProfile( + connection: Connection, + requestedName: String, + ): GameProfile? { + val player = channel(connection) + .attr(ConnectAttributes.CONNECT_PLAYER) + .get() + ?: return null + val profile = ConnectGameProfileMapper + .toMinecraft(player.gameProfile) + .getOrNull() + ?: return null + return profile.takeIf { + requestedName.equals(profile.name(), ignoreCase = true) + } + } + + @JvmStatic + fun isPassthroughConnect(connection: Connection): Boolean = + LocalSession.context(channel(connection)) + .map { it.player.auth.isPassthrough } + .orElse(false) + + @JvmStatic + fun hasDirectSession(connection: Connection): Boolean = + directSession(connection) != null + + @JvmStatic + fun shouldUseOfflineDirectProfile(connection: Connection): Boolean = + directSession(connection)?.let { + FabricDirectAuthenticationPolicy.minecraftAuthentication( + it.authMode(), + ) == DirectMinecraftAuthentication.OFFLINE_PROFILE + } == true + + @JvmStatic + fun offlineProfile(requestedName: String): GameProfile? = + requestedName.takeIf(StringUtil::isValidPlayerName) + ?.let(UUIDUtil::createOfflineProfile) + + @JvmStatic + fun requestPassthroughAdmission( + connection: Connection, + server: MinecraftServer, + profile: GameProfile, + allow: Runnable, + deny: Consumer, + ) { + val channel = channel(connection) + val context = LocalSession.context(channel).orElse(null) + if (context == null || !context.player.auth.isPassthrough) { + server.execute(allow) + return + } + val decision = FabricLoginAdmissionRegistry.request( + name = profile.name(), + uuid = profile.id(), + connectionId = context.player.sessionId, + minecraftAuthenticated = + server.usesAuthentication() && !connection.isMemoryConnection, + ingress = Ingress.CONNECT, + ).toCompletableFuture() + channel.closeFuture().addListener { + decision.cancel(false) + } + decision.whenComplete { answer, failure -> + server.execute { + if (!connection.isConnected) { + return@execute + } + if (failure == null && answer == AdmissionAnswer.ALLOW) { + allow.run() + } else { + deny.accept(denialReason(answer)) + } + } + } + } + + @JvmStatic + fun requestDirectAdmission( + connection: Connection, + server: MinecraftServer, + profile: GameProfile, + allow: Runnable, + deny: Consumer, + ) { + val channel = channel(connection) + val session = directSession(connection) + if (session == null) { + server.execute(allow) + return + } + val minecraftAuthenticated = + server.usesAuthentication() && !connection.isMemoryConnection + FabricDirectAuthenticationPolicy.validate( + session.authMode(), + minecraftAuthenticated, + ).onLeft { + server.execute { + deny.accept( + Component.literal( + ShareLoginMessages.AUTHENTICATION_REQUIRED.fallback, + ), + ) + } + return + } + val decision = FabricLoginAdmissionRegistry.request( + name = profile.name(), + uuid = profile.id(), + connectionId = session.connectionId(), + minecraftAuthenticated = minecraftAuthenticated, + ingress = session.route().toIngress(), + directPeerId = session.peerId(), + ).toCompletableFuture() + channel.closeFuture().addListener { + decision.cancel(false) + } + decision.whenComplete { answer, failure -> + server.execute { + if (!connection.isConnected) { + return@execute + } + if (failure == null && answer == AdmissionAnswer.ALLOW) { + allow.run() + } else { + deny.accept(denialReason(answer)) + } + } + } + } + + private fun channel(connection: Connection) = + (connection as ConnectionAccessor).connectShareChannel + + private fun directSession(connection: Connection): DirectP2pSession? = + channel(connection).attr(DirectSessionAttributes.SESSION).get() + + private fun DirectP2pRoute.toIngress(): Ingress = when (this) { + DirectP2pRoute.LAN -> Ingress.DIRECT_LAN + DirectP2pRoute.INTERNET -> Ingress.DIRECT_INTERNET + } + + private fun denialReason(answer: AdmissionAnswer?): Component = + Component.literal(ShareLoginMessages.denial(answer).fallback) +} diff --git a/share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/RecoveryScreen.kt b/share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/RecoveryScreen.kt new file mode 100644 index 000000000..328dfcc97 --- /dev/null +++ b/share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/RecoveryScreen.kt @@ -0,0 +1,276 @@ +package com.minekube.connect.share.fabric.v1_21_11 + +import com.minekube.connect.share.fabric.ConnectShareClient +import com.minekube.connect.share.fabric.ui.AdaptiveShareLayout +import com.minekube.connect.share.fabric.ui.ShareUiMessage +import java.nio.file.Path +import net.minecraft.ChatFormatting +import net.minecraft.client.gui.components.Button +import net.minecraft.client.gui.components.EditBox +import net.minecraft.client.gui.components.MultiLineTextWidget +import net.minecraft.client.gui.components.StringWidget +import net.minecraft.client.gui.screens.Screen +import net.minecraft.network.chat.CommonComponents +import net.minecraft.network.chat.Component +import net.minecraft.network.chat.Style +import net.minecraft.util.FormattedCharSequence +import org.lwjgl.util.tinyfd.TinyFileDialogs + +class RecoveryScreen( + private val parent: Screen, +) : Screen(Component.translatable("connect_share.recovery.title")) { + private val viewModel = ConnectShareClient.recoveryViewModel() + private var fingerprint = 0 + private var passphraseValue = "" + private var confirmationValue = "" + + override fun init() { + val state = viewModel.state.value + fingerprint = state.hashCode() + val layout = AdaptiveShareLayout.form(width, height, 2) + + addRenderableWidget( + centered(title.copy().withStyle(ChatFormatting.BOLD), layout.headerY), + ) + addRenderableWidget( + MultiLineTextWidget( + layout.contentX, + layout.subtitleY, + Component.translatable("connect_share.recovery.description"), + font, + ).setMaxWidth(layout.contentWidth).setCentered(true), + ) + + addRenderableWidget( + StringWidget( + layout.contentX, + layout.bodyTop, + layout.contentWidth, + 11, + Component.translatable("connect_share.recovery.secret"), + font, + ), + ) + secretBox( + value = passphraseValue, + y = layout.bodyTop + 12, + label = "connect_share.recovery.secret", + ) { passphraseValue = it } + + addRenderableWidget( + StringWidget( + layout.contentX, + layout.bodyTop + 38, + layout.contentWidth, + 11, + Component.translatable("connect_share.recovery.secret_confirm"), + font, + ), + ) + secretBox( + value = confirmationValue, + y = layout.bodyTop + 50, + label = "connect_share.recovery.secret_confirm", + ) { confirmationValue = it } + + val status = state.safeMessage?.component() + ?: state.summary?.let { summary -> + Component.translatable( + "connect_share.recovery.summary", + summary.entryCount, + Component.translatable( + if (summary.includesPreferences) { + "connect_share.recovery.included" + } else { + "connect_share.recovery.not_included" + }, + ), + Component.translatable( + if (summary.includesEndpointIdentity) { + "connect_share.recovery.included" + } else { + "connect_share.recovery.not_included" + }, + ), + ) + } + status?.let { + addRenderableWidget( + MultiLineTextWidget( + layout.contentX, + layout.bodyTop + 78, + it.withStyle( + if (state.safeMessage == null) { + ChatFormatting.GREEN + } else { + ChatFormatting.YELLOW + }, + ), + font, + ).setMaxWidth(layout.contentWidth).setCentered(true), + ) + } + addRenderableWidget( + MultiLineTextWidget( + layout.contentX, + layout.bodyTop + 106, + Component.translatable("connect_share.recovery.warning") + .withStyle(ChatFormatting.GRAY), + font, + ).setMaxWidth(layout.contentWidth).setCentered(true), + ) + + if (state.importConfirmationRequired) { + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.recovery.restore_confirm"), + ) { + viewModel.confirmImport() + }.bounds( + layout.contentX, + layout.footerTop, + layout.halfButtonWidth, + 20, + ).build(), + ).active = !state.operationInProgress + addRenderableWidget( + Button.builder(CommonComponents.GUI_CANCEL) { + viewModel.cancelImport() + }.bounds( + layout.contentX + layout.halfButtonWidth + 6, + layout.footerTop, + layout.halfButtonWidth, + 20, + ).build(), + ).active = !state.operationInProgress + } else { + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.recovery.export"), + ) { + chooseBackupDestination()?.let { target -> + val passphrase = passphraseValue.toCharArray() + val confirmation = confirmationValue.toCharArray() + clearInputs() + viewModel.export(target, passphrase, confirmation) + } + }.bounds( + layout.contentX, + layout.footerTop, + layout.halfButtonWidth, + 20, + ).build(), + ).active = !state.operationInProgress + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.recovery.import"), + ) { + chooseBackupSource()?.let { source -> + val passphrase = passphraseValue.toCharArray() + clearInputs() + viewModel.previewImport(source, passphrase) + } + }.bounds( + layout.contentX + layout.halfButtonWidth + 6, + layout.footerTop, + layout.halfButtonWidth, + 20, + ).build(), + ).active = !state.operationInProgress + } + + addRenderableWidget( + Button.builder(CommonComponents.GUI_BACK) { onClose() } + .bounds( + layout.contentX, + layout.footerTop + 24, + layout.contentWidth, + 20, + ).build(), + ) + } + + override fun tick() { + super.tick() + if (viewModel.state.value.hashCode() != fingerprint) { + rebuildWidgets() + } + } + + override fun onClose() { + clearInputs() + viewModel.close() + minecraft.setScreen(parent) + } + + private fun secretBox( + value: String, + y: Int, + label: String, + changed: (String) -> Unit, + ) { + val layout = AdaptiveShareLayout.form(width, height, 2) + addRenderableWidget( + EditBox( + font, + layout.contentX, + y, + layout.contentWidth, + 20, + Component.translatable(label), + ).also { box -> + box.value = value + box.setMaxLength(256) + box.setResponder(changed) + box.addFormatter { text, _ -> + FormattedCharSequence.forward( + "•".repeat(text.length), + Style.EMPTY, + ) + } + }, + ) + } + + private fun clearInputs() { + passphraseValue = "" + confirmationValue = "" + } + + private fun chooseBackupDestination(): Path? { + val selected = TinyFileDialogs.tinyfd_saveFileDialog( + Component.translatable("connect_share.recovery.export").string, + "connect-share-friends.backup", + null, + "Connect Share backup", + ) ?: return null + return Path.of(selected) + } + + private fun chooseBackupSource(): Path? { + val selected = TinyFileDialogs.tinyfd_openFileDialog( + Component.translatable("connect_share.recovery.import").string, + null, + null, + "Connect Share backup", + false, + ) ?: return null + return Path.of(selected) + } + + private fun centered(message: Component, y: Int): StringWidget { + val textWidth = font.width(message) + return StringWidget( + width / 2 - textWidth / 2, + y, + textWidth, + 11, + message, + font, + ) + } +} + +private fun ShareUiMessage.component() = + Component.translatable(translationKey, *arguments.toTypedArray()) + diff --git a/share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/ShareJoinScreen.kt b/share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/ShareJoinScreen.kt new file mode 100644 index 000000000..e45b265aa --- /dev/null +++ b/share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/ShareJoinScreen.kt @@ -0,0 +1,1501 @@ +package com.minekube.connect.share.fabric.v1_21_11 + +import com.minekube.connect.share.fabric.ConnectShareClient +import com.minekube.connect.share.fabric.FabricShareBrowser +import com.minekube.connect.share.fabric.FriendCardExchangeConsent +import com.minekube.connect.share.fabric.FriendJoinAttemptFailure +import com.minekube.connect.share.fabric.FriendPresenceMonitor +import com.minekube.connect.share.fabric.FriendActivityMonitor +import com.minekube.connect.share.fabric.GuestJoinTarget +import com.minekube.connect.share.admission.Ingress +import com.minekube.connect.share.friend.FriendControlRequest +import com.minekube.connect.share.friend.FriendJoinRequest +import com.minekube.connect.share.friend.FriendActivityKind +import com.minekube.connect.share.fabric.ui.FriendSummary +import com.minekube.connect.share.fabric.ui.AdaptiveShareLayout +import com.minekube.connect.share.fabric.ui.FriendFormDraft +import com.minekube.connect.share.fabric.ui.FriendPresenceTone +import com.minekube.connect.share.fabric.ui.FriendPrimaryAction +import com.minekube.connect.share.fabric.ui.FriendsOverview +import com.minekube.connect.share.fabric.ui.FriendsSummaryTone +import com.minekube.connect.share.fabric.ui.FriendsScreenLayout +import com.minekube.connect.share.fabric.ui.IncomingFriendRequestSummary +import com.minekube.connect.share.fabric.ui.OutgoingFriendRequestSummary +import com.minekube.connect.share.fabric.ui.FriendsViewModel +import com.minekube.connect.share.fabric.ui.ShareUiMessage +import com.minekube.connect.share.fabric.ui.uiMessage +import com.minekube.connect.share.fabric.ui.overview +import com.minekube.connect.share.fabric.ui.page +import com.minekube.connect.share.fabric.ui.presentation +import com.minekube.connect.share.fabric.ui.summary +import com.minekube.connect.share.friend.FriendPermissions +import com.minekube.connect.share.friend.FriendAccessPolicy +import com.minekube.connect.tunnel.p2p.DirectP2pAuthMode +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.cancel +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import net.minecraft.client.gui.components.Button +import net.minecraft.client.gui.components.Checkbox +import net.minecraft.client.gui.components.CycleButton +import net.minecraft.client.gui.components.EditBox +import net.minecraft.client.gui.components.MultiLineTextWidget +import net.minecraft.client.gui.components.StringWidget +import net.minecraft.client.gui.components.Tooltip +import net.minecraft.client.gui.screens.ConnectScreen +import net.minecraft.client.gui.screens.Screen +import net.minecraft.client.multiplayer.ServerData +import net.minecraft.client.multiplayer.resolver.ServerAddress +import net.minecraft.network.chat.CommonComponents +import net.minecraft.ChatFormatting +import net.minecraft.network.chat.Component +import java.util.UUID + +class ShareJoinScreen( + private val parent: Screen, + private val friends: FriendsViewModel, + private val browser: FabricShareBrowser, + private val remotePresence: FriendPresenceMonitor, + private val friendActivity: FriendActivityMonitor, +) : Screen(Component.translatable("connect_share.friends.title")) { + private var scope: CoroutineScope? = null + private var mode = Mode.FRIENDS + private var selectedPeerId: String? = null + private var nameValue = "" + private var invitationValue = "" + private var offlineSelected = false + private var internetSelected = false + private var nameBox: EditBox? = null + private var invitationBox: EditBox? = null + private var offlineMode: Checkbox? = null + private var internetDirect: Checkbox? = null + private var primaryButton: Button? = null + private var secondaryButton: Button? = null + private var safeMessage: ShareUiMessage? = null + private var fingerprint = 0 + private var joining = false + private var joiningPeerId: String? = null + private var reciprocalPairing = false + private var removeConfirmation = false + private var friendLinkState = FriendLinkState.IDLE + private var requestOperationInProgress = false + private var relationshipOffset = 0 + private val requestJobs = mutableMapOf() + private val requestStates = + mutableMapOf() + + override fun init() { + if (scope == null) { + scope = CoroutineScope( + SupervisorJob() + minecraft.asCoroutineDispatcher(), + ) + browser.start().onLeft { safeMessage = it.uiMessage() } + } + friends.updatePresence(browser.discovered.value) + friends.updateRemotePresence(remotePresence.state.value) + friends.updateActivities(friendActivity.state.value) + friends.updateIncoming( + ConnectShareClient.viewModel().state.value.pendingAdmissions, + ) + fingerprint = currentFingerprint() + nameBox = null + invitationBox = null + primaryButton = null + secondaryButton = null + + when (mode) { + Mode.FRIENDS -> buildFriends() + Mode.ADD -> buildAddFriend() + Mode.CONNECTION_OPTIONS -> buildConnectionOptions() + Mode.MANAGE -> buildManageFriend() + } + } + + override fun tick() { + super.tick() + friends.updatePresence(browser.discovered.value) + friends.updateRemotePresence(remotePresence.state.value) + friends.updateActivities(friendActivity.state.value) + friends.updateIncoming( + ConnectShareClient.viewModel().state.value.pendingAdmissions, + ) + val next = currentFingerprint() + if (next != fingerprint) { + rebuildWidgets() + } else { + refresh() + } + } + + override fun onClose() { + if (mode == Mode.MANAGE && removeConfirmation) { + removeConfirmation = false + rebuildWidgets() + return + } + when (mode) { + Mode.FRIENDS -> minecraft.setScreen(parent) + Mode.ADD, + Mode.CONNECTION_OPTIONS, + Mode.MANAGE, + -> { + val returningToAdd = mode == Mode.CONNECTION_OPTIONS + mode = if (returningToAdd) { + Mode.ADD + } else { + Mode.FRIENDS + } + if (!returningToAdd) resetFriendFormDraft() + selectedPeerId = null + safeMessage = null + rebuildWidgets() + } + } + } + + override fun removed() { + scope?.cancel() + scope = null + super.removed() + } + + private fun buildFriends() { + val layout = AdaptiveShareLayout.friends(width, height) + val state = friends.state.value + val overview = state.overview() + addRenderableWidget( + centered( + Component.translatable("connect_share.friends.title") + .withStyle(ChatFormatting.BOLD), + layout.headerY, + ), + ) + addRenderableWidget( + centered( + friendsSummary(overview), + layout.subtitleY, + ), + ) + + val relationships = buildList { + state.incomingRequests.forEach { + add(RelationshipRow.Incoming(it)) + } + state.outgoingRequests.forEach { + add(RelationshipRow.Outgoing(it)) + } + state.friends.forEach { add(RelationshipRow.Friend(it)) } + } + val page = relationships.page( + offset = relationshipOffset, + size = layout.visibleRows, + ) + relationshipOffset = page.offset + if (relationships.isEmpty()) { + addRenderableWidget( + centeredWrapped( + Component.translatable("connect_share.friends.empty"), + layout.rowsTop + 24, + layout.contentWidth, + ), + ) + } + page.items.forEachIndexed { index, relationship -> + val y = layout.rowY(index) + when (relationship) { + is RelationshipRow.Incoming -> + addIncomingRow(relationship.request, y, layout) + + is RelationshipRow.Outgoing -> + addOutgoingRow(relationship.request, y, layout) + + is RelationshipRow.Friend -> + addFriendRow(relationship.friend, y, layout) + } + } + if (page.pageCount > 1) { + val previousTooltip = Tooltip.create( + Component.translatable( + "connect_share.page.previous_tooltip", + page.pageNumber, + page.pageCount, + ), + ) + val nextTooltip = Tooltip.create( + Component.translatable( + "connect_share.page.next_tooltip", + page.pageNumber, + page.pageCount, + ), + ) + val previous = addRenderableWidget( + Button.builder(Component.literal("‹")) { + relationshipOffset = page.previousOffset ?: 0 + rebuildWidgets() + }.bounds(layout.contentX, layout.headerY, 24, 20) + .createNarration { + Component.translatable("connect_share.page.previous") + } + .tooltip(previousTooltip) + .build(), + ) + previous.active = page.hasPrevious + val next = addRenderableWidget( + Button.builder(Component.literal("›")) { + relationshipOffset = page.nextOffset ?: page.offset + rebuildWidgets() + }.bounds( + layout.contentX + layout.contentWidth - 24, + layout.headerY, + 24, + 20, + ) + .createNarration { + Component.translatable("connect_share.page.next") + } + .tooltip(nextTooltip) + .build(), + ) + next.active = page.hasNext + } + + safeMessage().let { message -> + if (message != null) { + addRenderableWidget( + centeredWrapped( + message.component() + .withStyle(ChatFormatting.YELLOW), + layout.messageY, + layout.contentWidth, + ), + ) + } + } + primaryButton = addRenderableWidget( + Button.builder( + Component.translatable(friendLinkState.translationKey), + ) { + copyMyFriendLink() + }.bounds( + layout.contentX, + layout.footerTop, + layout.halfButtonWidth, + 20, + ) + .tooltip( + Tooltip.create( + Component.translatable( + "connect_share.friends.copy_my_link.tooltip", + ), + ), + ) + .build(), + ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.friends.add"), + ) { + resetFriendFormDraft() + mode = Mode.ADD + safeMessage = null + rebuildWidgets() + }.bounds( + layout.contentX + layout.halfButtonWidth + 6, + layout.footerTop, + layout.halfButtonWidth, + 20, + ).build(), + ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.privacy.title"), + ) { + minecraft.setScreen(SharePrivacyScreen(this)) + }.bounds( + layout.contentX, + layout.footerTop + 24, + layout.halfButtonWidth, + 20, + ) + .build(), + ) + addRenderableWidget( + Button.builder(CommonComponents.GUI_BACK) { onClose() } + .bounds( + layout.contentX + layout.halfButtonWidth + 6, + layout.footerTop + 24, + layout.halfButtonWidth, + 20, + ) + .build(), + ) + } + + private fun addIncomingRow( + request: IncomingFriendRequestSummary, + y: Int, + layout: FriendsScreenLayout, + ) { + val buttonWidth = 58 + val textWidth = layout.contentWidth - buttonWidth * 2 - 12 + addRenderableWidget( + StringWidget( + layout.contentX, + y, + textWidth, + 20, + Component.translatable( + if (request.purpose == com.minekube.connect.share.admission.AdmissionPurpose.FRIEND) { + "connect_share.friends.incoming_request" + } else { + "connect_share.friends.incoming_join_request" + }, + request.displayName, + friendlyIngress(request.ingress), + ), + font, + ).setMaxWidth(textWidth), + ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.status.allow"), + ) { + ConnectShareClient.viewModel().allow(request.requestId) + }.bounds( + layout.contentX + layout.contentWidth - buttonWidth * 2 - 4, + y + 2, + buttonWidth, + 20, + ).build(), + ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.status.deny"), + ) { + ConnectShareClient.viewModel().deny(request.requestId) + }.bounds( + layout.contentX + layout.contentWidth - buttonWidth, + y + 2, + buttonWidth, + 20, + ).build(), + ) + } + + private fun addOutgoingRow( + request: OutgoingFriendRequestSummary, + y: Int, + layout: FriendsScreenLayout, + ) { + val deliveryState = requestStates[request.peerId] + val buttonWidth = 58 + val textWidth = layout.contentWidth - buttonWidth * 2 - 12 + addRenderableWidget( + StringWidget( + layout.contentX, + y, + textWidth, + 20, + outgoingRequestLabel(request.displayName, deliveryState), + font, + ), + ) + addRenderableWidget( + Button.builder( + Component.translatable( + deliveryState?.translationKey + ?: "connect_share.friends.retry_request", + ), + ) { + deliverOutgoing(request.peerId) + }.bounds( + layout.contentX + layout.contentWidth - buttonWidth * 2 - 4, + y + 2, + buttonWidth, + 20, + ).build().apply { + active = deliveryState == null || + deliveryState == RequestDeliveryState.FAILED + }, + ) + addRenderableWidget( + Button.builder( + Component.translatable( + "connect_share.friends.cancel_request", + ), + ) { + cancelOutgoing(request.peerId) + }.bounds( + layout.contentX + layout.contentWidth - buttonWidth, + y + 2, + buttonWidth, + 20, + ).build(), + ) + } + + private fun addFriendRow( + friend: FriendSummary, + y: Int, + layout: FriendsScreenLayout, + ) { + val actionWidth = 104 + val manageWidth = 28 + val textWidth = layout.contentWidth - actionWidth - manageWidth - 12 + val presentation = friend.presentation() + addRenderableWidget( + StringWidget( + layout.contentX, + y, + textWidth, + 11, + Component.literal(friend.displayName) + .withStyle(ChatFormatting.WHITE), + font, + ).setMaxWidth(textWidth), + ) + addRenderableWidget( + StringWidget( + layout.contentX, + y + 11, + textWidth, + 11, + friendStatus(friend).copy() + .withStyle(presentation.tone.color()), + font, + ).setMaxWidth(textWidth), + ) + addRenderableWidget( + Button.builder( + Component.translatable(presentation.action.translationKey), + ) { + when (presentation.action) { + FriendPrimaryAction.ASK_TO_JOIN -> + requestToJoin(friend.peerId) + FriendPrimaryAction.JOIN_NOW -> joinSaved(friend.peerId) + FriendPrimaryAction.CANCEL_FOLLOW -> + friends.cancelFollow(friend.peerId) + FriendPrimaryAction.JOIN_WHEN_READY -> + friends.follow(friend.peerId) + } + rebuildWidgets() + }.bounds( + layout.contentX + layout.contentWidth - actionWidth - manageWidth - 4, + y + 2, + actionWidth, + 20, + ).build(), + ) + addRenderableWidget( + Button.builder( + Component.literal("…"), + ) { + selectedPeerId = friend.peerId + applyFriendFormDraft( + FriendFormDraft.forManage(friend.displayName), + ) + mode = Mode.MANAGE + safeMessage = null + rebuildWidgets() + }.bounds( + layout.contentX + layout.contentWidth - manageWidth, + y + 2, + manageWidth, + 20, + ).tooltip( + Tooltip.create( + Component.translatable( + "connect_share.friends.manage_named", + friend.displayName, + ), + ), + ).createNarration { + Component.translatable( + "connect_share.friends.manage_named", + friend.displayName, + ) + }.build(), + ) + } + + private fun buildAddFriend() { + val layout = AdaptiveShareLayout.form(width, height, 4) + addRenderableWidget( + centered( + Component.translatable("connect_share.friends.add") + .withStyle(ChatFormatting.BOLD), + layout.headerY, + ), + ) + addRenderableWidget( + centeredWrapped( + Component.translatable("connect_share.friends.add_description"), + layout.subtitleY, + layout.contentWidth, + ), + ) + addRenderableWidget( + StringWidget( + layout.contentX, + layout.bodyTop, + layout.contentWidth, + 11, + Component.translatable("connect_share.friends.link"), + font, + ), + ) + invitationBox = addRenderableWidget( + EditBox( + font, + layout.contentX, + layout.bodyTop + 12, + layout.contentWidth, + 20, + Component.translatable("connect_share.join.invitation"), + ).apply { + setMaxLength(MAX_INVITATION_LENGTH) + setHint(Component.translatable("connect_share.join.invitation_hint")) + setValue(invitationValue) + setResponder { + invitationValue = it + friends.suggestedDisplayName(it).getOrNull() + ?.let { suggested -> + nameValue = suggested + nameBox?.setValue(suggested) + } + refresh() + } + }, + ) + addRenderableWidget( + StringWidget( + layout.contentX, + layout.bodyTop + 38, + layout.contentWidth, + 11, + Component.translatable("connect_share.friends.name"), + font, + ), + ) + nameBox = addRenderableWidget( + EditBox( + font, + layout.contentX, + layout.bodyTop + 50, + layout.contentWidth, + 20, + Component.translatable("connect_share.friends.name"), + ).apply { + setMaxLength(64) + setHint(Component.translatable("connect_share.friends.name_hint")) + setValue(nameValue) + setResponder { + nameValue = it + refresh() + } + }, + ) + addRenderableWidget( + Button.builder( + Component.translatable( + "connect_share.friends.connection_options.show", + ), + ) { + mode = Mode.CONNECTION_OPTIONS + rebuildWidgets() + }.bounds( + layout.contentX, + layout.bodyTop + 76, + layout.contentWidth, + 20, + ).build(), + ) + safeMessage().let { message -> + if (message != null) { + addRenderableWidget( + centeredWrapped( + message.component().withStyle(ChatFormatting.YELLOW), + layout.footerTop - 16, + layout.contentWidth, + ), + ) + } + } + primaryButton = addRenderableWidget( + Button.builder( + Component.translatable( + "connect_share.friends.send_request", + ), + ) { + createFriendRequest() + }.bounds( + layout.contentX, + layout.footerTop, + layout.contentWidth, + 20, + ).build(), + ) + secondaryButton = addRenderableWidget( + Button.builder( + Component.translatable("connect_share.friends.join_once"), + ) { + joinInvitation() + }.bounds( + layout.contentX, + layout.footerTop + 24, + layout.halfButtonWidth, + 20, + ).build(), + ) + addRenderableWidget( + Button.builder(CommonComponents.GUI_BACK) { onClose() } + .bounds( + layout.contentX + layout.halfButtonWidth + 6, + layout.footerTop + 24, + layout.halfButtonWidth, + 20, + ) + .build(), + ) + refresh() + } + + private fun buildConnectionOptions() { + val layout = AdaptiveShareLayout.form(width, height, 2) + addRenderableWidget( + centered( + Component.translatable( + "connect_share.friends.connection_options.title", + ).withStyle(ChatFormatting.BOLD), + layout.headerY, + ), + ) + addRenderableWidget( + centeredWrapped( + Component.translatable( + "connect_share.friends.connection_options.description", + ), + layout.subtitleY, + layout.contentWidth, + ), + ) + offlineMode = addRenderableWidget( + Checkbox.builder( + Component.translatable("connect_share.join.offline"), + font, + ).pos(layout.contentX, layout.bodyTop) + .selected(offlineSelected) + .onValueChange { _, selected -> offlineSelected = selected } + .tooltip( + Tooltip.create( + Component.translatable( + "connect_share.join.offline.tooltip", + ), + ), + ).build(), + ) + internetDirect = addRenderableWidget( + Checkbox.builder( + Component.translatable("connect_share.join.internet"), + font, + ).pos(layout.contentX, layout.bodyTop + 28) + .selected(internetSelected) + .onValueChange { _, selected -> internetSelected = selected } + .tooltip( + Tooltip.create( + Component.translatable( + "connect_share.join.internet.tooltip", + ), + ), + ).build(), + ) + addRenderableWidget( + centeredWrapped( + Component.translatable( + "connect_share.friends.connection_options.fallback", + ).withStyle(ChatFormatting.GRAY), + layout.bodyTop + 64, + layout.contentWidth, + ), + ) + addRenderableWidget( + Button.builder(CommonComponents.GUI_DONE) { onClose() } + .bounds( + layout.contentX, + layout.footerTop + 24, + layout.contentWidth, + 20, + ).build(), + ) + } + + private fun buildManageFriend() { + val friend = selectedFriend() + if (friend == null) { + mode = Mode.FRIENDS + rebuildWidgets() + return + } + if (removeConfirmation) { + buildRemoveFriendConfirmation(friend) + return + } + val layout = AdaptiveShareLayout.form(width, height, 5) + val form = AdaptiveShareLayout.manageFriendForm(layout.bodyTop) + var internetDirectSelected = friend.internetDirectGuestOptIn + addRenderableWidget( + centered( + Component.translatable( + "connect_share.friends.manage_title", + friend.displayName, + ).withStyle(ChatFormatting.BOLD), + layout.headerY, + ), + ) + addRenderableWidget( + centeredWrapped( + safeMessage()?.let { + it.component().withStyle(ChatFormatting.YELLOW) + } ?: friendStatus(friend).copy().withStyle( + friend.presentation().tone.color(), + ), + layout.subtitleY, + layout.contentWidth, + ), + ) + addRenderableWidget( + StringWidget( + layout.contentX, + form.nameLabelY, + layout.contentWidth, + 11, + Component.translatable("connect_share.friends.name"), + font, + ), + ) + nameBox = addRenderableWidget( + EditBox( + font, + layout.contentX, + form.nameInputY, + layout.contentWidth, + 20, + Component.translatable("connect_share.friends.name"), + ).apply { + setMaxLength(64) + setValue(nameValue.ifBlank { friend.displayName }) + setResponder { + nameValue = it + refresh() + } + }, + ) + val notify = addRenderableWidget( + Checkbox.builder( + Component.translatable("connect_share.friends.notify"), + font, + ).pos(layout.contentX, form.notifyY) + .selected(friend.permissions.notifyWhenOnline) + .build(), + ) + var accessPolicy = friend.permissions.accessPolicy + addRenderableWidget( + CycleButton.builder( + { policy: FriendAccessPolicy -> + Component.translatable( + "connect_share.friends.access.${policy.name.lowercase()}", + ) + }, + accessPolicy, + ).withValues(FriendAccessPolicy.entries) + .create( + layout.contentX, + form.accessPolicyY, + layout.contentWidth, + 20, + Component.translatable("connect_share.friends.access"), + ) { _, selected -> accessPolicy = selected }, + ) + val shareWorlds = addRenderableWidget( + Checkbox.builder( + Component.translatable("connect_share.friends.share_worlds"), + font, + ).pos(layout.contentX, form.shareWorldsY) + .selected(friend.permissions.canSeeMyWorlds) + .build(), + ) + addRenderableWidget( + CycleButton.onOffBuilder(friend.internetDirectGuestOptIn) + .create( + layout.contentX, + form.internetDirectY, + layout.contentWidth, + 20, + Component.translatable( + "connect_share.friends.internet_direct_short", + ), + ) { _, selected -> internetDirectSelected = selected } + .apply { + setTooltip( + Tooltip.create( + Component.translatable( + "connect_share.friends.internet_direct.tooltip", + ), + ), + ) + }, + ) + primaryButton = addRenderableWidget( + Button.builder( + Component.translatable("connect_share.friends.save_changes"), + ) { + val activeScope = scope ?: return@builder + requestOperationInProgress = true + refresh() + activeScope.launch { + withContext(Dispatchers.IO) { + friends.rename(friend.peerId, nameValue) + friends.updateInternetDirectGuestOptIn( + friend.peerId, + internetDirectSelected, + ) + friends.updatePermissions( + friend.peerId, + FriendPermissions( + notifyWhenOnline = notify.selected(), + canSeeMyWorlds = shareWorlds.selected(), + accessPolicy = accessPolicy, + ), + ) + } + requestOperationInProgress = false + mode = Mode.FRIENDS + selectedPeerId = null + resetFriendFormDraft() + rebuildWidgets() + } + }.bounds( + layout.contentX, + layout.footerTop, + layout.halfButtonWidth, + 20, + ).build(), + ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.friends.remove"), + ) { + removeConfirmation = true + rebuildWidgets() + }.bounds( + layout.contentX + layout.halfButtonWidth + 6, + layout.footerTop, + layout.halfButtonWidth, + 20, + ).build(), + ) + addRenderableWidget( + Button.builder(CommonComponents.GUI_BACK) { onClose() } + .bounds( + layout.contentX, + layout.footerTop + 24, + layout.contentWidth, + 20, + ) + .build(), + ) + refresh() + } + + private fun buildRemoveFriendConfirmation(friend: FriendSummary) { + val layout = AdaptiveShareLayout.form(width, height, 1) + addRenderableWidget( + centered( + Component.translatable( + "connect_share.friends.remove_confirm.title", + friend.displayName, + ).withStyle(ChatFormatting.BOLD), + layout.headerY + 12, + ), + ) + addRenderableWidget( + centeredWrapped( + Component.translatable( + "connect_share.friends.remove_confirm.message", + ), + layout.bodyTop, + layout.contentWidth, + ), + ) + addRenderableWidget( + Button.builder( + Component.translatable( + "connect_share.friends.remove_confirm.confirm", + ), + ) { + val activeScope = scope ?: return@builder + requestOperationInProgress = true + refresh() + activeScope.launch { + withContext(Dispatchers.IO) { + friends.remove(friend.peerId) + } + requestOperationInProgress = false + removeConfirmation = false + mode = Mode.FRIENDS + selectedPeerId = null + resetFriendFormDraft() + rebuildWidgets() + } + }.bounds( + layout.contentX, + layout.footerTop + 24, + (layout.contentWidth - 12) / 3, + 20, + ).build(), + ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.friends.block"), + ) { + val activeScope = scope ?: return@builder + requestOperationInProgress = true + refresh() + activeScope.launch { + withContext(Dispatchers.IO) { + friends.block(friend.peerId) + } + requestOperationInProgress = false + removeConfirmation = false + mode = Mode.FRIENDS + selectedPeerId = null + resetFriendFormDraft() + rebuildWidgets() + } + }.bounds( + layout.contentX + (layout.contentWidth - 12) / 3 + 6, + layout.footerTop + 24, + (layout.contentWidth - 12) / 3, + 20, + ).build(), + ) + addRenderableWidget( + Button.builder(CommonComponents.GUI_CANCEL) { + removeConfirmation = false + rebuildWidgets() + }.bounds( + layout.contentX + 2 * ((layout.contentWidth - 12) / 3 + 6), + layout.footerTop + 24, + (layout.contentWidth - 12) / 3, + 20, + ).build(), + ) + } + + private fun copyMyFriendLink() { + val activeScope = scope ?: return + if (friendLinkState == FriendLinkState.COPYING) { + return + } + friendLinkState = FriendLinkState.COPYING + rebuildWidgets() + activeScope.launch { + val invitation = withContext(Dispatchers.IO) { + ConnectShareClient.friendCardIssuer().issue() + } + invitation.fold( + ifLeft = { + friendLinkState = FriendLinkState.FAILED + }, + ifRight = { link -> + minecraft.keyboardHandler.setClipboard(link) + friendLinkState = FriendLinkState.COPIED + }, + ) + rebuildWidgets() + } + } + + private fun joinSaved(peerId: String) { + if (joining) return + joining = true + joiningPeerId = peerId + reciprocalPairing = true + safeMessage = null + refresh() + scope?.launch { + friends.join( + peerId = peerId, + browser = browser, + authMode = authMode(), + ownConnectAddress = + ConnectShareClient.connectPublicAddress(), + ).fold( + ifLeft = ::joinFailed, + ifRight = ::connect, + ) + } + } + + private fun requestToJoin( + peerId: String, + allowModMismatch: Boolean = false, + ) { + if (joining) return + joining = true + joiningPeerId = peerId + reciprocalPairing = false + safeMessage = null + refresh() + scope?.launch { + ConnectShareClient.friendJoinOrchestrator().request( + peerId, + FriendJoinRequest( + requestId = UUID.randomUUID(), + playerName = minecraft.user.name, + playerUuid = minecraft.user.profileId, + ), + allowModMismatch = allowModMismatch, + ).fold( + ifLeft = { failure -> + joining = false + if (failure is FriendJoinAttemptFailure.Compatibility) { + minecraft.setScreen( + CompatibilityMismatchScreen( + parent = this@ShareJoinScreen, + failure = failure, + tryAnyway = { + requestToJoin(peerId, true) + }, + ), + ) + } else { + safeMessage = failure.uiMessage() + rebuildWidgets() + } + }, + ifRight = ::connect, + ) + } + } + + private fun createFriendRequest() { + val activeScope = scope ?: return + if ( + requestOperationInProgress || + invitationValue.isBlank() || + nameValue.isBlank() + ) { + return + } + requestOperationInProgress = true + safeMessage = null + refresh() + activeScope.launch { + val peerId = withContext(Dispatchers.IO) { + friends.sendRequest( + invitationValue, + nameValue, + internetDirectGuestOptIn = internetSelected, + ) + } + requestOperationInProgress = false + if (peerId == null) { + rebuildWidgets() + return@launch + } + mode = Mode.FRIENDS + resetFriendFormDraft() + rebuildWidgets() + deliverOutgoing(peerId) + } + } + + private fun deliverOutgoing(peerId: String) { + val activeScope = scope ?: return + if (requestJobs[peerId]?.isActive == true) { + return + } + safeMessage = null + requestStates[peerId] = RequestDeliveryState.SENDING + rebuildWidgets() + val job = activeScope.launch { + val senderCard = withContext(Dispatchers.IO) { + ConnectShareClient.friendCardIssuer().issue().getOrNull() + } + if (senderCard == null) { + requestFailed( + peerId, + ShareUiMessage("connect_share.friends.request_failed"), + ) + return@launch + } + val targetResult = friends.routeOutgoing( + peerId = peerId, + browser = browser, + authMode = DirectP2pAuthMode.OFFLINE, + ) + val target = targetResult.getOrNull() + if (target == null) { + requestFailed( + peerId, + targetResult.leftOrNull()?.uiMessage() + ?: ShareUiMessage( + "connect_share.friends.request_failed", + ), + ) + return@launch + } + val displayName = friends.state.value.outgoingRequests + .firstOrNull { it.peerId == peerId } + ?.displayName + ?: peerId + val result = ConnectShareClient.friendRequestClient().exchange( + target = target, + request = FriendControlRequest( + requestId = UUID.randomUUID(), + relationshipId = friends.state.value.outgoingRequests + .firstOrNull { it.peerId == peerId } + ?.relationshipId + ?: run { + target.close() + requestFailed( + peerId, + ShareUiMessage( + "connect_share.friends.request_failed", + ), + ) + return@launch + }, + displayName = minecraft.user.name, + invitation = senderCard, + ), + onReceived = { + minecraft.execute { + requestStates[peerId] = + RequestDeliveryState.WAITING + rebuildWidgets() + } + }, + ) + val hostCard = result.getOrNull() + if (hostCard == null) { + requestFailed( + peerId, + result.leftOrNull()?.uiMessage() + ?: ShareUiMessage( + "connect_share.friends.request_failed", + ), + ) + return@launch + } + val accepted = withContext(Dispatchers.IO) { + ConnectShareClient.friendCardReceiver().receive( + invitation = hostCard, + displayName = displayName, + authenticatedMinecraftUuid = null, + relationshipId = friends.state.value.outgoingRequests + .firstOrNull { it.peerId == peerId } + ?.relationshipId, + ) + } + if (accepted.isLeft()) { + requestFailed( + peerId, + ShareUiMessage("connect_share.friends.request_failed"), + ) + return@launch + } + requestStates.remove(peerId) + friends.reload() + safeMessage = ShareUiMessage( + "connect_share.friends.request_accepted", + listOf(displayName), + ) + rebuildWidgets() + } + requestJobs[peerId] = job + job.invokeOnCompletion { + minecraft.execute { + requestJobs.remove(peerId, job) + } + } + } + + private fun cancelOutgoing(peerId: String) { + val activeScope = scope ?: return + requestJobs.remove(peerId)?.cancel() + requestStates[peerId] = RequestDeliveryState.CANCELLING + rebuildWidgets() + activeScope.launch { + withContext(Dispatchers.IO) { + friends.remove(peerId) + } + requestStates.remove(peerId) + rebuildWidgets() + } + } + + private fun requestFailed( + peerId: String, + message: ShareUiMessage, + ) { + requestStates[peerId] = RequestDeliveryState.FAILED + safeMessage = message + rebuildWidgets() + } + + private fun joinInvitation() { + if (joining || invitationValue.isBlank()) return + joining = true + joiningPeerId = null + reciprocalPairing = false + safeMessage = null + refresh() + scope?.launch { + browser.join( + invitationUri = invitationValue, + lanAddress = null, + internetOptIn = internetSelected, + authMode = authMode(), + ).fold( + ifLeft = ::joinFailed, + ifRight = ::connect, + ) + } + } + + private fun joinFailed(failure: com.minekube.connect.share.fabric.GuestJoinFailure) { + joining = false + joiningPeerId = null + reciprocalPairing = false + safeMessage = failure.uiMessage() + rebuildWidgets() + } + + private fun connect(target: GuestJoinTarget) { + val address = when (target) { + is GuestJoinTarget.Connect -> + ServerAddress.parseString(target.publicAddress) + + is GuestJoinTarget.Direct -> + ServerAddress( + target.localAddress.hostString, + target.localAddress.port, + ) + } + if (target is GuestJoinTarget.Direct) { + ConnectShareClient.holdGuestDirect(target) + } + val state = friends.state.value + val joiningFriend = state.friends.firstOrNull { + it.peerId == joiningPeerId + } + val data = ServerData( + joiningFriend?.displayName + ?: "Connect Share", + address.toString(), + ServerData.Type.OTHER, + ) + val exchangeFriendCard = FriendCardExchangeConsent.shouldArm( + savedFriendJoin = reciprocalPairing, + canSeeMyWorlds = + joiningFriend?.permissions?.canSeeMyWorlds == true, + ) + if (exchangeFriendCard && joiningPeerId != null) { + ConnectShareClient.armFriendCardExchange( + checkNotNull(joiningPeerId), + ) + } + ConnectScreen.startConnecting( + parent, + minecraft, + address, + data, + false, + null, + ) + } + + private fun refresh() { + val inputReady = invitationValue.isNotBlank() + primaryButton?.active = + !joining && !requestOperationInProgress && + friendLinkState != FriendLinkState.COPYING && + when (mode) { + Mode.ADD -> inputReady && nameValue.isNotBlank() + Mode.CONNECTION_OPTIONS -> true + Mode.MANAGE -> nameValue.isNotBlank() + Mode.FRIENDS -> true + } + secondaryButton?.active = !joining && inputReady + invitationBox?.setEditable(!joining) + nameBox?.setEditable(!joining) + } + + private fun resetFriendFormDraft() { + applyFriendFormDraft(currentFriendFormDraft().newRequest()) + } + + private fun currentFriendFormDraft(): FriendFormDraft = + FriendFormDraft( + displayName = nameValue, + invitation = invitationValue, + offlineMode = offlineSelected, + internetDirect = internetSelected, + ) + + private fun applyFriendFormDraft(draft: FriendFormDraft) { + nameValue = draft.displayName + invitationValue = draft.invitation + offlineSelected = draft.offlineMode + internetSelected = draft.internetDirect + } + + private fun friendsSummary(overview: FriendsOverview): Component = + overview.summary().let { presentation -> + Component.translatable( + presentation.translationKey, + *listOfNotNull(presentation.count).toTypedArray(), + ).withStyle( + when (presentation.tone) { + FriendsSummaryTone.ATTENTION -> ChatFormatting.YELLOW + FriendsSummaryTone.READY -> ChatFormatting.GREEN + FriendsSummaryTone.ONLINE -> ChatFormatting.AQUA + FriendsSummaryTone.MUTED -> ChatFormatting.GRAY + }, + ) + } + + private fun friendStatus(friend: FriendSummary): Component = + friend.presentation().let { presentation -> + Component.translatable( + presentation.statusKey, + *presentation.statusArguments.toTypedArray(), + ) + } + + private fun FriendPresenceTone.color(): ChatFormatting = when (this) { + FriendPresenceTone.JOINABLE -> ChatFormatting.GREEN + FriendPresenceTone.ONLINE -> ChatFormatting.AQUA + FriendPresenceTone.SAVED -> ChatFormatting.GRAY + FriendPresenceTone.OFFLINE -> ChatFormatting.DARK_GRAY + } + + private fun friendlyIngress(ingress: Ingress): Component = + Component.translatable( + when (ingress) { + Ingress.CONNECT -> "connect_share.ingress.online" + Ingress.DIRECT_LAN -> "connect_share.ingress.nearby" + Ingress.DIRECT_INTERNET -> "connect_share.ingress.direct" + }, + ) + + private fun outgoingRequestLabel( + displayName: String, + deliveryState: RequestDeliveryState?, + ): Component = Component.translatable( + if (deliveryState == null) { + "connect_share.friends.outgoing_request" + } else { + "connect_share.friends.outgoing_request_active" + }, + displayName, + ) + + private fun selectedFriend(): FriendSummary? = + friends.state.value.friends.firstOrNull { + it.peerId == selectedPeerId + } + + private fun safeMessage(): ShareUiMessage? = + safeMessage ?: friends.state.value.safeMessage + + private fun ShareUiMessage.component() = + Component.translatable(translationKey, *arguments.toTypedArray()) + + private fun authMode(): DirectP2pAuthMode = + if (offlineSelected) { + DirectP2pAuthMode.OFFLINE + } else { + DirectP2pAuthMode.ONLINE + } + + private fun currentFingerprint(): Int = + 31 * browser.discovered.value.hashCode() + + 31 * friends.state.value.hashCode() + + mode.hashCode() + + private fun centered(message: Component, y: Int): StringWidget { + val textWidth = font.width(message) + return StringWidget( + width / 2 - textWidth / 2, + y, + textWidth, + 9, + message, + font, + ) + } + + private fun centeredWrapped( + message: Component, + y: Int, + contentWidth: Int = CONTENT_WIDTH, + ): MultiLineTextWidget = + MultiLineTextWidget( + width / 2 - contentWidth / 2, + y, + message, + font, + ).setMaxWidth(contentWidth).setCentered(true) + + private enum class Mode { + FRIENDS, + ADD, + CONNECTION_OPTIONS, + MANAGE, + } + + private sealed interface RelationshipRow { + data class Incoming( + val request: IncomingFriendRequestSummary, + ) : RelationshipRow + + data class Outgoing( + val request: OutgoingFriendRequestSummary, + ) : RelationshipRow + + data class Friend( + val friend: FriendSummary, + ) : RelationshipRow + } + + private enum class FriendLinkState( + val translationKey: String, + ) { + IDLE("connect_share.friends.invite"), + COPYING("connect_share.friends.copying_my_link"), + COPIED("connect_share.friends.my_link_copied"), + FAILED("connect_share.friends.copy_my_link_failed"), + } + + private enum class RequestDeliveryState( + val translationKey: String, + ) { + SENDING("connect_share.friends.request_sending"), + WAITING("connect_share.friends.request_waiting"), + CANCELLING("connect_share.friends.request_cancelling"), + FAILED("connect_share.friends.retry_request"), + } + + private companion object { + const val MAX_INVITATION_LENGTH = 32_768 + const val MAX_VISIBLE_RELATIONSHIPS = 5 + const val CONTENT_WIDTH = 310 + } +} diff --git a/share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/SharePrivacyScreen.kt b/share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/SharePrivacyScreen.kt new file mode 100644 index 000000000..a56ffd107 --- /dev/null +++ b/share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/SharePrivacyScreen.kt @@ -0,0 +1,174 @@ +package com.minekube.connect.share.fabric.v1_21_11 + +import com.minekube.connect.share.fabric.ConnectShareClient +import com.minekube.connect.share.fabric.ui.AdaptiveShareLayout +import net.minecraft.ChatFormatting +import net.minecraft.client.gui.components.Button +import net.minecraft.client.gui.components.Checkbox +import net.minecraft.client.gui.components.MultiLineTextWidget +import net.minecraft.client.gui.components.StringWidget +import net.minecraft.client.gui.screens.Screen +import net.minecraft.network.chat.CommonComponents +import net.minecraft.network.chat.Component + +class SharePrivacyScreen( + private val parent: Screen, +) : Screen(Component.translatable("connect_share.privacy.title")) { + private val viewModel = ConnectShareClient.viewModel() + private var diagnosticsCopied = false + + override fun init() { + val layout = AdaptiveShareLayout.form(width, height, 4) + addRenderableWidget( + centered(title.copy().withStyle(ChatFormatting.BOLD), layout.headerY), + ) + addRenderableWidget( + MultiLineTextWidget( + layout.contentX, + layout.subtitleY, + Component.translatable("connect_share.privacy.description"), + font, + ).setMaxWidth(layout.contentWidth).setCentered(true), + ) + + val privacy = viewModel.state.value.presencePrivacy + privacyToggle("online", layout.bodyTop, privacy.showOnline) { value -> + viewModel.setPresencePrivacy( + viewModel.state.value.presencePrivacy.copy(showOnline = value), + ) + } + privacyToggle( + "playing", + layout.bodyTop + 24, + privacy.showPlaying, + ) { value -> + viewModel.setPresencePrivacy( + viewModel.state.value.presencePrivacy.copy(showPlaying = value), + ) + } + privacyToggle( + "current_server", + layout.bodyTop + 48, + privacy.showCurrentServer, + ) { value -> + viewModel.setPresencePrivacy( + viewModel.state.value.presencePrivacy.copy( + showCurrentServer = value, + ), + ) + } + privacyToggle( + "joinable", + layout.bodyTop + 72, + privacy.showJoinable, + ) { value -> + viewModel.setPresencePrivacy( + viewModel.state.value.presencePrivacy.copy(showJoinable = value), + ) + } + addRenderableWidget( + MultiLineTextWidget( + layout.contentX, + layout.bodyTop + 104, + Component.translatable("connect_share.privacy.confirmed_only") + .withStyle(ChatFormatting.GRAY), + font, + ).setMaxWidth(layout.contentWidth).setCentered(true), + ) + + val third = (layout.contentWidth - 12) / 3 + addRenderableWidget( + Button.builder( + Component.translatable( + if (diagnosticsCopied) { + "connect_share.diagnostics.copied" + } else { + "connect_share.diagnostics.copy" + }, + ), + ) { + minecraft.keyboardHandler.setClipboard( + ConnectShareClient.diagnosticBundle(), + ) + diagnosticsCopied = true + rebuildWidgets() + }.bounds( + layout.contentX, + layout.footerTop, + third, + 20, + ).build(), + ) + addRenderableWidget( + Button.builder( + Component.translatable( + "connect_share.privacy.blocked", + ConnectShareClient.friendsViewModel() + .state.value.blocked.size, + ), + ) { + minecraft.setScreen(BlockedFriendsScreen(this)) + }.bounds( + layout.contentX + third + 6, + layout.footerTop, + third, + 20, + ).build(), + ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.recovery.menu"), + ) { + minecraft.setScreen(RecoveryScreen(this)) + }.bounds( + layout.contentX + (third + 6) * 2, + layout.footerTop, + third, + 20, + ).build(), + ) + addRenderableWidget( + Button.builder(CommonComponents.GUI_DONE) { onClose() } + .bounds( + layout.contentX, + layout.footerTop + 24, + layout.contentWidth, + 20, + ).build(), + ) + } + + override fun onClose() { + minecraft.setScreen(parent) + } + + private fun privacyToggle( + key: String, + y: Int, + selected: Boolean, + changed: (Boolean) -> Unit, + ) { + val layout = AdaptiveShareLayout.form(width, height, 4) + addRenderableWidget( + Checkbox.builder( + Component.translatable("connect_share.privacy.$key"), + font, + ).pos(layout.contentX, y) + .selected(selected) + .onValueChange { _, value -> changed(value) } + .build(), + ) + } + + private fun centered(message: Component, y: Int): StringWidget { + val textWidth = font.width(message) + return StringWidget( + width / 2 - textWidth / 2, + y, + textWidth, + 11, + message, + font, + ) + } +} diff --git a/share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/ShareSetupScreen.kt b/share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/ShareSetupScreen.kt new file mode 100644 index 000000000..56cc7f524 --- /dev/null +++ b/share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/ShareSetupScreen.kt @@ -0,0 +1,224 @@ +package com.minekube.connect.share.fabric.v1_21_11 + +import com.minekube.connect.share.ShareGameMode +import com.minekube.connect.share.fabric.ConnectShareClient +import com.minekube.connect.share.fabric.ui.AdaptiveShareLayout +import com.minekube.connect.share.fabric.ui.FormScreenLayout +import com.minekube.connect.share.fabric.ui.ShareUiState +import net.minecraft.ChatFormatting +import net.minecraft.client.gui.components.Button +import net.minecraft.client.gui.components.Checkbox +import net.minecraft.client.gui.components.CycleButton +import net.minecraft.client.gui.components.MultiLineTextWidget +import net.minecraft.client.gui.components.StringWidget +import net.minecraft.client.gui.components.Tooltip +import net.minecraft.client.gui.screens.Screen +import net.minecraft.network.chat.CommonComponents +import net.minecraft.network.chat.Component + +class ShareSetupScreen( + private val parent: Screen, +) : Screen(Component.translatable("connect_share.setup.title")) { + private val viewModel = ConnectShareClient.viewModel() + private var startButton: Button? = null + private var defaultsLoaded = false + private var optionsExpanded = false + + override fun init() { + if (!defaultsLoaded) { + minecraft.singleplayerServer?.let { server -> + viewModel.setGameMode(server.defaultGameType.toShareGameMode()) + viewModel.setAllowCheats(server.worldData.isAllowCommands) + } + defaultsLoaded = true + } + val current = viewModel.state.value + val layout = AdaptiveShareLayout.form(width, height, 4) + + addRenderableWidget( + centered(title.copy().withStyle(ChatFormatting.BOLD), layout.headerY), + ) + addRenderableWidget( + MultiLineTextWidget( + layout.contentX, + layout.subtitleY, + Component.translatable("connect_share.setup.description"), + font, + ).setMaxWidth(layout.contentWidth).setCentered(true), + ) + addRenderableWidget( + centered( + Component.translatable("connect_share.setup.friends_only") + .withStyle(ChatFormatting.GREEN), + layout.bodyTop, + ), + ) + + if (optionsExpanded) { + addOptions(layout, current) + } else { + addRenderableWidget( + MultiLineTextWidget( + layout.contentX, + layout.bodyTop + 28, + Component.translatable("connect_share.setup.persistence") + .withStyle(ChatFormatting.GRAY), + font, + ).setMaxWidth(layout.contentWidth).setCentered(true), + ) + } + + startButton = addRenderableWidget( + Button.builder( + Component.translatable("connect_share.setup.start"), + ) { + viewModel.start() + minecraft.setScreen(ShareStatusScreen(parent)) + }.bounds( + layout.contentX, + layout.footerTop, + layout.contentWidth, + 20, + ).build(), + ) + + val third = (layout.contentWidth - 12) / 3 + addRenderableWidget( + Button.builder( + Component.translatable( + if (optionsExpanded) { + "connect_share.setup.options.hide" + } else { + "connect_share.setup.options.show" + }, + ), + ) { + optionsExpanded = !optionsExpanded + rebuildWidgets() + }.bounds( + layout.contentX, + layout.footerTop + 24, + third, + 20, + ).build(), + ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.privacy.title"), + ) { + minecraft.setScreen(SharePrivacyScreen(this)) + }.bounds( + layout.contentX + third + 6, + layout.footerTop + 24, + third, + 20, + ).build(), + ) + addRenderableWidget( + Button.builder(CommonComponents.GUI_CANCEL) { onClose() } + .bounds( + layout.contentX + (third + 6) * 2, + layout.footerTop + 24, + third, + 20, + ).build(), + ) + refresh() + } + + private fun addOptions( + layout: FormScreenLayout, + current: ShareUiState, + ) { + addRenderableWidget( + CycleButton.builder( + { mode: ShareGameMode -> + Component.translatable( + "connect_share.game_mode.${mode.name.lowercase()}", + ) + }, + current.options.gameMode, + ).withValues(ShareGameMode.entries) + .create( + layout.contentX, + layout.bodyTop + 24, + layout.halfButtonWidth, + 20, + Component.translatable("selectWorld.gameMode"), + ) { _, mode -> viewModel.setGameMode(mode) }, + ) + addRenderableWidget( + CycleButton.onOffBuilder(current.options.allowCheats) + .create( + layout.contentX + layout.halfButtonWidth + 6, + layout.bodyTop + 24, + layout.halfButtonWidth, + 20, + Component.translatable("selectWorld.allowCommands"), + ) { _, allowed -> viewModel.setAllowCheats(allowed) }, + ) + addRenderableWidget( + CycleButton.builder( + { guests: Int -> Component.literal(guests.toString()) }, + current.options.maxGuests, + ).withValues((1..16).toList()) + .create( + layout.contentX, + layout.bodyTop + 48, + layout.contentWidth, + 20, + Component.translatable("connect_share.setup.max_guests"), + ) { _, guests -> viewModel.setMaxGuests(guests) }, + ) + addRenderableWidget( + Checkbox.builder( + Component.translatable("connect_share.setup.internet"), + font, + ).pos(layout.contentX, layout.bodyTop + 74) + .selected(current.options.allowInternetDirect) + .onValueChange { _, allowed -> + viewModel.setAllowInternetDirect(allowed) + } + .tooltip( + Tooltip.create( + Component.translatable( + "connect_share.setup.internet.tooltip", + ), + ), + ) + .build(), + ) + } + + override fun tick() { + super.tick() + refresh() + } + + override fun onClose() { + minecraft.setScreen(parent) + } + + private fun refresh() { + startButton?.active = viewModel.state.value.startEnabled + } + + private fun centered(message: Component, y: Int): StringWidget { + val textWidth = font.width(message) + return StringWidget( + width / 2 - textWidth / 2, + y, + textWidth, + 11, + message, + font, + ) + } +} + +private fun net.minecraft.world.level.GameType.toShareGameMode(): ShareGameMode = when (this) { + net.minecraft.world.level.GameType.SURVIVAL -> ShareGameMode.SURVIVAL + net.minecraft.world.level.GameType.CREATIVE -> ShareGameMode.CREATIVE + net.minecraft.world.level.GameType.ADVENTURE -> ShareGameMode.ADVENTURE + net.minecraft.world.level.GameType.SPECTATOR -> ShareGameMode.SPECTATOR +} diff --git a/share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/ShareStatusScreen.kt b/share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/ShareStatusScreen.kt new file mode 100644 index 000000000..88f15c66e --- /dev/null +++ b/share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/ShareStatusScreen.kt @@ -0,0 +1,342 @@ +package com.minekube.connect.share.fabric.v1_21_11 + +import com.minekube.connect.share.ShareState +import com.minekube.connect.share.admission.AdmissionIdentity +import com.minekube.connect.share.admission.AdmissionPurpose +import com.minekube.connect.share.admission.Ingress +import com.minekube.connect.share.fabric.ConnectShareClient +import com.minekube.connect.share.fabric.ui.AdaptiveShareLayout +import com.minekube.connect.share.fabric.ui.FormScreenLayout +import net.minecraft.ChatFormatting +import net.minecraft.client.gui.components.Button +import net.minecraft.client.gui.components.MultiLineTextWidget +import net.minecraft.client.gui.components.StringWidget +import net.minecraft.client.gui.screens.Screen +import net.minecraft.network.chat.CommonComponents +import net.minecraft.network.chat.Component + +class ShareStatusScreen( + private val parent: Screen, +) : Screen(Component.translatable("connect_share.status.title")) { + private val viewModel = ConnectShareClient.viewModel() + private var fingerprint: Int = 0 + private var linkCopied = false + private var showConnectionDetails = false + + override fun init() { + val state = viewModel.state.value + fingerprint = state.hashCode() + val layout = AdaptiveShareLayout.form(width, height, 3) + val sharing = state.shareState as? ShareState.Sharing + + addRenderableWidget( + centered( + title.copy().withStyle( + if (sharing != null) { + ChatFormatting.GREEN + } else { + ChatFormatting.YELLOW + }, + ), + layout.headerY, + ), + ) + addRenderableWidget( + centeredWrapped( + Component.translatable(statusKey(state.shareState)) + .withStyle(ChatFormatting.GRAY), + layout.subtitleY, + layout.contentWidth, + ), + ) + + var requestHeadingY = layout.bodyTop + 54 + if (sharing != null) { + val invitation = viewModel.currentInvitation() + addRenderableWidget( + Button.builder( + Component.translatable( + if (linkCopied) { + "connect_share.status.friend_link_copied" + } else { + "connect_share.status.copy_friend_link" + }, + ), + ) { + invitation?.let { + minecraft.keyboardHandler.setClipboard(it) + linkCopied = true + rebuildWidgets() + } + }.bounds( + layout.contentX, + layout.bodyTop, + layout.contentWidth, + 20, + ).build().apply { active = invitation != null }, + ) + addRenderableWidget( + Button.builder( + Component.translatable( + if (showConnectionDetails) { + "connect_share.status.connection_details.hide" + } else { + "connect_share.status.connection_details.show" + }, + ), + ) { + showConnectionDetails = !showConnectionDetails + rebuildWidgets() + }.bounds( + layout.contentX, + layout.bodyTop + 24, + layout.contentWidth, + 20, + ).build(), + ) + if (showConnectionDetails) { + addConnectionDetails(layout, sharing) + requestHeadingY = layout.bodyTop + 102 + } + } + + addRenderableWidget( + centered( + Component.translatable("connect_share.status.requests") + .withStyle(ChatFormatting.BOLD), + requestHeadingY, + ), + ) + val pending = state.pendingAdmissions + val rowsTop = requestHeadingY + 14 + val visibleRows = ((layout.footerTop - rowsTop) / 24).coerceIn(1, 3) + pending.take(visibleRows).forEachIndexed { index, request -> + val y = rowsTop + index * 24 + val buttonWidth = 54 + val labelWidth = layout.contentWidth - buttonWidth * 2 - 10 + val identity = request.identity + val ingress = when (identity) { + is AdmissionIdentity.Authenticated -> + identity.ingress + is AdmissionIdentity.UnverifiedOffline -> + identity.ingress + } + val label = Component.translatable( + if (request.purpose == AdmissionPurpose.FRIEND) { + "connect_share.status.friend_request" + } else { + "connect_share.status.request" + }, + identity.name, + friendlyIngress(ingress), + ) + addRenderableWidget( + StringWidget( + layout.contentX, + y, + labelWidth, + 20, + label, + font, + ).setMaxWidth(labelWidth), + ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.status.allow"), + ) { + viewModel.allow(request.requestId) + }.bounds( + layout.contentX + layout.contentWidth - + buttonWidth * 2 - 4, + y, + buttonWidth, + 20, + ).build(), + ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.status.deny"), + ) { + viewModel.deny(request.requestId) + }.bounds( + layout.contentX + layout.contentWidth - buttonWidth, + y, + buttonWidth, + 20, + ).build(), + ) + } + if (pending.size > visibleRows) { + addRenderableWidget( + centered( + Component.translatable( + "connect_share.status.more", + pending.size - visibleRows, + ).withStyle(ChatFormatting.GRAY), + rowsTop + visibleRows * 24, + ), + ) + } else if (pending.isEmpty()) { + addRenderableWidget( + centered( + Component.translatable("connect_share.status.waiting") + .withStyle(ChatFormatting.GRAY), + rowsTop + 4, + ), + ) + } + + addFooter(layout) + } + + private fun addConnectionDetails( + layout: FormScreenLayout, + sharing: ShareState.Sharing, + ) { + val address = sharing.address + addRenderableWidget( + Button.builder( + Component.translatable( + if (address == null) { + "connect_share.status.address_unavailable" + } else { + "connect_share.status.copy_address" + }, + ), + ) { + address?.let(minecraft.keyboardHandler::setClipboard) + }.bounds( + layout.contentX, + layout.bodyTop + 48, + layout.contentWidth, + 20, + ).build().apply { active = address != null }, + ) + addRenderableWidget( + centeredWrapped( + Component.translatable( + "connect_share.status.route_summary", + routeLabel(sharing), + ).withStyle(ChatFormatting.GRAY), + layout.bodyTop + 74, + layout.contentWidth, + ), + ) + } + + private fun addFooter(layout: FormScreenLayout) { + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.privacy.title"), + ) { + minecraft.setScreen(SharePrivacyScreen(this)) + }.bounds( + layout.contentX, + layout.footerTop, + layout.halfButtonWidth, + 20, + ).build(), + ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.identity.manage"), + ) { + minecraft.setScreen(EndpointIdentityScreen(this)) + }.bounds( + layout.contentX + layout.halfButtonWidth + 6, + layout.footerTop, + layout.halfButtonWidth, + 20, + ).build(), + ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.status.stop"), + ) { + viewModel.stop() + minecraft.setScreen(parent) + }.bounds( + layout.contentX, + layout.footerTop + 24, + layout.halfButtonWidth, + 20, + ).build(), + ) + addRenderableWidget( + Button.builder(CommonComponents.GUI_BACK) { onClose() } + .bounds( + layout.contentX + layout.halfButtonWidth + 6, + layout.footerTop + 24, + layout.halfButtonWidth, + 20, + ).build(), + ) + } + + override fun tick() { + super.tick() + val next = viewModel.state.value.hashCode() + if (next != fingerprint) { + rebuildWidgets() + } + } + + override fun onClose() { + minecraft.setScreen(parent) + } + + private fun routeLabel(sharing: ShareState.Sharing): Component = + Component.translatable( + when { + sharing.internetDirectAvailable -> + "connect_share.status.route.internet" + sharing.lanDirectAvailable && sharing.connectAvailable -> + "connect_share.status.route.lan_connect" + sharing.lanDirectAvailable -> + "connect_share.status.route.lan" + sharing.connectAvailable -> + "connect_share.status.route.connect" + else -> "connect_share.status.route.starting" + }, + ) + + private fun friendlyIngress(ingress: Ingress): Component = + Component.translatable( + when (ingress) { + Ingress.CONNECT -> "connect_share.ingress.online" + Ingress.DIRECT_LAN -> "connect_share.ingress.nearby" + Ingress.DIRECT_INTERNET -> "connect_share.ingress.direct" + }, + ) + + private fun statusKey(state: ShareState): String = when (state) { + ShareState.Idle -> "connect_share.status.idle" + ShareState.Starting -> "connect_share.status.starting" + is ShareState.Sharing -> "connect_share.status.ready" + ShareState.Stopping -> "connect_share.status.stopping" + is ShareState.Failed -> "connect_share.status.failed" + } + + private fun centered(message: Component, y: Int): StringWidget { + val textWidth = font.width(message) + return StringWidget( + width / 2 - textWidth / 2, + y, + textWidth, + 11, + message, + font, + ) + } + + private fun centeredWrapped( + message: Component, + y: Int, + maxWidth: Int, + ): MultiLineTextWidget = MultiLineTextWidget( + width / 2 - maxWidth / 2, + y, + message, + font, + ).setMaxWidth(maxWidth).setCentered(true) +} diff --git a/share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/VanillaMinecraft12111Transport.kt b/share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/VanillaMinecraft12111Transport.kt new file mode 100644 index 000000000..ce254f130 --- /dev/null +++ b/share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/VanillaMinecraft12111Transport.kt @@ -0,0 +1,152 @@ +package com.minekube.connect.share.fabric.v1_21_11 + +import com.minekube.connect.share.ShareGameMode +import com.minekube.connect.share.ShareOptions +import com.minekube.connect.share.fabric.v1_21_11.mixin.IntegratedServerAccessor +import com.minekube.connect.share.fabric.v1_21_11.mixin.LanServerPingerAccessor +import com.minekube.connect.share.fabric.v1_21_11.mixin.ServerConnectionListenerAccessor +import io.netty.channel.Channel +import io.netty.channel.ChannelFuture +import io.netty.channel.ChannelInitializer +import io.netty.channel.EventLoopGroup +import java.net.InetSocketAddress +import net.minecraft.client.Minecraft +import net.minecraft.client.server.IntegratedServer +import net.minecraft.util.HttpUtil +import net.minecraft.world.level.GameType + +internal class VanillaMinecraft12111Transport( + private val serverProvider: () -> IntegratedServer? = { + Minecraft.getInstance().singleplayerServer + }, +) : Minecraft12111Transport { + override fun publish(options: ShareOptions): PublishedMinecraftTransport { + val server = checkNotNull(serverProvider()) { + "Connect Share requires an active singleplayer world" + } + check(!server.isPublished) { + "This singleplayer world is already published" + } + val connection = server.connection + val channels = (connection as ServerConnectionListenerAccessor) + .connectShareChannels + val before = synchronized(channels) { channels.toSet() } + val captureLease = CapturedServerTransport.arm() + var published = false + try { + published = server.publishServer( + options.gameMode.toMinecraft(), + options.allowCheats, + HttpUtil.getAvailablePort(), + ) + check(published) { "Minecraft could not start its private Share listener" } + val captured = captureLease.complete().fold( + ifLeft = { + throw IllegalStateException( + "Minecraft did not expose its Share channel initializer", + ) + }, + ifRight = { it }, + ) + val added = synchronized(channels) { + channels.filterNot(before::contains) + } + check(added.size == 1) { + "Minecraft created ${added.size} listeners for one Share start" + } + val loopback = added.single() + val address = loopback.channel().localAddress() as? InetSocketAddress + ?: throw IllegalStateException( + "Minecraft Share did not create a TCP listener", + ) + check(address.address.isLoopbackAddress) { + "Minecraft Share listener escaped loopback" + } + suppressLanAdvertisement(server) + return PublishedVanillaTransport( + server = server, + channels = channels, + loopback = loopback, + address = address, + childInitializer = captured.childInitializer, + eventLoopGroup = captured.eventLoopGroup, + ) + } catch (failure: Throwable) { + captureLease.close() + rollbackNewListeners(server, channels, before) + if (published) { + suppressLanAdvertisement(server) + } + throw failure + } + } + + private fun rollbackNewListeners( + server: IntegratedServer, + channels: MutableList, + before: Set, + ) { + val added = synchronized(channels) { + channels.filterNot(before::contains).also(channels::removeAll) + } + added.forEach(ChannelFuture::closeChannel) + (server as IntegratedServerAccessor).setConnectSharePublishedPort(-1) + } + + private fun suppressLanAdvertisement(server: IntegratedServer) { + val accessor = server as IntegratedServerAccessor + accessor.connectShareLanPinger?.let { pinger -> + pinger.interrupt() + (pinger as LanServerPingerAccessor).connectShareSocket.close() + } + accessor.connectShareLanPinger = null + } + + private fun ShareGameMode.toMinecraft(): GameType = when (this) { + ShareGameMode.SURVIVAL -> GameType.SURVIVAL + ShareGameMode.CREATIVE -> GameType.CREATIVE + ShareGameMode.ADVENTURE -> GameType.ADVENTURE + ShareGameMode.SPECTATOR -> GameType.SPECTATOR + } +} + +private class PublishedVanillaTransport( + private val server: IntegratedServer, + private val channels: MutableList, + private val loopback: ChannelFuture, + override val address: InetSocketAddress, + override val childInitializer: ChannelInitializer, + override val eventLoopGroup: EventLoopGroup, +) : PublishedMinecraftTransport { + override fun addLocalListener(listener: LocalShareChannel) { + val future = checkNotNull(listener.future) { + "Connect Share local channel did not expose its bound future" + } + synchronized(channels) { + check(channels.add(future)) { + "Minecraft already tracks the Connect Share local listener" + } + } + } + + override fun removeLocalListener(listener: LocalShareChannel) { + val future = listener.future ?: return + synchronized(channels) { + channels.remove(future) + } + } + + override fun close() { + synchronized(channels) { + channels.remove(loopback) + } + loopback.closeChannel() + (server as IntegratedServerAccessor).setConnectSharePublishedPort(-1) + } +} + +private fun ChannelFuture.closeChannel() { + if (channel().isOpen) { + channel().close().syncUninterruptibly() + } +} diff --git a/share/fabric-1.21.11/src/main/resources/assets/connect-share/lang/de_de.json b/share/fabric-1.21.11/src/main/resources/assets/connect-share/lang/de_de.json new file mode 100644 index 000000000..bf44f3379 --- /dev/null +++ b/share/fabric-1.21.11/src/main/resources/assets/connect-share/lang/de_de.json @@ -0,0 +1,286 @@ +{ + "connect_share.menu.share": "Welt teilen", + "connect_share.menu.active": "Welt geteilt", + "connect_share.menu.join": "Freunde", + "connect_share.setup.title": "Diese Welt mit Freunden spielen", + "connect_share.setup.description": "Mache diese Welt jetzt betretbar. Gespeicherte Freunde sehen sie automatisch.", + "connect_share.setup.max_guests": "Maximale Gäste", + "connect_share.setup.internet": "Schnellere direkte Internetverbindungen erlauben", + "connect_share.setup.internet.tooltip": "Optional. Zeigt eingeladenen Gästen mit Mod deine öffentliche IP-Adresse. Connect bleibt der Relay-Fallback.", + "connect_share.setup.persistence": "Beim Wechseln oder erneuten Öffnen einer Welt wird das Teilen automatisch fortgesetzt.", + "connect_share.setup.start": "Teilen starten", + "connect_share.game_mode.survival": "Überleben", + "connect_share.game_mode.creative": "Kreativ", + "connect_share.game_mode.adventure": "Abenteuer", + "connect_share.game_mode.spectator": "Zuschauer", + "connect_share.status.title": "Deine Welt ist bereit", + "connect_share.status.address": "Über Connect bereit · %s", + "connect_share.status.direct_only": "Für Freunde in der Nähe bereit", + "connect_share.status.copy_invitation": "Freundeslink kopieren", + "connect_share.status.copy_address": "Serveradresse kopieren", + "connect_share.status.link_help": "Sende den Freundeslink einmal. Danach erscheinen zukünftige Welten automatisch.", + "connect_share.status.connection_help": "Die beste Verbindung wird automatisch gewählt: direkt, wenn möglich; sonst Connect.", + "connect_share.status.requests": "Anfragen, die auf dich warten", + "connect_share.status.idle": "Nicht geteilt", + "connect_share.status.starting": "Wird gestartet…", + "connect_share.status.active": "Aktiv", + "connect_share.status.stopping": "Wird beendet…", + "connect_share.status.failed": "Start fehlgeschlagen", + "connect_share.status.request": "%s möchte sich verbinden · %s", + "connect_share.status.friend_request": "%s hat eine Freundschaftsanfrage gesendet · %s", + "connect_share.status.allow": "Annehmen", + "connect_share.status.deny": "Ablehnen", + "connect_share.status.more": "%s weitere Anfragen", + "connect_share.status.waiting": "Niemand wartet auf eine Antwort.", + "connect_share.status.stop": "Teilen mit Freunden beenden", + "connect_share.join.title": "Connect Share beitreten", + "connect_share.join.description": "Wähle eine Welt in der Nähe oder füge ihre signierte Einladung ein.", + "connect_share.join.invitation": "Connect-Share-Einladung", + "connect_share.join.invitation_hint": "minekube://share/…", + "connect_share.join.scanning": "Suche nach Connect-Share-Welten in der Nähe…", + "connect_share.join.discovered": "In der Nähe: %s", + "connect_share.join.offline": "Offline-Identität verwenden (ungeprüft)", + "connect_share.join.offline.tooltip": "Der Host sieht diese Identität als ungeprüft und muss sie freigeben.", + "connect_share.join.internet": "Direkte Internetverbindung versuchen", + "connect_share.join.internet.tooltip": "Optional. Zeigt dem Host deine öffentliche IP-Adresse. Connect bleibt der Relay-Fallback.", + "connect_share.join.join": "Beitreten", + "connect_share.friends.title": "Freunde", + "connect_share.friends.page": "Seite %s von %s", + "connect_share.friends.description": "Spielbereite Freunde erscheinen hier.", + "connect_share.friends.copy_my_link": "Meinen Freundeslink kopieren", + "connect_share.friends.copy_my_link.tooltip": "Enthält eine direkte Route, damit diese Person dich erreicht. Sende ihn nur an vertraute Personen.", + "connect_share.friends.copying_my_link": "Freundeslink wird erstellt…", + "connect_share.friends.my_link_copied": "Freundeslink kopiert", + "connect_share.friends.copy_my_link_failed": "Freundeslink konnte nicht kopiert werden", + "connect_share.friends.empty": "Noch keine Freunde oder gesendeten Anfragen. Füge einen Freundeslink ein, um eine zu senden.", + "connect_share.friends.outgoing_request": "Anfrage an %s", + "connect_share.friends.incoming_request": "Anfrage von %s · %s", + "connect_share.friends.incoming_join_request": "%s möchte beitreten · %s", + "connect_share.friends.outgoing_request_active": "%s · Anfrage läuft", + "connect_share.friends.retry_request": "Erneut", + "connect_share.friends.request_sending": "Wird gesendet…", + "connect_share.friends.request_waiting": "Warten…", + "connect_share.friends.request_cancelling": "Wird abgebrochen…", + "connect_share.friends.request_failed": "Die Anfrage konnte nicht zugestellt werden. Dein Freund ist möglicherweise offline oder noch nicht direkt erreichbar.", + "connect_share.friends.request_accepted": "%s hat deine Freundschaftsanfrage angenommen.", + "connect_share.friends.cancel_request": "Abbrechen", + "connect_share.friends.manage": "Verwalten", + "connect_share.friends.request_join": "Anfragen", + "connect_share.friends.follow": "Beitreten, sobald bereit", + "connect_share.friends.cancel_follow": "Folgen abbrechen", + "connect_share.notification.follow_waiting": "%s wird gefolgt", + "connect_share.notification.follow_waiting_detail": "Sobald die nächste Welt bereit ist, wird eine Beitrittsanfrage gesendet. Abbruch unter Freunde.", + "connect_share.notification.follow_ready": "%s ist bereit", + "connect_share.notification.follow_ready_detail": "Öffne Freunde, um jetzt beizutreten; dein aktives Spiel wurde nicht unterbrochen.", + "connect_share.notification.follow_expired": "Folgen abgelaufen", + "connect_share.notification.follow_expired_detail": "%s war nicht rechtzeitig beitrittsbereit.", + "connect_share.notification.follow_cancelled": "Folgen beendet", + "connect_share.notification.follow_cancelled_detail": "%s kann nicht mehr gefolgt werden, da sich Freundschaft oder Zugriff geändert haben.", + "connect_share.notification.follow_failed": "Freund konnte nicht gefolgt werden", + "connect_share.friends.playing_server": "%s · spielt auf %s", + "connect_share.friends.hosting_world": "%s · spielt %s", + "connect_share.friends.online": "%s · online", + "connect_share.friends.friend_unreachable": "Dein Freund ist gerade nicht über libp2p erreichbar.", + "connect_share.friends.add": "Freund hinzufügen", + "connect_share.friends.add_description": "Füge den Link deines Freundes ein und sende eine Anfrage. Die Person kann sie annehmen, solange ihr Spiel geöffnet ist.", + "connect_share.friends.name": "Name des Freundes", + "connect_share.friends.name_hint": "Name der Person, die den Link gesendet hat", + "connect_share.friends.save": "Freund speichern", + "connect_share.friends.send_request": "Anfrage senden", + "connect_share.friends.connecting_request": "Freundschaftsanfrage an %s", + "connect_share.friends.join_once": "Einmal beitreten", + "connect_share.friends.manage_title": "%s verwalten", + "connect_share.friends.notify": "Benachrichtigen, wenn die Welt bereit ist", + "connect_share.friends.share_worlds": "Meine Welten mit diesem Freund teilen", + "connect_share.friends.auto_join": "Diesen Freund automatisch beitreten lassen", + "connect_share.friends.auto_join.tooltip": "Überspringt die Freigabe nur, wenn die Mod die gespeicherte libp2p-Identität nachweist.", + "connect_share.friends.access": "Wenn diese Person beitreten möchte", + "connect_share.friends.access.ask_every_time": "Jedes Mal nachfragen", + "connect_share.friends.access.auto_accept": "Automatisch beitreten lassen", + "connect_share.friends.access.never_allow": "Beitritt nie erlauben", + "connect_share.privacy.title": "Privatsphäre", + "connect_share.privacy.description": "Lege fest, was bestätigte Freunde sehen. Andere Personen erhalten keine Informationen.", + "connect_share.privacy.online": "Anzeigen, dass ich online bin", + "connect_share.privacy.playing": "Anzeigen, wenn ich spiele", + "connect_share.privacy.current_server": "Server- oder Weltnamen anzeigen", + "connect_share.privacy.joinable": "Freunden Beitrittsanfragen erlauben", + "connect_share.privacy.confirmed_only": "Freundschaft, Entfernen und Blockieren werden vor Präsenz und Beitritt berücksichtigt.", + "connect_share.friends.save_changes": "Änderungen speichern", + "connect_share.friends.remove": "Freund entfernen", + "connect_share.friends.remove_confirm.title": "%s entfernen?", + "connect_share.friends.remove_confirm.message": "Diese Person wird nicht mehr automatisch vertraut. Du kannst ihren Freundeslink später erneut hinzufügen.", + "connect_share.friends.remove_confirm.confirm": "Freund entfernen", + "connect_share.friends.block": "Blockieren", + "connect_share.privacy.blocked": "Blockierte Identitäten (%s)", + "connect_share.privacy.blocked_title": "Blockierte Identitäten", + "connect_share.privacy.blocked_empty": "Du hast niemanden blockiert.", + "connect_share.privacy.unblock": "Entsperren", + "connect_share.friends.ready_lan": "%s · %s · direkt im LAN", + "connect_share.friends.ready_connect": "%s · %s · online über Connect", + "connect_share.friends.saved_connect": "%s · gespeichert · Connect-Fallback", + "connect_share.friends.saved_offline": "%s · keine Route verfügbar", + "connect_share.notification.join_request": "Beitrittsanfrage", + "connect_share.notification.join_request_detail": "%s möchte beitreten. Öffne Connect Share zum Annehmen oder Ablehnen.", + "connect_share.notification.friend_request": "Freundschaftsanfrage", + "connect_share.notification.friend_request_detail": "%s hat eine Freundschaftsanfrage gesendet. Öffne Connect Share zum Annehmen oder Ablehnen.", + "connect_share.notification.friend_online": "Welt eines Freundes ist bereit", + "connect_share.notification.friend_online_detail": "%s spielt %s. Öffne Freunde, um Beitritt anzufragen.", + "connect_share.notification.friend_accepted": "Freund hinzugefügt", + "connect_share.notification.friend_accepted_detail": "%s hat deine Freundschaftsanfrage angenommen.", + "connect_share.notification.friend_removed": "Freund entfernt", + "connect_share.notification.friend_removed_detail": "%s ist nicht mehr in deiner Freundesliste.", + "connect_share.notification.friend_playing": "Freund spielt", + "connect_share.notification.friend_playing_detail": "%s spielt auf %s. Öffne Freunde, um den Beitritt anzufragen.", + "connect_share.identity.manage": "Erweiterte Einstellungen…", + "connect_share.identity.title": "Connect-Endpunkt-Identität", + "connect_share.identity.current": "Endpunkt: %s", + "connect_share.identity.sources": "Endpunkt: %s · Zugang: %s", + "connect_share.identity.endpoint": "Vorhandener Endpunkt", + "connect_share.identity.token": "Endpunkt-Token", + "connect_share.identity.save": "Prüfen und speichern", + "connect_share.identity.choose_file": "token.json importieren…", + "connect_share.identity.reset": "Endpunkt-Identität zurücksetzen…", + "connect_share.identity.reset_confirm.title": "Connect-Identität zurücksetzen?", + "connect_share.identity.reset_confirm.message": "Die alte Beitrittsadresse und das Token werden nicht mehr verwendet. Dies kann nicht rückgängig gemacht werden.", + "connect_share.compatibility.title": "Andere Spieleinrichtung", + "connect_share.compatibility.copy_pack": "Modpack-Link des Hosts kopieren", + "connect_share.compatibility.pack_copied": "Modpack-Link kopiert", + "connect_share.compatibility.try_anyway": "Trotzdem versuchen", + "connect_share.diagnostics.copy": "Sichere Diagnose kopieren", + "connect_share.diagnostics.copied": "Diagnose kopiert", + "connect_share.friends.internet_direct": "Direkte Internetverbindungen für diesen Freund erlauben", + "connect_share.setup.friends_only": "Nur Freunde, die du bestätigst, können beitreten.", + "connect_share.setup.options.show": "Spieloptionen…", + "connect_share.setup.options.hide": "Spieloptionen ausblenden", + "connect_share.status.ready": "Freunde können diese Welt jetzt sehen und um Beitritt bitten.", + "connect_share.status.copy_friend_link": "Einladung für neuen Freund kopieren", + "connect_share.status.friend_link_copied": "Freundeseinladung kopiert", + "connect_share.status.connection_details.show": "Verbindungsdetails…", + "connect_share.status.connection_details.hide": "Verbindungsdetails ausblenden", + "connect_share.status.address_unavailable": "Serveradresse wird noch vorbereitet", + "connect_share.status.route_summary": "Verbindung: %s", + "connect_share.status.route.internet": "direkt, mit Connect als Ausweichroute", + "connect_share.status.route.lan_connect": "in der Nähe, mit Connect als Ausweichroute", + "connect_share.status.route.lan": "in der Nähe", + "connect_share.status.route.connect": "über Connect", + "connect_share.status.route.starting": "wird vorbereitet", + "connect_share.privacy.blocked_description": "Blockierte Personen können weder Anfragen senden noch deine Aktivität sehen.", + "connect_share.compatibility.description": "Eure Spieleinstellungen unterscheiden sich. Gleiche diese Punkte für einen zuverlässigen Beitritt ab.", + "connect_share.compatibility.more": "%s weitere Unterschiede", + "connect_share.identity.description": "Erweitert: Verwende einen Endpunkt aus dem Minekube-Dashboard. Dein Token bleibt auf diesem Gerät verborgen.", + "connect_share.identity.endpoint_hint": "dein-name.play.minekube.net", + "connect_share.identity.token_hint": "Endpunkt-Token einfügen", + "connect_share.page.previous": "Zurück", + "connect_share.page.next": "Weiter", + "connect_share.friends.summary.requests.one": "1 Anfrage wartet auf dich", + "connect_share.friends.summary.requests.many": "%s Anfragen warten auf dich", + "connect_share.friends.summary.welcome": "Spielt zusammen, ohne einen Server einzurichten.", + "connect_share.friends.summary.joinable.one": "1 Freund ist spielbereit", + "connect_share.friends.summary.joinable.many": "%s Freunde sind spielbereit", + "connect_share.friends.summary.online.one": "1 Freund ist online", + "connect_share.friends.summary.online.many": "%s Freunde sind online", + "connect_share.friends.summary.saved.one": "1 gespeicherter Freund", + "connect_share.friends.summary.saved.many": "%s gespeicherte Freunde", + "connect_share.friends.invite": "Freund einladen", + "connect_share.friends.manage_named": "%s verwalten", + "connect_share.friends.action.join_now": "Jetzt beitreten", + "connect_share.friends.action.ask_to_join": "Um Beitritt bitten", + "connect_share.friends.action.cancel_follow": "Abbrechen", + "connect_share.friends.action.join_when_ready": "Beitreten, sobald bereit", + "connect_share.friends.status.world": "Spielt %s", + "connect_share.friends.status.server": "Auf %s", + "connect_share.friends.status.ready": "Bereit zum Beitreten", + "connect_share.friends.status.online": "Online", + "connect_share.friends.status.saved": "Gespeicherter Freund", + "connect_share.friends.status.offline": "Offline", + "connect_share.friends.connection_options.show": "Verbindungsoptionen…", + "connect_share.friends.connection_options.hide": "Verbindungsoptionen ausblenden", + "connect_share.friends.internet_direct_short": "Schnellere Direktverbindungen", + "connect_share.friends.internet_direct.tooltip": "Optional. Teilt deine öffentliche IP-Adresse nur mit diesem Freund. Connect bleibt die Ausweichroute.", + "connect_share.ingress.online": "online", + "connect_share.ingress.nearby": "in der Nähe", + "connect_share.ingress.direct": "direkt", + "connect_share.compatibility.minecraft": "Minecraft: deins %s · Freund %s", + "connect_share.compatibility.loader": "Mod-Loader: deiner %s · Freund %s", + "connect_share.compatibility.install": "Installiere %s %s", + "connect_share.compatibility.host_missing": "Dein Freund benötigt %s %s", + "connect_share.compatibility.mod_version": "%s: deins %s · Freund %s", + "connect_share.menu.requests": "Freunde (%s)", + "connect_share.menu.ready": "Freunde (%s)", + "connect_share.friends.link": "Freundeslink", + "connect_share.friends.connection_options.title": "Verbindungsoptionen", + "connect_share.friends.connection_options.description": "Optionale Einstellungen für ungewöhnliche Konten oder schnellere Direktverbindungen.", + "connect_share.friends.connection_options.fallback": "Connect bleibt die Ausweichroute, wenn keine Direktverbindung verfügbar ist.", + "connect_share.identity.source.generated": "Auf diesem Gerät erstellt", + "connect_share.identity.source.imported": "Importiert", + "connect_share.identity.source.environment": "Vom Launcher verwaltet", + "connect_share.error.generic": "Connect Share konnte nicht aktualisiert werden", + "connect_share.error.identity_invalid": "Die Connect-Zugangsdaten sind ungültig", + "connect_share.error.identity_rejected": "Connect hat diese Zugangsdaten abgelehnt", + "connect_share.error.identity_network": "Connect konnte die Zugangsdaten nicht prüfen", + "connect_share.error.identity_managed": "Die Connect-Zugangsdaten werden von der Umgebung verwaltet", + "connect_share.error.identity_active": "Beende das Teilen, bevor du Connect-Zugangsdaten änderst", + "connect_share.error.preferences_save": "Die Connect-Share-Privatsphäre konnte nicht gespeichert werden", + "connect_share.error.share_already_active": "Connect Share ist bereits aktiv", + "connect_share.error.share_start_failed": "Connect Share konnte nicht gestartet werden", + "connect_share.error.share_stop_failed": "Connect Share wurde mit Bereinigungsfehlern beendet", + "connect_share.error.invalid_invitation": "Diese Connect-Share-Einladung ist ungültig", + "connect_share.error.invitation_malformed": "Diese Connect-Share-Einladung ist unvollständig oder fehlerhaft", + "connect_share.error.invitation_unsupported_version": "Diese Einladung verwendet das nicht unterstützte Share-Format %s", + "connect_share.error.invitation_expired": "Diese Connect-Share-Einladung ist abgelaufen — bitte um eine neue", + "connect_share.error.invitation_invalid_signature": "Diese Einladung konnte nicht verifiziert werden", + "connect_share.error.invitation_relay_forbidden": "Eine direkte Share-Einladung darf keine Relay-Route enthalten", + "connect_share.error.invitation_peer_mismatch": "Diese Einladung passt nicht zu dem Freund, der sie gesendet hat", + "connect_share.login.authentication_required": "Diese direkte Verbindung erfordert die Minecraft-Online-Authentifizierung", + "connect_share.login.approval_timed_out": "Der Host hat die Beitrittsanfrage nicht rechtzeitig beantwortet", + "connect_share.login.share_full": "Diese geteilte Welt ist voll", + "connect_share.login.sharing_stopped": "Der Host teilt diese Welt nicht mehr", + "connect_share.login.host_denied": "Der Host hat diese Verbindung abgelehnt", + "connect_share.error.invalid_friend_name": "Der Freundesname muss zwischen 1 und 64 Zeichen lang sein", + "connect_share.error.friend_identity_conflict": "Die Identität stimmt nicht mit dem gespeicherten Schlüssel überein", + "connect_share.error.friend_not_saved": "Dieser Freund ist nicht mehr gespeichert", + "connect_share.error.friend_blocked": "Diese Identität ist blockiert", + "connect_share.error.friends_load": "Gespeicherte Connect-Share-Freunde konnten nicht geladen werden", + "connect_share.error.friend_remove": "Dieser Connect-Share-Freund konnte nicht entfernt werden", + "connect_share.error.friend_block": "Diese Connect-Share-Identität konnte nicht blockiert werden", + "connect_share.error.friend_unblock": "Diese Connect-Share-Identität konnte nicht entsperrt werden", + "connect_share.error.join_peer_mismatch": "Der gefundene Host passt nicht zu dieser Einladung", + "connect_share.error.join_discovery_unavailable": "Automatische LAN-Suche ist nicht verfügbar", + "connect_share.error.join_no_route": "Keine Route zu dieser Connect-Share-Welt verfügbar", + "connect_share.error.identity_endpoint_conflict": "Dieses Profil verwendet denselben Connect-Endpunkt wie dein Freund", + "connect_share.error.friend_unreachable": "Dein Freund ist gerade nicht erreichbar", + "connect_share.error.friend_declined": "Dein Freund hat diese Anfrage abgelehnt", + "connect_share.error.friend_timed_out": "Dein Freund hat nicht rechtzeitig geantwortet", + "connect_share.error.friend_invalid_response": "Die Antwort auf die Freundschaftsanfrage war ungültig", + "connect_share.error.minecraft_version": "Eure Minecraft-Versionen stimmen nicht überein", + "connect_share.error.mod_loader": "Eure Mod-Loader stimmen nicht überein", + "connect_share.error.required_mods": "Die benötigten Mods stimmen nicht überein", + "connect_share.page.previous_tooltip": "Vorherige Seite · Seite %s von %s", + "connect_share.page.next_tooltip": "Nächste Seite · Seite %s von %s", + "connect_share.recovery.title": "Sichern & wiederherstellen", + "connect_share.recovery.menu": "Sicherung", + "connect_share.recovery.description": "Übertrage Freunde und deine Share-Identität auf ein anderes Gerät. Die verschlüsselte Sicherung bleibt offline.", + "connect_share.recovery.secret": "Wiederherstellungspasswort", + "connect_share.recovery.secret_confirm": "Passwort bestätigen", + "connect_share.recovery.warning": "Beim Wiederherstellen werden Freunde und Share-Identität dieses Geräts ersetzt. Beende zuerst das Teilen und starte Minecraft danach neu.", + "connect_share.recovery.export": "Freunde sichern", + "connect_share.recovery.import": "Sicherung wiederherstellen", + "connect_share.recovery.restore_confirm": "Ersetzen und wiederherstellen", + "connect_share.recovery.summary": "%s Einträge · Einstellungen: %s · Connect-Endpunkt: %s", + "connect_share.recovery.included": "enthalten", + "connect_share.recovery.not_included": "nicht enthalten", + "connect_share.recovery.exported": "Verschlüsselte Sicherung gespeichert. Bewahre das Passwort sicher auf.", + "connect_share.recovery.restored_restart": "Wiederherstellung abgeschlossen. Starte Minecraft vor dem Teilen oder Beitreten neu.", + "connect_share.recovery.error.secret_mismatch": "Die Wiederherstellungspasswörter stimmen nicht überein.", + "connect_share.recovery.error.weak_secret": "Verwende ein Wiederherstellungspasswort mit mindestens 12 Zeichen.", + "connect_share.recovery.error.authentication": "Falsches Passwort oder beschädigte Sicherung. Es wurde nichts geändert.", + "connect_share.recovery.error.unsupported": "Diese Sicherungsversion wird nicht unterstützt.", + "connect_share.recovery.error.too_large": "Diese Sicherung ist zu groß.", + "connect_share.recovery.error.invalid": "Dies ist keine gültige Connect-Share-Sicherung.", + "connect_share.recovery.error.nothing_to_export": "Es gibt noch keine vollständigen Freundesidentitäten zum Sichern.", + "connect_share.recovery.error.unsafe_files": "Die Sicherung wurde wegen einer unsicheren Share-Datei beendet.", + "connect_share.recovery.error.read": "Die ausgewählte Sicherung konnte nicht gelesen werden.", + "connect_share.recovery.error.write": "Die verschlüsselte Sicherung konnte nicht gespeichert werden.", + "connect_share.recovery.error.restore": "Die Wiederherstellung ist fehlgeschlagen; die vorherigen Daten dieses Geräts wurden wiederhergestellt.", + "connect_share.recovery.error.stop_sharing": "Beende das Teilen, bevor du eine Sicherung wiederherstellst." +} diff --git a/share/fabric-1.21.11/src/main/resources/assets/connect-share/lang/en_us.json b/share/fabric-1.21.11/src/main/resources/assets/connect-share/lang/en_us.json new file mode 100644 index 000000000..66fb81293 --- /dev/null +++ b/share/fabric-1.21.11/src/main/resources/assets/connect-share/lang/en_us.json @@ -0,0 +1,286 @@ +{ + "connect_share.menu.share": "Share world", + "connect_share.menu.active": "World shared", + "connect_share.menu.join": "Friends", + "connect_share.setup.title": "Play this world with friends", + "connect_share.setup.description": "Make this world joinable now. Saved friends will see it automatically.", + "connect_share.setup.max_guests": "Maximum guests", + "connect_share.setup.internet": "Allow faster direct internet connections", + "connect_share.setup.internet.tooltip": "Optional. Reveals your public IP address to invited modded guests. Connect remains the relay fallback.", + "connect_share.setup.persistence": "Sharing resumes automatically when you switch or reopen a world.", + "connect_share.setup.start": "Start sharing", + "connect_share.game_mode.survival": "Survival", + "connect_share.game_mode.creative": "Creative", + "connect_share.game_mode.adventure": "Adventure", + "connect_share.game_mode.spectator": "Spectator", + "connect_share.status.title": "Your world is ready", + "connect_share.status.address": "Ready through Connect · %s", + "connect_share.status.direct_only": "Ready for nearby friends", + "connect_share.status.copy_invitation": "Copy friend link", + "connect_share.status.copy_address": "Copy server address", + "connect_share.status.link_help": "Send the friend link once. After saving it, future worlds appear automatically.", + "connect_share.status.connection_help": "The best connection is chosen automatically: direct when possible, Connect otherwise.", + "connect_share.status.requests": "Requests waiting for you", + "connect_share.status.idle": "Not sharing", + "connect_share.status.starting": "Starting…", + "connect_share.status.active": "Active", + "connect_share.status.stopping": "Stopping…", + "connect_share.status.failed": "Could not start", + "connect_share.status.request": "%s wants to connect · %s", + "connect_share.status.friend_request": "%s sent a friend request · %s", + "connect_share.status.allow": "Accept", + "connect_share.status.deny": "Decline", + "connect_share.status.more": "%s more requests", + "connect_share.status.waiting": "No one is waiting for a response.", + "connect_share.status.stop": "Stop sharing with friends", + "connect_share.join.title": "Join Connect Share", + "connect_share.join.description": "Choose a nearby world or paste its signed invitation.", + "connect_share.join.invitation": "Connect Share invitation", + "connect_share.join.invitation_hint": "minekube://share/…", + "connect_share.join.scanning": "Scanning for nearby Connect Share worlds…", + "connect_share.join.discovered": "Nearby: %s", + "connect_share.join.offline": "Use an offline identity (unverified)", + "connect_share.join.offline.tooltip": "The host will see this identity as unverified and must approve it.", + "connect_share.join.internet": "Try a direct internet connection", + "connect_share.join.internet.tooltip": "Optional. Reveals your public IP address to the host. Connect remains the relay fallback.", + "connect_share.join.join": "Join", + "connect_share.friends.title": "Friends", + "connect_share.friends.page": "Page %s of %s", + "connect_share.friends.description": "See who is around and join them when they are ready.", + "connect_share.friends.summary.welcome": "Play together without setting up a server.", + "connect_share.friends.invite": "Invite a friend", + "connect_share.friends.copy_my_link": "Copy my friend link", + "connect_share.friends.copy_my_link.tooltip": "Includes a direct route so this person can reach you. Only send it to someone you trust.", + "connect_share.friends.copying_my_link": "Creating friend link…", + "connect_share.friends.my_link_copied": "Friend link copied", + "connect_share.friends.copy_my_link_failed": "Could not copy friend link", + "connect_share.friends.empty": "Invite someone or add their link to start playing together.", + "connect_share.friends.outgoing_request": "Waiting for %s", + "connect_share.friends.incoming_request": "%s wants to be friends · %s", + "connect_share.friends.incoming_join_request": "%s wants you to join · %s", + "connect_share.friends.outgoing_request_active": "Contacting %s…", + "connect_share.friends.retry_request": "Retry", + "connect_share.friends.request_sending": "Sending…", + "connect_share.friends.request_waiting": "Waiting…", + "connect_share.friends.request_cancelling": "Canceling…", + "connect_share.friends.request_failed": "Request could not be delivered. Your friend may be offline or not reachable directly yet.", + "connect_share.friends.request_accepted": "%s accepted your friend request.", + "connect_share.friends.cancel_request": "Cancel", + "connect_share.friends.manage": "Manage", + "connect_share.friends.manage_named": "Manage %s", + "connect_share.friends.action.join_now": "Join now", + "connect_share.friends.action.ask_to_join": "Ask to join", + "connect_share.friends.action.cancel_follow": "Cancel", + "connect_share.friends.action.join_when_ready": "Join when ready", + "connect_share.friends.status.world": "Playing %s", + "connect_share.friends.status.server": "On %s", + "connect_share.friends.status.ready": "Ready to join", + "connect_share.friends.status.online": "Online", + "connect_share.friends.status.saved": "Saved friend", + "connect_share.friends.status.offline": "Offline", + "connect_share.friends.request_join": "Request", + "connect_share.friends.follow": "Join when ready", + "connect_share.friends.cancel_follow": "Cancel follow", + "connect_share.notification.follow_waiting": "Following %s", + "connect_share.notification.follow_waiting_detail": "A join request will be sent when their next world is ready. Cancel from Friends.", + "connect_share.notification.follow_ready": "%s is ready", + "connect_share.notification.follow_ready_detail": "Open Friends to join now; active gameplay was not interrupted.", + "connect_share.notification.follow_expired": "Follow expired", + "connect_share.notification.follow_expired_detail": "%s did not become joinable in time.", + "connect_share.notification.follow_cancelled": "Follow cancelled", + "connect_share.notification.follow_cancelled_detail": "%s can no longer be followed because the friendship or access changed.", + "connect_share.notification.follow_failed": "Could not follow friend", + "connect_share.friends.playing_server": "%s · playing on %s", + "connect_share.friends.hosting_world": "%s · playing %s", + "connect_share.friends.online": "%s · online", + "connect_share.friends.friend_unreachable": "Your friend is not reachable over libp2p right now.", + "connect_share.friends.add": "Add from link", + "connect_share.friends.add_description": "Paste your friend's Connect Share link. Their name fills in automatically.", + "connect_share.friends.name": "Friend's name", + "connect_share.friends.name_hint": "Friend's name (filled from link)", + "connect_share.friends.save": "Save friend", + "connect_share.friends.send_request": "Send request", + "connect_share.friends.connecting_request": "Friend request to %s", + "connect_share.friends.join_once": "Join without adding", + "connect_share.friends.connection_options.show": "Connection options…", + "connect_share.friends.connection_options.hide": "Hide connection options", + "connect_share.friends.manage_title": "Manage %s", + "connect_share.friends.notify": "Notify me when their world is ready", + "connect_share.friends.share_worlds": "Share my worlds with this friend", + "connect_share.friends.auto_join": "Let this friend join automatically", + "connect_share.friends.auto_join.tooltip": "Skips the approval prompt only when their mod proves the saved libp2p identity.", + "connect_share.friends.access": "When they ask to join", + "connect_share.friends.access.ask_every_time": "Ask me every time", + "connect_share.friends.access.auto_accept": "Let them join automatically", + "connect_share.friends.access.never_allow": "Never allow joining", + "connect_share.privacy.title": "Privacy", + "connect_share.privacy.description": "Choose what confirmed friends can see. People who are not your friends receive nothing.", + "connect_share.privacy.online": "Show that I am online", + "connect_share.privacy.playing": "Show when I am playing", + "connect_share.privacy.current_server": "Show the server or world name", + "connect_share.privacy.joinable": "Let friends request to join", + "connect_share.privacy.confirmed_only": "Friend requests, removals, and blocks take effect before presence or joining.", + "connect_share.friends.save_changes": "Save changes", + "connect_share.friends.remove": "Remove friend", + "connect_share.friends.remove_confirm.title": "Remove %s?", + "connect_share.friends.remove_confirm.message": "They will no longer be trusted automatically. You can add their friend link again later.", + "connect_share.friends.remove_confirm.confirm": "Remove friend", + "connect_share.friends.block": "Block", + "connect_share.privacy.blocked": "Blocked identities (%s)", + "connect_share.privacy.blocked_title": "Blocked identities", + "connect_share.privacy.blocked_empty": "You have not blocked anyone.", + "connect_share.privacy.unblock": "Unblock", + "connect_share.friends.ready_lan": "%s · %s · direct LAN", + "connect_share.friends.ready_connect": "%s · %s · online via Connect", + "connect_share.friends.saved_connect": "%s · saved · Connect fallback", + "connect_share.friends.saved_offline": "%s · no route available", + "connect_share.notification.join_request": "Join request", + "connect_share.notification.join_request_detail": "%s wants to join. Open Connect Share to accept or decline.", + "connect_share.notification.friend_request": "Friend request", + "connect_share.notification.friend_request_detail": "%s sent a friend request. Open Connect Share to accept or decline.", + "connect_share.notification.friend_online": "Friend's world is ready", + "connect_share.notification.friend_online_detail": "%s is playing %s. Open Friends to request to join.", + "connect_share.notification.friend_accepted": "Friend added", + "connect_share.notification.friend_accepted_detail": "%s accepted your friend request.", + "connect_share.notification.friend_removed": "Friend removed", + "connect_share.notification.friend_removed_detail": "%s is no longer in your friends list.", + "connect_share.notification.friend_playing": "Friend is playing", + "connect_share.notification.friend_playing_detail": "%s is on %s. Open Friends to request to join.", + "connect_share.identity.manage": "Advanced settings…", + "connect_share.identity.title": "Connect endpoint identity", + "connect_share.identity.current": "Endpoint: %s", + "connect_share.identity.sources": "Endpoint: %s · Credential: %s", + "connect_share.identity.endpoint": "Existing endpoint", + "connect_share.identity.token": "Endpoint token", + "connect_share.identity.save": "Validate and save", + "connect_share.identity.choose_file": "Import token.json…", + "connect_share.identity.reset": "Reset endpoint identity…", + "connect_share.identity.reset_confirm.title": "Reset Connect identity?", + "connect_share.identity.reset_confirm.message": "The old join address and token will stop being used. This cannot be undone.", + "connect_share.compatibility.title": "Different game setup", + "connect_share.compatibility.copy_pack": "Copy the host's modpack link", + "connect_share.compatibility.pack_copied": "Modpack link copied", + "connect_share.compatibility.try_anyway": "Try anyway", + "connect_share.diagnostics.copy": "Copy safe diagnostics", + "connect_share.diagnostics.copied": "Diagnostics copied", + "connect_share.friends.internet_direct": "Allow direct internet routes for this friend", + "connect_share.friends.internet_direct_short": "Faster direct connections", + "connect_share.friends.internet_direct.tooltip": "Optional. Shares your public IP address only with this friend. Connect remains the fallback.", + "connect_share.ingress.online": "online", + "connect_share.ingress.nearby": "nearby", + "connect_share.ingress.direct": "direct", + "connect_share.compatibility.minecraft": "Minecraft: yours %s · friend's %s", + "connect_share.compatibility.loader": "Loader: yours %s · friend's %s", + "connect_share.compatibility.install": "Install %s %s", + "connect_share.compatibility.host_missing": "Your friend needs %s %s", + "connect_share.compatibility.mod_version": "%s: yours %s · friend's %s", + "connect_share.setup.friends_only": "Only friends you approve can join.", + "connect_share.setup.options.show": "Game options…", + "connect_share.setup.options.hide": "Hide game options", + "connect_share.status.ready": "Friends can now see this world and ask to join.", + "connect_share.status.copy_friend_link": "Copy invite for a new friend", + "connect_share.status.friend_link_copied": "Friend invite copied", + "connect_share.status.connection_details.show": "Connection details…", + "connect_share.status.connection_details.hide": "Hide connection details", + "connect_share.status.address_unavailable": "Server address is still starting", + "connect_share.status.route_summary": "Connection: %s", + "connect_share.status.route.internet": "direct, with Connect as fallback", + "connect_share.status.route.lan_connect": "nearby, with Connect as fallback", + "connect_share.status.route.lan": "nearby", + "connect_share.status.route.connect": "through Connect", + "connect_share.status.route.starting": "getting ready", + "connect_share.privacy.blocked_description": "Blocked people cannot send requests or see your activity.", + "connect_share.compatibility.description": "Your game setups differ. Match these items for the most reliable join.", + "connect_share.compatibility.more": "%s more differences", + "connect_share.identity.description": "Advanced: reuse an endpoint from the Minekube dashboard. Your token stays hidden on this device.", + "connect_share.identity.endpoint_hint": "your-name.play.minekube.net", + "connect_share.identity.token_hint": "Paste endpoint token", + "connect_share.page.previous": "Previous", + "connect_share.page.next": "Next", + "connect_share.friends.summary.requests.one": "1 request waiting for you", + "connect_share.friends.summary.requests.many": "%s requests waiting for you", + "connect_share.friends.summary.joinable.one": "1 friend ready to play", + "connect_share.friends.summary.joinable.many": "%s friends ready to play", + "connect_share.friends.summary.online.one": "1 friend online", + "connect_share.friends.summary.online.many": "%s friends online", + "connect_share.friends.summary.saved.one": "1 saved friend", + "connect_share.friends.summary.saved.many": "%s saved friends", + "connect_share.menu.requests": "Friends (%s)", + "connect_share.menu.ready": "Friends (%s)", + "connect_share.friends.link": "Friend link", + "connect_share.friends.connection_options.title": "Connection options", + "connect_share.friends.connection_options.description": "Optional choices for unusual accounts or faster direct routes.", + "connect_share.friends.connection_options.fallback": "Connect remains the fallback when a direct route is unavailable.", + "connect_share.identity.source.generated": "Generated on this device", + "connect_share.identity.source.imported": "Imported", + "connect_share.identity.source.environment": "Managed by launcher", + "connect_share.error.generic": "Could not update Connect Share", + "connect_share.error.identity_invalid": "Connect credentials are invalid", + "connect_share.error.identity_rejected": "Connect rejected these credentials", + "connect_share.error.identity_network": "Could not reach Connect to validate credentials", + "connect_share.error.identity_managed": "Connect credentials are managed by the environment", + "connect_share.error.identity_active": "Stop sharing before changing Connect credentials", + "connect_share.error.preferences_save": "Connect Share privacy settings could not be saved", + "connect_share.error.share_already_active": "Connect Share is already active", + "connect_share.error.share_start_failed": "Could not start Connect Share", + "connect_share.error.share_stop_failed": "Connect Share stopped with cleanup errors", + "connect_share.error.invalid_invitation": "This Connect Share invitation is invalid", + "connect_share.error.invitation_malformed": "This Connect Share invitation is incomplete or malformed", + "connect_share.error.invitation_unsupported_version": "This invitation uses unsupported Share format %s", + "connect_share.error.invitation_expired": "This Connect Share invitation has expired — ask for a new one", + "connect_share.error.invitation_invalid_signature": "This invitation could not be verified", + "connect_share.error.invitation_relay_forbidden": "A direct Share invitation cannot contain a relay route", + "connect_share.error.invitation_peer_mismatch": "This invitation does not match the friend who sent it", + "connect_share.login.authentication_required": "This direct connection requires Minecraft online authentication", + "connect_share.login.approval_timed_out": "The host did not answer the join request in time", + "connect_share.login.share_full": "This shared world is full", + "connect_share.login.sharing_stopped": "The host stopped sharing this world", + "connect_share.login.host_denied": "The host declined this connection", + "connect_share.error.invalid_friend_name": "Friend name must be between 1 and 64 characters", + "connect_share.error.friend_identity_conflict": "This friend identity does not match the saved key", + "connect_share.error.friend_not_saved": "This friend is no longer saved", + "connect_share.error.friend_blocked": "This identity is blocked", + "connect_share.error.friends_load": "Saved Connect Share friends could not be loaded", + "connect_share.error.friend_remove": "This Connect Share friend could not be removed", + "connect_share.error.friend_block": "This Connect Share identity could not be blocked", + "connect_share.error.friend_unblock": "This Connect Share identity could not be unblocked", + "connect_share.error.join_peer_mismatch": "The discovered host does not match this invitation", + "connect_share.error.join_discovery_unavailable": "Automatic LAN discovery is unavailable", + "connect_share.error.join_no_route": "No route to this Connect Share world is available", + "connect_share.error.identity_endpoint_conflict": "This profile uses the same Connect endpoint as your friend", + "connect_share.error.friend_unreachable": "Your friend is not reachable right now", + "connect_share.error.friend_declined": "Your friend declined this request", + "connect_share.error.friend_timed_out": "Your friend did not answer in time", + "connect_share.error.friend_invalid_response": "The friend request response was invalid", + "connect_share.error.minecraft_version": "Your Minecraft versions do not match", + "connect_share.error.mod_loader": "Your mod loaders do not match", + "connect_share.error.required_mods": "Your required mods do not match", + "connect_share.page.previous_tooltip": "Previous page · Page %s of %s", + "connect_share.page.next_tooltip": "Next page · Page %s of %s", + "connect_share.recovery.title": "Backup & restore", + "connect_share.recovery.menu": "Backup", + "connect_share.recovery.description": "Move your friends and Share identity to another device. The encrypted backup stays offline.", + "connect_share.recovery.secret": "Recovery password", + "connect_share.recovery.secret_confirm": "Confirm recovery password", + "connect_share.recovery.warning": "Restoring replaces this device’s friends and Share identity. Stop sharing first, then restart Minecraft after restoring.", + "connect_share.recovery.export": "Back up friends", + "connect_share.recovery.import": "Restore backup", + "connect_share.recovery.restore_confirm": "Replace and restore", + "connect_share.recovery.summary": "%s items · Preferences: %s · Connect endpoint: %s", + "connect_share.recovery.included": "included", + "connect_share.recovery.not_included": "not included", + "connect_share.recovery.exported": "Encrypted backup saved. Keep its password somewhere safe.", + "connect_share.recovery.restored_restart": "Restore complete. Restart Minecraft before sharing or joining.", + "connect_share.recovery.error.secret_mismatch": "The recovery passwords do not match.", + "connect_share.recovery.error.weak_secret": "Use a recovery password with at least 12 characters.", + "connect_share.recovery.error.authentication": "Wrong password or damaged backup. Nothing was changed.", + "connect_share.recovery.error.unsupported": "This backup version is not supported.", + "connect_share.recovery.error.too_large": "This backup is too large.", + "connect_share.recovery.error.invalid": "This is not a valid Connect Share backup.", + "connect_share.recovery.error.nothing_to_export": "There are no complete friend identities to back up yet.", + "connect_share.recovery.error.unsafe_files": "Backup stopped because a Share data file is unsafe.", + "connect_share.recovery.error.read": "The selected backup could not be read.", + "connect_share.recovery.error.write": "The encrypted backup could not be saved.", + "connect_share.recovery.error.restore": "Restore failed and this device’s previous data was recovered.", + "connect_share.recovery.error.stop_sharing": "Stop sharing before restoring a backup." +} diff --git a/share/fabric-1.21.11/src/main/resources/connect-share-fabric-1.21.11.mixins.json b/share/fabric-1.21.11/src/main/resources/connect-share-fabric-1.21.11.mixins.json new file mode 100644 index 000000000..3481a30ad --- /dev/null +++ b/share/fabric-1.21.11/src/main/resources/connect-share-fabric-1.21.11.mixins.json @@ -0,0 +1,22 @@ +{ + "required": true, + "minVersion": "0.8", + "package": "com.minekube.connect.share.fabric.v1_21_11.mixin", + "compatibilityLevel": "JAVA_21", + "mixins": [ + "ConnectionAccessor", + "ServerConnectionListenerAccessor", + "ServerConnectionListenerMixin", + "ServerLoginPacketListenerMixin" + ], + "client": [ + "IntegratedServerAccessor", + "IntegratedServerMixin", + "LanServerPingerAccessor", + "PauseScreenMixin", + "TitleScreenMixin" + ], + "injectors": { + "defaultRequire": 1 + } +} diff --git a/share/fabric-1.21.11/src/main/resources/fabric.mod.json b/share/fabric-1.21.11/src/main/resources/fabric.mod.json new file mode 100644 index 000000000..4217d8798 --- /dev/null +++ b/share/fabric-1.21.11/src/main/resources/fabric.mod.json @@ -0,0 +1,26 @@ +{ + "schemaVersion": 1, + "id": "connect-share", + "version": "${version}", + "name": "Connect Share", + "description": "Share a private Minecraft world through Minekube Connect.", + "environment": "client", + "entrypoints": { + "client": [ + { + "adapter": "kotlin", + "value": "com.minekube.connect.share.fabric.v1_21_11.ConnectShare12111Client" + } + ] + }, + "mixins": [ + "connect-share-fabric-1.21.11.mixins.json" + ], + "depends": { + "fabricloader": ">=0.19.3", + "fabric-api": "*", + "fabric-language-kotlin": ">=1.13.13", + "minecraft": "1.21.11", + "java": ">=21" + } +} diff --git a/share/fabric-1.21.11/src/test/kotlin/com/minekube/connect/share/fabric/v1_21_11/CapturedServerTransportTest.kt b/share/fabric-1.21.11/src/test/kotlin/com/minekube/connect/share/fabric/v1_21_11/CapturedServerTransportTest.kt new file mode 100644 index 000000000..ee9698dda --- /dev/null +++ b/share/fabric-1.21.11/src/test/kotlin/com/minekube/connect/share/fabric/v1_21_11/CapturedServerTransportTest.kt @@ -0,0 +1,58 @@ +package com.minekube.connect.share.fabric.v1_21_11 + +import com.minekube.connect.share.CaptureFailure +import io.netty.channel.Channel +import io.netty.channel.ChannelInitializer +import io.netty.channel.DefaultEventLoopGroup +import kotlin.concurrent.thread +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotSame +import kotlin.test.assertSame +import kotlin.test.assertTrue + +class CapturedServerTransportTest { + @Test + fun `captures the tagged vanilla initializer and group only for the armed thread`() { + val initializer = NoopInitializer + val group = DefaultEventLoopGroup(1) + try { + val lease = CapturedServerTransport.arm() + + assertTrue(CapturedServerTransport.isShareStartArmed()) + val taggedInitializer = + CapturedServerTransport.captureChildInitializer(initializer) + assertNotSame(initializer, taggedInitializer) + assertSame(group, CapturedServerTransport.captureEventLoopGroup(group)) + + var otherThreadArmed = true + thread { + otherThreadArmed = CapturedServerTransport.isShareStartArmed() + }.join() + + val captured = lease.complete().getOrNull() + requireNotNull(captured) + assertSame(taggedInitializer, captured.childInitializer) + assertSame(group, captured.eventLoopGroup) + assertFalse(otherThreadArmed) + assertFalse(CapturedServerTransport.isShareStartArmed()) + } finally { + group.shutdownGracefully().syncUninterruptibly() + } + } + + @Test + fun `incomplete capture is typed and always disarms`() { + val lease = CapturedServerTransport.arm() + + val failure = lease.complete().leftOrNull() + + assertEquals(CaptureFailure.Incomplete, failure) + assertFalse(CapturedServerTransport.isShareStartArmed()) + } + + private object NoopInitializer : ChannelInitializer() { + override fun initChannel(channel: Channel) = Unit + } +} diff --git a/share/fabric-1.21.11/src/test/kotlin/com/minekube/connect/share/fabric/v1_21_11/ConnectGameProfileMapperTest.kt b/share/fabric-1.21.11/src/test/kotlin/com/minekube/connect/share/fabric/v1_21_11/ConnectGameProfileMapperTest.kt new file mode 100644 index 000000000..93d337a4c --- /dev/null +++ b/share/fabric-1.21.11/src/test/kotlin/com/minekube/connect/share/fabric/v1_21_11/ConnectGameProfileMapperTest.kt @@ -0,0 +1,47 @@ +package com.minekube.connect.share.fabric.v1_21_11 + +import com.minekube.connect.api.player.GameProfile as ConnectGameProfile +import java.util.UUID +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ConnectGameProfileMapperTest { + @Test + fun `preserves identity and signed profile properties`() { + val id = UUID.randomUUID() + val mapped = ConnectGameProfileMapper.toMinecraft( + ConnectGameProfile( + "Robin", + id, + listOf( + ConnectGameProfile.Property("textures", "signed-value", "signature"), + ConnectGameProfile.Property("badge", "unsigned-value", ""), + ), + ), + ).getOrNull() + requireNotNull(mapped) + + assertEquals(id, mapped.id()) + assertEquals("Robin", mapped.name()) + val texture = mapped.properties()["textures"].single() + val badge = mapped.properties()["badge"].single() + assertTrue(texture.hasSignature()) + assertEquals("signature", texture.signature()) + assertFalse(badge.hasSignature()) + } + + @Test + fun `rejects malformed Connect profiles as a typed outcome`() { + val result = ConnectGameProfileMapper.toMinecraft( + ConnectGameProfile( + "", + UUID.randomUUID(), + emptyList(), + ), + ) + + assertEquals(ProfileMappingFailure.InvalidName, result.leftOrNull()) + } +} diff --git a/share/fabric-1.21.11/src/test/kotlin/com/minekube/connect/share/fabric/v1_21_11/Fabric12111ArtifactTest.kt b/share/fabric-1.21.11/src/test/kotlin/com/minekube/connect/share/fabric/v1_21_11/Fabric12111ArtifactTest.kt new file mode 100644 index 000000000..7c02088e5 --- /dev/null +++ b/share/fabric-1.21.11/src/test/kotlin/com/minekube/connect/share/fabric/v1_21_11/Fabric12111ArtifactTest.kt @@ -0,0 +1,290 @@ +package com.minekube.connect.share.fabric.v1_21_11 + +import com.minekube.connect.share.ShareCoordinator +import com.minekube.connect.share.fabric.ConnectShareClient +import com.minekube.connect.tunnel.p2p.DirectP2pNode +import com.minekube.connect.tunnel.p2p.Libp2pEndpoint +import com.minekube.connect.tunnel.p2p.Libp2pTunnelTransport +import java.nio.file.Files +import java.nio.file.Path +import java.net.URLClassLoader +import java.util.jar.JarInputStream +import java.util.jar.JarFile +import kotlin.io.path.name +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class Fabric12111ArtifactTest { + @Test + fun `artifact uses a friends first sharing vocabulary`() { + JarFile(artifact().toFile()).use { jar -> + val language = jar.getInputStream( + jar.getJarEntry( + "assets/connect-share/lang/en_us.json", + ), + ).bufferedReader().use { it.readText() } + + assertTrue( + "\"connect_share.setup.title\": \"Play this world with friends\"" in + language, + ) + assertTrue( + "\"connect_share.status.copy_friend_link\": " + + "\"Copy invite for a new friend\"" in language, + ) + assertTrue( + "\"connect_share.friends.copy_my_link\": " + + "\"Copy my friend link\"" in language, + ) + assertTrue( + "\"connect_share.friends.send_request\": " + + "\"Send request\"" in language, + ) + assertTrue( + "\"connect_share.friends.outgoing_request\": " + + "\"Waiting for %s\"" in language, + ) + assertTrue( + "\"connect_share.friends.incoming_request\": " + + "\"%s wants to be friends · %s\"" in language, + ) + assertTrue( + "\"connect_share.friends.retry_request\": \"Retry\"" in + language, + ) + assertTrue( + "\"connect_share.friends.cancel_request\": \"Cancel\"" in + language, + ) + assertTrue( + "\"connect_share.status.allow\": \"Accept\"" in language, + ) + assertTrue( + "\"connect_share.status.deny\": \"Decline\"" in language, + ) + assertFalse("connect_share.friends.accept_request" in language) + } + } + + @Test + fun `friend removal confirmation stays inside the friends screen`() { + JarFile(artifact().toFile()).use { jar -> + val bytecode = jar.entries().asSequence() + .filter { + it.name.startsWith( + "com/minekube/connect/share/fabric/v1_21_11/" + + "ShareJoinScreen", + ) && it.name.endsWith(".class") + } + .joinToString { + jar.getInputStream(it).use { stream -> + stream.readBytes().toString(Charsets.ISO_8859_1) + } + } + + assertFalse("net/minecraft/class_410" in bytecode) + assertTrue( + "connect_share.friends.remove_confirm.confirm" in bytecode, + ) + assertTrue("sendRequest" in bytecode) + assertTrue("suggestedDisplayName" in bytecode) + assertTrue("FriendRequestClient" in bytecode) + assertTrue("getIncomingRequests" in bytecode) + assertTrue("connect_share.status.allow" in bytecode) + assertTrue("connect_share.status.deny" in bytecode) + assertTrue("joinOutgoing" !in bytecode) + assertFalse("connect_share.friends.accept_request" in bytecode) + } + } + + @Test + fun `approved card exchange promotes an outgoing request`() { + JarFile(artifact().toFile()).use { jar -> + val bytecode = jar.entries().asSequence() + .filter { + it.name.startsWith( + "com/minekube/connect/share/fabric/v1_21_11/" + + "FriendCardNetworking", + ) && it.name.endsWith(".class") + } + .joinToString { + jar.getInputStream(it).use { input -> + input.readBytes().toString(Charsets.ISO_8859_1) + } + } + + assertTrue("confirmOutgoing" in bytecode) + } + } + + @Test + fun `remapped artifact is self contained and isolates networking runtime`() { + JarFile(artifact().toFile()).use { jar -> + val entries = jar.entries().asSequence().map { it.name }.toSet() + + assertTrue("fabric.mod.json" in entries) + assertTrue("LICENSE" in entries) + assertTrue("connect-share-fabric-1.21.11.mixins.json" in entries) + assertTrue( + "com/minekube/connect/share/fabric/v1_21_11/" + + "FriendCardNetworking.class" in entries, + ) + assertTrue( + entries.any { + it.startsWith("com/minekube/connect/share/") && + it.endsWith(".class") + }, + ) + assertTrue("META-INF/connect/libp2p-runtime.jar" in entries) + assertFalse(entries.any { it.startsWith("io/libp2p/") }) + assertFalse(entries.any { it.startsWith("io/netty/") }) + assertFalse(entries.any { it.startsWith("it/unimi/dsi/fastutil/") }) + assertFalse(entries.any { it.startsWith("kotlin/") }) + assertTrue( + entries.any { + it.startsWith( + "com/minekube/connect/shadow/it/unimi/dsi/fastutil/", + ) + }, + ) + + val payload = jar.getJarEntry("META-INF/connect/libp2p-runtime.jar") + val payloadEntries = JarInputStream(jar.getInputStream(payload)).use { nested -> + generateSequence(nested::getNextJarEntry).map { it.name }.toSet() + } + assertTrue(payloadEntries.any { it.startsWith("io/libp2p/") }) + assertTrue(payloadEntries.any { it.startsWith("io/netty/") }) + assertTrue(payloadEntries.any { it.startsWith("kotlin/") }) + assertTrue( + "com/minekube/connect/tunnel/p2p/DirectP2pNodeRuntime.class" in + payloadEntries, + ) + } + } + + @Test + fun `minecraft profile mapper preserves Mojang Guava ABI`() { + JarFile(artifact().toFile()).use { jar -> + val factory = jar.getJarEntry( + "com/minekube/connect/share/fabric/v1_21_11/" + + "MinecraftGameProfileFactory.class", + ) + assertNotNull(factory) + + val bytecode = jar.getInputStream(factory).use { + it.readBytes().toString(Charsets.ISO_8859_1) + } + assertTrue("com/google/common/collect/Multimap" in bytecode) + assertTrue( + "(Lcom/google/common/collect/Multimap;)V" in bytecode, + ) + assertFalse( + "com/minekube/connect/shadow/com/google/common" in bytecode, + ) + } + } + + @Test + fun `packaged loader reads libp2p only from child payload`() { + URLClassLoader( + arrayOf(artifact().toUri().toURL()), + ClassLoader.getPlatformClassLoader(), + ).use { artifactLoader -> + val loaderType = Class.forName( + "com.minekube.connect.tunnel.p2p.Libp2pRuntimeLoader", + true, + artifactLoader, + ) + val loaderMethod = loaderType.getDeclaredMethod("classLoader") + .apply { isAccessible = true } + val runtimeLoader = loaderMethod.invoke(null) as ClassLoader + try { + val host = Class.forName( + "io.libp2p.core.Host", + false, + runtimeLoader, + ) + val directRuntime = Class.forName( + "com.minekube.connect.tunnel.p2p.DirectP2pNodeRuntime", + false, + runtimeLoader, + ) + assertTrue(host.classLoader === runtimeLoader) + assertTrue(directRuntime.classLoader === runtimeLoader) + val directCodeSource = + directRuntime.protectionDomain.codeSource.location + assertNotNull(directCodeSource) + assertTrue( + directCodeSource.toString().contains("libp2p-runtime-"), + ) + val nodeType = Class.forName( + "com.minekube.connect.tunnel.p2p.DirectP2pNode", + true, + artifactLoader, + ) + val node = nodeType.getDeclaredConstructor().newInstance() + nodeType.getMethod("close").invoke(node) + } finally { + loaderType.getDeclaredMethod("close") + .apply { isAccessible = true } + .invoke(null) + } + } + } + + @Test + fun `parent facing APIs do not expose isolated runtime types`() { + listOf( + ConnectShareClient::class.java, + ShareCoordinator::class.java, + DirectP2pNode::class.java, + Libp2pEndpoint::class.java, + Libp2pTunnelTransport::class.java, + ).forEach(::assertParentFacingTypes) + } + + private fun assertParentFacingTypes(type: Class<*>) { + val exposed = buildList { + type.declaredFields.forEach { add(it.type.name) } + type.declaredConstructors.forEach { constructor -> + constructor.parameterTypes.forEach { add(it.name) } + } + type.declaredMethods.forEach { method -> + add(method.returnType.name) + method.parameterTypes.forEach { add(it.name) } + } + } + val invalid = exposed.filter { name -> + FORBIDDEN_TYPE_PREFIXES.any(name::startsWith) + } + assertTrue( + invalid.isEmpty(), + "${type.name} exposes isolated runtime types: $invalid", + ) + } + + private fun artifact(): Path { + val explicit = System.getProperty("connectShareArtifact") + if (explicit != null) { + return Path.of(explicit) + } + val directory = Path.of("build", "libs") + return Files.list(directory).use { paths -> + paths.filter { + it.name.startsWith("connect-share-fabric-1.21.11-") && + it.name.endsWith(".jar") && + !it.name.contains("sources") && + !it.name.contains("dev") + }.findFirst().orElse(null) + }.also(::assertNotNull) + } + + private companion object { + val FORBIDDEN_TYPE_PREFIXES = listOf( + "io.libp2p.", + "io.netty.", + ) + } +} diff --git a/share/fabric-1.21.11/src/test/kotlin/com/minekube/connect/share/fabric/v1_21_11/FriendCardPayloadTest.kt b/share/fabric-1.21.11/src/test/kotlin/com/minekube/connect/share/fabric/v1_21_11/FriendCardPayloadTest.kt new file mode 100644 index 000000000..746bd4980 --- /dev/null +++ b/share/fabric-1.21.11/src/test/kotlin/com/minekube/connect/share/fabric/v1_21_11/FriendCardPayloadTest.kt @@ -0,0 +1,42 @@ +package com.minekube.connect.share.fabric.v1_21_11 + +import io.netty.buffer.Unpooled +import java.util.UUID +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertSame +import net.minecraft.network.FriendlyByteBuf + +class FriendCardPayloadTest { + @Test + fun `signed friend card survives the network payload codec`() { + val invitation = "minekube://share/" + "signed-card".repeat(500) + val relationshipId = UUID.randomUUID() + val buffer = FriendlyByteBuf(Unpooled.buffer()) + + FriendCardPayload.CODEC.encode( + buffer, + FriendCardPayload(invitation, relationshipId), + ) + + val decoded = FriendCardPayload.CODEC.decode(buffer) + assertEquals(invitation, decoded.invitation) + assertEquals(relationshipId, decoded.relationshipId) + } + + @Test + fun `friend card request has a zero data payload`() { + val buffer = FriendlyByteBuf(Unpooled.buffer()) + + FriendCardRequestPayload.CODEC.encode( + buffer, + FriendCardRequestPayload, + ) + + assertEquals(0, buffer.readableBytes()) + assertSame( + FriendCardRequestPayload, + FriendCardRequestPayload.CODEC.decode(buffer), + ) + } +} diff --git a/share/fabric-1.21.11/src/test/kotlin/com/minekube/connect/share/fabric/v1_21_11/Minecraft12111BridgeTest.kt b/share/fabric-1.21.11/src/test/kotlin/com/minekube/connect/share/fabric/v1_21_11/Minecraft12111BridgeTest.kt new file mode 100644 index 000000000..eed36753b --- /dev/null +++ b/share/fabric-1.21.11/src/test/kotlin/com/minekube/connect/share/fabric/v1_21_11/Minecraft12111BridgeTest.kt @@ -0,0 +1,130 @@ +package com.minekube.connect.share.fabric.v1_21_11 + +import com.minekube.connect.share.ShareGameMode +import com.minekube.connect.share.ShareOptions +import io.netty.channel.Channel +import io.netty.channel.ChannelInitializer +import io.netty.channel.DefaultEventLoopGroup +import io.netty.channel.EventLoopGroup +import io.netty.channel.local.LocalAddress +import java.net.InetAddress +import java.net.InetSocketAddress +import java.net.SocketAddress +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.test.assertTrue +import kotlinx.coroutines.runBlocking + +class Minecraft12111BridgeTest { + @Test + fun `publishes only on loopback and releases every listener`() = runBlocking { + val transport = FakeMinecraftTransport() + val bridge = Minecraft12111Bridge(transport, FakeLocalChannelBinder()) + + val target = bridge.open(shareOptions) + + assertTrue(transport.boundAddress.address.isLoopbackAddress) + assertIs(target.address) + assertEquals(2, transport.listenerCount) + assertEquals(25565, transport.publishedPort) + + target.close() + + assertEquals(-1, transport.publishedPort) + assertEquals(0, transport.listenerCount) + } + + @Test + fun `can open again after close but never installs a second active listener`() = runBlocking { + val transport = FakeMinecraftTransport() + val bridge = Minecraft12111Bridge(transport, FakeLocalChannelBinder()) + + val first = bridge.open(shareOptions) + assertFailsWith { + bridge.open(shareOptions) + } + assertEquals(2, transport.listenerCount) + + first.close() + val second = bridge.open(shareOptions) + + assertEquals(2, transport.listenerCount) + second.close() + assertEquals(0, transport.listenerCount) + } + + @Test + fun `failed local bind rolls back the private vanilla listener`() = runBlocking { + val transport = FakeMinecraftTransport() + val bridge = Minecraft12111Bridge( + transport, + LocalShareChannelBinder { _, _ -> + throw IllegalStateException("local bind failed") + }, + ) + + assertFailsWith { + bridge.open(shareOptions) + } + + assertEquals(-1, transport.publishedPort) + assertEquals(0, transport.listenerCount) + } + + private class FakeMinecraftTransport : Minecraft12111Transport { + var publishedPort = -1 + var listenerCount = 0 + lateinit var boundAddress: InetSocketAddress + val eventLoopGroup: EventLoopGroup = DefaultEventLoopGroup(1) + + override fun publish(options: ShareOptions): PublishedMinecraftTransport { + check(publishedPort == -1) + boundAddress = InetSocketAddress(InetAddress.getLoopbackAddress(), 25565) + publishedPort = boundAddress.port + listenerCount++ + return object : PublishedMinecraftTransport { + override val address: InetSocketAddress = boundAddress + override val childInitializer: ChannelInitializer = NoopInitializer + override val eventLoopGroup = this@FakeMinecraftTransport.eventLoopGroup + + override fun addLocalListener(listener: LocalShareChannel) { + listenerCount++ + } + + override fun removeLocalListener(listener: LocalShareChannel) { + listenerCount-- + } + + override fun close() { + if (publishedPort != -1) { + listenerCount-- + publishedPort = -1 + } + } + } + } + } + + private class FakeLocalChannelBinder : LocalShareChannelBinder { + override fun bind( + childInitializer: ChannelInitializer, + eventLoopGroup: EventLoopGroup, + ): LocalShareChannel = object : LocalShareChannel { + override val address: SocketAddress = LocalAddress("connect-share-test") + override fun close() = Unit + } + } + + private object NoopInitializer : ChannelInitializer() { + override fun initChannel(channel: Channel) = Unit + } + + private companion object { + val shareOptions = ShareOptions( + gameMode = ShareGameMode.SURVIVAL, + allowCheats = false, + ) + } +} diff --git a/share/fabric-26.2/build.gradle.kts b/share/fabric-26.2/build.gradle.kts new file mode 100644 index 000000000..0334c3fca --- /dev/null +++ b/share/fabric-26.2/build.gradle.kts @@ -0,0 +1,160 @@ +import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar + +plugins { + id("connect.shadow-conventions") + id("net.fabricmc.fabric-loom") + id("org.jetbrains.kotlin.jvm") +} + +base { + archivesName = "connect-share-fabric-26.2" +} + +java { + sourceCompatibility = JavaVersion.VERSION_25 + targetCompatibility = JavaVersion.VERSION_25 + toolchain { + languageVersion = JavaLanguageVersion.of(25) + } +} + +kotlin { + jvmToolchain(25) +} + +repositories { + maven("https://repo.opencollab.dev/maven-releases") { + mavenContent { releasesOnly() } + } + maven("https://repo.opencollab.dev/maven-snapshots") { + mavenContent { snapshotsOnly() } + } + maven("https://dl.cloudsmith.io/public/libp2p/jvm-libp2p/maven/") + maven("https://dl.cloudsmith.io/public/consensys/maven/maven/") + maven("https://jitpack.io") { + content { includeGroupByRegex("com\\.github\\..*") } + } +} + +val connectShareParentRuntime by configurations.creating { + isCanBeConsumed = false + isCanBeResolved = true +} + +dependencies { + minecraft("com.mojang:minecraft:26.2") + implementation("net.fabricmc:fabric-loader:${Versions.fabricLoaderVersion}") + implementation("net.fabricmc.fabric-api:fabric-api:${Versions.fabricApi262Version}") + implementation("net.fabricmc:fabric-language-kotlin:${Versions.fabricLanguageKotlinVersion}") + + implementation(projects.core) + implementation(projects.share.common) + implementation(projects.share.fabricCommon) + connectShareParentRuntime(projects.share.fabricCommon) { + exclude(group = "io.libp2p") + exclude(group = "io.netty") + exclude(group = "org.jetbrains.kotlin") + exclude(group = "org.jetbrains.kotlinx") + exclude(group = "com.google.errorprone", module = "javac") + } + + testImplementation(kotlin("test")) + testRuntimeOnly("org.junit.platform:junit-platform-launcher") +} + +tasks.test { + useJUnitPlatform() +} + +tasks.processResources { + inputs.property("version", project.version) + filesMatching("fabric.mod.json") { + expand("version" to project.version) + } +} + +relocate("arrow") +relocate("aopalliance") +relocate("cloud.commandframework") +relocate("com.google.common") +relocate("com.google.gson") +relocate("com.google.inject") +relocate("com.google.protobuf") +relocate("it.unimi.dsi.fastutil") +relocate("io.grpc") +relocate("io.leangen.geantyref") +relocate("jakarta.inject") +relocate("javax.inject") +relocate("okhttp3") +relocate("okio") +relocate("org.bstats") +relocate("org.geysermc.configutils") +relocate("org.yaml.snakeyaml") + +val libp2pRuntimeJar = tasks.named("libp2pRuntimeJar") { + dependsOn(":core:classes") + from(rootProject.project(":core").layout.buildDirectory.dir("classes/java/main")) { + include( + "com/minekube/connect/tunnel/p2p/DirectP2pNodeRuntime*.class", + "com/minekube/connect/tunnel/p2p/Libp2pEndpointRuntime*.class", + "com/minekube/connect/tunnel/p2p/impl/Libp2pTunnelTransportRuntime*.class", + ) + } +} +val minecraftGameProfileFactory = + "com/minekube/connect/share/fabric/v26_2/" + + "MinecraftGameProfileFactory.class" +val connectShareShadowJar = tasks.named("shadowJar") { + configurations = listOf(connectShareParentRuntime) + archiveBaseName.set("connect-share-fabric-26.2") + archiveVersion.set(project.version.toString()) + archiveClassifier.set("parent-shadow") + mergeServiceFiles() + from(rootProject.file("LICENSE")) + // Authlib's PropertyMap constructor must keep Minecraft's Guava ABI. + exclude(minecraftGameProfileFactory) +} +val connectShareJar = tasks.register("connectShareJar") { + dependsOn(connectShareShadowJar, libp2pRuntimeJar) + archiveBaseName.set("connect-share-fabric-26.2") + archiveVersion.set(project.version.toString()) + archiveClassifier.set("") + duplicatesStrategy = DuplicatesStrategy.EXCLUDE + from({ + zipTree(connectShareShadowJar.get().archiveFile.get().asFile) + }) + from(libp2pRuntimeJar) { + into("META-INF/connect") + } + from(sourceSets.main.get().output) { + include(minecraftGameProfileFactory) + } +} + +tasks.assemble { + dependsOn(connectShareJar) +} + +tasks.test { + dependsOn(connectShareJar) + systemProperty( + "connectShareArtifact", + connectShareJar.flatMap { it.archiveFile } + .get() + .asFile + .absolutePath, + ) +} + +val verifyConnectShareArtifactSize = tasks.register("verifyConnectShareArtifactSize") { + dependsOn(connectShareJar) + val artifact = connectShareJar.flatMap { it.archiveFile } + inputs.file(artifact) + doLast { + val bytes = artifact.get().asFile.length() + val limit = 63L * 1024L * 1024L + logger.lifecycle("Connect Share Fabric 26.2 artifact: {} MiB", "%.1f".format(bytes / 1024.0 / 1024.0)) + check(bytes <= limit) { "Connect Share Fabric 26.2 exceeds the 63 MiB release budget ($bytes bytes)" } + } +} +tasks.check { dependsOn(verifyConnectShareArtifactSize) } diff --git a/share/fabric-26.2/src/main/java/com/minekube/connect/share/fabric/v26_2/MinecraftGameProfileFactory.java b/share/fabric-26.2/src/main/java/com/minekube/connect/share/fabric/v26_2/MinecraftGameProfileFactory.java new file mode 100644 index 000000000..d947fc6d5 --- /dev/null +++ b/share/fabric-26.2/src/main/java/com/minekube/connect/share/fabric/v26_2/MinecraftGameProfileFactory.java @@ -0,0 +1,21 @@ +package com.minekube.connect.share.fabric.v26_2; + +import com.google.common.collect.ArrayListMultimap; +import com.google.common.collect.Multimap; +import com.mojang.authlib.GameProfile; +import com.mojang.authlib.properties.Property; +import com.mojang.authlib.properties.PropertyMap; +import java.util.List; +import java.util.UUID; + +final class MinecraftGameProfileFactory { + private MinecraftGameProfileFactory() {} + + static GameProfile create(UUID id, String username, List properties) { + Multimap mapped = ArrayListMultimap.create(); + for (Property property : properties) { + mapped.put(property.name(), property); + } + return new GameProfile(id, username, new PropertyMap(mapped)); + } +} diff --git a/share/fabric-26.2/src/main/java/com/minekube/connect/share/fabric/v26_2/mixin/ConnectionAccessor.java b/share/fabric-26.2/src/main/java/com/minekube/connect/share/fabric/v26_2/mixin/ConnectionAccessor.java new file mode 100644 index 000000000..f1c7e5641 --- /dev/null +++ b/share/fabric-26.2/src/main/java/com/minekube/connect/share/fabric/v26_2/mixin/ConnectionAccessor.java @@ -0,0 +1,12 @@ +package com.minekube.connect.share.fabric.v26_2.mixin; + +import io.netty.channel.Channel; +import net.minecraft.network.Connection; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.gen.Accessor; + +@Mixin(Connection.class) +public interface ConnectionAccessor { + @Accessor("channel") + Channel getConnectShareChannel(); +} diff --git a/share/fabric-26.2/src/main/java/com/minekube/connect/share/fabric/v26_2/mixin/IntegratedServerAccessor.java b/share/fabric-26.2/src/main/java/com/minekube/connect/share/fabric/v26_2/mixin/IntegratedServerAccessor.java new file mode 100644 index 000000000..9cc89f613 --- /dev/null +++ b/share/fabric-26.2/src/main/java/com/minekube/connect/share/fabric/v26_2/mixin/IntegratedServerAccessor.java @@ -0,0 +1,16 @@ +package com.minekube.connect.share.fabric.v26_2.mixin; + +import net.minecraft.client.server.IntegratedServer; +import net.minecraft.client.server.LanServerPinger; +import org.jspecify.annotations.Nullable; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.gen.Accessor; + +@Mixin(IntegratedServer.class) +public interface IntegratedServerAccessor { + @Accessor("lanPinger") + @Nullable LanServerPinger getConnectShareLanPinger(); + + @Accessor("lanPinger") + void setConnectShareLanPinger(@Nullable LanServerPinger pinger); +} diff --git a/share/fabric-26.2/src/main/java/com/minekube/connect/share/fabric/v26_2/mixin/IntegratedServerMixin.java b/share/fabric-26.2/src/main/java/com/minekube/connect/share/fabric/v26_2/mixin/IntegratedServerMixin.java new file mode 100644 index 000000000..042dd91d8 --- /dev/null +++ b/share/fabric-26.2/src/main/java/com/minekube/connect/share/fabric/v26_2/mixin/IntegratedServerMixin.java @@ -0,0 +1,24 @@ +package com.minekube.connect.share.fabric.v26_2.mixin; + +import com.minekube.connect.share.CapturedServerTransport; +import net.minecraft.client.server.IntegratedServer; +import net.minecraft.client.server.LanServerPinger; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Redirect; + +@Mixin(IntegratedServer.class) +public abstract class IntegratedServerMixin { + @Redirect( + method = "publishServer(Lnet/minecraft/server/MinecraftServer$MultiplayerScope;I)Z", + at = @At( + value = "INVOKE", + target = "Lnet/minecraft/client/server/LanServerPinger;start()V")) + private void connectShare$suppressLanAdvertisement(LanServerPinger pinger) { + if (CapturedServerTransport.isShareStartArmed()) { + pinger.interrupt(); + } else { + pinger.start(); + } + } +} diff --git a/share/fabric-26.2/src/main/java/com/minekube/connect/share/fabric/v26_2/mixin/LanServerPingerAccessor.java b/share/fabric-26.2/src/main/java/com/minekube/connect/share/fabric/v26_2/mixin/LanServerPingerAccessor.java new file mode 100644 index 000000000..e3a5f2a67 --- /dev/null +++ b/share/fabric-26.2/src/main/java/com/minekube/connect/share/fabric/v26_2/mixin/LanServerPingerAccessor.java @@ -0,0 +1,12 @@ +package com.minekube.connect.share.fabric.v26_2.mixin; + +import java.net.DatagramSocket; +import net.minecraft.client.server.LanServerPinger; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.gen.Accessor; + +@Mixin(LanServerPinger.class) +public interface LanServerPingerAccessor { + @Accessor("socket") + DatagramSocket getConnectShareSocket(); +} diff --git a/share/fabric-26.2/src/main/java/com/minekube/connect/share/fabric/v26_2/mixin/PauseScreenMixin.java b/share/fabric-26.2/src/main/java/com/minekube/connect/share/fabric/v26_2/mixin/PauseScreenMixin.java new file mode 100644 index 000000000..15de130df --- /dev/null +++ b/share/fabric-26.2/src/main/java/com/minekube/connect/share/fabric/v26_2/mixin/PauseScreenMixin.java @@ -0,0 +1,85 @@ +package com.minekube.connect.share.fabric.v26_2.mixin; + +import com.minekube.connect.share.fabric.ConnectShareClient; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.network.chat.Component; +import org.jspecify.annotations.Nullable; +import org.spongepowered.asm.mixin.Final; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.Unique; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +@Mixin(net.minecraft.client.gui.screens.PauseScreen.class) +abstract class PauseScreenMixin extends Screen { + @Shadow @Final private boolean showPauseMenu; + @Shadow private @Nullable Button disconnectButton; + @Unique private @Nullable Button connectShareButton; + @Unique private @Nullable Button connectShareFriendsButton; + + protected PauseScreenMixin(Component title) { + super(title); + } + + @Inject(method = "init", at = @At("TAIL")) + private void connectShare$addButtons(CallbackInfo ci) { + Minecraft client = Minecraft.getInstance(); + if (!showPauseMenu + || disconnectButton == null + || !ConnectShareClient.isInstalled()) { + return; + } + + int rowX = disconnectButton.getX(); + int rowY = disconnectButton.getY(); + disconnectButton.setY(rowY + 24); + removeWidget(disconnectButton); + if (client.hasSingleplayerServer()) { + connectShareButton = addRenderableWidget( + Button.builder( + Component.translatable( + ConnectShareClient.pauseButtonTranslationKey()), + button -> ConnectShareClient.openPauseScreen(this)) + .bounds(rowX, rowY, 100, 20) + .build()); + connectShareFriendsButton = addRenderableWidget( + Button.builder( + connectShare$friendsLabel(), + button -> ConnectShareClient.openJoinScreen(this)) + .bounds(rowX + 104, rowY, 100, 20) + .build()); + } else { + connectShareFriendsButton = addRenderableWidget( + Button.builder( + connectShare$friendsLabel(), + button -> ConnectShareClient.openJoinScreen(this)) + .bounds(rowX, rowY, 204, 20) + .build()); + } + addRenderableWidget(disconnectButton); + } + + @Inject(method = "tick", at = @At("TAIL")) + private void connectShare$refreshButtons(CallbackInfo ci) { + if (connectShareButton != null) { + connectShareButton.setMessage( + Component.translatable(ConnectShareClient.pauseButtonTranslationKey())); + } + if (connectShareFriendsButton != null) { + connectShareFriendsButton.setMessage(connectShare$friendsLabel()); + } + } + + @Unique + private Component connectShare$friendsLabel() { + int count = ConnectShareClient.friendsButtonCount(); + String key = ConnectShareClient.friendsButtonTranslationKey(); + return count > 0 + ? Component.translatable(key, count) + : Component.translatable(key); + } +} diff --git a/share/fabric-26.2/src/main/java/com/minekube/connect/share/fabric/v26_2/mixin/ServerConnectionListenerAccessor.java b/share/fabric-26.2/src/main/java/com/minekube/connect/share/fabric/v26_2/mixin/ServerConnectionListenerAccessor.java new file mode 100644 index 000000000..3cc135e8f --- /dev/null +++ b/share/fabric-26.2/src/main/java/com/minekube/connect/share/fabric/v26_2/mixin/ServerConnectionListenerAccessor.java @@ -0,0 +1,13 @@ +package com.minekube.connect.share.fabric.v26_2.mixin; + +import io.netty.channel.ChannelFuture; +import java.util.List; +import net.minecraft.server.network.ServerConnectionListener; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.gen.Accessor; + +@Mixin(ServerConnectionListener.class) +public interface ServerConnectionListenerAccessor { + @Accessor("channels") + List getConnectShareChannels(); +} diff --git a/share/fabric-26.2/src/main/java/com/minekube/connect/share/fabric/v26_2/mixin/ServerConnectionListenerMixin.java b/share/fabric-26.2/src/main/java/com/minekube/connect/share/fabric/v26_2/mixin/ServerConnectionListenerMixin.java new file mode 100644 index 000000000..44f2d8d05 --- /dev/null +++ b/share/fabric-26.2/src/main/java/com/minekube/connect/share/fabric/v26_2/mixin/ServerConnectionListenerMixin.java @@ -0,0 +1,57 @@ +package com.minekube.connect.share.fabric.v26_2.mixin; + +import com.minekube.connect.share.CapturedServerTransport; +import io.netty.channel.Channel; +import io.netty.channel.ChannelHandler; +import io.netty.channel.ChannelInitializer; +import io.netty.channel.EventLoopGroup; +import java.net.InetAddress; +import net.minecraft.server.network.ServerConnectionListener; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.ModifyArg; +import org.spongepowered.asm.mixin.injection.ModifyVariable; + +@Mixin(ServerConnectionListener.class) +public abstract class ServerConnectionListenerMixin { + @ModifyVariable( + method = "startTcpServerListener", + at = @At("HEAD"), + argsOnly = true, + ordinal = 0) + private InetAddress connectShare$forceLoopback(InetAddress requestedAddress) { + return CapturedServerTransport.isShareStartArmed() + ? InetAddress.getLoopbackAddress() + : requestedAddress; + } + + @ModifyArg( + method = "startTcpServerListener", + at = @At( + value = "INVOKE", + target = "Lio/netty/bootstrap/ServerBootstrap;childHandler" + + "(Lio/netty/channel/ChannelHandler;)" + + "Lio/netty/bootstrap/ServerBootstrap;"), + index = 0) + @SuppressWarnings("unchecked") + private ChannelHandler connectShare$captureChildInitializer(ChannelHandler handler) { + if (CapturedServerTransport.isShareStartArmed() + && handler instanceof ChannelInitializer) { + CapturedServerTransport.captureChildInitializer( + (ChannelInitializer) handler); + } + return handler; + } + + @ModifyArg( + method = "startTcpServerListener", + at = @At( + value = "INVOKE", + target = "Lio/netty/bootstrap/ServerBootstrap;group" + + "(Lio/netty/channel/EventLoopGroup;)" + + "Lio/netty/bootstrap/ServerBootstrap;"), + index = 0) + private EventLoopGroup connectShare$captureEventLoopGroup(EventLoopGroup group) { + return CapturedServerTransport.captureEventLoopGroup(group); + } +} diff --git a/share/fabric-26.2/src/main/java/com/minekube/connect/share/fabric/v26_2/mixin/ServerLoginPacketListenerMixin.java b/share/fabric-26.2/src/main/java/com/minekube/connect/share/fabric/v26_2/mixin/ServerLoginPacketListenerMixin.java new file mode 100644 index 000000000..faaa8f7b7 --- /dev/null +++ b/share/fabric-26.2/src/main/java/com/minekube/connect/share/fabric/v26_2/mixin/ServerLoginPacketListenerMixin.java @@ -0,0 +1,103 @@ +package com.minekube.connect.share.fabric.v26_2.mixin; + +import com.mojang.authlib.GameProfile; +import com.minekube.connect.share.fabric.v26_2.Minecraft262LoginBridge; +import net.minecraft.network.Connection; +import net.minecraft.network.chat.Component; +import net.minecraft.network.protocol.login.ServerboundHelloPacket; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.network.ServerLoginPacketListenerImpl; +import org.jspecify.annotations.Nullable; +import org.spongepowered.asm.mixin.Final; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.Unique; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +@Mixin(ServerLoginPacketListenerImpl.class) +public abstract class ServerLoginPacketListenerMixin { + @Shadow @Final private MinecraftServer server; + @Shadow @Final private Connection connection; + @Shadow private @Nullable String requestedUsername; + + @Shadow + private void startClientVerification(GameProfile profile) { + throw new AssertionError(); + } + + @Shadow + public abstract void disconnect(Component reason); + + @Unique private boolean connectShare$admissionStarted; + @Unique private boolean connectShare$admissionAllowed; + + @Inject(method = "handleHello", at = @At("HEAD"), cancellable = true) + private void connectShare$acceptConnectProfile( + ServerboundHelloPacket hello, + CallbackInfo callback) { + if (!Minecraft262LoginBridge.hasConnectIdentity(connection)) { + if (Minecraft262LoginBridge.shouldUseOfflineDirectProfile(connection)) { + GameProfile profile = Minecraft262LoginBridge.offlineProfile(hello.name()); + if (profile == null) { + disconnect(Component.literal("Invalid characters in username")); + } else { + requestedUsername = profile.name(); + startClientVerification(profile); + } + callback.cancel(); + } + return; + } + + GameProfile profile = Minecraft262LoginBridge.authenticatedProfile( + connection, hello.name()); + if (profile == null) { + disconnect(Component.literal("Connect identity is invalid")); + callback.cancel(); + return; + } + + requestedUsername = profile.name(); + startClientVerification(profile); + callback.cancel(); + } + + @Inject( + method = "verifyLoginAndFinishConnectionSetup", + at = @At("HEAD"), + cancellable = true) + private void connectShare$awaitPassthroughAdmission( + GameProfile profile, + CallbackInfo callback) { + boolean direct = Minecraft262LoginBridge.hasDirectSession(connection); + if (!direct && !Minecraft262LoginBridge.isPassthroughConnect(connection)) { + return; + } + if (connectShare$admissionAllowed) { + return; + } + + callback.cancel(); + if (connectShare$admissionStarted) { + return; + } + connectShare$admissionStarted = true; + if (direct) { + Minecraft262LoginBridge.requestDirectAdmission( + connection, + server, + profile, + () -> connectShare$admissionAllowed = true, + this::disconnect); + } else { + Minecraft262LoginBridge.requestPassthroughAdmission( + connection, + server, + profile, + () -> connectShare$admissionAllowed = true, + this::disconnect); + } + } +} diff --git a/share/fabric-26.2/src/main/java/com/minekube/connect/share/fabric/v26_2/mixin/TitleScreenMixin.java b/share/fabric-26.2/src/main/java/com/minekube/connect/share/fabric/v26_2/mixin/TitleScreenMixin.java new file mode 100644 index 000000000..bfd176ae7 --- /dev/null +++ b/share/fabric-26.2/src/main/java/com/minekube/connect/share/fabric/v26_2/mixin/TitleScreenMixin.java @@ -0,0 +1,49 @@ +package com.minekube.connect.share.fabric.v26_2.mixin; + +import com.minekube.connect.share.fabric.ConnectShareClient; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.network.chat.Component; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Unique; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +@Mixin(net.minecraft.client.gui.screens.TitleScreen.class) +abstract class TitleScreenMixin extends Screen { + @Unique private Button connectShare$friendsButton; + + protected TitleScreenMixin(Component title) { + super(title); + } + + @Inject(method = "init", at = @At("TAIL")) + private void connectShare$addJoinButton(CallbackInfo ci) { + if (!ConnectShareClient.isInstalled()) { + return; + } + connectShare$friendsButton = addRenderableWidget( + Button.builder( + connectShare$friendsLabel(), + button -> ConnectShareClient.openJoinScreen(this)) + .bounds(width - 74, 4, 70, 20) + .build()); + } + + @Inject(method = "tick", at = @At("TAIL")) + private void connectShare$refreshJoinButton(CallbackInfo ci) { + if (connectShare$friendsButton != null) { + connectShare$friendsButton.setMessage(connectShare$friendsLabel()); + } + } + + @Unique + private Component connectShare$friendsLabel() { + int count = ConnectShareClient.friendsButtonCount(); + String key = ConnectShareClient.friendsButtonTranslationKey(); + return count > 0 + ? Component.translatable(key, count) + : Component.translatable(key); + } +} diff --git a/share/fabric-26.2/src/main/kotlin/com/minekube/connect/share/fabric/v26_2/BlockedFriendsScreen.kt b/share/fabric-26.2/src/main/kotlin/com/minekube/connect/share/fabric/v26_2/BlockedFriendsScreen.kt new file mode 100644 index 000000000..25c4e968e --- /dev/null +++ b/share/fabric-26.2/src/main/kotlin/com/minekube/connect/share/fabric/v26_2/BlockedFriendsScreen.kt @@ -0,0 +1,142 @@ +package com.minekube.connect.share.fabric.v26_2 + +import com.minekube.connect.share.fabric.ConnectShareClient +import com.minekube.connect.share.fabric.ui.AdaptiveShareLayout +import com.minekube.connect.share.fabric.ui.page +import net.minecraft.ChatFormatting +import net.minecraft.client.gui.components.Button +import net.minecraft.client.gui.components.MultiLineTextWidget +import net.minecraft.client.gui.components.StringWidget +import net.minecraft.client.gui.screens.Screen +import net.minecraft.network.chat.CommonComponents +import net.minecraft.network.chat.Component + +class BlockedFriendsScreen( + private val parent: Screen, +) : Screen(Component.translatable("connect_share.privacy.blocked_title")) { + private var offset = 0 + + override fun init() { + val friends = ConnectShareClient.friendsViewModel() + val layout = AdaptiveShareLayout.friends(width, height) + val blocked = friends.state.value.blocked + val page = blocked.page(offset, layout.visibleRows) + offset = page.offset + + addRenderableWidget( + centered(title.copy().withStyle(ChatFormatting.BOLD), layout.headerY), + ) + addRenderableWidget( + MultiLineTextWidget( + layout.contentX, + layout.subtitleY, + Component.translatable( + "connect_share.privacy.blocked_description", + ).withStyle(ChatFormatting.GRAY), + font, + ).setMaxWidth(layout.contentWidth).setCentered(true), + ) + + page.items.forEachIndexed { index, identity -> + val y = layout.rowY(index) + val buttonWidth = 88 + addRenderableWidget( + StringWidget( + layout.contentX, + y, + layout.contentWidth - buttonWidth - 6, + 20, + Component.literal(identity.displayName), + font, + ).setMaxWidth(layout.contentWidth - buttonWidth - 6), + ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.privacy.unblock"), + ) { + friends.unblock(identity.peerId) + rebuildWidgets() + }.bounds( + layout.contentX + layout.contentWidth - buttonWidth, + y, + buttonWidth, + 20, + ).build(), + ) + } + + if (blocked.isEmpty()) { + addRenderableWidget( + centered( + Component.translatable("connect_share.privacy.blocked_empty") + .withStyle(ChatFormatting.GRAY), + layout.rowsTop + 18, + ), + ) + } else { + addRenderableWidget( + centered( + Component.translatable( + "connect_share.friends.page", + page.pageNumber, + page.pageCount, + ).withStyle(ChatFormatting.DARK_GRAY), + layout.messageY, + ), + ) + } + + val pageButtonWidth = layout.halfButtonWidth + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.page.previous"), + ) { + offset = page.previousOffset ?: 0 + rebuildWidgets() + }.bounds( + layout.contentX, + layout.footerTop, + pageButtonWidth, + 20, + ).build().apply { active = page.hasPrevious }, + ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.page.next"), + ) { + offset = page.nextOffset ?: page.offset + rebuildWidgets() + }.bounds( + layout.contentX + pageButtonWidth + 6, + layout.footerTop, + pageButtonWidth, + 20, + ).build().apply { active = page.hasNext }, + ) + addRenderableWidget( + Button.builder(CommonComponents.GUI_BACK) { onClose() } + .bounds( + layout.contentX, + layout.footerTop + 24, + layout.contentWidth, + 20, + ).build(), + ) + } + + override fun onClose() { + minecraft.gui.setScreen(parent) + } + + private fun centered(message: Component, y: Int): StringWidget { + val textWidth = font.width(message) + return StringWidget( + width / 2 - textWidth / 2, + y, + textWidth, + 11, + message, + font, + ) + } +} diff --git a/share/fabric-26.2/src/main/kotlin/com/minekube/connect/share/fabric/v26_2/CompatibilityMismatchScreen.kt b/share/fabric-26.2/src/main/kotlin/com/minekube/connect/share/fabric/v26_2/CompatibilityMismatchScreen.kt new file mode 100644 index 000000000..29ed1e988 --- /dev/null +++ b/share/fabric-26.2/src/main/kotlin/com/minekube/connect/share/fabric/v26_2/CompatibilityMismatchScreen.kt @@ -0,0 +1,142 @@ +package com.minekube.connect.share.fabric.v26_2 + +import com.minekube.connect.share.fabric.FriendJoinAttemptFailure +import com.minekube.connect.share.fabric.ui.AdaptiveShareLayout +import com.minekube.connect.share.fabric.ui.presentation +import net.minecraft.ChatFormatting +import net.minecraft.client.gui.components.Button +import net.minecraft.client.gui.components.MultiLineTextWidget +import net.minecraft.client.gui.components.StringWidget +import net.minecraft.client.gui.screens.Screen +import net.minecraft.network.chat.CommonComponents +import net.minecraft.network.chat.Component + +class CompatibilityMismatchScreen( + private val parent: Screen, + private val failure: FriendJoinAttemptFailure.Compatibility, + private val tryAnyway: () -> Unit, +) : Screen(Component.translatable("connect_share.compatibility.title")) { + private var packCopied = false + + override fun init() { + val layout = AdaptiveShareLayout.form(width, height, 1) + addRenderableWidget( + centered(title.copy().withStyle(ChatFormatting.YELLOW), layout.headerY), + ) + addRenderableWidget( + MultiLineTextWidget( + layout.contentX, + layout.subtitleY, + Component.translatable("connect_share.compatibility.description"), + font, + ).setMaxWidth(layout.contentWidth).setCentered(true), + ) + + val visibleDifferences = ((layout.availableBodyHeight - 24) / 15) + .coerceIn(1, 5) + failure.report.differences + .take(visibleDifferences) + .forEachIndexed { index, difference -> + val line = difference.presentation() + addRenderableWidget( + StringWidget( + layout.contentX, + layout.bodyTop + 12 + index * 15, + layout.contentWidth, + 11, + Component.translatable( + line.translationKey, + *line.arguments.toTypedArray(), + ), + font, + ).setMaxWidth(layout.contentWidth), + ) + } + val hidden = failure.report.differences.size - visibleDifferences + if (hidden > 0) { + addRenderableWidget( + centered( + Component.translatable( + "connect_share.compatibility.more", + hidden, + ).withStyle(ChatFormatting.GRAY), + layout.bodyTop + 12 + visibleDifferences * 15, + ), + ) + } + + failure.report.pack?.let { pack -> + addRenderableWidget( + Button.builder( + Component.translatable( + if (packCopied) { + "connect_share.compatibility.pack_copied" + } else { + "connect_share.compatibility.copy_pack" + }, + ), + ) { + minecraft.keyboardHandler.setClipboard(pack.url) + packCopied = true + rebuildWidgets() + }.bounds( + layout.contentX, + layout.footerTop, + layout.contentWidth, + 20, + ).build(), + ) + } + val actionY = layout.footerTop + 24 + if (failure.canTryAnyway) { + addRenderableWidget( + Button.builder( + Component.translatable( + "connect_share.compatibility.try_anyway", + ), + ) { + minecraft.gui.setScreen(parent) + tryAnyway() + }.bounds( + layout.contentX, + actionY, + layout.halfButtonWidth, + 20, + ).build(), + ) + } + addRenderableWidget( + Button.builder(CommonComponents.GUI_BACK) { onClose() } + .bounds( + if (failure.canTryAnyway) { + layout.contentX + layout.halfButtonWidth + 6 + } else { + layout.contentX + }, + actionY, + if (failure.canTryAnyway) { + layout.halfButtonWidth + } else { + layout.contentWidth + }, + 20, + ).build(), + ) + } + + override fun onClose() { + minecraft.gui.setScreen(parent) + } + + private fun centered(message: Component, y: Int): StringWidget { + val textWidth = font.width(message) + return StringWidget( + width / 2 - textWidth / 2, + y, + textWidth, + 11, + message, + font, + ) + } +} diff --git a/share/fabric-26.2/src/main/kotlin/com/minekube/connect/share/fabric/v26_2/ConnectGameProfileMapper.kt b/share/fabric-26.2/src/main/kotlin/com/minekube/connect/share/fabric/v26_2/ConnectGameProfileMapper.kt new file mode 100644 index 000000000..1c23b4404 --- /dev/null +++ b/share/fabric-26.2/src/main/kotlin/com/minekube/connect/share/fabric/v26_2/ConnectGameProfileMapper.kt @@ -0,0 +1,48 @@ +package com.minekube.connect.share.fabric.v26_2 + +import arrow.core.Either +import arrow.core.raise.either +import arrow.core.raise.ensure +import com.mojang.authlib.GameProfile +import com.mojang.authlib.properties.Property +import com.minekube.connect.api.player.GameProfile as ConnectGameProfile +import net.minecraft.util.StringUtil + +object ConnectGameProfileMapper { + fun toMinecraft( + source: ConnectGameProfile, + ): Either = either { + ensure( + source.username.isNotBlank() && + StringUtil.isValidPlayerName(source.username), + ) { + ProfileMappingFailure.InvalidName + } + val properties = source.properties.map { property -> + ensure(property.name.isNotBlank() && property.value.isNotBlank()) { + ProfileMappingFailure.InvalidProperty + } + val signature = property.signature?.takeIf(String::isNotEmpty) + if (signature == null) { + Property(property.name, property.value) + } else { + Property(property.name, property.value, signature) + } + } + MinecraftGameProfileFactory.create( + source.uniqueId, + source.username, + properties, + ) + } + + @JvmStatic + fun toMinecraftOrNull(source: ConnectGameProfile): GameProfile? = + toMinecraft(source).getOrNull() +} + +sealed interface ProfileMappingFailure { + data object InvalidName : ProfileMappingFailure + + data object InvalidProperty : ProfileMappingFailure +} diff --git a/share/fabric-26.2/src/main/kotlin/com/minekube/connect/share/fabric/v26_2/ConnectShare262Client.kt b/share/fabric-26.2/src/main/kotlin/com/minekube/connect/share/fabric/v26_2/ConnectShare262Client.kt new file mode 100644 index 000000000..be982c1b8 --- /dev/null +++ b/share/fabric-26.2/src/main/kotlin/com/minekube/connect/share/fabric/v26_2/ConnectShare262Client.kt @@ -0,0 +1,497 @@ +package com.minekube.connect.share.fabric.v26_2 + +import com.minekube.connect.share.admission.NewAdmissionTracker +import com.minekube.connect.share.admission.AdmissionPurpose +import com.minekube.connect.share.fabric.ConnectShareClient +import com.minekube.connect.share.fabric.ConnectShareInstallation +import com.minekube.connect.share.fabric.FabricLocalLoginAdmission +import com.minekube.connect.share.fabric.FabricLocalLoginAdmissionGate +import com.minekube.connect.share.fabric.FabricShareBootstrap +import com.minekube.connect.share.fabric.FabricShareBrowser +import com.minekube.connect.share.fabric.FriendCardReceiver +import com.minekube.connect.share.fabric.FriendActivityResolver +import com.minekube.connect.share.fabric.SocialEvent +import com.minekube.connect.share.fabric.SocialEventTracker +import com.minekube.connect.share.fabric.FriendPresenceMonitor +import com.minekube.connect.share.fabric.FollowAction +import com.minekube.connect.share.fabric.GuestJoinTarget +import com.minekube.connect.share.fabric.MinecraftStatusProbe +import com.minekube.connect.share.fabric.LoadedCompatibilityProfileFactory +import com.minekube.connect.share.fabric.LoadedMod +import com.minekube.connect.share.fabric.ModSide +import com.minekube.connect.share.fabric.ui.ShareUiMessage +import com.minekube.connect.share.fabric.ui.terminalNotification +import com.minekube.connect.share.fabric.ui.uiMessage +import com.minekube.connect.share.ShareState +import com.minekube.connect.share.friend.FriendStore +import com.minekube.connect.share.friend.FriendActivity +import com.minekube.connect.share.friend.FriendActivityKind +import com.minekube.connect.share.friend.FriendJoinRequest +import com.minekube.connect.share.friend.ModLoader +import com.minekube.connect.tunnel.p2p.DirectP2pAuthMode +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicReference +import java.util.UUID +import java.util.logging.Level +import java.util.logging.Logger +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.cancel +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import net.fabricmc.api.ClientModInitializer +import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientLifecycleEvents +import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents +import net.fabricmc.loader.api.FabricLoader +import net.minecraft.SharedConstants +import net.minecraft.client.Minecraft +import net.minecraft.client.gui.components.toasts.SystemToast +import net.minecraft.client.gui.screens.Screen +import net.minecraft.client.gui.screens.ConnectScreen +import net.minecraft.client.gui.screens.TitleScreen +import net.minecraft.client.multiplayer.ServerData +import net.minecraft.client.multiplayer.resolver.ServerAddress +import net.minecraft.network.chat.Component + +class ConnectShare262Client : ClientModInitializer { + override fun onInitializeClient() { + val client = Minecraft.getInstance() + val clientDispatcher = client.asCoroutineDispatcher() + val scope = CoroutineScope( + SupervisorJob() + clientDispatcher, + ) + val installationReference = + AtomicReference() + val worldAvailableSnapshot = + AtomicBoolean(client.hasSingleplayerServer()) + val playerCountSnapshot = AtomicInteger( + client.singleplayerServer?.playerList?.playerCount ?: 0, + ) + val worldNameSnapshot = AtomicReference( + client.singleplayerServer?.worldData?.levelName + ?: "Minecraft world", + ) + val activitySnapshot = AtomicReference( + FriendActivity(FriendActivityKind.ONLINE), + ) + val activityIdentitySnapshot = AtomicReference(null) + val activityEpochSnapshot = AtomicReference(null) + val joinTargetSnapshot = AtomicReference(null) + val minecraftVersion = + SharedConstants.getCurrentVersion().name() + val modVersion = FabricLoader.getInstance() + .getModContainer("connect-share") + .orElseThrow() + .metadata.version.friendlyString + val compatibilityProfile = LoadedCompatibilityProfileFactory.create( + minecraftVersion = minecraftVersion, + loader = ModLoader.FABRIC, + mods = FabricLoader.getInstance().allMods.map { container -> + val metadata = container.metadata + LoadedMod( + id = metadata.id, + version = metadata.version.friendlyString, + side = when (metadata.environment.name) { + "CLIENT" -> ModSide.CLIENT + "SERVER" -> ModSide.SERVER + else -> ModSide.UNIVERSAL + }, + builtIn = metadata.type == "builtin", + ) + }, + packEnvironment = System.getenv(), + ) + val dataDirectory = FabricLoader.getInstance().configDir + .resolve("minekube-connect-share") + val friendStore = FriendStore(dataDirectory) + val browserReference = + AtomicReference() + val statusProbe = MinecraftStatusProbe() + val remotePresence = FriendPresenceMonitor( + store = friendStore, + directProbe = { friend -> + browserReference.get()?.probeDirect( + friend = friend, + authMode = DirectP2pAuthMode.OFFLINE, + probe = statusProbe, + ) + }, + ) + scope.launch { + while (isActive) { + remotePresence.refresh() + delay(PRESENCE_REFRESH_MILLIS) + } + } + val bootstrapJob = scope.launch(Dispatchers.IO) { + try { + val installation = FabricShareBootstrap.create( + scope = scope, + dataDirectory = dataDirectory, + minecraftVersion = minecraftVersion, + modVersion = modVersion, + worldAvailable = worldAvailableSnapshot.get(), + friendStore = friendStore, + playerCount = playerCountSnapshot::get, + worldDisplayName = worldNameSnapshot::get, + playerDisplayName = { client.user.name }, + friendActivity = activitySnapshot::get, + compatibilityProfile = { compatibilityProfile }, + friendJoinTarget = joinTargetSnapshot::get, + bridgeFactory = { + admission, + admissionScope, + approvedJoins, + gateway, + -> + GatewayMinecraft262Bridge(gateway) { + FabricLocalLoginAdmissionGate( + admission = FabricLocalLoginAdmission( + admission, + approvedJoins, + ), + scope = admissionScope, + ) + } + }, + screens = { parent, active -> + val parentScreen = parent as Screen + client.execute { + client.gui.setScreen( + if (active) { + ShareStatusScreen(parentScreen) + } else { + ShareSetupScreen(parentScreen) + }, + ) + } + }, + guestScreens = { parent, browser, activity -> + val parentScreen = parent as Screen + client.execute { + client.gui.setScreen( + ShareJoinScreen( + parent = parentScreen, + friends = + ConnectShareClient.friendsViewModel(), + browser = browser, + remotePresence = remotePresence, + friendActivity = activity, + ), + ) + } + }, + ) + browserReference.set(installation.browser) + withContext(clientDispatcher) { + FriendCardNetworking.install( + scope = scope, + issuer = installation.friendCardIssuer, + receiver = installation.friendCardReceiver, + approvedJoins = installation.approvedJoins, + ) + ConnectShareClient.install(installation) + installationReference.set(installation) + LOGGER.info( + "Connect Share friend gateway is ready", + ) + } + } catch (cancellation: CancellationException) { + throw cancellation + } catch (failure: Throwable) { + LOGGER.log( + Level.SEVERE, + "Connect Share initialization failed", + failure, + ) + } + } + val admissionNotifications = NewAdmissionTracker() + val socialNotifications = SocialEventTracker() + val admissionToastId = SystemToast.SystemToastId() + + ClientTickEvents.END_CLIENT_TICK.register { minecraft -> + val installation = + installationReference.get() + ?: return@register + val server = minecraft.singleplayerServer + val worldAvailable = server != null && minecraft.connection != null + worldAvailableSnapshot.set(worldAvailable) + playerCountSnapshot.set( + server?.playerList?.playerCount ?: 0, + ) + worldNameSnapshot.set( + server?.worldData?.levelName ?: "Minecraft world", + ) + val currentServer = minecraft.currentServer + val externalServer = currentServer + ?.takeIf { !worldAvailable } + joinTargetSnapshot.set(externalServer?.ip) + val activityIdentity: Any? = when { + externalServer != null -> "server:${externalServer.ip}" + worldAvailable -> server + else -> null + } + if (activityIdentitySnapshot.getAndSet(activityIdentity) != + activityIdentity + ) { + activityEpochSnapshot.set( + activityIdentity?.let { UUID.randomUUID().toString() }, + ) + } + activitySnapshot.set( + FriendActivityResolver.resolve( + worldAvailable = worldAvailable, + worldSharingActive = installation.viewModel.state.value + .shareState is ShareState.Sharing, + worldName = worldNameSnapshot.get(), + externalServerName = externalServer?.name, + sessionEpoch = activityEpochSnapshot.get(), + compatibility = compatibilityProfile, + ), + ) + ConnectShareClient.integratedWorldChanged( + worldAvailable, + server, + ) + ConnectShareClient.guestConnectionChanged( + minecraft.connection != null, + ) + admissionNotifications.update( + installation.viewModel.state.value.pendingAdmissions, + ).firstOrNull()?.let { request -> + SystemToast.add( + minecraft.gui.toastManager(), + admissionToastId, + Component.translatable( + if (request.purpose == AdmissionPurpose.FRIEND) { + "connect_share.notification.friend_request" + } else { + "connect_share.notification.join_request" + }, + ), + Component.translatable( + if (request.purpose == AdmissionPurpose.FRIEND) { + "connect_share.notification.friend_request_detail" + } else { + "connect_share.notification.join_request_detail" + }, + request.identity.name, + ), + ) + } + val friends = installation.friendsViewModel + friends.updateIncoming( + installation.viewModel.state.value.pendingAdmissions, + ) + friends.updateRemotePresence(remotePresence.state.value) + friends.updateActivities(installation.friendActivity.state.value) + processFollowActions(minecraft, installation, scope) + socialNotifications.update(friends.state.value).forEach { event -> + SystemToast.add( + minecraft.gui.toastManager(), + SystemToast.SystemToastId(), + event.title(), + event.detail(), + ) + } + } + ClientLifecycleEvents.CLIENT_STOPPING.register { + scope.launch(Dispatchers.IO) { + bootstrapJob.cancelAndJoin() + try { + if (installationReference.get() != null) { + ConnectShareClient.shutdown() + } + } finally { + scope.cancel() + } + } + } + } + + private companion object { + const val PRESENCE_REFRESH_MILLIS = 10_000L + val LOGGER: Logger = Logger.getLogger("Connect") + } + + private fun processFollowActions( + minecraft: Minecraft, + installation: ConnectShareInstallation, + scope: CoroutineScope, + ) { + installation.friendsViewModel.followActions( + activeGameplay = minecraft.level != null, + ).forEach { action -> + when (action) { + is FollowAction.RequestJoin -> { + followToast( + minecraft, + "connect_share.notification.follow_waiting", + "connect_share.notification.follow_waiting_detail", + action.displayName, + ) + scope.launch(Dispatchers.IO) { + installation.friendJoinOrchestrator.request( + action.peerId, + FriendJoinRequest( + requestId = UUID.randomUUID(), + playerName = minecraft.user.name, + playerUuid = minecraft.user.profileId, + ), + ).fold( + ifLeft = { failure -> + minecraft.execute { + followToast( + minecraft, + "connect_share.notification.follow_failed", + null, + failure.uiMessage().component(), + ) + } + }, + ifRight = { target -> + minecraft.execute { + if (minecraft.level != null) { + target.close() + followToast( + minecraft, + "connect_share.notification.follow_ready", + "connect_share.notification.follow_ready_detail", + action.displayName, + ) + } else { + connectFollow( + minecraft, + installation, + action, + target, + ) + } + } + }, + ) + } + } + + is FollowAction.OfferJoinNow -> followToast( + minecraft, + "connect_share.notification.follow_ready", + "connect_share.notification.follow_ready_detail", + action.displayName, + ) + + is FollowAction.Expired, + is FollowAction.Cancelled -> + checkNotNull(action.terminalNotification()).let { + followToast( + minecraft, + it.titleKey, + it.detailKey, + it.displayName, + ) + } + } + } + } + + private fun connectFollow( + minecraft: Minecraft, + installation: ConnectShareInstallation, + action: FollowAction.RequestJoin, + target: GuestJoinTarget, + ) { + val address = when (target) { + is GuestJoinTarget.Connect -> + ServerAddress.parseString(target.publicAddress) + is GuestJoinTarget.Direct -> ServerAddress( + target.localAddress.hostString, + target.localAddress.port, + ) + } + if (target is GuestJoinTarget.Direct) { + ConnectShareClient.holdGuestDirect(target) + } + installation.friendsViewModel.completeFollow(action.peerId) + ConnectScreen.startConnecting( + minecraft.gui.screen() ?: TitleScreen(), + minecraft, + address, + ServerData( + action.displayName, + address.toString(), + ServerData.Type.OTHER, + ), + false, + null, + ) + } + + private fun followToast( + minecraft: Minecraft, + titleKey: String, + detailKey: String?, + value: String, + ) { + followToast(minecraft, titleKey, detailKey, Component.literal(value)) + } + + private fun followToast( + minecraft: Minecraft, + titleKey: String, + detailKey: String?, + value: Component, + ) { + SystemToast.add( + minecraft.gui.toastManager(), + SystemToast.SystemToastId(), + Component.translatable(titleKey, value), + detailKey?.let { Component.translatable(it, value) } + ?: value, + ) + } + + private fun ShareUiMessage.component() = + Component.translatable(translationKey, *arguments.toTypedArray()) + + private fun SocialEvent.title(): Component = Component.translatable( + when (this) { + is SocialEvent.FriendAccepted -> + "connect_share.notification.friend_accepted" + is SocialEvent.FriendRemoved -> + "connect_share.notification.friend_removed" + is SocialEvent.PlayingServer -> + "connect_share.notification.friend_playing" + is SocialEvent.WorldReady -> + "connect_share.notification.friend_online" + }, + ) + + private fun SocialEvent.detail(): Component = when (this) { + is SocialEvent.FriendAccepted -> Component.translatable( + "connect_share.notification.friend_accepted_detail", + displayName, + ) + is SocialEvent.FriendRemoved -> Component.translatable( + "connect_share.notification.friend_removed_detail", + displayName, + ) + is SocialEvent.PlayingServer -> Component.translatable( + "connect_share.notification.friend_playing_detail", + displayName, + serverName, + ) + is SocialEvent.WorldReady -> Component.translatable( + "connect_share.notification.friend_online_detail", + displayName, + worldName ?: "Minecraft world", + ) + } +} diff --git a/share/fabric-26.2/src/main/kotlin/com/minekube/connect/share/fabric/v26_2/EndpointIdentityScreen.kt b/share/fabric-26.2/src/main/kotlin/com/minekube/connect/share/fabric/v26_2/EndpointIdentityScreen.kt new file mode 100644 index 000000000..466e2e896 --- /dev/null +++ b/share/fabric-26.2/src/main/kotlin/com/minekube/connect/share/fabric/v26_2/EndpointIdentityScreen.kt @@ -0,0 +1,255 @@ +package com.minekube.connect.share.fabric.v26_2 + +import com.minekube.connect.share.fabric.ConnectShareClient +import com.minekube.connect.share.fabric.ui.ShareUiMessage +import com.minekube.connect.share.fabric.ui.AdaptiveShareLayout +import com.minekube.connect.share.identity.CredentialSource +import java.nio.file.Path +import net.minecraft.ChatFormatting +import net.minecraft.client.gui.components.Button +import net.minecraft.client.gui.components.EditBox +import net.minecraft.client.gui.components.MultiLineTextWidget +import net.minecraft.client.gui.components.StringWidget +import net.minecraft.client.gui.screens.ConfirmScreen +import net.minecraft.client.gui.screens.Screen +import net.minecraft.network.chat.CommonComponents +import net.minecraft.network.chat.Component +import net.minecraft.network.chat.Style +import net.minecraft.util.FormattedCharSequence +import org.lwjgl.util.tinyfd.TinyFileDialogs + +class EndpointIdentityScreen( + private val parent: Screen, +) : Screen(Component.translatable("connect_share.identity.title")) { + private val viewModel = ConnectShareClient.viewModel() + private var fingerprint = 0 + private var endpointBox: EditBox? = null + private var tokenBox: EditBox? = null + + override fun init() { + val state = viewModel.state.value + fingerprint = state.copy( + importDraft = state.importDraft.copy(token = ""), + ).hashCode() + val layout = AdaptiveShareLayout.form(width, height, 2) + + addRenderableWidget( + centered(title.copy().withStyle(ChatFormatting.BOLD), layout.headerY), + ) + addRenderableWidget( + MultiLineTextWidget( + layout.contentX, + layout.subtitleY, + Component.translatable("connect_share.identity.description"), + font, + ).setMaxWidth(layout.contentWidth).setCentered(true), + ) + + addRenderableWidget( + StringWidget( + layout.contentX, + layout.bodyTop, + layout.contentWidth, + 11, + Component.translatable("connect_share.identity.endpoint"), + font, + ), + ) + endpointBox = EditBox( + font, + layout.contentX, + layout.bodyTop + 12, + layout.contentWidth, + 20, + Component.translatable("connect_share.identity.endpoint"), + ).also { box -> + box.value = state.importDraft.endpoint + box.setHint(Component.translatable("connect_share.identity.endpoint_hint")) + box.setResponder(viewModel::setImportEndpoint) + box.setEditable(state.importDraft.endpointEditable) + addRenderableWidget(box) + } + + addRenderableWidget( + StringWidget( + layout.contentX, + layout.bodyTop + 38, + layout.contentWidth, + 11, + Component.translatable("connect_share.identity.token"), + font, + ), + ) + tokenBox = EditBox( + font, + layout.contentX, + layout.bodyTop + 50, + layout.contentWidth, + 20, + Component.translatable("connect_share.identity.token"), + ).also { box -> + box.value = state.importDraft.token + box.setHint(Component.translatable("connect_share.identity.token_hint")) + box.setResponder(viewModel::setImportToken) + box.addFormatter { text, _ -> + FormattedCharSequence.forward( + "•".repeat(text.length), + Style.EMPTY, + ) + } + box.setEditable(state.importDraft.tokenEditable) + addRenderableWidget(box) + } + + val identity = state.identity + addRenderableWidget( + MultiLineTextWidget( + layout.contentX, + layout.bodyTop + 78, + Component.translatable( + "connect_share.identity.sources", + identity?.endpointSource?.displayName() ?: "…", + identity?.tokenSource?.displayName() ?: "…", + ).withStyle(ChatFormatting.GRAY), + font, + ).setMaxWidth(layout.contentWidth).setCentered(true), + ) + state.safeMessage?.let { safeMessage -> + addRenderableWidget( + MultiLineTextWidget( + layout.contentX, + layout.bodyTop + 104, + safeMessage.component() + .withStyle(ChatFormatting.YELLOW), + font, + ).setMaxWidth(layout.contentWidth).setCentered(true), + ) + } + + val save = addRenderableWidget( + Button.builder( + Component.translatable("connect_share.identity.save"), + ) { + viewModel.importIdentity() + }.bounds( + layout.contentX, + layout.footerTop, + layout.halfButtonWidth, + 20, + ).build(), + ) + val choose = addRenderableWidget( + Button.builder( + Component.translatable("connect_share.identity.choose_file"), + ) { + chooseTokenFile()?.let(viewModel::importTokenFile) + }.bounds( + layout.contentX + layout.halfButtonWidth + 6, + layout.footerTop, + layout.halfButtonWidth, + 20, + ).build(), + ) + save.active = state.importDraft.endpointEditable && + state.importDraft.tokenEditable && + !state.operationInProgress + choose.active = state.importDraft.endpointEditable && + state.importDraft.tokenEditable && + !state.operationInProgress + + val reset = addRenderableWidget( + Button.builder( + Component.translatable("connect_share.identity.reset"), + ) { + confirmReset() + }.bounds( + layout.contentX, + layout.footerTop + 24, + layout.halfButtonWidth, + 20, + ).build(), + ) + reset.active = state.importDraft.endpointEditable && + state.importDraft.tokenEditable && + !state.operationInProgress + addRenderableWidget( + Button.builder(CommonComponents.GUI_BACK) { onClose() } + .bounds( + layout.contentX + layout.halfButtonWidth + 6, + layout.footerTop + 24, + layout.halfButtonWidth, + 20, + ).build(), + ) + } + + override fun tick() { + super.tick() + val state = viewModel.state.value + val next = state.copy( + importDraft = state.importDraft.copy(token = ""), + ).hashCode() + if ( + next != fingerprint || + state.importDraft.token.isEmpty() && + tokenBox?.value?.isNotEmpty() == true + ) { + rebuildWidgets() + } + } + + override fun onClose() { + viewModel.setImportToken("") + minecraft.gui.setScreen(parent) + } + + private fun confirmReset() { + minecraft.gui.setScreen( + ConfirmScreen( + { confirmed -> + if (confirmed) { + viewModel.resetIdentity() + } + minecraft.gui.setScreen(this) + }, + Component.translatable( + "connect_share.identity.reset_confirm.title", + ), + Component.translatable( + "connect_share.identity.reset_confirm.message", + ), + ), + ) + } + + private fun chooseTokenFile(): Path? { + val selected = TinyFileDialogs.tinyfd_openFileDialog( + Component.translatable("connect_share.identity.choose_file").string, + null, + null, + "token.json", + false, + ) ?: return null + return Path.of(selected) + } + + private fun centered(message: Component, y: Int): StringWidget { + val textWidth = font.width(message) + return StringWidget( + width / 2 - textWidth / 2, + y, + textWidth, + 11, + message, + font, + ) + } +} + +private fun ShareUiMessage.component() = + Component.translatable(translationKey, *arguments.toTypedArray()) + +private fun CredentialSource.displayName(): Component = + Component.translatable( + "connect_share.identity.source.${name.lowercase()}", + ) diff --git a/share/fabric-26.2/src/main/kotlin/com/minekube/connect/share/fabric/v26_2/FriendCardNetworking.kt b/share/fabric-26.2/src/main/kotlin/com/minekube/connect/share/fabric/v26_2/FriendCardNetworking.kt new file mode 100644 index 000000000..707ca5080 --- /dev/null +++ b/share/fabric-26.2/src/main/kotlin/com/minekube/connect/share/fabric/v26_2/FriendCardNetworking.kt @@ -0,0 +1,101 @@ +package com.minekube.connect.share.fabric.v26_2 + +import com.minekube.connect.share.fabric.ApprovedJoinTracker +import com.minekube.connect.share.fabric.ConnectShareClient +import com.minekube.connect.share.fabric.FriendCardIssuer +import com.minekube.connect.share.fabric.FriendCardReceiver +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking +import net.fabricmc.fabric.api.networking.v1.PayloadTypeRegistry +import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents +import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking + +object FriendCardNetworking { + fun install( + scope: CoroutineScope, + issuer: FriendCardIssuer, + receiver: FriendCardReceiver, + approvedJoins: ApprovedJoinTracker, + ) { + PayloadTypeRegistry.serverboundPlay().register( + FriendCardPayload.TYPE, + FriendCardPayload.CODEC, + ) + PayloadTypeRegistry.clientboundPlay().register( + FriendCardRequestPayload.TYPE, + FriendCardRequestPayload.CODEC, + ) + ServerPlayNetworking.registerGlobalReceiver( + FriendCardPayload.TYPE, + ) { payload, context -> + context.server().execute { + val player = context.player() + val proof = approvedJoins.consume( + player.gameProfile.name(), + player.uuid, + payload.invitation, + ) ?: return@execute + receiver.receive( + invitation = payload.invitation, + displayName = player.gameProfile.name(), + authenticatedMinecraftUuid = + proof.authenticatedMinecraftUuid, + allowAutomaticJoin = true, + relationshipId = payload.relationshipId, + ) + } + } + ServerPlayConnectionEvents.JOIN.register( + ServerPlayConnectionEvents.Join { handler, _, _ -> + val player = handler.player + if ( + approvedJoins.hasProof( + player.gameProfile.name(), + player.uuid, + ) && + ServerPlayNetworking.canSend( + player, + FriendCardRequestPayload.TYPE, + ) + ) { + ServerPlayNetworking.send( + player, + FriendCardRequestPayload, + ) + } + }, + ) + ClientPlayNetworking.registerGlobalReceiver( + FriendCardRequestPayload.TYPE, + ) { _, context -> + val exchange = + ConnectShareClient.consumeFriendCardExchangeConsent() + ?: return@registerGlobalReceiver + val client = context.client() + scope.launch(Dispatchers.IO) { + issuer.issue().getOrNull()?.let { invitation -> + client.execute { + if ( + client.connection != null && + ClientPlayNetworking.canSend( + FriendCardPayload.TYPE, + ) + ) { + ClientPlayNetworking.send( + FriendCardPayload( + invitation, + exchange.relationshipId, + ), + ) + scope.launch(Dispatchers.IO) { + receiver.confirmOutgoing(exchange.peerId) + } + } + } + } + } + } + } +} diff --git a/share/fabric-26.2/src/main/kotlin/com/minekube/connect/share/fabric/v26_2/FriendCardPayload.kt b/share/fabric-26.2/src/main/kotlin/com/minekube/connect/share/fabric/v26_2/FriendCardPayload.kt new file mode 100644 index 000000000..dc048c503 --- /dev/null +++ b/share/fabric-26.2/src/main/kotlin/com/minekube/connect/share/fabric/v26_2/FriendCardPayload.kt @@ -0,0 +1,59 @@ +package com.minekube.connect.share.fabric.v26_2 + +import java.util.UUID +import net.minecraft.network.FriendlyByteBuf +import net.minecraft.network.codec.StreamCodec +import net.minecraft.network.protocol.common.custom.CustomPacketPayload +import net.minecraft.resources.Identifier + +data class FriendCardPayload( + val invitation: String, + val relationshipId: UUID = UUID.randomUUID(), +) : CustomPacketPayload { + override fun type(): CustomPacketPayload.Type = TYPE + + companion object { + const val MAX_CARD_CHARS = 16_384 + + val TYPE: CustomPacketPayload.Type = + CustomPacketPayload.Type( + Identifier.fromNamespaceAndPath( + "connect-share", + "friend-card", + ), + ) + + val CODEC: StreamCodec = + CustomPacketPayload.codec( + { payload, buffer -> + buffer.writeUtf( + payload.invitation, + MAX_CARD_CHARS, + ) + buffer.writeUUID(payload.relationshipId) + }, + { buffer -> + FriendCardPayload( + invitation = buffer.readUtf(MAX_CARD_CHARS), + relationshipId = buffer.readUUID(), + ) + }, + ) + } +} + +data object FriendCardRequestPayload : CustomPacketPayload { + override fun type(): CustomPacketPayload.Type = + TYPE + + val TYPE: CustomPacketPayload.Type = + CustomPacketPayload.Type( + Identifier.fromNamespaceAndPath( + "connect-share", + "friend-card-request", + ), + ) + + val CODEC: StreamCodec = + StreamCodec.unit(FriendCardRequestPayload) +} diff --git a/share/fabric-26.2/src/main/kotlin/com/minekube/connect/share/fabric/v26_2/Minecraft262Bridge.kt b/share/fabric-26.2/src/main/kotlin/com/minekube/connect/share/fabric/v26_2/Minecraft262Bridge.kt new file mode 100644 index 000000000..91b054fa0 --- /dev/null +++ b/share/fabric-26.2/src/main/kotlin/com/minekube/connect/share/fabric/v26_2/Minecraft262Bridge.kt @@ -0,0 +1,56 @@ +package com.minekube.connect.share.fabric.v26_2 + +import com.minekube.connect.share.LocalShareChannel as CommonLocalShareChannel +import com.minekube.connect.share.LocalShareChannelBinder as CommonLocalShareChannelBinder +import com.minekube.connect.share.MinecraftVersionTransport +import com.minekube.connect.share.NettyLocalShareChannelBinder +import com.minekube.connect.share.PublishedMinecraftTransport as CommonPublishedMinecraftTransport +import com.minekube.connect.share.ShareConnectionGateway +import com.minekube.connect.share.VersionedMinecraftBridge +import com.minekube.connect.share.fabric.FabricLocalLoginAdmissionGate +import com.minekube.connect.share.fabric.FabricLoginAdmissionRegistry + +class Minecraft262Bridge internal constructor( + transport: Minecraft262Transport, + localBinder: LocalShareChannelBinder, + loginAdmissionFactory: (() -> FabricLocalLoginAdmissionGate)? = null, +) : VersionedMinecraftBridge( + transport = transport, + localBinder = localBinder, + loginAdmissionAcquire = loginAdmissionFactory?.let { factory -> + { + FabricLoginAdmissionRegistry.install(factory()) + } + }, +) { + constructor() : this( + VanillaMinecraft262Transport(), + NettyLocalShareChannelBinder(), + ) + + constructor( + loginAdmissionFactory: () -> FabricLocalLoginAdmissionGate, + ) : this( + VanillaMinecraft262Transport(), + NettyLocalShareChannelBinder(), + loginAdmissionFactory, + ) +} + +internal class GatewayMinecraft262Bridge( + gateway: ShareConnectionGateway, + loginAdmissionFactory: () -> FabricLocalLoginAdmissionGate, +) : VersionedMinecraftBridge( + transport = VanillaMinecraft262Transport(), + gateway = gateway, + loginAdmissionAcquire = { + FabricLoginAdmissionRegistry.install( + loginAdmissionFactory(), + ) + }, +) + +internal typealias Minecraft262Transport = MinecraftVersionTransport +internal typealias PublishedMinecraftTransport = CommonPublishedMinecraftTransport +internal typealias LocalShareChannelBinder = CommonLocalShareChannelBinder +internal typealias LocalShareChannel = CommonLocalShareChannel diff --git a/share/fabric-26.2/src/main/kotlin/com/minekube/connect/share/fabric/v26_2/Minecraft262LoginBridge.kt b/share/fabric-26.2/src/main/kotlin/com/minekube/connect/share/fabric/v26_2/Minecraft262LoginBridge.kt new file mode 100644 index 000000000..870c927e4 --- /dev/null +++ b/share/fabric-26.2/src/main/kotlin/com/minekube/connect/share/fabric/v26_2/Minecraft262LoginBridge.kt @@ -0,0 +1,175 @@ +package com.minekube.connect.share.fabric.v26_2 + +import com.mojang.authlib.GameProfile +import com.minekube.connect.api.ConnectAttributes +import com.minekube.connect.network.netty.LocalSession +import com.minekube.connect.share.admission.AdmissionAnswer +import com.minekube.connect.share.admission.Ingress +import com.minekube.connect.share.direct.DirectSessionAttributes +import com.minekube.connect.share.fabric.DirectMinecraftAuthentication +import com.minekube.connect.share.fabric.FabricDirectAuthenticationPolicy +import com.minekube.connect.share.fabric.FabricLoginAdmissionRegistry +import com.minekube.connect.share.fabric.ui.ShareLoginMessages +import com.minekube.connect.tunnel.p2p.DirectP2pRoute +import com.minekube.connect.tunnel.p2p.DirectP2pSession +import com.minekube.connect.share.fabric.v26_2.mixin.ConnectionAccessor +import java.util.function.Consumer +import net.minecraft.network.Connection +import net.minecraft.network.chat.Component +import net.minecraft.server.MinecraftServer +import net.minecraft.core.UUIDUtil +import net.minecraft.util.StringUtil + +object Minecraft262LoginBridge { + @JvmStatic + fun hasConnectIdentity(connection: Connection): Boolean = + channel(connection).attr(ConnectAttributes.CONNECT_PLAYER).get() != null + + @JvmStatic + fun authenticatedProfile( + connection: Connection, + requestedName: String, + ): GameProfile? { + val player = channel(connection) + .attr(ConnectAttributes.CONNECT_PLAYER) + .get() + ?: return null + val profile = ConnectGameProfileMapper + .toMinecraft(player.gameProfile) + .getOrNull() + ?: return null + return profile.takeIf { + requestedName.equals(profile.name(), ignoreCase = true) + } + } + + @JvmStatic + fun isPassthroughConnect(connection: Connection): Boolean = + LocalSession.context(channel(connection)) + .map { it.player.auth.isPassthrough } + .orElse(false) + + @JvmStatic + fun hasDirectSession(connection: Connection): Boolean = + directSession(connection) != null + + @JvmStatic + fun shouldUseOfflineDirectProfile(connection: Connection): Boolean = + directSession(connection)?.let { + FabricDirectAuthenticationPolicy.minecraftAuthentication( + it.authMode(), + ) == DirectMinecraftAuthentication.OFFLINE_PROFILE + } == true + + @JvmStatic + fun offlineProfile(requestedName: String): GameProfile? = + requestedName.takeIf(StringUtil::isValidPlayerName) + ?.let(UUIDUtil::createOfflineProfile) + + @JvmStatic + fun requestPassthroughAdmission( + connection: Connection, + server: MinecraftServer, + profile: GameProfile, + allow: Runnable, + deny: Consumer, + ) { + val channel = channel(connection) + val context = LocalSession.context(channel).orElse(null) + if (context == null || !context.player.auth.isPassthrough) { + server.execute(allow) + return + } + val decision = FabricLoginAdmissionRegistry.request( + name = profile.name(), + uuid = profile.id(), + connectionId = context.player.sessionId, + minecraftAuthenticated = + server.usesAuthentication() && !connection.isMemoryConnection, + ingress = Ingress.CONNECT, + ).toCompletableFuture() + channel.closeFuture().addListener { + decision.cancel(false) + } + decision.whenComplete { answer, failure -> + server.execute { + if (!connection.isConnected) { + return@execute + } + if (failure == null && answer == AdmissionAnswer.ALLOW) { + allow.run() + } else { + deny.accept(denialReason(answer)) + } + } + } + } + + @JvmStatic + fun requestDirectAdmission( + connection: Connection, + server: MinecraftServer, + profile: GameProfile, + allow: Runnable, + deny: Consumer, + ) { + val channel = channel(connection) + val session = directSession(connection) + if (session == null) { + server.execute(allow) + return + } + val minecraftAuthenticated = + server.usesAuthentication() && !connection.isMemoryConnection + FabricDirectAuthenticationPolicy.validate( + session.authMode(), + minecraftAuthenticated, + ).onLeft { + server.execute { + deny.accept( + Component.literal( + ShareLoginMessages.AUTHENTICATION_REQUIRED.fallback, + ), + ) + } + return + } + val decision = FabricLoginAdmissionRegistry.request( + name = profile.name(), + uuid = profile.id(), + connectionId = session.connectionId(), + minecraftAuthenticated = minecraftAuthenticated, + ingress = session.route().toIngress(), + directPeerId = session.peerId(), + ).toCompletableFuture() + channel.closeFuture().addListener { + decision.cancel(false) + } + decision.whenComplete { answer, failure -> + server.execute { + if (!connection.isConnected) { + return@execute + } + if (failure == null && answer == AdmissionAnswer.ALLOW) { + allow.run() + } else { + deny.accept(denialReason(answer)) + } + } + } + } + + private fun channel(connection: Connection) = + (connection as ConnectionAccessor).connectShareChannel + + private fun directSession(connection: Connection): DirectP2pSession? = + channel(connection).attr(DirectSessionAttributes.SESSION).get() + + private fun DirectP2pRoute.toIngress(): Ingress = when (this) { + DirectP2pRoute.LAN -> Ingress.DIRECT_LAN + DirectP2pRoute.INTERNET -> Ingress.DIRECT_INTERNET + } + + private fun denialReason(answer: AdmissionAnswer?): Component = + Component.literal(ShareLoginMessages.denial(answer).fallback) +} diff --git a/share/fabric-26.2/src/main/kotlin/com/minekube/connect/share/fabric/v26_2/RecoveryScreen.kt b/share/fabric-26.2/src/main/kotlin/com/minekube/connect/share/fabric/v26_2/RecoveryScreen.kt new file mode 100644 index 000000000..3c561d4aa --- /dev/null +++ b/share/fabric-26.2/src/main/kotlin/com/minekube/connect/share/fabric/v26_2/RecoveryScreen.kt @@ -0,0 +1,276 @@ +package com.minekube.connect.share.fabric.v26_2 + +import com.minekube.connect.share.fabric.ConnectShareClient +import com.minekube.connect.share.fabric.ui.AdaptiveShareLayout +import com.minekube.connect.share.fabric.ui.ShareUiMessage +import java.nio.file.Path +import net.minecraft.ChatFormatting +import net.minecraft.client.gui.components.Button +import net.minecraft.client.gui.components.EditBox +import net.minecraft.client.gui.components.MultiLineTextWidget +import net.minecraft.client.gui.components.StringWidget +import net.minecraft.client.gui.screens.Screen +import net.minecraft.network.chat.CommonComponents +import net.minecraft.network.chat.Component +import net.minecraft.network.chat.Style +import net.minecraft.util.FormattedCharSequence +import org.lwjgl.util.tinyfd.TinyFileDialogs + +class RecoveryScreen( + private val parent: Screen, +) : Screen(Component.translatable("connect_share.recovery.title")) { + private val viewModel = ConnectShareClient.recoveryViewModel() + private var fingerprint = 0 + private var passphraseValue = "" + private var confirmationValue = "" + + override fun init() { + val state = viewModel.state.value + fingerprint = state.hashCode() + val layout = AdaptiveShareLayout.form(width, height, 2) + + addRenderableWidget( + centered(title.copy().withStyle(ChatFormatting.BOLD), layout.headerY), + ) + addRenderableWidget( + MultiLineTextWidget( + layout.contentX, + layout.subtitleY, + Component.translatable("connect_share.recovery.description"), + font, + ).setMaxWidth(layout.contentWidth).setCentered(true), + ) + + addRenderableWidget( + StringWidget( + layout.contentX, + layout.bodyTop, + layout.contentWidth, + 11, + Component.translatable("connect_share.recovery.secret"), + font, + ), + ) + secretBox( + value = passphraseValue, + y = layout.bodyTop + 12, + label = "connect_share.recovery.secret", + ) { passphraseValue = it } + + addRenderableWidget( + StringWidget( + layout.contentX, + layout.bodyTop + 38, + layout.contentWidth, + 11, + Component.translatable("connect_share.recovery.secret_confirm"), + font, + ), + ) + secretBox( + value = confirmationValue, + y = layout.bodyTop + 50, + label = "connect_share.recovery.secret_confirm", + ) { confirmationValue = it } + + val status = state.safeMessage?.component() + ?: state.summary?.let { summary -> + Component.translatable( + "connect_share.recovery.summary", + summary.entryCount, + Component.translatable( + if (summary.includesPreferences) { + "connect_share.recovery.included" + } else { + "connect_share.recovery.not_included" + }, + ), + Component.translatable( + if (summary.includesEndpointIdentity) { + "connect_share.recovery.included" + } else { + "connect_share.recovery.not_included" + }, + ), + ) + } + status?.let { + addRenderableWidget( + MultiLineTextWidget( + layout.contentX, + layout.bodyTop + 78, + it.withStyle( + if (state.safeMessage == null) { + ChatFormatting.GREEN + } else { + ChatFormatting.YELLOW + }, + ), + font, + ).setMaxWidth(layout.contentWidth).setCentered(true), + ) + } + addRenderableWidget( + MultiLineTextWidget( + layout.contentX, + layout.bodyTop + 106, + Component.translatable("connect_share.recovery.warning") + .withStyle(ChatFormatting.GRAY), + font, + ).setMaxWidth(layout.contentWidth).setCentered(true), + ) + + if (state.importConfirmationRequired) { + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.recovery.restore_confirm"), + ) { + viewModel.confirmImport() + }.bounds( + layout.contentX, + layout.footerTop, + layout.halfButtonWidth, + 20, + ).build(), + ).active = !state.operationInProgress + addRenderableWidget( + Button.builder(CommonComponents.GUI_CANCEL) { + viewModel.cancelImport() + }.bounds( + layout.contentX + layout.halfButtonWidth + 6, + layout.footerTop, + layout.halfButtonWidth, + 20, + ).build(), + ).active = !state.operationInProgress + } else { + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.recovery.export"), + ) { + chooseBackupDestination()?.let { target -> + val passphrase = passphraseValue.toCharArray() + val confirmation = confirmationValue.toCharArray() + clearInputs() + viewModel.export(target, passphrase, confirmation) + } + }.bounds( + layout.contentX, + layout.footerTop, + layout.halfButtonWidth, + 20, + ).build(), + ).active = !state.operationInProgress + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.recovery.import"), + ) { + chooseBackupSource()?.let { source -> + val passphrase = passphraseValue.toCharArray() + clearInputs() + viewModel.previewImport(source, passphrase) + } + }.bounds( + layout.contentX + layout.halfButtonWidth + 6, + layout.footerTop, + layout.halfButtonWidth, + 20, + ).build(), + ).active = !state.operationInProgress + } + + addRenderableWidget( + Button.builder(CommonComponents.GUI_BACK) { onClose() } + .bounds( + layout.contentX, + layout.footerTop + 24, + layout.contentWidth, + 20, + ).build(), + ) + } + + override fun tick() { + super.tick() + if (viewModel.state.value.hashCode() != fingerprint) { + rebuildWidgets() + } + } + + override fun onClose() { + clearInputs() + viewModel.close() + minecraft.gui.setScreen(parent) + } + + private fun secretBox( + value: String, + y: Int, + label: String, + changed: (String) -> Unit, + ) { + val layout = AdaptiveShareLayout.form(width, height, 2) + addRenderableWidget( + EditBox( + font, + layout.contentX, + y, + layout.contentWidth, + 20, + Component.translatable(label), + ).also { box -> + box.value = value + box.setMaxLength(256) + box.setResponder(changed) + box.addFormatter { text, _ -> + FormattedCharSequence.forward( + "•".repeat(text.length), + Style.EMPTY, + ) + } + }, + ) + } + + private fun clearInputs() { + passphraseValue = "" + confirmationValue = "" + } + + private fun chooseBackupDestination(): Path? { + val selected = TinyFileDialogs.tinyfd_saveFileDialog( + Component.translatable("connect_share.recovery.export").string, + "connect-share-friends.backup", + null, + "Connect Share backup", + ) ?: return null + return Path.of(selected) + } + + private fun chooseBackupSource(): Path? { + val selected = TinyFileDialogs.tinyfd_openFileDialog( + Component.translatable("connect_share.recovery.import").string, + null, + null, + "Connect Share backup", + false, + ) ?: return null + return Path.of(selected) + } + + private fun centered(message: Component, y: Int): StringWidget { + val textWidth = font.width(message) + return StringWidget( + width / 2 - textWidth / 2, + y, + textWidth, + 11, + message, + font, + ) + } +} + +private fun ShareUiMessage.component() = + Component.translatable(translationKey, *arguments.toTypedArray()) + diff --git a/share/fabric-26.2/src/main/kotlin/com/minekube/connect/share/fabric/v26_2/ShareJoinScreen.kt b/share/fabric-26.2/src/main/kotlin/com/minekube/connect/share/fabric/v26_2/ShareJoinScreen.kt new file mode 100644 index 000000000..9bd32750f --- /dev/null +++ b/share/fabric-26.2/src/main/kotlin/com/minekube/connect/share/fabric/v26_2/ShareJoinScreen.kt @@ -0,0 +1,1501 @@ +package com.minekube.connect.share.fabric.v26_2 + +import com.minekube.connect.share.fabric.ConnectShareClient +import com.minekube.connect.share.fabric.FabricShareBrowser +import com.minekube.connect.share.fabric.FriendCardExchangeConsent +import com.minekube.connect.share.fabric.FriendJoinAttemptFailure +import com.minekube.connect.share.fabric.FriendPresenceMonitor +import com.minekube.connect.share.fabric.FriendActivityMonitor +import com.minekube.connect.share.fabric.GuestJoinTarget +import com.minekube.connect.share.admission.Ingress +import com.minekube.connect.share.friend.FriendControlRequest +import com.minekube.connect.share.friend.FriendJoinRequest +import com.minekube.connect.share.friend.FriendActivityKind +import com.minekube.connect.share.fabric.ui.FriendSummary +import com.minekube.connect.share.fabric.ui.AdaptiveShareLayout +import com.minekube.connect.share.fabric.ui.FriendFormDraft +import com.minekube.connect.share.fabric.ui.FriendPresenceTone +import com.minekube.connect.share.fabric.ui.FriendPrimaryAction +import com.minekube.connect.share.fabric.ui.FriendsOverview +import com.minekube.connect.share.fabric.ui.FriendsSummaryTone +import com.minekube.connect.share.fabric.ui.FriendsScreenLayout +import com.minekube.connect.share.fabric.ui.IncomingFriendRequestSummary +import com.minekube.connect.share.fabric.ui.OutgoingFriendRequestSummary +import com.minekube.connect.share.fabric.ui.FriendsViewModel +import com.minekube.connect.share.fabric.ui.ShareUiMessage +import com.minekube.connect.share.fabric.ui.uiMessage +import com.minekube.connect.share.fabric.ui.overview +import com.minekube.connect.share.fabric.ui.page +import com.minekube.connect.share.fabric.ui.presentation +import com.minekube.connect.share.fabric.ui.summary +import com.minekube.connect.share.friend.FriendPermissions +import com.minekube.connect.share.friend.FriendAccessPolicy +import com.minekube.connect.tunnel.p2p.DirectP2pAuthMode +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.cancel +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import net.minecraft.client.gui.components.Button +import net.minecraft.client.gui.components.Checkbox +import net.minecraft.client.gui.components.CycleButton +import net.minecraft.client.gui.components.EditBox +import net.minecraft.client.gui.components.MultiLineTextWidget +import net.minecraft.client.gui.components.StringWidget +import net.minecraft.client.gui.components.Tooltip +import net.minecraft.client.gui.screens.ConnectScreen +import net.minecraft.client.gui.screens.Screen +import net.minecraft.client.multiplayer.ServerData +import net.minecraft.client.multiplayer.resolver.ServerAddress +import net.minecraft.network.chat.CommonComponents +import net.minecraft.ChatFormatting +import net.minecraft.network.chat.Component +import java.util.UUID + +class ShareJoinScreen( + private val parent: Screen, + private val friends: FriendsViewModel, + private val browser: FabricShareBrowser, + private val remotePresence: FriendPresenceMonitor, + private val friendActivity: FriendActivityMonitor, +) : Screen(Component.translatable("connect_share.friends.title")) { + private var scope: CoroutineScope? = null + private var mode = Mode.FRIENDS + private var selectedPeerId: String? = null + private var nameValue = "" + private var invitationValue = "" + private var offlineSelected = false + private var internetSelected = false + private var nameBox: EditBox? = null + private var invitationBox: EditBox? = null + private var offlineMode: Checkbox? = null + private var internetDirect: Checkbox? = null + private var primaryButton: Button? = null + private var secondaryButton: Button? = null + private var safeMessage: ShareUiMessage? = null + private var fingerprint = 0 + private var joining = false + private var joiningPeerId: String? = null + private var reciprocalPairing = false + private var removeConfirmation = false + private var friendLinkState = FriendLinkState.IDLE + private var requestOperationInProgress = false + private var relationshipOffset = 0 + private val requestJobs = mutableMapOf() + private val requestStates = + mutableMapOf() + + override fun init() { + if (scope == null) { + scope = CoroutineScope( + SupervisorJob() + minecraft.asCoroutineDispatcher(), + ) + browser.start().onLeft { safeMessage = it.uiMessage() } + } + friends.updatePresence(browser.discovered.value) + friends.updateRemotePresence(remotePresence.state.value) + friends.updateActivities(friendActivity.state.value) + friends.updateIncoming( + ConnectShareClient.viewModel().state.value.pendingAdmissions, + ) + fingerprint = currentFingerprint() + nameBox = null + invitationBox = null + primaryButton = null + secondaryButton = null + + when (mode) { + Mode.FRIENDS -> buildFriends() + Mode.ADD -> buildAddFriend() + Mode.CONNECTION_OPTIONS -> buildConnectionOptions() + Mode.MANAGE -> buildManageFriend() + } + } + + override fun tick() { + super.tick() + friends.updatePresence(browser.discovered.value) + friends.updateRemotePresence(remotePresence.state.value) + friends.updateActivities(friendActivity.state.value) + friends.updateIncoming( + ConnectShareClient.viewModel().state.value.pendingAdmissions, + ) + val next = currentFingerprint() + if (next != fingerprint) { + rebuildWidgets() + } else { + refresh() + } + } + + override fun onClose() { + if (mode == Mode.MANAGE && removeConfirmation) { + removeConfirmation = false + rebuildWidgets() + return + } + when (mode) { + Mode.FRIENDS -> minecraft.gui.setScreen(parent) + Mode.ADD, + Mode.CONNECTION_OPTIONS, + Mode.MANAGE, + -> { + val returningToAdd = mode == Mode.CONNECTION_OPTIONS + mode = if (returningToAdd) { + Mode.ADD + } else { + Mode.FRIENDS + } + if (!returningToAdd) resetFriendFormDraft() + selectedPeerId = null + safeMessage = null + rebuildWidgets() + } + } + } + + override fun removed() { + scope?.cancel() + scope = null + super.removed() + } + + private fun buildFriends() { + val layout = AdaptiveShareLayout.friends(width, height) + val state = friends.state.value + val overview = state.overview() + addRenderableWidget( + centered( + Component.translatable("connect_share.friends.title") + .withStyle(ChatFormatting.BOLD), + layout.headerY, + ), + ) + addRenderableWidget( + centered( + friendsSummary(overview), + layout.subtitleY, + ), + ) + + val relationships = buildList { + state.incomingRequests.forEach { + add(RelationshipRow.Incoming(it)) + } + state.outgoingRequests.forEach { + add(RelationshipRow.Outgoing(it)) + } + state.friends.forEach { add(RelationshipRow.Friend(it)) } + } + val page = relationships.page( + offset = relationshipOffset, + size = layout.visibleRows, + ) + relationshipOffset = page.offset + if (relationships.isEmpty()) { + addRenderableWidget( + centeredWrapped( + Component.translatable("connect_share.friends.empty"), + layout.rowsTop + 24, + layout.contentWidth, + ), + ) + } + page.items.forEachIndexed { index, relationship -> + val y = layout.rowY(index) + when (relationship) { + is RelationshipRow.Incoming -> + addIncomingRow(relationship.request, y, layout) + + is RelationshipRow.Outgoing -> + addOutgoingRow(relationship.request, y, layout) + + is RelationshipRow.Friend -> + addFriendRow(relationship.friend, y, layout) + } + } + if (page.pageCount > 1) { + val previousTooltip = Tooltip.create( + Component.translatable( + "connect_share.page.previous_tooltip", + page.pageNumber, + page.pageCount, + ), + ) + val nextTooltip = Tooltip.create( + Component.translatable( + "connect_share.page.next_tooltip", + page.pageNumber, + page.pageCount, + ), + ) + val previous = addRenderableWidget( + Button.builder(Component.literal("‹")) { + relationshipOffset = page.previousOffset ?: 0 + rebuildWidgets() + }.bounds(layout.contentX, layout.headerY, 24, 20) + .createNarration { + Component.translatable("connect_share.page.previous") + } + .tooltip(previousTooltip) + .build(), + ) + previous.active = page.hasPrevious + val next = addRenderableWidget( + Button.builder(Component.literal("›")) { + relationshipOffset = page.nextOffset ?: page.offset + rebuildWidgets() + }.bounds( + layout.contentX + layout.contentWidth - 24, + layout.headerY, + 24, + 20, + ) + .createNarration { + Component.translatable("connect_share.page.next") + } + .tooltip(nextTooltip) + .build(), + ) + next.active = page.hasNext + } + + safeMessage().let { message -> + if (message != null) { + addRenderableWidget( + centeredWrapped( + message.component() + .withStyle(ChatFormatting.YELLOW), + layout.messageY, + layout.contentWidth, + ), + ) + } + } + primaryButton = addRenderableWidget( + Button.builder( + Component.translatable(friendLinkState.translationKey), + ) { + copyMyFriendLink() + }.bounds( + layout.contentX, + layout.footerTop, + layout.halfButtonWidth, + 20, + ) + .tooltip( + Tooltip.create( + Component.translatable( + "connect_share.friends.copy_my_link.tooltip", + ), + ), + ) + .build(), + ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.friends.add"), + ) { + resetFriendFormDraft() + mode = Mode.ADD + safeMessage = null + rebuildWidgets() + }.bounds( + layout.contentX + layout.halfButtonWidth + 6, + layout.footerTop, + layout.halfButtonWidth, + 20, + ).build(), + ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.privacy.title"), + ) { + minecraft.gui.setScreen(SharePrivacyScreen(this)) + }.bounds( + layout.contentX, + layout.footerTop + 24, + layout.halfButtonWidth, + 20, + ) + .build(), + ) + addRenderableWidget( + Button.builder(CommonComponents.GUI_BACK) { onClose() } + .bounds( + layout.contentX + layout.halfButtonWidth + 6, + layout.footerTop + 24, + layout.halfButtonWidth, + 20, + ) + .build(), + ) + } + + private fun addIncomingRow( + request: IncomingFriendRequestSummary, + y: Int, + layout: FriendsScreenLayout, + ) { + val buttonWidth = 58 + val textWidth = layout.contentWidth - buttonWidth * 2 - 12 + addRenderableWidget( + StringWidget( + layout.contentX, + y, + textWidth, + 20, + Component.translatable( + if (request.purpose == com.minekube.connect.share.admission.AdmissionPurpose.FRIEND) { + "connect_share.friends.incoming_request" + } else { + "connect_share.friends.incoming_join_request" + }, + request.displayName, + friendlyIngress(request.ingress), + ), + font, + ).setMaxWidth(textWidth), + ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.status.allow"), + ) { + ConnectShareClient.viewModel().allow(request.requestId) + }.bounds( + layout.contentX + layout.contentWidth - buttonWidth * 2 - 4, + y + 2, + buttonWidth, + 20, + ).build(), + ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.status.deny"), + ) { + ConnectShareClient.viewModel().deny(request.requestId) + }.bounds( + layout.contentX + layout.contentWidth - buttonWidth, + y + 2, + buttonWidth, + 20, + ).build(), + ) + } + + private fun addOutgoingRow( + request: OutgoingFriendRequestSummary, + y: Int, + layout: FriendsScreenLayout, + ) { + val deliveryState = requestStates[request.peerId] + val buttonWidth = 58 + val textWidth = layout.contentWidth - buttonWidth * 2 - 12 + addRenderableWidget( + StringWidget( + layout.contentX, + y, + textWidth, + 20, + outgoingRequestLabel(request.displayName, deliveryState), + font, + ), + ) + addRenderableWidget( + Button.builder( + Component.translatable( + deliveryState?.translationKey + ?: "connect_share.friends.retry_request", + ), + ) { + deliverOutgoing(request.peerId) + }.bounds( + layout.contentX + layout.contentWidth - buttonWidth * 2 - 4, + y + 2, + buttonWidth, + 20, + ).build().apply { + active = deliveryState == null || + deliveryState == RequestDeliveryState.FAILED + }, + ) + addRenderableWidget( + Button.builder( + Component.translatable( + "connect_share.friends.cancel_request", + ), + ) { + cancelOutgoing(request.peerId) + }.bounds( + layout.contentX + layout.contentWidth - buttonWidth, + y + 2, + buttonWidth, + 20, + ).build(), + ) + } + + private fun addFriendRow( + friend: FriendSummary, + y: Int, + layout: FriendsScreenLayout, + ) { + val actionWidth = 104 + val manageWidth = 28 + val textWidth = layout.contentWidth - actionWidth - manageWidth - 12 + val presentation = friend.presentation() + addRenderableWidget( + StringWidget( + layout.contentX, + y, + textWidth, + 11, + Component.literal(friend.displayName) + .withStyle(ChatFormatting.WHITE), + font, + ).setMaxWidth(textWidth), + ) + addRenderableWidget( + StringWidget( + layout.contentX, + y + 11, + textWidth, + 11, + friendStatus(friend).copy() + .withStyle(presentation.tone.color()), + font, + ).setMaxWidth(textWidth), + ) + addRenderableWidget( + Button.builder( + Component.translatable(presentation.action.translationKey), + ) { + when (presentation.action) { + FriendPrimaryAction.ASK_TO_JOIN -> + requestToJoin(friend.peerId) + FriendPrimaryAction.JOIN_NOW -> joinSaved(friend.peerId) + FriendPrimaryAction.CANCEL_FOLLOW -> + friends.cancelFollow(friend.peerId) + FriendPrimaryAction.JOIN_WHEN_READY -> + friends.follow(friend.peerId) + } + rebuildWidgets() + }.bounds( + layout.contentX + layout.contentWidth - actionWidth - manageWidth - 4, + y + 2, + actionWidth, + 20, + ).build(), + ) + addRenderableWidget( + Button.builder( + Component.literal("…"), + ) { + selectedPeerId = friend.peerId + applyFriendFormDraft( + FriendFormDraft.forManage(friend.displayName), + ) + mode = Mode.MANAGE + safeMessage = null + rebuildWidgets() + }.bounds( + layout.contentX + layout.contentWidth - manageWidth, + y + 2, + manageWidth, + 20, + ).tooltip( + Tooltip.create( + Component.translatable( + "connect_share.friends.manage_named", + friend.displayName, + ), + ), + ).createNarration { + Component.translatable( + "connect_share.friends.manage_named", + friend.displayName, + ) + }.build(), + ) + } + + private fun buildAddFriend() { + val layout = AdaptiveShareLayout.form(width, height, 4) + addRenderableWidget( + centered( + Component.translatable("connect_share.friends.add") + .withStyle(ChatFormatting.BOLD), + layout.headerY, + ), + ) + addRenderableWidget( + centeredWrapped( + Component.translatable("connect_share.friends.add_description"), + layout.subtitleY, + layout.contentWidth, + ), + ) + addRenderableWidget( + StringWidget( + layout.contentX, + layout.bodyTop, + layout.contentWidth, + 11, + Component.translatable("connect_share.friends.link"), + font, + ), + ) + invitationBox = addRenderableWidget( + EditBox( + font, + layout.contentX, + layout.bodyTop + 12, + layout.contentWidth, + 20, + Component.translatable("connect_share.join.invitation"), + ).apply { + setMaxLength(MAX_INVITATION_LENGTH) + setHint(Component.translatable("connect_share.join.invitation_hint")) + setValue(invitationValue) + setResponder { + invitationValue = it + friends.suggestedDisplayName(it).getOrNull() + ?.let { suggested -> + nameValue = suggested + nameBox?.setValue(suggested) + } + refresh() + } + }, + ) + addRenderableWidget( + StringWidget( + layout.contentX, + layout.bodyTop + 38, + layout.contentWidth, + 11, + Component.translatable("connect_share.friends.name"), + font, + ), + ) + nameBox = addRenderableWidget( + EditBox( + font, + layout.contentX, + layout.bodyTop + 50, + layout.contentWidth, + 20, + Component.translatable("connect_share.friends.name"), + ).apply { + setMaxLength(64) + setHint(Component.translatable("connect_share.friends.name_hint")) + setValue(nameValue) + setResponder { + nameValue = it + refresh() + } + }, + ) + addRenderableWidget( + Button.builder( + Component.translatable( + "connect_share.friends.connection_options.show", + ), + ) { + mode = Mode.CONNECTION_OPTIONS + rebuildWidgets() + }.bounds( + layout.contentX, + layout.bodyTop + 76, + layout.contentWidth, + 20, + ).build(), + ) + safeMessage().let { message -> + if (message != null) { + addRenderableWidget( + centeredWrapped( + message.component().withStyle(ChatFormatting.YELLOW), + layout.footerTop - 16, + layout.contentWidth, + ), + ) + } + } + primaryButton = addRenderableWidget( + Button.builder( + Component.translatable( + "connect_share.friends.send_request", + ), + ) { + createFriendRequest() + }.bounds( + layout.contentX, + layout.footerTop, + layout.contentWidth, + 20, + ).build(), + ) + secondaryButton = addRenderableWidget( + Button.builder( + Component.translatable("connect_share.friends.join_once"), + ) { + joinInvitation() + }.bounds( + layout.contentX, + layout.footerTop + 24, + layout.halfButtonWidth, + 20, + ).build(), + ) + addRenderableWidget( + Button.builder(CommonComponents.GUI_BACK) { onClose() } + .bounds( + layout.contentX + layout.halfButtonWidth + 6, + layout.footerTop + 24, + layout.halfButtonWidth, + 20, + ) + .build(), + ) + refresh() + } + + private fun buildConnectionOptions() { + val layout = AdaptiveShareLayout.form(width, height, 2) + addRenderableWidget( + centered( + Component.translatable( + "connect_share.friends.connection_options.title", + ).withStyle(ChatFormatting.BOLD), + layout.headerY, + ), + ) + addRenderableWidget( + centeredWrapped( + Component.translatable( + "connect_share.friends.connection_options.description", + ), + layout.subtitleY, + layout.contentWidth, + ), + ) + offlineMode = addRenderableWidget( + Checkbox.builder( + Component.translatable("connect_share.join.offline"), + font, + ).pos(layout.contentX, layout.bodyTop) + .selected(offlineSelected) + .onValueChange { _, selected -> offlineSelected = selected } + .tooltip( + Tooltip.create( + Component.translatable( + "connect_share.join.offline.tooltip", + ), + ), + ).build(), + ) + internetDirect = addRenderableWidget( + Checkbox.builder( + Component.translatable("connect_share.join.internet"), + font, + ).pos(layout.contentX, layout.bodyTop + 28) + .selected(internetSelected) + .onValueChange { _, selected -> internetSelected = selected } + .tooltip( + Tooltip.create( + Component.translatable( + "connect_share.join.internet.tooltip", + ), + ), + ).build(), + ) + addRenderableWidget( + centeredWrapped( + Component.translatable( + "connect_share.friends.connection_options.fallback", + ).withStyle(ChatFormatting.GRAY), + layout.bodyTop + 64, + layout.contentWidth, + ), + ) + addRenderableWidget( + Button.builder(CommonComponents.GUI_DONE) { onClose() } + .bounds( + layout.contentX, + layout.footerTop + 24, + layout.contentWidth, + 20, + ).build(), + ) + } + + private fun buildManageFriend() { + val friend = selectedFriend() + if (friend == null) { + mode = Mode.FRIENDS + rebuildWidgets() + return + } + if (removeConfirmation) { + buildRemoveFriendConfirmation(friend) + return + } + val layout = AdaptiveShareLayout.form(width, height, 5) + val form = AdaptiveShareLayout.manageFriendForm(layout.bodyTop) + var internetDirectSelected = friend.internetDirectGuestOptIn + addRenderableWidget( + centered( + Component.translatable( + "connect_share.friends.manage_title", + friend.displayName, + ).withStyle(ChatFormatting.BOLD), + layout.headerY, + ), + ) + addRenderableWidget( + centeredWrapped( + safeMessage()?.let { + it.component().withStyle(ChatFormatting.YELLOW) + } ?: friendStatus(friend).copy().withStyle( + friend.presentation().tone.color(), + ), + layout.subtitleY, + layout.contentWidth, + ), + ) + addRenderableWidget( + StringWidget( + layout.contentX, + form.nameLabelY, + layout.contentWidth, + 11, + Component.translatable("connect_share.friends.name"), + font, + ), + ) + nameBox = addRenderableWidget( + EditBox( + font, + layout.contentX, + form.nameInputY, + layout.contentWidth, + 20, + Component.translatable("connect_share.friends.name"), + ).apply { + setMaxLength(64) + setValue(nameValue.ifBlank { friend.displayName }) + setResponder { + nameValue = it + refresh() + } + }, + ) + val notify = addRenderableWidget( + Checkbox.builder( + Component.translatable("connect_share.friends.notify"), + font, + ).pos(layout.contentX, form.notifyY) + .selected(friend.permissions.notifyWhenOnline) + .build(), + ) + var accessPolicy = friend.permissions.accessPolicy + addRenderableWidget( + CycleButton.builder( + { policy: FriendAccessPolicy -> + Component.translatable( + "connect_share.friends.access.${policy.name.lowercase()}", + ) + }, + accessPolicy, + ).withValues(FriendAccessPolicy.entries) + .create( + layout.contentX, + form.accessPolicyY, + layout.contentWidth, + 20, + Component.translatable("connect_share.friends.access"), + ) { _, selected -> accessPolicy = selected }, + ) + val shareWorlds = addRenderableWidget( + Checkbox.builder( + Component.translatable("connect_share.friends.share_worlds"), + font, + ).pos(layout.contentX, form.shareWorldsY) + .selected(friend.permissions.canSeeMyWorlds) + .build(), + ) + addRenderableWidget( + CycleButton.onOffBuilder(friend.internetDirectGuestOptIn) + .create( + layout.contentX, + form.internetDirectY, + layout.contentWidth, + 20, + Component.translatable( + "connect_share.friends.internet_direct_short", + ), + ) { _, selected -> internetDirectSelected = selected } + .apply { + setTooltip( + Tooltip.create( + Component.translatable( + "connect_share.friends.internet_direct.tooltip", + ), + ), + ) + }, + ) + primaryButton = addRenderableWidget( + Button.builder( + Component.translatable("connect_share.friends.save_changes"), + ) { + val activeScope = scope ?: return@builder + requestOperationInProgress = true + refresh() + activeScope.launch { + withContext(Dispatchers.IO) { + friends.rename(friend.peerId, nameValue) + friends.updateInternetDirectGuestOptIn( + friend.peerId, + internetDirectSelected, + ) + friends.updatePermissions( + friend.peerId, + FriendPermissions( + notifyWhenOnline = notify.selected(), + canSeeMyWorlds = shareWorlds.selected(), + accessPolicy = accessPolicy, + ), + ) + } + requestOperationInProgress = false + mode = Mode.FRIENDS + selectedPeerId = null + resetFriendFormDraft() + rebuildWidgets() + } + }.bounds( + layout.contentX, + layout.footerTop, + layout.halfButtonWidth, + 20, + ).build(), + ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.friends.remove"), + ) { + removeConfirmation = true + rebuildWidgets() + }.bounds( + layout.contentX + layout.halfButtonWidth + 6, + layout.footerTop, + layout.halfButtonWidth, + 20, + ).build(), + ) + addRenderableWidget( + Button.builder(CommonComponents.GUI_BACK) { onClose() } + .bounds( + layout.contentX, + layout.footerTop + 24, + layout.contentWidth, + 20, + ) + .build(), + ) + refresh() + } + + private fun buildRemoveFriendConfirmation(friend: FriendSummary) { + val layout = AdaptiveShareLayout.form(width, height, 1) + addRenderableWidget( + centered( + Component.translatable( + "connect_share.friends.remove_confirm.title", + friend.displayName, + ).withStyle(ChatFormatting.BOLD), + layout.headerY + 12, + ), + ) + addRenderableWidget( + centeredWrapped( + Component.translatable( + "connect_share.friends.remove_confirm.message", + ), + layout.bodyTop, + layout.contentWidth, + ), + ) + addRenderableWidget( + Button.builder( + Component.translatable( + "connect_share.friends.remove_confirm.confirm", + ), + ) { + val activeScope = scope ?: return@builder + requestOperationInProgress = true + refresh() + activeScope.launch { + withContext(Dispatchers.IO) { + friends.remove(friend.peerId) + } + requestOperationInProgress = false + removeConfirmation = false + mode = Mode.FRIENDS + selectedPeerId = null + resetFriendFormDraft() + rebuildWidgets() + } + }.bounds( + layout.contentX, + layout.footerTop + 24, + (layout.contentWidth - 12) / 3, + 20, + ).build(), + ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.friends.block"), + ) { + val activeScope = scope ?: return@builder + requestOperationInProgress = true + refresh() + activeScope.launch { + withContext(Dispatchers.IO) { + friends.block(friend.peerId) + } + requestOperationInProgress = false + removeConfirmation = false + mode = Mode.FRIENDS + selectedPeerId = null + resetFriendFormDraft() + rebuildWidgets() + } + }.bounds( + layout.contentX + (layout.contentWidth - 12) / 3 + 6, + layout.footerTop + 24, + (layout.contentWidth - 12) / 3, + 20, + ).build(), + ) + addRenderableWidget( + Button.builder(CommonComponents.GUI_CANCEL) { + removeConfirmation = false + rebuildWidgets() + }.bounds( + layout.contentX + 2 * ((layout.contentWidth - 12) / 3 + 6), + layout.footerTop + 24, + (layout.contentWidth - 12) / 3, + 20, + ).build(), + ) + } + + private fun copyMyFriendLink() { + val activeScope = scope ?: return + if (friendLinkState == FriendLinkState.COPYING) { + return + } + friendLinkState = FriendLinkState.COPYING + rebuildWidgets() + activeScope.launch { + val invitation = withContext(Dispatchers.IO) { + ConnectShareClient.friendCardIssuer().issue() + } + invitation.fold( + ifLeft = { + friendLinkState = FriendLinkState.FAILED + }, + ifRight = { link -> + minecraft.keyboardHandler.setClipboard(link) + friendLinkState = FriendLinkState.COPIED + }, + ) + rebuildWidgets() + } + } + + private fun joinSaved(peerId: String) { + if (joining) return + joining = true + joiningPeerId = peerId + reciprocalPairing = true + safeMessage = null + refresh() + scope?.launch { + friends.join( + peerId = peerId, + browser = browser, + authMode = authMode(), + ownConnectAddress = + ConnectShareClient.connectPublicAddress(), + ).fold( + ifLeft = ::joinFailed, + ifRight = ::connect, + ) + } + } + + private fun requestToJoin( + peerId: String, + allowModMismatch: Boolean = false, + ) { + if (joining) return + joining = true + joiningPeerId = peerId + reciprocalPairing = false + safeMessage = null + refresh() + scope?.launch { + ConnectShareClient.friendJoinOrchestrator().request( + peerId, + FriendJoinRequest( + requestId = UUID.randomUUID(), + playerName = minecraft.user.name, + playerUuid = minecraft.user.profileId, + ), + allowModMismatch = allowModMismatch, + ).fold( + ifLeft = { failure -> + joining = false + if (failure is FriendJoinAttemptFailure.Compatibility) { + minecraft.gui.setScreen( + CompatibilityMismatchScreen( + parent = this@ShareJoinScreen, + failure = failure, + tryAnyway = { + requestToJoin(peerId, true) + }, + ), + ) + } else { + safeMessage = failure.uiMessage() + rebuildWidgets() + } + }, + ifRight = ::connect, + ) + } + } + + private fun createFriendRequest() { + val activeScope = scope ?: return + if ( + requestOperationInProgress || + invitationValue.isBlank() || + nameValue.isBlank() + ) { + return + } + requestOperationInProgress = true + safeMessage = null + refresh() + activeScope.launch { + val peerId = withContext(Dispatchers.IO) { + friends.sendRequest( + invitationValue, + nameValue, + internetDirectGuestOptIn = internetSelected, + ) + } + requestOperationInProgress = false + if (peerId == null) { + rebuildWidgets() + return@launch + } + mode = Mode.FRIENDS + resetFriendFormDraft() + rebuildWidgets() + deliverOutgoing(peerId) + } + } + + private fun deliverOutgoing(peerId: String) { + val activeScope = scope ?: return + if (requestJobs[peerId]?.isActive == true) { + return + } + safeMessage = null + requestStates[peerId] = RequestDeliveryState.SENDING + rebuildWidgets() + val job = activeScope.launch { + val senderCard = withContext(Dispatchers.IO) { + ConnectShareClient.friendCardIssuer().issue().getOrNull() + } + if (senderCard == null) { + requestFailed( + peerId, + ShareUiMessage("connect_share.friends.request_failed"), + ) + return@launch + } + val targetResult = friends.routeOutgoing( + peerId = peerId, + browser = browser, + authMode = DirectP2pAuthMode.OFFLINE, + ) + val target = targetResult.getOrNull() + if (target == null) { + requestFailed( + peerId, + targetResult.leftOrNull()?.uiMessage() + ?: ShareUiMessage( + "connect_share.friends.request_failed", + ), + ) + return@launch + } + val displayName = friends.state.value.outgoingRequests + .firstOrNull { it.peerId == peerId } + ?.displayName + ?: peerId + val result = ConnectShareClient.friendRequestClient().exchange( + target = target, + request = FriendControlRequest( + requestId = UUID.randomUUID(), + relationshipId = friends.state.value.outgoingRequests + .firstOrNull { it.peerId == peerId } + ?.relationshipId + ?: run { + target.close() + requestFailed( + peerId, + ShareUiMessage( + "connect_share.friends.request_failed", + ), + ) + return@launch + }, + displayName = minecraft.user.name, + invitation = senderCard, + ), + onReceived = { + minecraft.execute { + requestStates[peerId] = + RequestDeliveryState.WAITING + rebuildWidgets() + } + }, + ) + val hostCard = result.getOrNull() + if (hostCard == null) { + requestFailed( + peerId, + result.leftOrNull()?.uiMessage() + ?: ShareUiMessage( + "connect_share.friends.request_failed", + ), + ) + return@launch + } + val accepted = withContext(Dispatchers.IO) { + ConnectShareClient.friendCardReceiver().receive( + invitation = hostCard, + displayName = displayName, + authenticatedMinecraftUuid = null, + relationshipId = friends.state.value.outgoingRequests + .firstOrNull { it.peerId == peerId } + ?.relationshipId, + ) + } + if (accepted.isLeft()) { + requestFailed( + peerId, + ShareUiMessage("connect_share.friends.request_failed"), + ) + return@launch + } + requestStates.remove(peerId) + friends.reload() + safeMessage = ShareUiMessage( + "connect_share.friends.request_accepted", + listOf(displayName), + ) + rebuildWidgets() + } + requestJobs[peerId] = job + job.invokeOnCompletion { + minecraft.execute { + requestJobs.remove(peerId, job) + } + } + } + + private fun cancelOutgoing(peerId: String) { + val activeScope = scope ?: return + requestJobs.remove(peerId)?.cancel() + requestStates[peerId] = RequestDeliveryState.CANCELLING + rebuildWidgets() + activeScope.launch { + withContext(Dispatchers.IO) { + friends.remove(peerId) + } + requestStates.remove(peerId) + rebuildWidgets() + } + } + + private fun requestFailed( + peerId: String, + message: ShareUiMessage, + ) { + requestStates[peerId] = RequestDeliveryState.FAILED + safeMessage = message + rebuildWidgets() + } + + private fun joinInvitation() { + if (joining || invitationValue.isBlank()) return + joining = true + joiningPeerId = null + reciprocalPairing = false + safeMessage = null + refresh() + scope?.launch { + browser.join( + invitationUri = invitationValue, + lanAddress = null, + internetOptIn = internetSelected, + authMode = authMode(), + ).fold( + ifLeft = ::joinFailed, + ifRight = ::connect, + ) + } + } + + private fun joinFailed(failure: com.minekube.connect.share.fabric.GuestJoinFailure) { + joining = false + joiningPeerId = null + reciprocalPairing = false + safeMessage = failure.uiMessage() + rebuildWidgets() + } + + private fun connect(target: GuestJoinTarget) { + val address = when (target) { + is GuestJoinTarget.Connect -> + ServerAddress.parseString(target.publicAddress) + + is GuestJoinTarget.Direct -> + ServerAddress( + target.localAddress.hostString, + target.localAddress.port, + ) + } + if (target is GuestJoinTarget.Direct) { + ConnectShareClient.holdGuestDirect(target) + } + val state = friends.state.value + val joiningFriend = state.friends.firstOrNull { + it.peerId == joiningPeerId + } + val data = ServerData( + joiningFriend?.displayName + ?: "Connect Share", + address.toString(), + ServerData.Type.OTHER, + ) + val exchangeFriendCard = FriendCardExchangeConsent.shouldArm( + savedFriendJoin = reciprocalPairing, + canSeeMyWorlds = + joiningFriend?.permissions?.canSeeMyWorlds == true, + ) + if (exchangeFriendCard && joiningPeerId != null) { + ConnectShareClient.armFriendCardExchange( + checkNotNull(joiningPeerId), + ) + } + ConnectScreen.startConnecting( + parent, + minecraft, + address, + data, + false, + null, + ) + } + + private fun refresh() { + val inputReady = invitationValue.isNotBlank() + primaryButton?.active = + !joining && !requestOperationInProgress && + friendLinkState != FriendLinkState.COPYING && + when (mode) { + Mode.ADD -> inputReady && nameValue.isNotBlank() + Mode.CONNECTION_OPTIONS -> true + Mode.MANAGE -> nameValue.isNotBlank() + Mode.FRIENDS -> true + } + secondaryButton?.active = !joining && inputReady + invitationBox?.setEditable(!joining) + nameBox?.setEditable(!joining) + } + + private fun resetFriendFormDraft() { + applyFriendFormDraft(currentFriendFormDraft().newRequest()) + } + + private fun currentFriendFormDraft(): FriendFormDraft = + FriendFormDraft( + displayName = nameValue, + invitation = invitationValue, + offlineMode = offlineSelected, + internetDirect = internetSelected, + ) + + private fun applyFriendFormDraft(draft: FriendFormDraft) { + nameValue = draft.displayName + invitationValue = draft.invitation + offlineSelected = draft.offlineMode + internetSelected = draft.internetDirect + } + + private fun friendsSummary(overview: FriendsOverview): Component = + overview.summary().let { presentation -> + Component.translatable( + presentation.translationKey, + *listOfNotNull(presentation.count).toTypedArray(), + ).withStyle( + when (presentation.tone) { + FriendsSummaryTone.ATTENTION -> ChatFormatting.YELLOW + FriendsSummaryTone.READY -> ChatFormatting.GREEN + FriendsSummaryTone.ONLINE -> ChatFormatting.AQUA + FriendsSummaryTone.MUTED -> ChatFormatting.GRAY + }, + ) + } + + private fun friendStatus(friend: FriendSummary): Component = + friend.presentation().let { presentation -> + Component.translatable( + presentation.statusKey, + *presentation.statusArguments.toTypedArray(), + ) + } + + private fun FriendPresenceTone.color(): ChatFormatting = when (this) { + FriendPresenceTone.JOINABLE -> ChatFormatting.GREEN + FriendPresenceTone.ONLINE -> ChatFormatting.AQUA + FriendPresenceTone.SAVED -> ChatFormatting.GRAY + FriendPresenceTone.OFFLINE -> ChatFormatting.DARK_GRAY + } + + private fun friendlyIngress(ingress: Ingress): Component = + Component.translatable( + when (ingress) { + Ingress.CONNECT -> "connect_share.ingress.online" + Ingress.DIRECT_LAN -> "connect_share.ingress.nearby" + Ingress.DIRECT_INTERNET -> "connect_share.ingress.direct" + }, + ) + + private fun outgoingRequestLabel( + displayName: String, + deliveryState: RequestDeliveryState?, + ): Component = Component.translatable( + if (deliveryState == null) { + "connect_share.friends.outgoing_request" + } else { + "connect_share.friends.outgoing_request_active" + }, + displayName, + ) + + private fun selectedFriend(): FriendSummary? = + friends.state.value.friends.firstOrNull { + it.peerId == selectedPeerId + } + + private fun safeMessage(): ShareUiMessage? = + safeMessage ?: friends.state.value.safeMessage + + private fun ShareUiMessage.component() = + Component.translatable(translationKey, *arguments.toTypedArray()) + + private fun authMode(): DirectP2pAuthMode = + if (offlineSelected) { + DirectP2pAuthMode.OFFLINE + } else { + DirectP2pAuthMode.ONLINE + } + + private fun currentFingerprint(): Int = + 31 * browser.discovered.value.hashCode() + + 31 * friends.state.value.hashCode() + + mode.hashCode() + + private fun centered(message: Component, y: Int): StringWidget { + val textWidth = font.width(message) + return StringWidget( + width / 2 - textWidth / 2, + y, + textWidth, + 9, + message, + font, + ) + } + + private fun centeredWrapped( + message: Component, + y: Int, + contentWidth: Int = CONTENT_WIDTH, + ): MultiLineTextWidget = + MultiLineTextWidget( + width / 2 - contentWidth / 2, + y, + message, + font, + ).setMaxWidth(contentWidth).setCentered(true) + + private enum class Mode { + FRIENDS, + ADD, + CONNECTION_OPTIONS, + MANAGE, + } + + private sealed interface RelationshipRow { + data class Incoming( + val request: IncomingFriendRequestSummary, + ) : RelationshipRow + + data class Outgoing( + val request: OutgoingFriendRequestSummary, + ) : RelationshipRow + + data class Friend( + val friend: FriendSummary, + ) : RelationshipRow + } + + private enum class FriendLinkState( + val translationKey: String, + ) { + IDLE("connect_share.friends.invite"), + COPYING("connect_share.friends.copying_my_link"), + COPIED("connect_share.friends.my_link_copied"), + FAILED("connect_share.friends.copy_my_link_failed"), + } + + private enum class RequestDeliveryState( + val translationKey: String, + ) { + SENDING("connect_share.friends.request_sending"), + WAITING("connect_share.friends.request_waiting"), + CANCELLING("connect_share.friends.request_cancelling"), + FAILED("connect_share.friends.retry_request"), + } + + private companion object { + const val MAX_INVITATION_LENGTH = 32_768 + const val MAX_VISIBLE_RELATIONSHIPS = 5 + const val CONTENT_WIDTH = 310 + } +} diff --git a/share/fabric-26.2/src/main/kotlin/com/minekube/connect/share/fabric/v26_2/SharePrivacyScreen.kt b/share/fabric-26.2/src/main/kotlin/com/minekube/connect/share/fabric/v26_2/SharePrivacyScreen.kt new file mode 100644 index 000000000..deac19b50 --- /dev/null +++ b/share/fabric-26.2/src/main/kotlin/com/minekube/connect/share/fabric/v26_2/SharePrivacyScreen.kt @@ -0,0 +1,174 @@ +package com.minekube.connect.share.fabric.v26_2 + +import com.minekube.connect.share.fabric.ConnectShareClient +import com.minekube.connect.share.fabric.ui.AdaptiveShareLayout +import net.minecraft.ChatFormatting +import net.minecraft.client.gui.components.Button +import net.minecraft.client.gui.components.Checkbox +import net.minecraft.client.gui.components.MultiLineTextWidget +import net.minecraft.client.gui.components.StringWidget +import net.minecraft.client.gui.screens.Screen +import net.minecraft.network.chat.CommonComponents +import net.minecraft.network.chat.Component + +class SharePrivacyScreen( + private val parent: Screen, +) : Screen(Component.translatable("connect_share.privacy.title")) { + private val viewModel = ConnectShareClient.viewModel() + private var diagnosticsCopied = false + + override fun init() { + val layout = AdaptiveShareLayout.form(width, height, 4) + addRenderableWidget( + centered(title.copy().withStyle(ChatFormatting.BOLD), layout.headerY), + ) + addRenderableWidget( + MultiLineTextWidget( + layout.contentX, + layout.subtitleY, + Component.translatable("connect_share.privacy.description"), + font, + ).setMaxWidth(layout.contentWidth).setCentered(true), + ) + + val privacy = viewModel.state.value.presencePrivacy + privacyToggle("online", layout.bodyTop, privacy.showOnline) { value -> + viewModel.setPresencePrivacy( + viewModel.state.value.presencePrivacy.copy(showOnline = value), + ) + } + privacyToggle( + "playing", + layout.bodyTop + 24, + privacy.showPlaying, + ) { value -> + viewModel.setPresencePrivacy( + viewModel.state.value.presencePrivacy.copy(showPlaying = value), + ) + } + privacyToggle( + "current_server", + layout.bodyTop + 48, + privacy.showCurrentServer, + ) { value -> + viewModel.setPresencePrivacy( + viewModel.state.value.presencePrivacy.copy( + showCurrentServer = value, + ), + ) + } + privacyToggle( + "joinable", + layout.bodyTop + 72, + privacy.showJoinable, + ) { value -> + viewModel.setPresencePrivacy( + viewModel.state.value.presencePrivacy.copy(showJoinable = value), + ) + } + addRenderableWidget( + MultiLineTextWidget( + layout.contentX, + layout.bodyTop + 104, + Component.translatable("connect_share.privacy.confirmed_only") + .withStyle(ChatFormatting.GRAY), + font, + ).setMaxWidth(layout.contentWidth).setCentered(true), + ) + + val third = (layout.contentWidth - 12) / 3 + addRenderableWidget( + Button.builder( + Component.translatable( + if (diagnosticsCopied) { + "connect_share.diagnostics.copied" + } else { + "connect_share.diagnostics.copy" + }, + ), + ) { + minecraft.keyboardHandler.setClipboard( + ConnectShareClient.diagnosticBundle(), + ) + diagnosticsCopied = true + rebuildWidgets() + }.bounds( + layout.contentX, + layout.footerTop, + third, + 20, + ).build(), + ) + addRenderableWidget( + Button.builder( + Component.translatable( + "connect_share.privacy.blocked", + ConnectShareClient.friendsViewModel() + .state.value.blocked.size, + ), + ) { + minecraft.gui.setScreen(BlockedFriendsScreen(this)) + }.bounds( + layout.contentX + third + 6, + layout.footerTop, + third, + 20, + ).build(), + ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.recovery.menu"), + ) { + minecraft.gui.setScreen(RecoveryScreen(this)) + }.bounds( + layout.contentX + (third + 6) * 2, + layout.footerTop, + third, + 20, + ).build(), + ) + addRenderableWidget( + Button.builder(CommonComponents.GUI_DONE) { onClose() } + .bounds( + layout.contentX, + layout.footerTop + 24, + layout.contentWidth, + 20, + ).build(), + ) + } + + override fun onClose() { + minecraft.gui.setScreen(parent) + } + + private fun privacyToggle( + key: String, + y: Int, + selected: Boolean, + changed: (Boolean) -> Unit, + ) { + val layout = AdaptiveShareLayout.form(width, height, 4) + addRenderableWidget( + Checkbox.builder( + Component.translatable("connect_share.privacy.$key"), + font, + ).pos(layout.contentX, y) + .selected(selected) + .onValueChange { _, value -> changed(value) } + .build(), + ) + } + + private fun centered(message: Component, y: Int): StringWidget { + val textWidth = font.width(message) + return StringWidget( + width / 2 - textWidth / 2, + y, + textWidth, + 11, + message, + font, + ) + } +} diff --git a/share/fabric-26.2/src/main/kotlin/com/minekube/connect/share/fabric/v26_2/ShareSetupScreen.kt b/share/fabric-26.2/src/main/kotlin/com/minekube/connect/share/fabric/v26_2/ShareSetupScreen.kt new file mode 100644 index 000000000..349a5fa14 --- /dev/null +++ b/share/fabric-26.2/src/main/kotlin/com/minekube/connect/share/fabric/v26_2/ShareSetupScreen.kt @@ -0,0 +1,224 @@ +package com.minekube.connect.share.fabric.v26_2 + +import com.minekube.connect.share.ShareGameMode +import com.minekube.connect.share.fabric.ConnectShareClient +import com.minekube.connect.share.fabric.ui.AdaptiveShareLayout +import com.minekube.connect.share.fabric.ui.FormScreenLayout +import com.minekube.connect.share.fabric.ui.ShareUiState +import net.minecraft.ChatFormatting +import net.minecraft.client.gui.components.Button +import net.minecraft.client.gui.components.Checkbox +import net.minecraft.client.gui.components.CycleButton +import net.minecraft.client.gui.components.MultiLineTextWidget +import net.minecraft.client.gui.components.StringWidget +import net.minecraft.client.gui.components.Tooltip +import net.minecraft.client.gui.screens.Screen +import net.minecraft.network.chat.CommonComponents +import net.minecraft.network.chat.Component + +class ShareSetupScreen( + private val parent: Screen, +) : Screen(Component.translatable("connect_share.setup.title")) { + private val viewModel = ConnectShareClient.viewModel() + private var startButton: Button? = null + private var defaultsLoaded = false + private var optionsExpanded = false + + override fun init() { + if (!defaultsLoaded) { + minecraft.singleplayerServer?.let { server -> + viewModel.setGameMode(server.defaultGameType.toShareGameMode()) + viewModel.setAllowCheats(server.worldData.isAllowCommands) + } + defaultsLoaded = true + } + val current = viewModel.state.value + val layout = AdaptiveShareLayout.form(width, height, 4) + + addRenderableWidget( + centered(title.copy().withStyle(ChatFormatting.BOLD), layout.headerY), + ) + addRenderableWidget( + MultiLineTextWidget( + layout.contentX, + layout.subtitleY, + Component.translatable("connect_share.setup.description"), + font, + ).setMaxWidth(layout.contentWidth).setCentered(true), + ) + addRenderableWidget( + centered( + Component.translatable("connect_share.setup.friends_only") + .withStyle(ChatFormatting.GREEN), + layout.bodyTop, + ), + ) + + if (optionsExpanded) { + addOptions(layout, current) + } else { + addRenderableWidget( + MultiLineTextWidget( + layout.contentX, + layout.bodyTop + 28, + Component.translatable("connect_share.setup.persistence") + .withStyle(ChatFormatting.GRAY), + font, + ).setMaxWidth(layout.contentWidth).setCentered(true), + ) + } + + startButton = addRenderableWidget( + Button.builder( + Component.translatable("connect_share.setup.start"), + ) { + viewModel.start() + minecraft.gui.setScreen(ShareStatusScreen(parent)) + }.bounds( + layout.contentX, + layout.footerTop, + layout.contentWidth, + 20, + ).build(), + ) + + val third = (layout.contentWidth - 12) / 3 + addRenderableWidget( + Button.builder( + Component.translatable( + if (optionsExpanded) { + "connect_share.setup.options.hide" + } else { + "connect_share.setup.options.show" + }, + ), + ) { + optionsExpanded = !optionsExpanded + rebuildWidgets() + }.bounds( + layout.contentX, + layout.footerTop + 24, + third, + 20, + ).build(), + ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.privacy.title"), + ) { + minecraft.gui.setScreen(SharePrivacyScreen(this)) + }.bounds( + layout.contentX + third + 6, + layout.footerTop + 24, + third, + 20, + ).build(), + ) + addRenderableWidget( + Button.builder(CommonComponents.GUI_CANCEL) { onClose() } + .bounds( + layout.contentX + (third + 6) * 2, + layout.footerTop + 24, + third, + 20, + ).build(), + ) + refresh() + } + + private fun addOptions( + layout: FormScreenLayout, + current: ShareUiState, + ) { + addRenderableWidget( + CycleButton.builder( + { mode: ShareGameMode -> + Component.translatable( + "connect_share.game_mode.${mode.name.lowercase()}", + ) + }, + current.options.gameMode, + ).withValues(ShareGameMode.entries) + .create( + layout.contentX, + layout.bodyTop + 24, + layout.halfButtonWidth, + 20, + Component.translatable("selectWorld.gameMode"), + ) { _, mode -> viewModel.setGameMode(mode) }, + ) + addRenderableWidget( + CycleButton.onOffBuilder(current.options.allowCheats) + .create( + layout.contentX + layout.halfButtonWidth + 6, + layout.bodyTop + 24, + layout.halfButtonWidth, + 20, + Component.translatable("selectWorld.allowCommands"), + ) { _, allowed -> viewModel.setAllowCheats(allowed) }, + ) + addRenderableWidget( + CycleButton.builder( + { guests: Int -> Component.literal(guests.toString()) }, + current.options.maxGuests, + ).withValues((1..16).toList()) + .create( + layout.contentX, + layout.bodyTop + 48, + layout.contentWidth, + 20, + Component.translatable("connect_share.setup.max_guests"), + ) { _, guests -> viewModel.setMaxGuests(guests) }, + ) + addRenderableWidget( + Checkbox.builder( + Component.translatable("connect_share.setup.internet"), + font, + ).pos(layout.contentX, layout.bodyTop + 74) + .selected(current.options.allowInternetDirect) + .onValueChange { _, allowed -> + viewModel.setAllowInternetDirect(allowed) + } + .tooltip( + Tooltip.create( + Component.translatable( + "connect_share.setup.internet.tooltip", + ), + ), + ) + .build(), + ) + } + + override fun tick() { + super.tick() + refresh() + } + + override fun onClose() { + minecraft.gui.setScreen(parent) + } + + private fun refresh() { + startButton?.active = viewModel.state.value.startEnabled + } + + private fun centered(message: Component, y: Int): StringWidget { + val textWidth = font.width(message) + return StringWidget( + width / 2 - textWidth / 2, + y, + textWidth, + 11, + message, + font, + ) + } +} + +private fun net.minecraft.world.level.GameType.toShareGameMode(): ShareGameMode = when (this) { + net.minecraft.world.level.GameType.SURVIVAL -> ShareGameMode.SURVIVAL + net.minecraft.world.level.GameType.CREATIVE -> ShareGameMode.CREATIVE + net.minecraft.world.level.GameType.ADVENTURE -> ShareGameMode.ADVENTURE + net.minecraft.world.level.GameType.SPECTATOR -> ShareGameMode.SPECTATOR +} diff --git a/share/fabric-26.2/src/main/kotlin/com/minekube/connect/share/fabric/v26_2/ShareStatusScreen.kt b/share/fabric-26.2/src/main/kotlin/com/minekube/connect/share/fabric/v26_2/ShareStatusScreen.kt new file mode 100644 index 000000000..7a8753392 --- /dev/null +++ b/share/fabric-26.2/src/main/kotlin/com/minekube/connect/share/fabric/v26_2/ShareStatusScreen.kt @@ -0,0 +1,342 @@ +package com.minekube.connect.share.fabric.v26_2 + +import com.minekube.connect.share.ShareState +import com.minekube.connect.share.admission.AdmissionIdentity +import com.minekube.connect.share.admission.AdmissionPurpose +import com.minekube.connect.share.admission.Ingress +import com.minekube.connect.share.fabric.ConnectShareClient +import com.minekube.connect.share.fabric.ui.AdaptiveShareLayout +import com.minekube.connect.share.fabric.ui.FormScreenLayout +import net.minecraft.ChatFormatting +import net.minecraft.client.gui.components.Button +import net.minecraft.client.gui.components.MultiLineTextWidget +import net.minecraft.client.gui.components.StringWidget +import net.minecraft.client.gui.screens.Screen +import net.minecraft.network.chat.CommonComponents +import net.minecraft.network.chat.Component + +class ShareStatusScreen( + private val parent: Screen, +) : Screen(Component.translatable("connect_share.status.title")) { + private val viewModel = ConnectShareClient.viewModel() + private var fingerprint: Int = 0 + private var linkCopied = false + private var showConnectionDetails = false + + override fun init() { + val state = viewModel.state.value + fingerprint = state.hashCode() + val layout = AdaptiveShareLayout.form(width, height, 3) + val sharing = state.shareState as? ShareState.Sharing + + addRenderableWidget( + centered( + title.copy().withStyle( + if (sharing != null) { + ChatFormatting.GREEN + } else { + ChatFormatting.YELLOW + }, + ), + layout.headerY, + ), + ) + addRenderableWidget( + centeredWrapped( + Component.translatable(statusKey(state.shareState)) + .withStyle(ChatFormatting.GRAY), + layout.subtitleY, + layout.contentWidth, + ), + ) + + var requestHeadingY = layout.bodyTop + 54 + if (sharing != null) { + val invitation = viewModel.currentInvitation() + addRenderableWidget( + Button.builder( + Component.translatable( + if (linkCopied) { + "connect_share.status.friend_link_copied" + } else { + "connect_share.status.copy_friend_link" + }, + ), + ) { + invitation?.let { + minecraft.keyboardHandler.setClipboard(it) + linkCopied = true + rebuildWidgets() + } + }.bounds( + layout.contentX, + layout.bodyTop, + layout.contentWidth, + 20, + ).build().apply { active = invitation != null }, + ) + addRenderableWidget( + Button.builder( + Component.translatable( + if (showConnectionDetails) { + "connect_share.status.connection_details.hide" + } else { + "connect_share.status.connection_details.show" + }, + ), + ) { + showConnectionDetails = !showConnectionDetails + rebuildWidgets() + }.bounds( + layout.contentX, + layout.bodyTop + 24, + layout.contentWidth, + 20, + ).build(), + ) + if (showConnectionDetails) { + addConnectionDetails(layout, sharing) + requestHeadingY = layout.bodyTop + 102 + } + } + + addRenderableWidget( + centered( + Component.translatable("connect_share.status.requests") + .withStyle(ChatFormatting.BOLD), + requestHeadingY, + ), + ) + val pending = state.pendingAdmissions + val rowsTop = requestHeadingY + 14 + val visibleRows = ((layout.footerTop - rowsTop) / 24).coerceIn(1, 3) + pending.take(visibleRows).forEachIndexed { index, request -> + val y = rowsTop + index * 24 + val buttonWidth = 54 + val labelWidth = layout.contentWidth - buttonWidth * 2 - 10 + val identity = request.identity + val ingress = when (identity) { + is AdmissionIdentity.Authenticated -> + identity.ingress + is AdmissionIdentity.UnverifiedOffline -> + identity.ingress + } + val label = Component.translatable( + if (request.purpose == AdmissionPurpose.FRIEND) { + "connect_share.status.friend_request" + } else { + "connect_share.status.request" + }, + identity.name, + friendlyIngress(ingress), + ) + addRenderableWidget( + StringWidget( + layout.contentX, + y, + labelWidth, + 20, + label, + font, + ).setMaxWidth(labelWidth), + ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.status.allow"), + ) { + viewModel.allow(request.requestId) + }.bounds( + layout.contentX + layout.contentWidth - + buttonWidth * 2 - 4, + y, + buttonWidth, + 20, + ).build(), + ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.status.deny"), + ) { + viewModel.deny(request.requestId) + }.bounds( + layout.contentX + layout.contentWidth - buttonWidth, + y, + buttonWidth, + 20, + ).build(), + ) + } + if (pending.size > visibleRows) { + addRenderableWidget( + centered( + Component.translatable( + "connect_share.status.more", + pending.size - visibleRows, + ).withStyle(ChatFormatting.GRAY), + rowsTop + visibleRows * 24, + ), + ) + } else if (pending.isEmpty()) { + addRenderableWidget( + centered( + Component.translatable("connect_share.status.waiting") + .withStyle(ChatFormatting.GRAY), + rowsTop + 4, + ), + ) + } + + addFooter(layout) + } + + private fun addConnectionDetails( + layout: FormScreenLayout, + sharing: ShareState.Sharing, + ) { + val address = sharing.address + addRenderableWidget( + Button.builder( + Component.translatable( + if (address == null) { + "connect_share.status.address_unavailable" + } else { + "connect_share.status.copy_address" + }, + ), + ) { + address?.let(minecraft.keyboardHandler::setClipboard) + }.bounds( + layout.contentX, + layout.bodyTop + 48, + layout.contentWidth, + 20, + ).build().apply { active = address != null }, + ) + addRenderableWidget( + centeredWrapped( + Component.translatable( + "connect_share.status.route_summary", + routeLabel(sharing), + ).withStyle(ChatFormatting.GRAY), + layout.bodyTop + 74, + layout.contentWidth, + ), + ) + } + + private fun addFooter(layout: FormScreenLayout) { + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.privacy.title"), + ) { + minecraft.gui.setScreen(SharePrivacyScreen(this)) + }.bounds( + layout.contentX, + layout.footerTop, + layout.halfButtonWidth, + 20, + ).build(), + ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.identity.manage"), + ) { + minecraft.gui.setScreen(EndpointIdentityScreen(this)) + }.bounds( + layout.contentX + layout.halfButtonWidth + 6, + layout.footerTop, + layout.halfButtonWidth, + 20, + ).build(), + ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.status.stop"), + ) { + viewModel.stop() + minecraft.gui.setScreen(parent) + }.bounds( + layout.contentX, + layout.footerTop + 24, + layout.halfButtonWidth, + 20, + ).build(), + ) + addRenderableWidget( + Button.builder(CommonComponents.GUI_BACK) { onClose() } + .bounds( + layout.contentX + layout.halfButtonWidth + 6, + layout.footerTop + 24, + layout.halfButtonWidth, + 20, + ).build(), + ) + } + + override fun tick() { + super.tick() + val next = viewModel.state.value.hashCode() + if (next != fingerprint) { + rebuildWidgets() + } + } + + override fun onClose() { + minecraft.gui.setScreen(parent) + } + + private fun routeLabel(sharing: ShareState.Sharing): Component = + Component.translatable( + when { + sharing.internetDirectAvailable -> + "connect_share.status.route.internet" + sharing.lanDirectAvailable && sharing.connectAvailable -> + "connect_share.status.route.lan_connect" + sharing.lanDirectAvailable -> + "connect_share.status.route.lan" + sharing.connectAvailable -> + "connect_share.status.route.connect" + else -> "connect_share.status.route.starting" + }, + ) + + private fun friendlyIngress(ingress: Ingress): Component = + Component.translatable( + when (ingress) { + Ingress.CONNECT -> "connect_share.ingress.online" + Ingress.DIRECT_LAN -> "connect_share.ingress.nearby" + Ingress.DIRECT_INTERNET -> "connect_share.ingress.direct" + }, + ) + + private fun statusKey(state: ShareState): String = when (state) { + ShareState.Idle -> "connect_share.status.idle" + ShareState.Starting -> "connect_share.status.starting" + is ShareState.Sharing -> "connect_share.status.ready" + ShareState.Stopping -> "connect_share.status.stopping" + is ShareState.Failed -> "connect_share.status.failed" + } + + private fun centered(message: Component, y: Int): StringWidget { + val textWidth = font.width(message) + return StringWidget( + width / 2 - textWidth / 2, + y, + textWidth, + 11, + message, + font, + ) + } + + private fun centeredWrapped( + message: Component, + y: Int, + maxWidth: Int, + ): MultiLineTextWidget = MultiLineTextWidget( + width / 2 - maxWidth / 2, + y, + message, + font, + ).setMaxWidth(maxWidth).setCentered(true) +} diff --git a/share/fabric-26.2/src/main/kotlin/com/minekube/connect/share/fabric/v26_2/VanillaMinecraft262Transport.kt b/share/fabric-26.2/src/main/kotlin/com/minekube/connect/share/fabric/v26_2/VanillaMinecraft262Transport.kt new file mode 100644 index 000000000..f9c33834c --- /dev/null +++ b/share/fabric-26.2/src/main/kotlin/com/minekube/connect/share/fabric/v26_2/VanillaMinecraft262Transport.kt @@ -0,0 +1,158 @@ +package com.minekube.connect.share.fabric.v26_2 + +import com.minekube.connect.share.CapturedServerTransport +import com.minekube.connect.share.ShareGameMode +import com.minekube.connect.share.ShareOptions +import com.minekube.connect.share.fabric.v26_2.mixin.IntegratedServerAccessor +import com.minekube.connect.share.fabric.v26_2.mixin.LanServerPingerAccessor +import com.minekube.connect.share.fabric.v26_2.mixin.ServerConnectionListenerAccessor +import io.netty.channel.Channel +import io.netty.channel.ChannelFuture +import io.netty.channel.ChannelInitializer +import io.netty.channel.EventLoopGroup +import java.net.InetSocketAddress +import net.minecraft.client.Minecraft +import net.minecraft.client.server.IntegratedServer +import net.minecraft.server.MinecraftServer +import net.minecraft.util.HttpUtil +import net.minecraft.world.level.GameType + +internal class VanillaMinecraft262Transport( + private val serverProvider: () -> IntegratedServer? = { + Minecraft.getInstance().singleplayerServer + }, +) : Minecraft262Transport { + override fun publish(options: ShareOptions): PublishedMinecraftTransport { + val server = checkNotNull(serverProvider()) { + "Connect Share requires an active singleplayer world" + } + check(!server.isPublished) { + "This singleplayer world is already published" + } + val connection = server.connection + val channels = (connection as ServerConnectionListenerAccessor) + .connectShareChannels + val before = synchronized(channels) { channels.toSet() } + val captureLease = CapturedServerTransport.arm() + var published = false + try { + published = server.publishServer( + MinecraftServer.MultiplayerScope.LAN, + options.gameMode.toMinecraft(), + options.allowCheats, + HttpUtil.getAvailablePort(), + ) + check(published) { "Minecraft could not start its private Share listener" } + val captured = captureLease.complete().fold( + ifLeft = { + throw IllegalStateException( + "Minecraft did not expose its Share channel initializer", + ) + }, + ifRight = { it }, + ) + val added = synchronized(channels) { + channels.filterNot(before::contains) + } + check(added.size == 1) { + "Minecraft created ${added.size} listeners for one Share start" + } + val loopback = added.single() + val address = loopback.channel().localAddress() as? InetSocketAddress + ?: throw IllegalStateException( + "Minecraft Share did not create a TCP listener", + ) + check(address.address.isLoopbackAddress) { + "Minecraft Share listener escaped loopback" + } + suppressLanAdvertisement(server) + return PublishedVanillaTransport( + server = server, + channels = channels, + loopback = loopback, + address = address, + childInitializer = captured.childInitializer, + eventLoopGroup = captured.eventLoopGroup, + ) + } catch (failure: Throwable) { + captureLease.close() + rollbackNewListeners(server, channels, before) + if (published) { + suppressLanAdvertisement(server) + } + throw failure + } + } + + private fun rollbackNewListeners( + server: IntegratedServer, + channels: MutableList, + before: Set, + ) { + val added = synchronized(channels) { + channels.filterNot(before::contains).also(channels::removeAll) + } + added.forEach(ChannelFuture::closeChannel) + if (server.isPublished) { + server.unpublishServer() + } + } + + private fun suppressLanAdvertisement(server: IntegratedServer) { + val accessor = server as IntegratedServerAccessor + accessor.connectShareLanPinger?.let { pinger -> + pinger.interrupt() + (pinger as LanServerPingerAccessor).connectShareSocket.close() + } + accessor.connectShareLanPinger = null + } + + private fun ShareGameMode.toMinecraft(): GameType = when (this) { + ShareGameMode.SURVIVAL -> GameType.SURVIVAL + ShareGameMode.CREATIVE -> GameType.CREATIVE + ShareGameMode.ADVENTURE -> GameType.ADVENTURE + ShareGameMode.SPECTATOR -> GameType.SPECTATOR + } +} + +private class PublishedVanillaTransport( + private val server: IntegratedServer, + private val channels: MutableList, + private val loopback: ChannelFuture, + override val address: InetSocketAddress, + override val childInitializer: ChannelInitializer, + override val eventLoopGroup: EventLoopGroup, +) : PublishedMinecraftTransport { + override fun addLocalListener(listener: LocalShareChannel) { + val future = checkNotNull(listener.future) { + "Connect Share local channel did not expose its bound future" + } + synchronized(channels) { + check(channels.add(future)) { + "Minecraft already tracks the Connect Share local listener" + } + } + } + + override fun removeLocalListener(listener: LocalShareChannel) { + val future = listener.future ?: return + synchronized(channels) { + channels.remove(future) + } + } + + override fun close() { + if (!server.unpublishServer()) { + synchronized(channels) { + channels.remove(loopback) + } + loopback.closeChannel() + } + } +} + +private fun ChannelFuture.closeChannel() { + if (channel().isOpen) { + channel().close().syncUninterruptibly() + } +} diff --git a/share/fabric-26.2/src/main/resources/assets/connect-share/lang/de_de.json b/share/fabric-26.2/src/main/resources/assets/connect-share/lang/de_de.json new file mode 100644 index 000000000..bf44f3379 --- /dev/null +++ b/share/fabric-26.2/src/main/resources/assets/connect-share/lang/de_de.json @@ -0,0 +1,286 @@ +{ + "connect_share.menu.share": "Welt teilen", + "connect_share.menu.active": "Welt geteilt", + "connect_share.menu.join": "Freunde", + "connect_share.setup.title": "Diese Welt mit Freunden spielen", + "connect_share.setup.description": "Mache diese Welt jetzt betretbar. Gespeicherte Freunde sehen sie automatisch.", + "connect_share.setup.max_guests": "Maximale Gäste", + "connect_share.setup.internet": "Schnellere direkte Internetverbindungen erlauben", + "connect_share.setup.internet.tooltip": "Optional. Zeigt eingeladenen Gästen mit Mod deine öffentliche IP-Adresse. Connect bleibt der Relay-Fallback.", + "connect_share.setup.persistence": "Beim Wechseln oder erneuten Öffnen einer Welt wird das Teilen automatisch fortgesetzt.", + "connect_share.setup.start": "Teilen starten", + "connect_share.game_mode.survival": "Überleben", + "connect_share.game_mode.creative": "Kreativ", + "connect_share.game_mode.adventure": "Abenteuer", + "connect_share.game_mode.spectator": "Zuschauer", + "connect_share.status.title": "Deine Welt ist bereit", + "connect_share.status.address": "Über Connect bereit · %s", + "connect_share.status.direct_only": "Für Freunde in der Nähe bereit", + "connect_share.status.copy_invitation": "Freundeslink kopieren", + "connect_share.status.copy_address": "Serveradresse kopieren", + "connect_share.status.link_help": "Sende den Freundeslink einmal. Danach erscheinen zukünftige Welten automatisch.", + "connect_share.status.connection_help": "Die beste Verbindung wird automatisch gewählt: direkt, wenn möglich; sonst Connect.", + "connect_share.status.requests": "Anfragen, die auf dich warten", + "connect_share.status.idle": "Nicht geteilt", + "connect_share.status.starting": "Wird gestartet…", + "connect_share.status.active": "Aktiv", + "connect_share.status.stopping": "Wird beendet…", + "connect_share.status.failed": "Start fehlgeschlagen", + "connect_share.status.request": "%s möchte sich verbinden · %s", + "connect_share.status.friend_request": "%s hat eine Freundschaftsanfrage gesendet · %s", + "connect_share.status.allow": "Annehmen", + "connect_share.status.deny": "Ablehnen", + "connect_share.status.more": "%s weitere Anfragen", + "connect_share.status.waiting": "Niemand wartet auf eine Antwort.", + "connect_share.status.stop": "Teilen mit Freunden beenden", + "connect_share.join.title": "Connect Share beitreten", + "connect_share.join.description": "Wähle eine Welt in der Nähe oder füge ihre signierte Einladung ein.", + "connect_share.join.invitation": "Connect-Share-Einladung", + "connect_share.join.invitation_hint": "minekube://share/…", + "connect_share.join.scanning": "Suche nach Connect-Share-Welten in der Nähe…", + "connect_share.join.discovered": "In der Nähe: %s", + "connect_share.join.offline": "Offline-Identität verwenden (ungeprüft)", + "connect_share.join.offline.tooltip": "Der Host sieht diese Identität als ungeprüft und muss sie freigeben.", + "connect_share.join.internet": "Direkte Internetverbindung versuchen", + "connect_share.join.internet.tooltip": "Optional. Zeigt dem Host deine öffentliche IP-Adresse. Connect bleibt der Relay-Fallback.", + "connect_share.join.join": "Beitreten", + "connect_share.friends.title": "Freunde", + "connect_share.friends.page": "Seite %s von %s", + "connect_share.friends.description": "Spielbereite Freunde erscheinen hier.", + "connect_share.friends.copy_my_link": "Meinen Freundeslink kopieren", + "connect_share.friends.copy_my_link.tooltip": "Enthält eine direkte Route, damit diese Person dich erreicht. Sende ihn nur an vertraute Personen.", + "connect_share.friends.copying_my_link": "Freundeslink wird erstellt…", + "connect_share.friends.my_link_copied": "Freundeslink kopiert", + "connect_share.friends.copy_my_link_failed": "Freundeslink konnte nicht kopiert werden", + "connect_share.friends.empty": "Noch keine Freunde oder gesendeten Anfragen. Füge einen Freundeslink ein, um eine zu senden.", + "connect_share.friends.outgoing_request": "Anfrage an %s", + "connect_share.friends.incoming_request": "Anfrage von %s · %s", + "connect_share.friends.incoming_join_request": "%s möchte beitreten · %s", + "connect_share.friends.outgoing_request_active": "%s · Anfrage läuft", + "connect_share.friends.retry_request": "Erneut", + "connect_share.friends.request_sending": "Wird gesendet…", + "connect_share.friends.request_waiting": "Warten…", + "connect_share.friends.request_cancelling": "Wird abgebrochen…", + "connect_share.friends.request_failed": "Die Anfrage konnte nicht zugestellt werden. Dein Freund ist möglicherweise offline oder noch nicht direkt erreichbar.", + "connect_share.friends.request_accepted": "%s hat deine Freundschaftsanfrage angenommen.", + "connect_share.friends.cancel_request": "Abbrechen", + "connect_share.friends.manage": "Verwalten", + "connect_share.friends.request_join": "Anfragen", + "connect_share.friends.follow": "Beitreten, sobald bereit", + "connect_share.friends.cancel_follow": "Folgen abbrechen", + "connect_share.notification.follow_waiting": "%s wird gefolgt", + "connect_share.notification.follow_waiting_detail": "Sobald die nächste Welt bereit ist, wird eine Beitrittsanfrage gesendet. Abbruch unter Freunde.", + "connect_share.notification.follow_ready": "%s ist bereit", + "connect_share.notification.follow_ready_detail": "Öffne Freunde, um jetzt beizutreten; dein aktives Spiel wurde nicht unterbrochen.", + "connect_share.notification.follow_expired": "Folgen abgelaufen", + "connect_share.notification.follow_expired_detail": "%s war nicht rechtzeitig beitrittsbereit.", + "connect_share.notification.follow_cancelled": "Folgen beendet", + "connect_share.notification.follow_cancelled_detail": "%s kann nicht mehr gefolgt werden, da sich Freundschaft oder Zugriff geändert haben.", + "connect_share.notification.follow_failed": "Freund konnte nicht gefolgt werden", + "connect_share.friends.playing_server": "%s · spielt auf %s", + "connect_share.friends.hosting_world": "%s · spielt %s", + "connect_share.friends.online": "%s · online", + "connect_share.friends.friend_unreachable": "Dein Freund ist gerade nicht über libp2p erreichbar.", + "connect_share.friends.add": "Freund hinzufügen", + "connect_share.friends.add_description": "Füge den Link deines Freundes ein und sende eine Anfrage. Die Person kann sie annehmen, solange ihr Spiel geöffnet ist.", + "connect_share.friends.name": "Name des Freundes", + "connect_share.friends.name_hint": "Name der Person, die den Link gesendet hat", + "connect_share.friends.save": "Freund speichern", + "connect_share.friends.send_request": "Anfrage senden", + "connect_share.friends.connecting_request": "Freundschaftsanfrage an %s", + "connect_share.friends.join_once": "Einmal beitreten", + "connect_share.friends.manage_title": "%s verwalten", + "connect_share.friends.notify": "Benachrichtigen, wenn die Welt bereit ist", + "connect_share.friends.share_worlds": "Meine Welten mit diesem Freund teilen", + "connect_share.friends.auto_join": "Diesen Freund automatisch beitreten lassen", + "connect_share.friends.auto_join.tooltip": "Überspringt die Freigabe nur, wenn die Mod die gespeicherte libp2p-Identität nachweist.", + "connect_share.friends.access": "Wenn diese Person beitreten möchte", + "connect_share.friends.access.ask_every_time": "Jedes Mal nachfragen", + "connect_share.friends.access.auto_accept": "Automatisch beitreten lassen", + "connect_share.friends.access.never_allow": "Beitritt nie erlauben", + "connect_share.privacy.title": "Privatsphäre", + "connect_share.privacy.description": "Lege fest, was bestätigte Freunde sehen. Andere Personen erhalten keine Informationen.", + "connect_share.privacy.online": "Anzeigen, dass ich online bin", + "connect_share.privacy.playing": "Anzeigen, wenn ich spiele", + "connect_share.privacy.current_server": "Server- oder Weltnamen anzeigen", + "connect_share.privacy.joinable": "Freunden Beitrittsanfragen erlauben", + "connect_share.privacy.confirmed_only": "Freundschaft, Entfernen und Blockieren werden vor Präsenz und Beitritt berücksichtigt.", + "connect_share.friends.save_changes": "Änderungen speichern", + "connect_share.friends.remove": "Freund entfernen", + "connect_share.friends.remove_confirm.title": "%s entfernen?", + "connect_share.friends.remove_confirm.message": "Diese Person wird nicht mehr automatisch vertraut. Du kannst ihren Freundeslink später erneut hinzufügen.", + "connect_share.friends.remove_confirm.confirm": "Freund entfernen", + "connect_share.friends.block": "Blockieren", + "connect_share.privacy.blocked": "Blockierte Identitäten (%s)", + "connect_share.privacy.blocked_title": "Blockierte Identitäten", + "connect_share.privacy.blocked_empty": "Du hast niemanden blockiert.", + "connect_share.privacy.unblock": "Entsperren", + "connect_share.friends.ready_lan": "%s · %s · direkt im LAN", + "connect_share.friends.ready_connect": "%s · %s · online über Connect", + "connect_share.friends.saved_connect": "%s · gespeichert · Connect-Fallback", + "connect_share.friends.saved_offline": "%s · keine Route verfügbar", + "connect_share.notification.join_request": "Beitrittsanfrage", + "connect_share.notification.join_request_detail": "%s möchte beitreten. Öffne Connect Share zum Annehmen oder Ablehnen.", + "connect_share.notification.friend_request": "Freundschaftsanfrage", + "connect_share.notification.friend_request_detail": "%s hat eine Freundschaftsanfrage gesendet. Öffne Connect Share zum Annehmen oder Ablehnen.", + "connect_share.notification.friend_online": "Welt eines Freundes ist bereit", + "connect_share.notification.friend_online_detail": "%s spielt %s. Öffne Freunde, um Beitritt anzufragen.", + "connect_share.notification.friend_accepted": "Freund hinzugefügt", + "connect_share.notification.friend_accepted_detail": "%s hat deine Freundschaftsanfrage angenommen.", + "connect_share.notification.friend_removed": "Freund entfernt", + "connect_share.notification.friend_removed_detail": "%s ist nicht mehr in deiner Freundesliste.", + "connect_share.notification.friend_playing": "Freund spielt", + "connect_share.notification.friend_playing_detail": "%s spielt auf %s. Öffne Freunde, um den Beitritt anzufragen.", + "connect_share.identity.manage": "Erweiterte Einstellungen…", + "connect_share.identity.title": "Connect-Endpunkt-Identität", + "connect_share.identity.current": "Endpunkt: %s", + "connect_share.identity.sources": "Endpunkt: %s · Zugang: %s", + "connect_share.identity.endpoint": "Vorhandener Endpunkt", + "connect_share.identity.token": "Endpunkt-Token", + "connect_share.identity.save": "Prüfen und speichern", + "connect_share.identity.choose_file": "token.json importieren…", + "connect_share.identity.reset": "Endpunkt-Identität zurücksetzen…", + "connect_share.identity.reset_confirm.title": "Connect-Identität zurücksetzen?", + "connect_share.identity.reset_confirm.message": "Die alte Beitrittsadresse und das Token werden nicht mehr verwendet. Dies kann nicht rückgängig gemacht werden.", + "connect_share.compatibility.title": "Andere Spieleinrichtung", + "connect_share.compatibility.copy_pack": "Modpack-Link des Hosts kopieren", + "connect_share.compatibility.pack_copied": "Modpack-Link kopiert", + "connect_share.compatibility.try_anyway": "Trotzdem versuchen", + "connect_share.diagnostics.copy": "Sichere Diagnose kopieren", + "connect_share.diagnostics.copied": "Diagnose kopiert", + "connect_share.friends.internet_direct": "Direkte Internetverbindungen für diesen Freund erlauben", + "connect_share.setup.friends_only": "Nur Freunde, die du bestätigst, können beitreten.", + "connect_share.setup.options.show": "Spieloptionen…", + "connect_share.setup.options.hide": "Spieloptionen ausblenden", + "connect_share.status.ready": "Freunde können diese Welt jetzt sehen und um Beitritt bitten.", + "connect_share.status.copy_friend_link": "Einladung für neuen Freund kopieren", + "connect_share.status.friend_link_copied": "Freundeseinladung kopiert", + "connect_share.status.connection_details.show": "Verbindungsdetails…", + "connect_share.status.connection_details.hide": "Verbindungsdetails ausblenden", + "connect_share.status.address_unavailable": "Serveradresse wird noch vorbereitet", + "connect_share.status.route_summary": "Verbindung: %s", + "connect_share.status.route.internet": "direkt, mit Connect als Ausweichroute", + "connect_share.status.route.lan_connect": "in der Nähe, mit Connect als Ausweichroute", + "connect_share.status.route.lan": "in der Nähe", + "connect_share.status.route.connect": "über Connect", + "connect_share.status.route.starting": "wird vorbereitet", + "connect_share.privacy.blocked_description": "Blockierte Personen können weder Anfragen senden noch deine Aktivität sehen.", + "connect_share.compatibility.description": "Eure Spieleinstellungen unterscheiden sich. Gleiche diese Punkte für einen zuverlässigen Beitritt ab.", + "connect_share.compatibility.more": "%s weitere Unterschiede", + "connect_share.identity.description": "Erweitert: Verwende einen Endpunkt aus dem Minekube-Dashboard. Dein Token bleibt auf diesem Gerät verborgen.", + "connect_share.identity.endpoint_hint": "dein-name.play.minekube.net", + "connect_share.identity.token_hint": "Endpunkt-Token einfügen", + "connect_share.page.previous": "Zurück", + "connect_share.page.next": "Weiter", + "connect_share.friends.summary.requests.one": "1 Anfrage wartet auf dich", + "connect_share.friends.summary.requests.many": "%s Anfragen warten auf dich", + "connect_share.friends.summary.welcome": "Spielt zusammen, ohne einen Server einzurichten.", + "connect_share.friends.summary.joinable.one": "1 Freund ist spielbereit", + "connect_share.friends.summary.joinable.many": "%s Freunde sind spielbereit", + "connect_share.friends.summary.online.one": "1 Freund ist online", + "connect_share.friends.summary.online.many": "%s Freunde sind online", + "connect_share.friends.summary.saved.one": "1 gespeicherter Freund", + "connect_share.friends.summary.saved.many": "%s gespeicherte Freunde", + "connect_share.friends.invite": "Freund einladen", + "connect_share.friends.manage_named": "%s verwalten", + "connect_share.friends.action.join_now": "Jetzt beitreten", + "connect_share.friends.action.ask_to_join": "Um Beitritt bitten", + "connect_share.friends.action.cancel_follow": "Abbrechen", + "connect_share.friends.action.join_when_ready": "Beitreten, sobald bereit", + "connect_share.friends.status.world": "Spielt %s", + "connect_share.friends.status.server": "Auf %s", + "connect_share.friends.status.ready": "Bereit zum Beitreten", + "connect_share.friends.status.online": "Online", + "connect_share.friends.status.saved": "Gespeicherter Freund", + "connect_share.friends.status.offline": "Offline", + "connect_share.friends.connection_options.show": "Verbindungsoptionen…", + "connect_share.friends.connection_options.hide": "Verbindungsoptionen ausblenden", + "connect_share.friends.internet_direct_short": "Schnellere Direktverbindungen", + "connect_share.friends.internet_direct.tooltip": "Optional. Teilt deine öffentliche IP-Adresse nur mit diesem Freund. Connect bleibt die Ausweichroute.", + "connect_share.ingress.online": "online", + "connect_share.ingress.nearby": "in der Nähe", + "connect_share.ingress.direct": "direkt", + "connect_share.compatibility.minecraft": "Minecraft: deins %s · Freund %s", + "connect_share.compatibility.loader": "Mod-Loader: deiner %s · Freund %s", + "connect_share.compatibility.install": "Installiere %s %s", + "connect_share.compatibility.host_missing": "Dein Freund benötigt %s %s", + "connect_share.compatibility.mod_version": "%s: deins %s · Freund %s", + "connect_share.menu.requests": "Freunde (%s)", + "connect_share.menu.ready": "Freunde (%s)", + "connect_share.friends.link": "Freundeslink", + "connect_share.friends.connection_options.title": "Verbindungsoptionen", + "connect_share.friends.connection_options.description": "Optionale Einstellungen für ungewöhnliche Konten oder schnellere Direktverbindungen.", + "connect_share.friends.connection_options.fallback": "Connect bleibt die Ausweichroute, wenn keine Direktverbindung verfügbar ist.", + "connect_share.identity.source.generated": "Auf diesem Gerät erstellt", + "connect_share.identity.source.imported": "Importiert", + "connect_share.identity.source.environment": "Vom Launcher verwaltet", + "connect_share.error.generic": "Connect Share konnte nicht aktualisiert werden", + "connect_share.error.identity_invalid": "Die Connect-Zugangsdaten sind ungültig", + "connect_share.error.identity_rejected": "Connect hat diese Zugangsdaten abgelehnt", + "connect_share.error.identity_network": "Connect konnte die Zugangsdaten nicht prüfen", + "connect_share.error.identity_managed": "Die Connect-Zugangsdaten werden von der Umgebung verwaltet", + "connect_share.error.identity_active": "Beende das Teilen, bevor du Connect-Zugangsdaten änderst", + "connect_share.error.preferences_save": "Die Connect-Share-Privatsphäre konnte nicht gespeichert werden", + "connect_share.error.share_already_active": "Connect Share ist bereits aktiv", + "connect_share.error.share_start_failed": "Connect Share konnte nicht gestartet werden", + "connect_share.error.share_stop_failed": "Connect Share wurde mit Bereinigungsfehlern beendet", + "connect_share.error.invalid_invitation": "Diese Connect-Share-Einladung ist ungültig", + "connect_share.error.invitation_malformed": "Diese Connect-Share-Einladung ist unvollständig oder fehlerhaft", + "connect_share.error.invitation_unsupported_version": "Diese Einladung verwendet das nicht unterstützte Share-Format %s", + "connect_share.error.invitation_expired": "Diese Connect-Share-Einladung ist abgelaufen — bitte um eine neue", + "connect_share.error.invitation_invalid_signature": "Diese Einladung konnte nicht verifiziert werden", + "connect_share.error.invitation_relay_forbidden": "Eine direkte Share-Einladung darf keine Relay-Route enthalten", + "connect_share.error.invitation_peer_mismatch": "Diese Einladung passt nicht zu dem Freund, der sie gesendet hat", + "connect_share.login.authentication_required": "Diese direkte Verbindung erfordert die Minecraft-Online-Authentifizierung", + "connect_share.login.approval_timed_out": "Der Host hat die Beitrittsanfrage nicht rechtzeitig beantwortet", + "connect_share.login.share_full": "Diese geteilte Welt ist voll", + "connect_share.login.sharing_stopped": "Der Host teilt diese Welt nicht mehr", + "connect_share.login.host_denied": "Der Host hat diese Verbindung abgelehnt", + "connect_share.error.invalid_friend_name": "Der Freundesname muss zwischen 1 und 64 Zeichen lang sein", + "connect_share.error.friend_identity_conflict": "Die Identität stimmt nicht mit dem gespeicherten Schlüssel überein", + "connect_share.error.friend_not_saved": "Dieser Freund ist nicht mehr gespeichert", + "connect_share.error.friend_blocked": "Diese Identität ist blockiert", + "connect_share.error.friends_load": "Gespeicherte Connect-Share-Freunde konnten nicht geladen werden", + "connect_share.error.friend_remove": "Dieser Connect-Share-Freund konnte nicht entfernt werden", + "connect_share.error.friend_block": "Diese Connect-Share-Identität konnte nicht blockiert werden", + "connect_share.error.friend_unblock": "Diese Connect-Share-Identität konnte nicht entsperrt werden", + "connect_share.error.join_peer_mismatch": "Der gefundene Host passt nicht zu dieser Einladung", + "connect_share.error.join_discovery_unavailable": "Automatische LAN-Suche ist nicht verfügbar", + "connect_share.error.join_no_route": "Keine Route zu dieser Connect-Share-Welt verfügbar", + "connect_share.error.identity_endpoint_conflict": "Dieses Profil verwendet denselben Connect-Endpunkt wie dein Freund", + "connect_share.error.friend_unreachable": "Dein Freund ist gerade nicht erreichbar", + "connect_share.error.friend_declined": "Dein Freund hat diese Anfrage abgelehnt", + "connect_share.error.friend_timed_out": "Dein Freund hat nicht rechtzeitig geantwortet", + "connect_share.error.friend_invalid_response": "Die Antwort auf die Freundschaftsanfrage war ungültig", + "connect_share.error.minecraft_version": "Eure Minecraft-Versionen stimmen nicht überein", + "connect_share.error.mod_loader": "Eure Mod-Loader stimmen nicht überein", + "connect_share.error.required_mods": "Die benötigten Mods stimmen nicht überein", + "connect_share.page.previous_tooltip": "Vorherige Seite · Seite %s von %s", + "connect_share.page.next_tooltip": "Nächste Seite · Seite %s von %s", + "connect_share.recovery.title": "Sichern & wiederherstellen", + "connect_share.recovery.menu": "Sicherung", + "connect_share.recovery.description": "Übertrage Freunde und deine Share-Identität auf ein anderes Gerät. Die verschlüsselte Sicherung bleibt offline.", + "connect_share.recovery.secret": "Wiederherstellungspasswort", + "connect_share.recovery.secret_confirm": "Passwort bestätigen", + "connect_share.recovery.warning": "Beim Wiederherstellen werden Freunde und Share-Identität dieses Geräts ersetzt. Beende zuerst das Teilen und starte Minecraft danach neu.", + "connect_share.recovery.export": "Freunde sichern", + "connect_share.recovery.import": "Sicherung wiederherstellen", + "connect_share.recovery.restore_confirm": "Ersetzen und wiederherstellen", + "connect_share.recovery.summary": "%s Einträge · Einstellungen: %s · Connect-Endpunkt: %s", + "connect_share.recovery.included": "enthalten", + "connect_share.recovery.not_included": "nicht enthalten", + "connect_share.recovery.exported": "Verschlüsselte Sicherung gespeichert. Bewahre das Passwort sicher auf.", + "connect_share.recovery.restored_restart": "Wiederherstellung abgeschlossen. Starte Minecraft vor dem Teilen oder Beitreten neu.", + "connect_share.recovery.error.secret_mismatch": "Die Wiederherstellungspasswörter stimmen nicht überein.", + "connect_share.recovery.error.weak_secret": "Verwende ein Wiederherstellungspasswort mit mindestens 12 Zeichen.", + "connect_share.recovery.error.authentication": "Falsches Passwort oder beschädigte Sicherung. Es wurde nichts geändert.", + "connect_share.recovery.error.unsupported": "Diese Sicherungsversion wird nicht unterstützt.", + "connect_share.recovery.error.too_large": "Diese Sicherung ist zu groß.", + "connect_share.recovery.error.invalid": "Dies ist keine gültige Connect-Share-Sicherung.", + "connect_share.recovery.error.nothing_to_export": "Es gibt noch keine vollständigen Freundesidentitäten zum Sichern.", + "connect_share.recovery.error.unsafe_files": "Die Sicherung wurde wegen einer unsicheren Share-Datei beendet.", + "connect_share.recovery.error.read": "Die ausgewählte Sicherung konnte nicht gelesen werden.", + "connect_share.recovery.error.write": "Die verschlüsselte Sicherung konnte nicht gespeichert werden.", + "connect_share.recovery.error.restore": "Die Wiederherstellung ist fehlgeschlagen; die vorherigen Daten dieses Geräts wurden wiederhergestellt.", + "connect_share.recovery.error.stop_sharing": "Beende das Teilen, bevor du eine Sicherung wiederherstellst." +} diff --git a/share/fabric-26.2/src/main/resources/assets/connect-share/lang/en_us.json b/share/fabric-26.2/src/main/resources/assets/connect-share/lang/en_us.json new file mode 100644 index 000000000..66fb81293 --- /dev/null +++ b/share/fabric-26.2/src/main/resources/assets/connect-share/lang/en_us.json @@ -0,0 +1,286 @@ +{ + "connect_share.menu.share": "Share world", + "connect_share.menu.active": "World shared", + "connect_share.menu.join": "Friends", + "connect_share.setup.title": "Play this world with friends", + "connect_share.setup.description": "Make this world joinable now. Saved friends will see it automatically.", + "connect_share.setup.max_guests": "Maximum guests", + "connect_share.setup.internet": "Allow faster direct internet connections", + "connect_share.setup.internet.tooltip": "Optional. Reveals your public IP address to invited modded guests. Connect remains the relay fallback.", + "connect_share.setup.persistence": "Sharing resumes automatically when you switch or reopen a world.", + "connect_share.setup.start": "Start sharing", + "connect_share.game_mode.survival": "Survival", + "connect_share.game_mode.creative": "Creative", + "connect_share.game_mode.adventure": "Adventure", + "connect_share.game_mode.spectator": "Spectator", + "connect_share.status.title": "Your world is ready", + "connect_share.status.address": "Ready through Connect · %s", + "connect_share.status.direct_only": "Ready for nearby friends", + "connect_share.status.copy_invitation": "Copy friend link", + "connect_share.status.copy_address": "Copy server address", + "connect_share.status.link_help": "Send the friend link once. After saving it, future worlds appear automatically.", + "connect_share.status.connection_help": "The best connection is chosen automatically: direct when possible, Connect otherwise.", + "connect_share.status.requests": "Requests waiting for you", + "connect_share.status.idle": "Not sharing", + "connect_share.status.starting": "Starting…", + "connect_share.status.active": "Active", + "connect_share.status.stopping": "Stopping…", + "connect_share.status.failed": "Could not start", + "connect_share.status.request": "%s wants to connect · %s", + "connect_share.status.friend_request": "%s sent a friend request · %s", + "connect_share.status.allow": "Accept", + "connect_share.status.deny": "Decline", + "connect_share.status.more": "%s more requests", + "connect_share.status.waiting": "No one is waiting for a response.", + "connect_share.status.stop": "Stop sharing with friends", + "connect_share.join.title": "Join Connect Share", + "connect_share.join.description": "Choose a nearby world or paste its signed invitation.", + "connect_share.join.invitation": "Connect Share invitation", + "connect_share.join.invitation_hint": "minekube://share/…", + "connect_share.join.scanning": "Scanning for nearby Connect Share worlds…", + "connect_share.join.discovered": "Nearby: %s", + "connect_share.join.offline": "Use an offline identity (unverified)", + "connect_share.join.offline.tooltip": "The host will see this identity as unverified and must approve it.", + "connect_share.join.internet": "Try a direct internet connection", + "connect_share.join.internet.tooltip": "Optional. Reveals your public IP address to the host. Connect remains the relay fallback.", + "connect_share.join.join": "Join", + "connect_share.friends.title": "Friends", + "connect_share.friends.page": "Page %s of %s", + "connect_share.friends.description": "See who is around and join them when they are ready.", + "connect_share.friends.summary.welcome": "Play together without setting up a server.", + "connect_share.friends.invite": "Invite a friend", + "connect_share.friends.copy_my_link": "Copy my friend link", + "connect_share.friends.copy_my_link.tooltip": "Includes a direct route so this person can reach you. Only send it to someone you trust.", + "connect_share.friends.copying_my_link": "Creating friend link…", + "connect_share.friends.my_link_copied": "Friend link copied", + "connect_share.friends.copy_my_link_failed": "Could not copy friend link", + "connect_share.friends.empty": "Invite someone or add their link to start playing together.", + "connect_share.friends.outgoing_request": "Waiting for %s", + "connect_share.friends.incoming_request": "%s wants to be friends · %s", + "connect_share.friends.incoming_join_request": "%s wants you to join · %s", + "connect_share.friends.outgoing_request_active": "Contacting %s…", + "connect_share.friends.retry_request": "Retry", + "connect_share.friends.request_sending": "Sending…", + "connect_share.friends.request_waiting": "Waiting…", + "connect_share.friends.request_cancelling": "Canceling…", + "connect_share.friends.request_failed": "Request could not be delivered. Your friend may be offline or not reachable directly yet.", + "connect_share.friends.request_accepted": "%s accepted your friend request.", + "connect_share.friends.cancel_request": "Cancel", + "connect_share.friends.manage": "Manage", + "connect_share.friends.manage_named": "Manage %s", + "connect_share.friends.action.join_now": "Join now", + "connect_share.friends.action.ask_to_join": "Ask to join", + "connect_share.friends.action.cancel_follow": "Cancel", + "connect_share.friends.action.join_when_ready": "Join when ready", + "connect_share.friends.status.world": "Playing %s", + "connect_share.friends.status.server": "On %s", + "connect_share.friends.status.ready": "Ready to join", + "connect_share.friends.status.online": "Online", + "connect_share.friends.status.saved": "Saved friend", + "connect_share.friends.status.offline": "Offline", + "connect_share.friends.request_join": "Request", + "connect_share.friends.follow": "Join when ready", + "connect_share.friends.cancel_follow": "Cancel follow", + "connect_share.notification.follow_waiting": "Following %s", + "connect_share.notification.follow_waiting_detail": "A join request will be sent when their next world is ready. Cancel from Friends.", + "connect_share.notification.follow_ready": "%s is ready", + "connect_share.notification.follow_ready_detail": "Open Friends to join now; active gameplay was not interrupted.", + "connect_share.notification.follow_expired": "Follow expired", + "connect_share.notification.follow_expired_detail": "%s did not become joinable in time.", + "connect_share.notification.follow_cancelled": "Follow cancelled", + "connect_share.notification.follow_cancelled_detail": "%s can no longer be followed because the friendship or access changed.", + "connect_share.notification.follow_failed": "Could not follow friend", + "connect_share.friends.playing_server": "%s · playing on %s", + "connect_share.friends.hosting_world": "%s · playing %s", + "connect_share.friends.online": "%s · online", + "connect_share.friends.friend_unreachable": "Your friend is not reachable over libp2p right now.", + "connect_share.friends.add": "Add from link", + "connect_share.friends.add_description": "Paste your friend's Connect Share link. Their name fills in automatically.", + "connect_share.friends.name": "Friend's name", + "connect_share.friends.name_hint": "Friend's name (filled from link)", + "connect_share.friends.save": "Save friend", + "connect_share.friends.send_request": "Send request", + "connect_share.friends.connecting_request": "Friend request to %s", + "connect_share.friends.join_once": "Join without adding", + "connect_share.friends.connection_options.show": "Connection options…", + "connect_share.friends.connection_options.hide": "Hide connection options", + "connect_share.friends.manage_title": "Manage %s", + "connect_share.friends.notify": "Notify me when their world is ready", + "connect_share.friends.share_worlds": "Share my worlds with this friend", + "connect_share.friends.auto_join": "Let this friend join automatically", + "connect_share.friends.auto_join.tooltip": "Skips the approval prompt only when their mod proves the saved libp2p identity.", + "connect_share.friends.access": "When they ask to join", + "connect_share.friends.access.ask_every_time": "Ask me every time", + "connect_share.friends.access.auto_accept": "Let them join automatically", + "connect_share.friends.access.never_allow": "Never allow joining", + "connect_share.privacy.title": "Privacy", + "connect_share.privacy.description": "Choose what confirmed friends can see. People who are not your friends receive nothing.", + "connect_share.privacy.online": "Show that I am online", + "connect_share.privacy.playing": "Show when I am playing", + "connect_share.privacy.current_server": "Show the server or world name", + "connect_share.privacy.joinable": "Let friends request to join", + "connect_share.privacy.confirmed_only": "Friend requests, removals, and blocks take effect before presence or joining.", + "connect_share.friends.save_changes": "Save changes", + "connect_share.friends.remove": "Remove friend", + "connect_share.friends.remove_confirm.title": "Remove %s?", + "connect_share.friends.remove_confirm.message": "They will no longer be trusted automatically. You can add their friend link again later.", + "connect_share.friends.remove_confirm.confirm": "Remove friend", + "connect_share.friends.block": "Block", + "connect_share.privacy.blocked": "Blocked identities (%s)", + "connect_share.privacy.blocked_title": "Blocked identities", + "connect_share.privacy.blocked_empty": "You have not blocked anyone.", + "connect_share.privacy.unblock": "Unblock", + "connect_share.friends.ready_lan": "%s · %s · direct LAN", + "connect_share.friends.ready_connect": "%s · %s · online via Connect", + "connect_share.friends.saved_connect": "%s · saved · Connect fallback", + "connect_share.friends.saved_offline": "%s · no route available", + "connect_share.notification.join_request": "Join request", + "connect_share.notification.join_request_detail": "%s wants to join. Open Connect Share to accept or decline.", + "connect_share.notification.friend_request": "Friend request", + "connect_share.notification.friend_request_detail": "%s sent a friend request. Open Connect Share to accept or decline.", + "connect_share.notification.friend_online": "Friend's world is ready", + "connect_share.notification.friend_online_detail": "%s is playing %s. Open Friends to request to join.", + "connect_share.notification.friend_accepted": "Friend added", + "connect_share.notification.friend_accepted_detail": "%s accepted your friend request.", + "connect_share.notification.friend_removed": "Friend removed", + "connect_share.notification.friend_removed_detail": "%s is no longer in your friends list.", + "connect_share.notification.friend_playing": "Friend is playing", + "connect_share.notification.friend_playing_detail": "%s is on %s. Open Friends to request to join.", + "connect_share.identity.manage": "Advanced settings…", + "connect_share.identity.title": "Connect endpoint identity", + "connect_share.identity.current": "Endpoint: %s", + "connect_share.identity.sources": "Endpoint: %s · Credential: %s", + "connect_share.identity.endpoint": "Existing endpoint", + "connect_share.identity.token": "Endpoint token", + "connect_share.identity.save": "Validate and save", + "connect_share.identity.choose_file": "Import token.json…", + "connect_share.identity.reset": "Reset endpoint identity…", + "connect_share.identity.reset_confirm.title": "Reset Connect identity?", + "connect_share.identity.reset_confirm.message": "The old join address and token will stop being used. This cannot be undone.", + "connect_share.compatibility.title": "Different game setup", + "connect_share.compatibility.copy_pack": "Copy the host's modpack link", + "connect_share.compatibility.pack_copied": "Modpack link copied", + "connect_share.compatibility.try_anyway": "Try anyway", + "connect_share.diagnostics.copy": "Copy safe diagnostics", + "connect_share.diagnostics.copied": "Diagnostics copied", + "connect_share.friends.internet_direct": "Allow direct internet routes for this friend", + "connect_share.friends.internet_direct_short": "Faster direct connections", + "connect_share.friends.internet_direct.tooltip": "Optional. Shares your public IP address only with this friend. Connect remains the fallback.", + "connect_share.ingress.online": "online", + "connect_share.ingress.nearby": "nearby", + "connect_share.ingress.direct": "direct", + "connect_share.compatibility.minecraft": "Minecraft: yours %s · friend's %s", + "connect_share.compatibility.loader": "Loader: yours %s · friend's %s", + "connect_share.compatibility.install": "Install %s %s", + "connect_share.compatibility.host_missing": "Your friend needs %s %s", + "connect_share.compatibility.mod_version": "%s: yours %s · friend's %s", + "connect_share.setup.friends_only": "Only friends you approve can join.", + "connect_share.setup.options.show": "Game options…", + "connect_share.setup.options.hide": "Hide game options", + "connect_share.status.ready": "Friends can now see this world and ask to join.", + "connect_share.status.copy_friend_link": "Copy invite for a new friend", + "connect_share.status.friend_link_copied": "Friend invite copied", + "connect_share.status.connection_details.show": "Connection details…", + "connect_share.status.connection_details.hide": "Hide connection details", + "connect_share.status.address_unavailable": "Server address is still starting", + "connect_share.status.route_summary": "Connection: %s", + "connect_share.status.route.internet": "direct, with Connect as fallback", + "connect_share.status.route.lan_connect": "nearby, with Connect as fallback", + "connect_share.status.route.lan": "nearby", + "connect_share.status.route.connect": "through Connect", + "connect_share.status.route.starting": "getting ready", + "connect_share.privacy.blocked_description": "Blocked people cannot send requests or see your activity.", + "connect_share.compatibility.description": "Your game setups differ. Match these items for the most reliable join.", + "connect_share.compatibility.more": "%s more differences", + "connect_share.identity.description": "Advanced: reuse an endpoint from the Minekube dashboard. Your token stays hidden on this device.", + "connect_share.identity.endpoint_hint": "your-name.play.minekube.net", + "connect_share.identity.token_hint": "Paste endpoint token", + "connect_share.page.previous": "Previous", + "connect_share.page.next": "Next", + "connect_share.friends.summary.requests.one": "1 request waiting for you", + "connect_share.friends.summary.requests.many": "%s requests waiting for you", + "connect_share.friends.summary.joinable.one": "1 friend ready to play", + "connect_share.friends.summary.joinable.many": "%s friends ready to play", + "connect_share.friends.summary.online.one": "1 friend online", + "connect_share.friends.summary.online.many": "%s friends online", + "connect_share.friends.summary.saved.one": "1 saved friend", + "connect_share.friends.summary.saved.many": "%s saved friends", + "connect_share.menu.requests": "Friends (%s)", + "connect_share.menu.ready": "Friends (%s)", + "connect_share.friends.link": "Friend link", + "connect_share.friends.connection_options.title": "Connection options", + "connect_share.friends.connection_options.description": "Optional choices for unusual accounts or faster direct routes.", + "connect_share.friends.connection_options.fallback": "Connect remains the fallback when a direct route is unavailable.", + "connect_share.identity.source.generated": "Generated on this device", + "connect_share.identity.source.imported": "Imported", + "connect_share.identity.source.environment": "Managed by launcher", + "connect_share.error.generic": "Could not update Connect Share", + "connect_share.error.identity_invalid": "Connect credentials are invalid", + "connect_share.error.identity_rejected": "Connect rejected these credentials", + "connect_share.error.identity_network": "Could not reach Connect to validate credentials", + "connect_share.error.identity_managed": "Connect credentials are managed by the environment", + "connect_share.error.identity_active": "Stop sharing before changing Connect credentials", + "connect_share.error.preferences_save": "Connect Share privacy settings could not be saved", + "connect_share.error.share_already_active": "Connect Share is already active", + "connect_share.error.share_start_failed": "Could not start Connect Share", + "connect_share.error.share_stop_failed": "Connect Share stopped with cleanup errors", + "connect_share.error.invalid_invitation": "This Connect Share invitation is invalid", + "connect_share.error.invitation_malformed": "This Connect Share invitation is incomplete or malformed", + "connect_share.error.invitation_unsupported_version": "This invitation uses unsupported Share format %s", + "connect_share.error.invitation_expired": "This Connect Share invitation has expired — ask for a new one", + "connect_share.error.invitation_invalid_signature": "This invitation could not be verified", + "connect_share.error.invitation_relay_forbidden": "A direct Share invitation cannot contain a relay route", + "connect_share.error.invitation_peer_mismatch": "This invitation does not match the friend who sent it", + "connect_share.login.authentication_required": "This direct connection requires Minecraft online authentication", + "connect_share.login.approval_timed_out": "The host did not answer the join request in time", + "connect_share.login.share_full": "This shared world is full", + "connect_share.login.sharing_stopped": "The host stopped sharing this world", + "connect_share.login.host_denied": "The host declined this connection", + "connect_share.error.invalid_friend_name": "Friend name must be between 1 and 64 characters", + "connect_share.error.friend_identity_conflict": "This friend identity does not match the saved key", + "connect_share.error.friend_not_saved": "This friend is no longer saved", + "connect_share.error.friend_blocked": "This identity is blocked", + "connect_share.error.friends_load": "Saved Connect Share friends could not be loaded", + "connect_share.error.friend_remove": "This Connect Share friend could not be removed", + "connect_share.error.friend_block": "This Connect Share identity could not be blocked", + "connect_share.error.friend_unblock": "This Connect Share identity could not be unblocked", + "connect_share.error.join_peer_mismatch": "The discovered host does not match this invitation", + "connect_share.error.join_discovery_unavailable": "Automatic LAN discovery is unavailable", + "connect_share.error.join_no_route": "No route to this Connect Share world is available", + "connect_share.error.identity_endpoint_conflict": "This profile uses the same Connect endpoint as your friend", + "connect_share.error.friend_unreachable": "Your friend is not reachable right now", + "connect_share.error.friend_declined": "Your friend declined this request", + "connect_share.error.friend_timed_out": "Your friend did not answer in time", + "connect_share.error.friend_invalid_response": "The friend request response was invalid", + "connect_share.error.minecraft_version": "Your Minecraft versions do not match", + "connect_share.error.mod_loader": "Your mod loaders do not match", + "connect_share.error.required_mods": "Your required mods do not match", + "connect_share.page.previous_tooltip": "Previous page · Page %s of %s", + "connect_share.page.next_tooltip": "Next page · Page %s of %s", + "connect_share.recovery.title": "Backup & restore", + "connect_share.recovery.menu": "Backup", + "connect_share.recovery.description": "Move your friends and Share identity to another device. The encrypted backup stays offline.", + "connect_share.recovery.secret": "Recovery password", + "connect_share.recovery.secret_confirm": "Confirm recovery password", + "connect_share.recovery.warning": "Restoring replaces this device’s friends and Share identity. Stop sharing first, then restart Minecraft after restoring.", + "connect_share.recovery.export": "Back up friends", + "connect_share.recovery.import": "Restore backup", + "connect_share.recovery.restore_confirm": "Replace and restore", + "connect_share.recovery.summary": "%s items · Preferences: %s · Connect endpoint: %s", + "connect_share.recovery.included": "included", + "connect_share.recovery.not_included": "not included", + "connect_share.recovery.exported": "Encrypted backup saved. Keep its password somewhere safe.", + "connect_share.recovery.restored_restart": "Restore complete. Restart Minecraft before sharing or joining.", + "connect_share.recovery.error.secret_mismatch": "The recovery passwords do not match.", + "connect_share.recovery.error.weak_secret": "Use a recovery password with at least 12 characters.", + "connect_share.recovery.error.authentication": "Wrong password or damaged backup. Nothing was changed.", + "connect_share.recovery.error.unsupported": "This backup version is not supported.", + "connect_share.recovery.error.too_large": "This backup is too large.", + "connect_share.recovery.error.invalid": "This is not a valid Connect Share backup.", + "connect_share.recovery.error.nothing_to_export": "There are no complete friend identities to back up yet.", + "connect_share.recovery.error.unsafe_files": "Backup stopped because a Share data file is unsafe.", + "connect_share.recovery.error.read": "The selected backup could not be read.", + "connect_share.recovery.error.write": "The encrypted backup could not be saved.", + "connect_share.recovery.error.restore": "Restore failed and this device’s previous data was recovered.", + "connect_share.recovery.error.stop_sharing": "Stop sharing before restoring a backup." +} diff --git a/share/fabric-26.2/src/main/resources/connect-share-fabric-26.2.mixins.json b/share/fabric-26.2/src/main/resources/connect-share-fabric-26.2.mixins.json new file mode 100644 index 000000000..bd97d04d1 --- /dev/null +++ b/share/fabric-26.2/src/main/resources/connect-share-fabric-26.2.mixins.json @@ -0,0 +1,22 @@ +{ + "required": true, + "minVersion": "0.8", + "package": "com.minekube.connect.share.fabric.v26_2.mixin", + "compatibilityLevel": "JAVA_25", + "mixins": [ + "ConnectionAccessor", + "ServerConnectionListenerAccessor", + "ServerConnectionListenerMixin", + "ServerLoginPacketListenerMixin" + ], + "client": [ + "IntegratedServerAccessor", + "IntegratedServerMixin", + "LanServerPingerAccessor", + "PauseScreenMixin", + "TitleScreenMixin" + ], + "injectors": { + "defaultRequire": 1 + } +} diff --git a/share/fabric-26.2/src/main/resources/fabric.mod.json b/share/fabric-26.2/src/main/resources/fabric.mod.json new file mode 100644 index 000000000..a2f169fec --- /dev/null +++ b/share/fabric-26.2/src/main/resources/fabric.mod.json @@ -0,0 +1,26 @@ +{ + "schemaVersion": 1, + "id": "connect-share", + "version": "${version}", + "name": "Connect Share", + "description": "Share a private Minecraft world through Minekube Connect.", + "environment": "client", + "entrypoints": { + "client": [ + { + "adapter": "kotlin", + "value": "com.minekube.connect.share.fabric.v26_2.ConnectShare262Client" + } + ] + }, + "mixins": [ + "connect-share-fabric-26.2.mixins.json" + ], + "depends": { + "fabricloader": ">=0.19.3", + "fabric-api": "*", + "fabric-language-kotlin": ">=1.13.13", + "minecraft": "26.2", + "java": ">=25" + } +} diff --git a/share/fabric-26.2/src/test/kotlin/com/minekube/connect/share/fabric/v26_2/ConnectGameProfileMapperTest.kt b/share/fabric-26.2/src/test/kotlin/com/minekube/connect/share/fabric/v26_2/ConnectGameProfileMapperTest.kt new file mode 100644 index 000000000..086dc3cc8 --- /dev/null +++ b/share/fabric-26.2/src/test/kotlin/com/minekube/connect/share/fabric/v26_2/ConnectGameProfileMapperTest.kt @@ -0,0 +1,34 @@ +package com.minekube.connect.share.fabric.v26_2 + +import com.minekube.connect.api.player.GameProfile as ConnectGameProfile +import java.util.UUID +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ConnectGameProfileMapperTest { + @Test + fun `preserves signed and unsigned profile properties`() { + val id = UUID.randomUUID() + val mapped = ConnectGameProfileMapper.toMinecraft( + ConnectGameProfile( + "Robin", + id, + listOf( + ConnectGameProfile.Property("textures", "skin", "signature"), + ConnectGameProfile.Property("badge", "value", ""), + ), + ), + ).getOrNull() + requireNotNull(mapped) + + assertEquals(id, mapped.id()) + assertEquals("Robin", mapped.name()) + val texture = mapped.properties()["textures"].single() + val badge = mapped.properties()["badge"].single() + assertTrue(texture.hasSignature()) + assertEquals("signature", texture.signature()) + assertFalse(badge.hasSignature()) + } +} diff --git a/share/fabric-26.2/src/test/kotlin/com/minekube/connect/share/fabric/v26_2/Fabric262ArtifactTest.kt b/share/fabric-26.2/src/test/kotlin/com/minekube/connect/share/fabric/v26_2/Fabric262ArtifactTest.kt new file mode 100644 index 000000000..8240cf673 --- /dev/null +++ b/share/fabric-26.2/src/test/kotlin/com/minekube/connect/share/fabric/v26_2/Fabric262ArtifactTest.kt @@ -0,0 +1,442 @@ +package com.minekube.connect.share.fabric.v26_2 + +import com.minekube.connect.share.ShareCoordinator +import com.minekube.connect.share.fabric.ConnectShareClient +import com.minekube.connect.tunnel.p2p.DirectP2pNode +import com.minekube.connect.tunnel.p2p.Libp2pEndpoint +import com.minekube.connect.tunnel.p2p.Libp2pTunnelTransport +import java.lang.reflect.Proxy +import java.net.URLClassLoader +import java.nio.file.Files +import java.nio.file.Path +import java.time.Duration +import java.util.jar.JarInputStream +import java.util.jar.JarFile +import kotlin.io.path.name +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class Fabric262ArtifactTest { + @Test + fun `pause menu keeps Share before the destructive disconnect action`() { + JarFile(artifact().toFile()).use { jar -> + val mixin = jar.getJarEntry( + "com/minekube/connect/share/fabric/v26_2/mixin/" + + "PauseScreenMixin.class", + ) + assertNotNull(mixin) + val bytecode = jar.getInputStream(mixin).use { + it.readBytes().toString(Charsets.ISO_8859_1) + } + assertTrue("removeWidget" in bytecode) + } + } + + @Test + fun `artifact uses a friends first sharing vocabulary`() { + JarFile(artifact().toFile()).use { jar -> + val language = jar.getInputStream( + jar.getJarEntry( + "assets/connect-share/lang/en_us.json", + ), + ).bufferedReader().use { it.readText() } + + assertTrue( + "\"connect_share.setup.title\": \"Play this world with friends\"" in + language, + ) + assertTrue( + "\"connect_share.status.copy_friend_link\": " + + "\"Copy invite for a new friend\"" in language, + ) + assertTrue( + "\"connect_share.friends.copy_my_link\": " + + "\"Copy my friend link\"" in language, + ) + assertTrue( + "\"connect_share.friends.send_request\": " + + "\"Send request\"" in language, + ) + assertTrue( + "\"connect_share.friends.outgoing_request\": " + + "\"Waiting for %s\"" in language, + ) + assertTrue( + "\"connect_share.friends.incoming_request\": " + + "\"%s wants to be friends · %s\"" in language, + ) + assertTrue( + "\"connect_share.friends.retry_request\": \"Retry\"" in + language, + ) + assertTrue( + "\"connect_share.friends.cancel_request\": \"Cancel\"" in + language, + ) + assertTrue( + "\"connect_share.status.allow\": \"Accept\"" in language, + ) + assertTrue( + "\"connect_share.status.deny\": \"Decline\"" in language, + ) + assertFalse("connect_share.friends.accept_request" in language) + } + } + + @Test + fun `friend removal confirmation stays inside the friends screen`() { + JarFile(artifact().toFile()).use { jar -> + val bytecode = jar.entries().asSequence() + .filter { + it.name.startsWith( + "com/minekube/connect/share/fabric/v26_2/" + + "ShareJoinScreen", + ) && it.name.endsWith(".class") + } + .joinToString { + jar.getInputStream(it).use { stream -> + stream.readBytes().toString(Charsets.ISO_8859_1) + } + } + + assertFalse( + "net/minecraft/client/gui/screens/ConfirmScreen" in bytecode, + ) + assertTrue( + "connect_share.friends.remove_confirm.confirm" in bytecode, + ) + assertTrue("sendRequest" in bytecode) + assertTrue("suggestedDisplayName" in bytecode) + assertTrue("FriendRequestClient" in bytecode) + assertTrue("getIncomingRequests" in bytecode) + assertTrue("connect_share.status.allow" in bytecode) + assertTrue("connect_share.status.deny" in bytecode) + assertTrue("joinOutgoing" !in bytecode) + assertFalse("connect_share.friends.accept_request" in bytecode) + } + } + + @Test + fun `approved card exchange promotes an outgoing request`() { + JarFile(artifact().toFile()).use { jar -> + val bytecode = jar.entries().asSequence() + .filter { + it.name.startsWith( + "com/minekube/connect/share/fabric/v26_2/" + + "FriendCardNetworking", + ) && it.name.endsWith(".class") + } + .joinToString { + jar.getInputStream(it).use { input -> + input.readBytes().toString(Charsets.ISO_8859_1) + } + } + + assertTrue("confirmOutgoing" in bytecode) + } + } + + @Test + fun `artifact is self contained and isolates networking runtime`() { + JarFile(artifact().toFile()).use { jar -> + val entries = jar.entries().asSequence().map { it.name }.toSet() + + assertTrue("fabric.mod.json" in entries) + assertTrue("LICENSE" in entries) + assertTrue("connect-share-fabric-26.2.mixins.json" in entries) + assertTrue( + "com/minekube/connect/share/fabric/v26_2/" + + "FriendCardNetworking.class" in entries, + ) + assertTrue( + entries.any { + it.startsWith("com/minekube/connect/share/") && + it.endsWith(".class") + }, + ) + assertTrue("META-INF/connect/libp2p-runtime.jar" in entries) + assertFalse(entries.any { it.startsWith("io/libp2p/") }) + assertFalse(entries.any { it.startsWith("io/netty/") }) + assertFalse(entries.any { it.startsWith("it/unimi/dsi/fastutil/") }) + assertFalse(entries.any { it.startsWith("kotlin/") }) + assertTrue( + entries.any { + it.startsWith( + "com/minekube/connect/shadow/it/unimi/dsi/fastutil/", + ) + }, + ) + + val payload = jar.getJarEntry("META-INF/connect/libp2p-runtime.jar") + val payloadEntries = JarInputStream(jar.getInputStream(payload)).use { nested -> + generateSequence(nested::getNextJarEntry).map { it.name }.toSet() + } + assertTrue(payloadEntries.any { it.startsWith("io/libp2p/") }) + assertTrue(payloadEntries.any { it.startsWith("io/netty/") }) + assertTrue(payloadEntries.any { it.startsWith("kotlin/") }) + assertFalse( + payloadEntries.any { + it.startsWith("org/bouncycastle/pqc/") || + (it.startsWith("META-INF/versions/") && + "/org/bouncycastle/pqc/" in it) + }, + ) + assertTrue( + "com/minekube/connect/tunnel/p2p/DirectP2pNodeRuntime.class" in + payloadEntries, + ) + } + } + + @Test + fun `artifact stays within the adoption download budget`() { + val bytes = Files.size(artifact()) + + assertTrue( + bytes <= MAX_ARTIFACT_BYTES, + "Connect Share artifact is $bytes bytes; budget is " + + "$MAX_ARTIFACT_BYTES bytes", + ) + } + + @Test + fun `minecraft profile mapper preserves Mojang Guava ABI`() { + JarFile(artifact().toFile()).use { jar -> + val factory = jar.getJarEntry( + "com/minekube/connect/share/fabric/v26_2/" + + "MinecraftGameProfileFactory.class", + ) + assertNotNull(factory) + + val bytecode = jar.getInputStream(factory).use { + it.readBytes().toString(Charsets.ISO_8859_1) + } + assertTrue("com/google/common/collect/Multimap" in bytecode) + assertTrue( + "(Lcom/google/common/collect/Multimap;)V" in bytecode, + ) + assertFalse( + "com/minekube/connect/shadow/com/google/common" in bytecode, + ) + } + } + + @Test + fun `mixin redirects the two argument 262 publish overload`() { + JarFile(artifact().toFile()).use { jar -> + val mixin = jar.getJarEntry( + "com/minekube/connect/share/fabric/v26_2/mixin/" + + "IntegratedServerMixin.class", + ) + assertNotNull(mixin) + val bytecode = jar.getInputStream(mixin).use { + it.readBytes().toString(Charsets.ISO_8859_1) + } + assertTrue( + "publishServer(Lnet/minecraft/server/MinecraftServer\$MultiplayerScope;I)Z" in + bytecode, + ) + assertFalse( + "publishServer(Lnet/minecraft/server/MinecraftServer\$MultiplayerScope;" + + "Lnet/minecraft/world/level/GameType;ZI)Z" in + bytecode, + ) + } + } + + @Test + fun `packaged loader reads libp2p only from child payload`() { + URLClassLoader( + arrayOf(artifact().toUri().toURL()), + ClassLoader.getPlatformClassLoader(), + ).use { artifactLoader -> + val loaderType = Class.forName( + "com.minekube.connect.tunnel.p2p.Libp2pRuntimeLoader", + true, + artifactLoader, + ) + val loaderMethod = loaderType.getDeclaredMethod("classLoader") + .apply { isAccessible = true } + val runtimeLoader = loaderMethod.invoke(null) as ClassLoader + try { + val host = Class.forName( + "io.libp2p.core.Host", + false, + runtimeLoader, + ) + val directRuntime = Class.forName( + "com.minekube.connect.tunnel.p2p.DirectP2pNodeRuntime", + false, + runtimeLoader, + ) + assertTrue(host.classLoader === runtimeLoader) + assertTrue(directRuntime.classLoader === runtimeLoader) + val directCodeSource = + directRuntime.protectionDomain.codeSource.location + assertNotNull(directCodeSource) + assertTrue( + directCodeSource.toString().contains("libp2p-runtime-"), + ) + val nodeType = Class.forName( + "com.minekube.connect.tunnel.p2p.DirectP2pNode", + true, + artifactLoader, + ) + val node = nodeType.getDeclaredConstructor().newInstance() + nodeType.getMethod("close").invoke(node) + } finally { + loaderType.getDeclaredMethod("close") + .apply { isAccessible = true } + .invoke(null) + } + } + } + + @Test + fun `packaged runtime starts two peers and inspects a published world`() { + URLClassLoader( + arrayOf(artifact().toUri().toURL()), + ClassLoader.getPlatformClassLoader(), + ).use { artifactLoader -> + val loaderType = Class.forName( + "com.minekube.connect.tunnel.p2p.Libp2pRuntimeLoader", + true, + artifactLoader, + ) + val nodeType = Class.forName( + "com.minekube.connect.tunnel.p2p.DirectP2pNode", + true, + artifactLoader, + ) + val configType = Class.forName( + "com.minekube.connect.tunnel.p2p.DirectP2pHostConfig", + true, + artifactLoader, + ) + val handlerType = Class.forName( + "com.minekube.connect.tunnel.p2p.DirectP2pHostHandler", + true, + artifactLoader, + ) + val hostInfoType = Class.forName( + "com.minekube.connect.tunnel.p2p.DirectP2pHostInfo", + true, + artifactLoader, + ) + val discoveredType = Class.forName( + "com.minekube.connect.tunnel.p2p.DirectP2pDiscoveredShare", + true, + artifactLoader, + ) + val host = nodeType.getDeclaredConstructor().newInstance() + val guest = nodeType.getDeclaredConstructor().newInstance() + try { + val config = configType.getDeclaredConstructor( + String::class.java, + String::class.java, + String::class.java, + Boolean::class.javaPrimitiveType, + ).newInstance( + "packaged-share", + "packaged-capability-123456789", + "Packaged world", + false, + ) + val handler = Proxy.newProxyInstance( + artifactLoader, + arrayOf(handlerType), + ) { _, _, _ -> java.net.Socket() } + val hostInfo = nodeType.getMethod( + "startHost", + configType, + handlerType, + ).invoke(host, config, handler) + nodeType.getMethod("publish", String::class.java).invoke( + host, + "minekube://share/packaged-runtime", + ) + @Suppress("UNCHECKED_CAST") + val lanAddresses = hostInfoType.getMethod("lanAddresses") + .invoke(hostInfo) as List + assertTrue(lanAddresses.isNotEmpty()) + + val discovered = nodeType.getMethod( + "inspect", + String::class.java, + Duration::class.java, + ).invoke( + guest, + lanAddresses.first(), + Duration.ofSeconds(3), + ) + assertTrue( + discoveredType.getMethod("displayName") + .invoke(discovered) == "Packaged world", + ) + } finally { + nodeType.getMethod("close").invoke(guest) + nodeType.getMethod("close").invoke(host) + loaderType.getDeclaredMethod("close") + .apply { isAccessible = true } + .invoke(null) + } + } + } + + @Test + fun `parent facing APIs do not expose isolated runtime types`() { + listOf( + ConnectShareClient::class.java, + ShareCoordinator::class.java, + DirectP2pNode::class.java, + Libp2pEndpoint::class.java, + Libp2pTunnelTransport::class.java, + ).forEach(::assertParentFacingTypes) + } + + private fun assertParentFacingTypes(type: Class<*>) { + val exposed = buildList { + type.declaredFields.forEach { add(it.type.name) } + type.declaredConstructors.forEach { constructor -> + constructor.parameterTypes.forEach { add(it.name) } + } + type.declaredMethods.forEach { method -> + add(method.returnType.name) + method.parameterTypes.forEach { add(it.name) } + } + } + val invalid = exposed.filter { name -> + FORBIDDEN_TYPE_PREFIXES.any(name::startsWith) + } + assertTrue( + invalid.isEmpty(), + "${type.name} exposes isolated runtime types: $invalid", + ) + } + + private fun artifact(): Path { + val explicit = System.getProperty("connectShareArtifact") + if (explicit != null) { + return Path.of(explicit) + } + val directory = Path.of("build", "libs") + return Files.list(directory).use { paths -> + paths.filter { + it.name.startsWith("connect-share-fabric-26.2-") && + it.name.endsWith(".jar") && + !it.name.contains("sources") && + !it.name.contains("dev") + }.findFirst().orElse(null) + }.also(::assertNotNull) + } + + private companion object { + const val MAX_ARTIFACT_BYTES = 63L * 1024L * 1024L + val FORBIDDEN_TYPE_PREFIXES = listOf( + "io.libp2p.", + "io.netty.", + ) + } +} diff --git a/share/fabric-26.2/src/test/kotlin/com/minekube/connect/share/fabric/v26_2/FriendCardPayloadTest.kt b/share/fabric-26.2/src/test/kotlin/com/minekube/connect/share/fabric/v26_2/FriendCardPayloadTest.kt new file mode 100644 index 000000000..12e48bee9 --- /dev/null +++ b/share/fabric-26.2/src/test/kotlin/com/minekube/connect/share/fabric/v26_2/FriendCardPayloadTest.kt @@ -0,0 +1,42 @@ +package com.minekube.connect.share.fabric.v26_2 + +import io.netty.buffer.Unpooled +import java.util.UUID +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertSame +import net.minecraft.network.FriendlyByteBuf + +class FriendCardPayloadTest { + @Test + fun `signed friend card survives the network payload codec`() { + val invitation = "minekube://share/" + "signed-card".repeat(500) + val relationshipId = UUID.randomUUID() + val buffer = FriendlyByteBuf(Unpooled.buffer()) + + FriendCardPayload.CODEC.encode( + buffer, + FriendCardPayload(invitation, relationshipId), + ) + + val decoded = FriendCardPayload.CODEC.decode(buffer) + assertEquals(invitation, decoded.invitation) + assertEquals(relationshipId, decoded.relationshipId) + } + + @Test + fun `friend card request has a zero data payload`() { + val buffer = FriendlyByteBuf(Unpooled.buffer()) + + FriendCardRequestPayload.CODEC.encode( + buffer, + FriendCardRequestPayload, + ) + + assertEquals(0, buffer.readableBytes()) + assertSame( + FriendCardRequestPayload, + FriendCardRequestPayload.CODEC.decode(buffer), + ) + } +} diff --git a/share/fabric-26.2/src/test/kotlin/com/minekube/connect/share/fabric/v26_2/Minecraft262BridgeTest.kt b/share/fabric-26.2/src/test/kotlin/com/minekube/connect/share/fabric/v26_2/Minecraft262BridgeTest.kt new file mode 100644 index 000000000..215e4353b --- /dev/null +++ b/share/fabric-26.2/src/test/kotlin/com/minekube/connect/share/fabric/v26_2/Minecraft262BridgeTest.kt @@ -0,0 +1,100 @@ +package com.minekube.connect.share.fabric.v26_2 + +import com.minekube.connect.share.ShareGameMode +import com.minekube.connect.share.ShareOptions +import io.netty.channel.Channel +import io.netty.channel.ChannelInitializer +import io.netty.channel.DefaultEventLoopGroup +import io.netty.channel.EventLoopGroup +import io.netty.channel.local.LocalAddress +import java.net.InetAddress +import java.net.InetSocketAddress +import java.net.SocketAddress +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.test.assertTrue +import kotlinx.coroutines.runBlocking + +class Minecraft262BridgeTest { + @Test + fun `matches the cross-version private bridge contract`() = runBlocking { + val transport = FakeMinecraftTransport() + val bridge = Minecraft262Bridge(transport, FakeLocalChannelBinder()) + + val first = bridge.open(options) + assertTrue(transport.boundAddress.address.isLoopbackAddress) + assertIs(first.address) + assertEquals(2, transport.listenerCount) + assertFailsWith { + bridge.open(options) + } + first.close() + + val second = bridge.open(options) + assertTrue(transport.boundAddress.address.isLoopbackAddress) + second.close() + + assertEquals(2, transport.publishCount) + assertEquals(-1, transport.publishedPort) + assertEquals(0, transport.listenerCount) + } + + private class FakeMinecraftTransport : Minecraft262Transport { + var publishedPort = -1 + var listenerCount = 0 + var publishCount = 0 + lateinit var boundAddress: InetSocketAddress + val eventLoopGroup: EventLoopGroup = DefaultEventLoopGroup(1) + + override fun publish(options: ShareOptions): PublishedMinecraftTransport { + check(publishedPort == -1) + publishCount++ + boundAddress = InetSocketAddress(InetAddress.getLoopbackAddress(), 25565) + publishedPort = boundAddress.port + listenerCount++ + return object : PublishedMinecraftTransport { + override val address = boundAddress + override val childInitializer = NoopInitializer + override val eventLoopGroup = this@FakeMinecraftTransport.eventLoopGroup + + override fun addLocalListener(listener: LocalShareChannel) { + listenerCount++ + } + + override fun removeLocalListener(listener: LocalShareChannel) { + listenerCount-- + } + + override fun close() { + if (publishedPort != -1) { + publishedPort = -1 + listenerCount-- + } + } + } + } + } + + private class FakeLocalChannelBinder : LocalShareChannelBinder { + override fun bind( + childInitializer: ChannelInitializer, + eventLoopGroup: EventLoopGroup, + ): LocalShareChannel = object : LocalShareChannel { + override val address: SocketAddress = LocalAddress("connect-share-26-2") + override fun close() = Unit + } + } + + private object NoopInitializer : ChannelInitializer() { + override fun initChannel(channel: Channel) = Unit + } + + private companion object { + val options = ShareOptions( + gameMode = ShareGameMode.SURVIVAL, + allowCheats = false, + ) + } +} diff --git a/share/fabric-common/build.gradle.kts b/share/fabric-common/build.gradle.kts new file mode 100644 index 000000000..c3354ae2f --- /dev/null +++ b/share/fabric-common/build.gradle.kts @@ -0,0 +1,40 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + `java-library` + id("org.jetbrains.kotlin.jvm") +} + +java { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } +} + +kotlin { + jvmToolchain(21) + compilerOptions.jvmTarget.set(JvmTarget.JVM_17) +} + +dependencies { + implementation(projects.core) + implementation(projects.share.common) + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:${Versions.coroutinesVersion}") + implementation(platform("io.arrow-kt:arrow-stack:${Versions.arrowVersion}")) + implementation("io.arrow-kt:arrow-core") + implementation("io.arrow-kt:arrow-fx-coroutines") + implementation("com.google.protobuf:protobuf-java:${Versions.protocVersion}") + implementation("io.grpc:grpc-protobuf:${Versions.gRPCVersion}") + implementation("com.squareup.okhttp3:okhttp:4.9.3") + + testImplementation(kotlin("test")) + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:${Versions.coroutinesVersion}") + testImplementation("com.squareup.okhttp3:mockwebserver:4.9.3") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") +} + +tasks.test { + useJUnitPlatform() +} diff --git a/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/ApprovedJoinTracker.kt b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/ApprovedJoinTracker.kt new file mode 100644 index 000000000..b8d865533 --- /dev/null +++ b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/ApprovedJoinTracker.kt @@ -0,0 +1,116 @@ +package com.minekube.connect.share.fabric + +import com.minekube.connect.share.admission.AdmissionAnswer +import com.minekube.connect.share.admission.AdmissionIdentity +import com.minekube.connect.share.direct.ShareInviteCodec +import java.time.Instant +import java.util.Locale +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap + +data class ApprovedJoinProof( + val authenticatedMinecraftUuid: UUID?, +) + +class ApprovedJoinTracker( + private val nowMillis: () -> Long = System::currentTimeMillis, +) { + private val approved = + ConcurrentHashMap() + + fun record( + identity: AdmissionIdentity, + answer: AdmissionAnswer, + ) { + if (answer != AdmissionAnswer.ALLOW) { + return + } + val now = nowMillis() + approved.entries.removeIf { + now - it.value.approvedAtMillis > PROOF_LIFETIME_MILLIS + } + approved[ + PlayerKey(identity.name.normalized(), identity.uuid), + ] = TimedProof( + proof = ApprovedJoinProof( + authenticatedMinecraftUuid = + (identity as? AdmissionIdentity.Authenticated)?.uuid, + ), + directPeerId = identity.directPeerId, + approvedAtMillis = now, + ) + } + + fun hasProof( + name: String, + uuid: UUID, + ): Boolean { + val key = PlayerKey(name.normalized(), uuid) + val timedProof = approved[key] ?: return false + if ( + nowMillis() - timedProof.approvedAtMillis > + PROOF_LIFETIME_MILLIS + ) { + approved.remove(key, timedProof) + return false + } + return timedProof.directPeerId != null + } + + fun consume( + name: String, + uuid: UUID, + invitation: String, + ): ApprovedJoinProof? { + val timedProof = approved.remove( + PlayerKey(name.normalized(), uuid), + ) ?: return null + val now = nowMillis() + if ( + now - timedProof.approvedAtMillis > + PROOF_LIFETIME_MILLIS + ) { + return null + } + val expectedPeerId = timedProof.directPeerId ?: return null + val invitationPeerId = ShareInviteCodec.decode( + invitation, + Instant.ofEpochMilli(now), + ).getOrNull()?.payload?.peerId ?: return null + return timedProof.proof.takeIf { invitationPeerId == expectedPeerId } + } + + fun revokeDirectPeer( + peerId: String, + minecraftUuid: UUID? = null, + ): Int { + val matches = approved.entries.filter { + it.value.directPeerId == peerId || + ( + it.value.directPeerId == null && + minecraftUuid != null && + it.key.uuid == minecraftUuid + ) + } + matches.forEach { approved.remove(it.key, it.value) } + return matches.size + } + + private fun String.normalized(): String = + lowercase(Locale.ROOT) + + private data class PlayerKey( + val name: String, + val uuid: UUID, + ) + + private data class TimedProof( + val proof: ApprovedJoinProof, + val directPeerId: String?, + val approvedAtMillis: Long, + ) + + private companion object { + const val PROOF_LIFETIME_MILLIS = 120_000L + } +} diff --git a/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/ConnectControlPlane.kt b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/ConnectControlPlane.kt new file mode 100644 index 000000000..8bbc31119 --- /dev/null +++ b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/ConnectControlPlane.kt @@ -0,0 +1,64 @@ +package com.minekube.connect.share.fabric + +import com.minekube.connect.share.identity.EndpointIdentity +import java.net.SocketAddress +import java.util.concurrent.atomic.AtomicReference +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class ConnectControlPlane( + private val scope: CoroutineScope, + private val ingress: PersistentConnectIngress, + private val identity: suspend () -> EndpointIdentity, + private val target: SocketAddress, + private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, + private val failureReporter: (String) -> Unit = {}, +) { + private val startJob = AtomicReference() + + val state = ingress.state + + fun start() { + if (state.value == PersistentConnectState.Closed) { + return + } + val launched = scope.launch( + context = ioDispatcher, + start = CoroutineStart.LAZY, + ) { + val result = ingress.startControl(identity(), target) + result.leftOrNull()?.let { + failureReporter(it.safeMessage) + } + } + if (!startJob.compareAndSet(null, launched)) { + launched.cancel() + return + } + launched.invokeOnCompletion { + startJob.compareAndSet(launched, null) + } + launched.start() + } + + suspend fun shutdown() { + startJob.getAndSet(null)?.cancelAndJoin() + withContext(ioDispatcher) { + ingress.shutdown() + } + } + + suspend fun restart() { + startJob.getAndSet(null)?.cancelAndJoin() + withContext(ioDispatcher) { + ingress.restart() + } + start() + } +} diff --git a/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/ConnectShareClient.kt b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/ConnectShareClient.kt new file mode 100644 index 000000000..1a1bf347b --- /dev/null +++ b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/ConnectShareClient.kt @@ -0,0 +1,273 @@ +package com.minekube.connect.share.fabric + +import com.minekube.connect.share.ShareState +import com.minekube.connect.share.ShareConnectionGateway +import com.minekube.connect.share.fabric.ui.ShareViewModel +import com.minekube.connect.share.fabric.ui.FriendsViewModel +import com.minekube.connect.share.fabric.ui.menuLabel +import com.minekube.connect.share.fabric.ui.overview +import com.minekube.connect.share.fabric.recovery.RecoveryViewModel + +fun interface ConnectShareScreenFactory { + fun open(parent: Any, active: Boolean) +} + +fun interface ConnectShareGuestScreenFactory { + fun open( + parent: Any, + browser: FabricShareBrowser, + activity: FriendActivityMonitor, + ) +} + +data class ConnectShareInstallation( + val viewModel: ShareViewModel, + val friendsViewModel: FriendsViewModel, + val recoveryViewModel: RecoveryViewModel, + val runtime: ConnectShareRuntime, + val friendCardIssuer: FriendCardIssuer, + val friendCardReceiver: FriendCardReceiver, + val friendRequestClient: FriendRequestClient, + val friendPairingClient: FriendPairingClient, + val friendJoinOrchestrator: FriendJoinOrchestrator, + val diagnostics: ShareJoinDiagnostics, + val minecraftVersion: String, + val modVersion: String, + val approvedJoins: ApprovedJoinTracker, + val controlPlane: ConnectControlPlane, + val directControlPlane: DirectControlPlane, + val browser: FabricShareBrowser, + val friendActivity: FriendActivityMonitor, + val gateway: ShareConnectionGateway, + val ownConnectAddress: () -> String, + val screens: ConnectShareScreenFactory, + val guestScreens: ConnectShareGuestScreenFactory, +) + +object ConnectShareClient { + @Volatile + private var installation: ConnectShareInstallation? = null + private val guestLease = GuestConnectionLease() + private val friendCardConsent = FriendCardExchangeConsent() + + fun install(value: ConnectShareInstallation) { + check(installation == null) { + "Connect Share client is already installed" + } + installation = value + } + + @JvmStatic + fun isInstalled(): Boolean = installation != null + + @JvmStatic + fun pauseButtonTranslationKey(): String = + if (isShareActive()) { + "connect_share.menu.active" + } else { + "connect_share.menu.share" + } + + @JvmStatic + fun friendsButtonTranslationKey(): String = installation + ?.friendsViewModel + ?.state + ?.value + ?.overview() + ?.menuLabel() + ?.translationKey + ?: "connect_share.menu.join" + + @JvmStatic + fun friendsButtonCount(): Int = installation + ?.friendsViewModel + ?.state + ?.value + ?.overview() + ?.menuLabel() + ?.count + ?: 0 + + @JvmStatic + fun openPauseScreen(parent: Any) { + installation?.let { installed -> + installed.screens.open(parent, isShareActive()) + } + } + + @JvmStatic + fun openJoinScreen(parent: Any) { + installation?.let { installed -> + installed.guestScreens.open( + parent, + installed.browser, + installed.friendActivity, + ) + } + } + + fun holdGuestDirect( + target: GuestJoinTarget.Direct, + ) { + guestLease.hold(target, NOOP_CLOSE) + } + + @JvmStatic + fun guestConnectionChanged(connected: Boolean) { + guestLease.connectionChanged(connected) + } + + @JvmStatic + fun viewModel(): ShareViewModel = + checkNotNull(installation).viewModel + + @JvmStatic + fun friendsViewModel(): FriendsViewModel = + checkNotNull(installation).friendsViewModel + + @JvmStatic + fun recoveryViewModel(): RecoveryViewModel = + checkNotNull(installation).recoveryViewModel + + @JvmStatic + fun friendCardIssuer(): FriendCardIssuer = + checkNotNull(installation).friendCardIssuer + + @JvmStatic + fun friendCardReceiver(): FriendCardReceiver = + checkNotNull(installation).friendCardReceiver + + @JvmStatic + fun friendRequestClient(): FriendRequestClient = + checkNotNull(installation).friendRequestClient + + @JvmStatic + fun friendPairingClient(): FriendPairingClient = + checkNotNull(installation).friendPairingClient + + @JvmStatic + fun friendJoinOrchestrator(): FriendJoinOrchestrator = + checkNotNull(installation).friendJoinOrchestrator + + @JvmStatic + fun diagnosticBundle(): String = checkNotNull(installation).let { + it.diagnostics.bundle(it.minecraftVersion, it.modVersion) + } + + @JvmStatic + fun connectPublicAddress(): String? = + installation?.ownConnectAddress?.invoke() + + @JvmStatic + fun armFriendCardExchange(peerId: String) { + installation?.friendsViewModel + ?.relationshipId(peerId) + ?.let { relationshipId -> + friendCardConsent.arm(peerId, relationshipId) + } + } + + @JvmStatic + fun consumeFriendCardExchangeConsent(): FriendCardExchangeProof? = + friendCardConsent.consume() + + @JvmStatic + fun integratedWorldChanged( + worldAvailable: Boolean, + identity: Any?, + ) { + installation?.runtime?.integratedWorldChanged(worldAvailable, identity) + } + + @JvmStatic + suspend fun shutdown() { + friendCardConsent.cancel() + guestLease.close() + installation?.let { installed -> + installed.recoveryViewModel.close() + installed.runtime.shutdown() + installed.directControlPlane.shutdown() + installed.controlPlane.shutdown() + installed.browser.close() + installed.gateway.close() + } + installation = null + } + + private fun isShareActive(): Boolean = when ( + installation?.viewModel?.state?.value?.shareState + ) { + null, + ShareState.Idle, + is ShareState.Failed, + -> false + + ShareState.Starting, + is ShareState.Sharing, + ShareState.Stopping, + -> true + } + + private val NOOP_CLOSE = AutoCloseable {} +} + +internal class GuestConnectionLease( + private val nowNanos: () -> Long = System::nanoTime, + private val connectTimeoutNanos: Long = 60_000_000_000L, +) : AutoCloseable { + private var active: Active? = null + + @Synchronized + fun hold( + connection: AutoCloseable, + owner: AutoCloseable, + ) { + closeActive() + active = Active( + connection = connection, + owner = owner, + startedAtNanos = nowNanos(), + ) + } + + @Synchronized + fun connectionChanged(connected: Boolean) { + val current = active ?: return + if (connected) { + current.connectionSeen = true + return + } + val timedOut = + nowNanos() - current.startedAtNanos >= connectTimeoutNanos + if (current.connectionSeen || timedOut) { + closeActive() + } + } + + @Synchronized + override fun close() { + closeActive() + } + + private fun closeActive() { + val current = active ?: return + active = null + closeBestEffort(current.connection) + closeBestEffort(current.owner) + } + + private fun closeBestEffort(resource: AutoCloseable) { + try { + resource.close() + } catch (_: Exception) { + // Closing a stale guest route must not prevent later joins. + } + } + + private data class Active( + val connection: AutoCloseable, + val owner: AutoCloseable, + val startedAtNanos: Long, + var connectionSeen: Boolean = false, + ) +} diff --git a/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/ConnectShareRuntime.kt b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/ConnectShareRuntime.kt new file mode 100644 index 000000000..0a360d666 --- /dev/null +++ b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/ConnectShareRuntime.kt @@ -0,0 +1,80 @@ +package com.minekube.connect.share.fabric + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext + +class ConnectShareRuntime( + private val scope: CoroutineScope, + private val stopShare: suspend () -> Unit, + private val resumeShare: suspend () -> Unit = {}, + private val worldAvailabilityChanged: (Boolean) -> Unit = {}, + private val lifecycleDispatcher: CoroutineDispatcher = Dispatchers.IO, +) { + private val lock = Any() + private val lifecycle = Mutex() + private var currentWorldIdentity: Any? = null + + fun integratedWorldChanged( + worldAvailable: Boolean, + identity: Any? = if (worldAvailable) DEFAULT_WORLD_IDENTITY else null, + ) { + val transition = synchronized(lock) { + val previous = currentWorldIdentity + val current = if (worldAvailable) identity else null + currentWorldIdentity = current + if (previous == current) { + null + } else { + WorldTransition( + stopPrevious = previous != null, + resumeCurrent = current != null, + ) + } + } + if (transition == null) { + worldAvailabilityChanged(worldAvailable) + return + } + scope.launch(lifecycleDispatcher) { + lifecycle.withLock { + if (transition.stopPrevious) { + stopShare() + } + worldAvailabilityChanged(worldAvailable) + if (transition.resumeCurrent) { + resumeShare() + } + } + } + } + + suspend fun shutdown() { + val shouldStop = synchronized(lock) { + (currentWorldIdentity != null).also { + currentWorldIdentity = null + } + } + withContext(lifecycleDispatcher) { + worldAvailabilityChanged(false) + if (shouldStop) { + lifecycle.withLock { + stopShare() + } + } + } + } + + private data class WorldTransition( + val stopPrevious: Boolean, + val resumeCurrent: Boolean, + ) + + private companion object { + val DEFAULT_WORLD_IDENTITY = Any() + } +} diff --git a/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/DirectControlPlane.kt b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/DirectControlPlane.kt new file mode 100644 index 000000000..bd86ef964 --- /dev/null +++ b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/DirectControlPlane.kt @@ -0,0 +1,69 @@ +package com.minekube.connect.share.fabric + +import com.minekube.connect.share.ShareOptions +import java.net.SocketAddress +import java.util.concurrent.atomic.AtomicReference +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class DirectControlPlane( + private val scope: CoroutineScope, + private val ingress: PersistentDirectIngress, + private val options: ShareOptions, + private val target: SocketAddress, + private val connectAddress: suspend () -> String?, + private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, + private val failureReporter: (String) -> Unit = {}, +) { + private val startJob = AtomicReference() + + val state = ingress.state + + fun start() { + if (state.value == PersistentDirectState.Closed) { + return + } + val launched = scope.launch( + context = ioDispatcher, + start = CoroutineStart.LAZY, + ) { + val result = ingress.startControl( + options = options, + target = target, + connectAddress = connectAddress(), + ) + result.leftOrNull()?.let { + failureReporter(it.safeMessage) + } + } + if (!startJob.compareAndSet(null, launched)) { + launched.cancel() + return + } + launched.invokeOnCompletion { + startJob.compareAndSet(launched, null) + } + launched.start() + } + + suspend fun shutdown() { + startJob.getAndSet(null)?.cancelAndJoin() + withContext(ioDispatcher) { + ingress.shutdown() + } + } + + suspend fun restart() { + startJob.getAndSet(null)?.cancelAndJoin() + withContext(ioDispatcher) { + ingress.restart() + } + start() + } +} diff --git a/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FabricConnectIngress.kt b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FabricConnectIngress.kt new file mode 100644 index 000000000..f6b7d9647 --- /dev/null +++ b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FabricConnectIngress.kt @@ -0,0 +1,221 @@ +package com.minekube.connect.share.fabric + +import com.google.inject.AbstractModule +import com.google.inject.Guice +import com.google.inject.multibindings.OptionalBinder +import com.google.inject.name.Names +import com.minekube.connect.ConnectPlatform +import com.minekube.connect.api.ConnectApi +import com.minekube.connect.api.logger.ConnectLogger +import com.minekube.connect.api.packet.PacketHandlers +import com.minekube.connect.bedrock.BedrockAdmissionCoordinator +import com.minekube.connect.config.ConfigHolder +import com.minekube.connect.config.ConnectConfig +import com.minekube.connect.identity.EndpointTokenStore +import com.minekube.connect.inject.CommonPlatformInjector +import com.minekube.connect.module.Libp2pEndpointModule +import com.minekube.connect.module.ServerCommonModule +import com.minekube.connect.module.WatcherModule +import com.minekube.connect.platform.util.PlatformUtils +import com.minekube.connect.share.ConnectShareHandle +import com.minekube.connect.share.ConnectShareIngress +import com.minekube.connect.share.admission.AdmissionController +import com.minekube.connect.share.identity.EndpointIdentity +import com.minekube.connect.share.identity.EndpointIdentityStore +import com.minekube.connect.tunnel.p2p.Libp2pRuntime +import com.minekube.connect.watch.SessionAdmissionGate +import java.net.SocketAddress +import java.nio.file.Files +import java.nio.file.Path +import java.util.concurrent.atomic.AtomicBoolean +import kotlinx.coroutines.CoroutineScope + +class FabricConnectIngress private constructor( + private val dataDirectory: Path, + private val admission: AdmissionController, + private val approvedJoins: ApprovedJoinTracker, + private val scope: CoroutineScope, + private val worldAvailable: () -> Boolean, + private val runtimeFactory: FabricConnectRuntimeFactory, +) : ConnectShareIngress { + constructor( + dataDirectory: Path, + platformInjector: CommonPlatformInjector, + logger: ConnectLogger, + platformUtils: FabricPlatformUtils, + admission: AdmissionController, + approvedJoins: ApprovedJoinTracker, + scope: CoroutineScope, + worldAvailable: () -> Boolean = { true }, + ) : this( + dataDirectory = dataDirectory, + admission = admission, + approvedJoins = approvedJoins, + scope = scope, + worldAvailable = worldAvailable, + runtimeFactory = GuiceFabricConnectRuntimeFactory( + dataDirectory = dataDirectory, + platformInjector = platformInjector, + logger = logger, + platformUtils = platformUtils, + ), + ) + + override suspend fun start( + identity: EndpointIdentity, + target: SocketAddress, + ): ConnectShareHandle { + val tokenFile = dataDirectory.resolve(EndpointIdentityStore.TOKEN_FILE_NAME) + check(Files.isRegularFile(tokenFile)) { + "Connect endpoint token must exist before sharing starts" + } + val persistedToken = EndpointTokenStore() + .load(tokenFile, System.getenv()) + .orElseThrow { + IllegalStateException("Connect endpoint token is missing") + } + check(persistedToken == identity.token) { + "Connect endpoint identity changed before sharing started" + } + + val gate = FabricSessionAdmissionGate( + admission, + scope, + approvedJoins, + worldAvailable, + ) + val runtime = try { + runtimeFactory.start(identity, target, gate) + } catch (failure: Throwable) { + gate.stop() + throw failure + } + val closed = AtomicBoolean() + return ConnectShareHandle( + endpoint = identity.endpoint, + publicAddress = "${identity.endpoint}.play.minekube.net", + close = { + if (closed.compareAndSet(false, true)) { + gate.stop() + runtime.close() + } + }, + ) + } + + companion object { + internal fun testing( + dataDirectory: Path, + admission: AdmissionController, + scope: CoroutineScope, + runtimeFactory: FabricConnectRuntimeFactory, + approvedJoins: ApprovedJoinTracker = + ApprovedJoinTracker(), + worldAvailable: () -> Boolean = { true }, + ) = FabricConnectIngress( + dataDirectory = dataDirectory, + admission = admission, + approvedJoins = approvedJoins, + scope = scope, + worldAvailable = worldAvailable, + runtimeFactory = runtimeFactory, + ) + } +} + +fun interface FabricConnectRuntime { + fun close() +} + +fun interface FabricConnectRuntimeFactory { + fun start( + identity: EndpointIdentity, + target: SocketAddress, + admissionGate: SessionAdmissionGate, + ): FabricConnectRuntime +} + +private class GuiceFabricConnectRuntimeFactory( + private val dataDirectory: Path, + private val platformInjector: CommonPlatformInjector, + private val logger: ConnectLogger, + private val platformUtils: FabricPlatformUtils, +) : FabricConnectRuntimeFactory { + override fun start( + identity: EndpointIdentity, + target: SocketAddress, + admissionGate: SessionAdmissionGate, + ): FabricConnectRuntime { + check(platformInjector.serverSocketAddress == target) { + "Minecraft bridge target changed before Connect started" + } + val injector = Guice.createInjector( + ServerCommonModule(dataDirectory), + FabricPlatformModule( + platformInjector = platformInjector, + logger = logger, + platformUtils = platformUtils, + admissionGate = admissionGate, + ), + ) + val platform = ConnectPlatform( + injector.getInstance(ConnectApi::class.java), + platformInjector, + logger, + injector, + injector.getInstance(BedrockAdmissionCoordinator::class.java), + ) + try { + platform.initEmbedded( + dataDirectory, + ConnectConfig.embedded(identity.endpoint, true), + injector.getInstance(ConfigHolder::class.java), + injector.getInstance(PacketHandlers::class.java), + ) + if (!platform.enable( + Libp2pEndpointModule(), + WatcherModule(), + )) { + throw IllegalStateException( + "Could not inject the Minecraft integrated server", + ) + } + return FabricConnectRuntime { + try { + platform.disable() + } finally { + Libp2pRuntime.close() + } + } + } catch (failure: Throwable) { + try { + platform.disable() + } catch (cleanupFailure: Throwable) { + failure.addSuppressed(cleanupFailure) + } finally { + Libp2pRuntime.close() + } + throw failure + } + } +} + +private class FabricPlatformModule( + private val platformInjector: CommonPlatformInjector, + private val logger: ConnectLogger, + private val platformUtils: FabricPlatformUtils, + private val admissionGate: SessionAdmissionGate, +) : AbstractModule() { + override fun configure() { + bind(CommonPlatformInjector::class.java).toInstance(platformInjector) + bind(ConnectLogger::class.java).toInstance(logger) + bind(PlatformUtils::class.java).toInstance(platformUtils) + bindConstant() + .annotatedWith(Names.named("platformName")) + .to("Fabric") + OptionalBinder.newOptionalBinder( + binder(), + SessionAdmissionGate::class.java, + ).setBinding().toInstance(admissionGate) + } +} diff --git a/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FabricDirectAuthenticationPolicy.kt b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FabricDirectAuthenticationPolicy.kt new file mode 100644 index 000000000..722cc2245 --- /dev/null +++ b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FabricDirectAuthenticationPolicy.kt @@ -0,0 +1,38 @@ +package com.minekube.connect.share.fabric + +import arrow.core.Either +import arrow.core.raise.either +import arrow.core.raise.ensure +import com.minekube.connect.tunnel.p2p.DirectP2pAuthMode + +data object DirectOnlineAuthenticationRequired { + const val SAFE_MESSAGE = + "This direct guest requested online authentication, but Minecraft did not verify it" +} + +enum class DirectMinecraftAuthentication { + MOJANG, + OFFLINE_PROFILE, +} + +object FabricDirectAuthenticationPolicy { + fun minecraftAuthentication( + requestedMode: DirectP2pAuthMode, + ): DirectMinecraftAuthentication = when (requestedMode) { + DirectP2pAuthMode.ONLINE -> DirectMinecraftAuthentication.MOJANG + DirectP2pAuthMode.OFFLINE -> + DirectMinecraftAuthentication.OFFLINE_PROFILE + } + + fun validate( + requestedMode: DirectP2pAuthMode, + minecraftAuthenticated: Boolean, + ): Either = either { + ensure( + requestedMode != DirectP2pAuthMode.ONLINE || + minecraftAuthenticated, + ) { + DirectOnlineAuthenticationRequired + } + } +} diff --git a/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FabricDirectPeerRuntime.kt b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FabricDirectPeerRuntime.kt new file mode 100644 index 000000000..e62035cc5 --- /dev/null +++ b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FabricDirectPeerRuntime.kt @@ -0,0 +1,112 @@ +package com.minekube.connect.share.fabric + +import com.minekube.connect.tunnel.p2p.DirectP2pAuthMode +import com.minekube.connect.tunnel.p2p.DirectP2pDiscoveryListener +import com.minekube.connect.tunnel.p2p.DirectP2pHostConfig +import com.minekube.connect.tunnel.p2p.DirectP2pHostHandler +import com.minekube.connect.tunnel.p2p.DirectP2pHostInfo +import com.minekube.connect.tunnel.p2p.DirectP2pNode +import com.minekube.connect.tunnel.p2p.DirectP2pProxy +import com.minekube.connect.share.friend.ShareAccessIdentityStore +import java.nio.file.Path +import java.time.Duration +import java.util.concurrent.atomic.AtomicBoolean + +internal class FabricDirectPeerRuntime private constructor( + val browser: FabricShareBrowser, + val ingress: FabricDirectShareIngress, +) { + constructor( + dataDirectory: Path, + accessIdentityStore: ShareAccessIdentityStore = + ShareAccessIdentityStore(dataDirectory), + displayName: () -> String, + ) : this( + node = CoreFabricDirectPeerNode( + DirectP2pNode(dataDirectory.resolve(IDENTITY_FILE_NAME)), + ), + dataDirectory = dataDirectory, + accessIdentityStore = accessIdentityStore, + displayName = displayName, + ) + + private constructor( + node: FabricDirectPeerNode, + dataDirectory: Path, + accessIdentityStore: ShareAccessIdentityStore = + ShareAccessIdentityStore(dataDirectory), + displayName: () -> String, + ) : this( + browser = FabricShareBrowser(node), + ingress = FabricDirectShareIngress( + node = node, + dataDirectory = dataDirectory, + accessIdentityStore = accessIdentityStore, + displayName = displayName, + ), + ) + + companion object { + internal fun testing( + node: FabricDirectPeerNode, + dataDirectory: Path, + displayName: () -> String, + ) = FabricDirectPeerRuntime( + node = node, + dataDirectory = dataDirectory, + displayName = displayName, + ) + + private const val IDENTITY_FILE_NAME = "share-libp2p-identity.key" + } +} + +internal interface FabricDirectPeerNode : + FabricGuestDirectNode, + FabricDirectNode + +private class CoreFabricDirectPeerNode( + private val node: DirectP2pNode, +) : FabricDirectPeerNode { + private val closed = AtomicBoolean() + + override fun peerId(): String = node.peerId() + + override fun startDiscovery(listener: DirectP2pDiscoveryListener) { + node.startDiscovery(listener) + } + + override fun startHost( + config: DirectP2pHostConfig, + handler: DirectP2pHostHandler, + ): DirectP2pHostInfo = node.startHost(config, handler) + + override fun sign(payload: ByteArray): ByteArray = node.sign(payload) + + override fun publish( + invitation: String, + discoveryInvitation: String, + ) { + node.publish(invitation, discoveryInvitation) + } + + override fun openProxy( + address: String, + shareId: String, + capability: String, + authMode: DirectP2pAuthMode, + timeout: Duration, + ): DirectP2pProxy = node.openProxy( + address, + shareId, + capability, + authMode, + timeout, + ) + + override fun close() { + if (closed.compareAndSet(false, true)) { + node.close() + } + } +} diff --git a/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FabricDirectShareIngress.kt b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FabricDirectShareIngress.kt new file mode 100644 index 000000000..7dcf9058a --- /dev/null +++ b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FabricDirectShareIngress.kt @@ -0,0 +1,330 @@ +package com.minekube.connect.share.fabric + +import com.minekube.connect.share.DirectShareHandle +import com.minekube.connect.share.DirectShareIngress +import com.minekube.connect.share.ShareOptions +import com.minekube.connect.share.direct.DirectSessionRegistry +import com.minekube.connect.share.direct.ShareInviteCodec +import com.minekube.connect.share.direct.ShareInvitePayload +import com.minekube.connect.share.direct.SignedShareInvite +import com.minekube.connect.share.friend.ShareAccessIdentity +import com.minekube.connect.share.friend.ShareAccessIdentityStore +import com.minekube.connect.tunnel.p2p.DirectP2pHostConfig +import com.minekube.connect.tunnel.p2p.DirectP2pHostHandler +import com.minekube.connect.tunnel.p2p.DirectP2pHostInfo +import com.minekube.connect.tunnel.p2p.DirectP2pNode +import com.minekube.connect.tunnel.p2p.DirectP2pSession +import java.net.InetAddress +import java.net.InetSocketAddress +import java.net.Socket +import java.net.SocketAddress +import java.nio.file.Path +import java.time.Instant +import java.util.UUID +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicReference +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch + +class FabricDirectShareIngress private constructor( + private val nodeFactory: () -> FabricDirectNode, + private val now: () -> Instant, + private val accessIdentity: () -> ShareAccessIdentity, + private val displayName: () -> String, + private val localSocket: (SocketAddress, DirectP2pSession) -> Socket, + private val closeNodeOnHandleClose: Boolean, + private val renewalDispatcher: kotlinx.coroutines.CoroutineDispatcher, +) : DirectShareIngress { + constructor( + dataDirectory: Path, + accessIdentityStore: ShareAccessIdentityStore = + ShareAccessIdentityStore(dataDirectory), + displayName: () -> String, + identityFile: Path = dataDirectory.resolve(IDENTITY_FILE_NAME), + ) : this( + nodeFactory = { + CoreFabricDirectNode( + DirectP2pNode(identityFile), + ) + }, + now = Instant::now, + accessIdentity = accessIdentityStore::currentOrCreate, + displayName = displayName, + localSocket = ::openTaggedLoopbackSocket, + closeNodeOnHandleClose = true, + renewalDispatcher = kotlinx.coroutines.Dispatchers.IO, + ) + + internal constructor( + node: FabricDirectNode, + dataDirectory: Path, + accessIdentityStore: ShareAccessIdentityStore = + ShareAccessIdentityStore(dataDirectory), + displayName: () -> String, + ) : this( + nodeFactory = { node }, + now = Instant::now, + accessIdentity = accessIdentityStore::currentOrCreate, + displayName = displayName, + localSocket = ::openTaggedLoopbackSocket, + closeNodeOnHandleClose = false, + renewalDispatcher = kotlinx.coroutines.Dispatchers.IO, + ) + + override suspend fun start( + options: ShareOptions, + target: SocketAddress, + connectAddress: String?, + ): DirectShareHandle { + val node = nodeFactory() + try { + val access = accessIdentity() + val id = access.shareId + val secret = access.capability + val host = node.startHost( + DirectP2pHostConfig( + id.toString(), + secret, + displayName().ifBlank { DEFAULT_DISPLAY_NAME }, + options.allowInternetDirect, + ), + DirectP2pHostHandler { session -> + localSocket(target, session) + }, + ) + val internetCandidates = if (options.allowInternetDirect) { + host.internetAddresses() + } else { + emptyList() + } + val invitation = createInvitation( + node = node, + host = host, + shareId = id, + secret = secret, + connectAddress = connectAddress, + options = options, + ) + val currentInvitation = AtomicReference(invitation) + node.publish( + invitation, + createInvitation( + node = node, + host = host, + shareId = id, + secret = secret, + connectAddress = connectAddress, + options = options.copy(allowInternetDirect = false), + ), + ) + val renewalScope = CoroutineScope(SupervisorJob() + renewalDispatcher) + val renewalJob = renewalScope.launch { + while (isActive) { + delay(INVITATION_RENEWAL_MILLIS) + try { + val renewed = createInvitation( + node = node, + host = host, + shareId = id, + secret = secret, + connectAddress = connectAddress, + options = options, + ) + node.publish( + renewed, + createInvitation( + node = node, + host = host, + shareId = id, + secret = secret, + connectAddress = connectAddress, + options = options.copy( + allowInternetDirect = false, + ), + ), + ) + currentInvitation.set(renewed) + } catch (cancellation: CancellationException) { + throw cancellation + } catch (_: RuntimeException) { + } + } + } + val closed = AtomicBoolean() + return DirectShareHandle( + invitationProvider = currentInvitation::get, + lanAvailable = true, + internetAvailable = + options.allowInternetDirect && + internetCandidates.isNotEmpty(), + close = { + if (closed.compareAndSet(false, true)) { + renewalJob.cancelAndJoin() + renewalScope.cancel() + if (closeNodeOnHandleClose) { + node.close() + } + } + }, + ) + } catch (failure: Throwable) { + if (closeNodeOnHandleClose) { + try { + node.close() + } catch (cleanupFailure: Throwable) { + if (cleanupFailure !== failure) { + failure.addSuppressed(cleanupFailure) + } + } + } + throw failure + } + } + + private fun createInvitation( + node: FabricDirectNode, + host: DirectP2pHostInfo, + shareId: UUID, + secret: String, + connectAddress: String?, + options: ShareOptions, + ): String { + val internetCandidates = if (options.allowInternetDirect) { + host.internetAddresses() + } else { + emptyList() + } + val payload = ShareInvitePayload( + wireVersion = ShareInviteCodec.WIRE_VERSION, + shareId = shareId, + expiresAtEpochMillis = now() + .plusSeconds(INVITATION_LIFETIME_SECONDS) + .toEpochMilli(), + connectAddress = connectAddress, + peerId = host.peerId(), + internetDirectEnabled = options.allowInternetDirect, + directCandidates = internetCandidates, + capability = secret, + ) + return ShareInviteCodec.encode( + SignedShareInvite( + payload = payload, + publicKey = host.publicKey(), + signature = node.sign( + ShareInviteCodec.unsignedBytes( + payload, + host.publicKey(), + ), + ), + ), + ) + } + + companion object { + internal fun testing( + nodeFactory: () -> FabricDirectNode, + now: () -> Instant, + shareId: () -> UUID, + capability: () -> String, + displayName: () -> String, + localSocket: (SocketAddress, DirectP2pSession) -> Socket, + renewalDispatcher: kotlinx.coroutines.CoroutineDispatcher = + kotlinx.coroutines.Dispatchers.IO, + ) = FabricDirectShareIngress( + nodeFactory = nodeFactory, + now = now, + accessIdentity = { + ShareAccessIdentity( + shareId = shareId(), + capability = capability(), + ) + }, + displayName = displayName, + localSocket = localSocket, + closeNodeOnHandleClose = true, + renewalDispatcher = renewalDispatcher, + ) + + private fun openTaggedLoopbackSocket( + target: SocketAddress, + session: DirectP2pSession, + ): Socket { + val destination = target as? InetSocketAddress + ?: throw IllegalArgumentException( + "Direct Minecraft target must be an internet socket", + ) + check(destination.address.isLoopbackAddress) { + "Direct Minecraft target escaped loopback" + } + val socket = Socket() + socket.bind(InetSocketAddress(InetAddress.getLoopbackAddress(), 0)) + val registration = DirectSessionRegistry.register( + sourcePort = socket.localPort, + session = session, + ) + try { + socket.connect(destination, LOCAL_CONNECT_TIMEOUT_MILLIS) + return socket + } catch (failure: Throwable) { + registration.close() + try { + socket.close() + } catch (cleanupFailure: Throwable) { + if (cleanupFailure !== failure) { + failure.addSuppressed(cleanupFailure) + } + } + throw failure + } + } + + private const val DEFAULT_DISPLAY_NAME = "Minecraft world" + private const val IDENTITY_FILE_NAME = "share-libp2p-identity.key" + private const val INVITATION_LIFETIME_SECONDS = 24 * 60 * 60L + private const val INVITATION_RENEWAL_MILLIS = 12 * 60 * 60 * 1_000L + private const val LOCAL_CONNECT_TIMEOUT_MILLIS = 3_000 + } +} + +internal interface FabricDirectNode : AutoCloseable { + fun startHost( + config: DirectP2pHostConfig, + handler: DirectP2pHostHandler, + ): DirectP2pHostInfo + + fun sign(payload: ByteArray): ByteArray + + fun publish( + invitation: String, + discoveryInvitation: String, + ) +} + +private class CoreFabricDirectNode( + private val node: DirectP2pNode, +) : FabricDirectNode { + override fun startHost( + config: DirectP2pHostConfig, + handler: DirectP2pHostHandler, + ): DirectP2pHostInfo = node.startHost(config, handler) + + override fun sign(payload: ByteArray): ByteArray = node.sign(payload) + + override fun publish( + invitation: String, + discoveryInvitation: String, + ) { + node.publish(invitation, discoveryInvitation) + } + + override fun close() { + node.close() + } +} diff --git a/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FabricLoginAdmissionRegistry.kt b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FabricLoginAdmissionRegistry.kt new file mode 100644 index 000000000..4d5946854 --- /dev/null +++ b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FabricLoginAdmissionRegistry.kt @@ -0,0 +1,46 @@ +package com.minekube.connect.share.fabric + +import com.minekube.connect.share.admission.AdmissionAnswer +import com.minekube.connect.share.admission.Ingress +import java.util.UUID +import java.util.concurrent.CompletableFuture +import java.util.concurrent.CompletionStage +import java.util.concurrent.atomic.AtomicReference + +object FabricLoginAdmissionRegistry { + private val installed = AtomicReference() + + fun install(gate: FabricLocalLoginAdmissionGate): AutoCloseable { + check(installed.compareAndSet(null, gate)) { + "A Minecraft login admission gate is already installed" + } + return AutoCloseable { + if (installed.compareAndSet(gate, null)) { + gate.stop() + } + } + } + + @JvmStatic + fun request( + name: String, + uuid: UUID, + connectionId: String, + minecraftAuthenticated: Boolean, + ingress: Ingress, + directPeerId: String? = null, + ): CompletionStage { + val gate = installed.get() + if (gate == null) { + return CompletableFuture.completedFuture(AdmissionAnswer.STOPPED) + } + return gate.request( + name = name, + uuid = uuid, + connectionId = connectionId, + minecraftAuthenticated = minecraftAuthenticated, + ingress = ingress, + directPeerId = directPeerId, + ) + } +} diff --git a/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FabricPlatformUtils.kt b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FabricPlatformUtils.kt new file mode 100644 index 000000000..a7d9ccd78 --- /dev/null +++ b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FabricPlatformUtils.kt @@ -0,0 +1,16 @@ +package com.minekube.connect.share.fabric + +import com.minekube.connect.platform.util.PlatformUtils + +class FabricPlatformUtils( + private val minecraftVersion: String, + private val playerCount: () -> Int, +) : PlatformUtils() { + override fun authType(): AuthType = AuthType.OFFLINE + + override fun minecraftVersion(): String = minecraftVersion + + override fun serverImplementationName(): String = "Minecraft integrated server" + + override fun getPlayerCount(): Int = playerCount() +} diff --git a/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FabricSessionAdmissionGate.kt b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FabricSessionAdmissionGate.kt new file mode 100644 index 000000000..7f143da68 --- /dev/null +++ b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FabricSessionAdmissionGate.kt @@ -0,0 +1,276 @@ +package com.minekube.connect.share.fabric + +import arrow.core.Either +import arrow.core.raise.either +import arrow.core.raise.ensure +import com.minekube.connect.share.admission.AdmissionAnswer +import com.minekube.connect.share.admission.AdmissionController +import com.minekube.connect.share.admission.AdmissionIdentity +import com.minekube.connect.share.admission.AuthSource +import com.minekube.connect.share.admission.Ingress +import com.minekube.connect.share.fabric.ui.ShareLoginMessages +import com.minekube.connect.watch.SessionAdmissionDecision +import com.minekube.connect.watch.SessionAdmissionGate +import com.minekube.connect.watch.SessionProposal +import java.util.UUID +import java.util.concurrent.CompletableFuture +import java.util.concurrent.CompletionStage +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull + +class FabricSessionAdmissionGate( + private val admission: AdmissionController, + private val scope: CoroutineScope, + private val approvedJoins: ApprovedJoinTracker = + ApprovedJoinTracker(), + private val worldAvailable: () -> Boolean = { true }, + private val decisionTimeout: Duration = 20.seconds, +) : SessionAdmissionGate { + private val stopped = AtomicBoolean() + private val active = ConcurrentHashMap, Job>() + + init { + require(decisionTimeout.isPositive()) { + "Connect admission decision timeout must be positive" + } + } + + override fun request( + proposal: SessionProposal, + ): CompletionStage { + if (proposal.isStatusProbe()) { + return CompletableFuture.completedFuture( + SessionAdmissionDecision.allow(), + ) + } + if (!worldAvailable()) { + return CompletableFuture.completedFuture( + SessionAdmissionDecision.deny(NO_SHARED_WORLD), + ) + } + if (proposal.session.auth.passthrough) { + return CompletableFuture.completedFuture( + SessionAdmissionDecision.deferToLocalLogin(), + ) + } + val identity = authenticatedIdentity(proposal).fold( + ifLeft = { + return CompletableFuture.completedFuture( + SessionAdmissionDecision.deny(INVALID_PROFILE), + ) + }, + ifRight = { it }, + ) + val future = CompletableFuture() + lateinit var job: Job + job = scope.launch(start = CoroutineStart.LAZY) { + try { + val answer = withTimeoutOrNull(decisionTimeout) { + admission.request(identity) + } ?: AdmissionAnswer.TIMEOUT + approvedJoins.record(identity, answer) + future.complete(answer.toCoreDecision()) + } catch (cancellation: CancellationException) { + future.cancel(false) + throw cancellation + } catch (_: Exception) { + future.complete(SessionAdmissionDecision.deny(ADMISSION_FAILED)) + } finally { + active.remove(future) + } + } + active[future] = job + job.invokeOnCompletion { failure -> + active.remove(future) + if (failure is CancellationException && !future.isDone) { + future.cancel(false) + } + } + future.whenComplete { _, _ -> + if (future.isCancelled) { + job.cancel() + } + } + if (stopped.get()) { + active.remove(future) + future.cancel(false) + job.cancel() + } else { + job.start() + } + return future + } + + private fun SessionProposal.isStatusProbe(): Boolean { + val session = session + return !session.hasPlayer() || + !session.player.hasProfile() || + session.player.profile.name.isBlank() + } + + fun stop() { + if (!stopped.compareAndSet(false, true)) { + return + } + active.forEach { (future, job) -> + future.cancel(false) + job.cancel() + } + active.clear() + } + + private fun authenticatedIdentity( + proposal: SessionProposal, + ): Either = either { + val session = proposal.session + ensure(session.hasPlayer() && session.player.hasProfile()) { InvalidProfile } + val profile = session.player.profile + ensure(profile.name.isNotBlank()) { InvalidProfile } + val uuid = Either.catch { + UUID.fromString(profile.id) + }.mapLeft { InvalidProfile }.bind() + AdmissionIdentity.Authenticated( + name = profile.name, + uuid = uuid, + source = AuthSource.CONNECT, + ) + } + + private fun AdmissionAnswer.toCoreDecision(): SessionAdmissionDecision = when (this) { + AdmissionAnswer.ALLOW -> SessionAdmissionDecision.allow() + AdmissionAnswer.DENY, + AdmissionAnswer.TIMEOUT, + AdmissionAnswer.STOPPED, + AdmissionAnswer.CAPACITY -> SessionAdmissionDecision.deny( + ShareLoginMessages.denial(this).fallback, + ) + } + + private companion object { + data object InvalidProfile + const val INVALID_PROFILE = "Connect profile is invalid" + const val ADMISSION_FAILED = "Could not ask the host for approval" + const val NO_SHARED_WORLD = "No shared world is active" + } +} + +class FabricLocalLoginAdmission( + private val admission: AdmissionController, + private val approvedJoins: ApprovedJoinTracker = + ApprovedJoinTracker(), +) { + suspend fun request( + name: String, + uuid: UUID, + connectionId: String, + minecraftAuthenticated: Boolean, + ingress: Ingress = Ingress.CONNECT, + directPeerId: String? = null, + ): AdmissionAnswer { + val identity = if (minecraftAuthenticated) { + AdmissionIdentity.Authenticated( + name = name, + uuid = uuid, + source = AuthSource.MOJANG, + ingress = ingress, + directPeerId = directPeerId, + ) + } else { + AdmissionIdentity.UnverifiedOffline( + name = name, + uuid = uuid, + connectionId = connectionId, + ingress = ingress, + directPeerId = directPeerId, + ) + } + return admission.request(identity).also { answer -> + approvedJoins.record(identity, answer) + } + } +} + +class FabricLocalLoginAdmissionGate( + private val admission: FabricLocalLoginAdmission, + private val scope: CoroutineScope, +) { + private val stopped = AtomicBoolean() + private val active = ConcurrentHashMap, Job>() + + fun request( + name: String, + uuid: UUID, + connectionId: String, + minecraftAuthenticated: Boolean, + ingress: Ingress = Ingress.CONNECT, + directPeerId: String? = null, + ): CompletionStage { + val future = CompletableFuture() + if (stopped.get()) { + future.cancel(false) + return future + } + + lateinit var job: Job + job = scope.launch(start = CoroutineStart.LAZY) { + try { + future.complete( + admission.request( + name = name, + uuid = uuid, + connectionId = connectionId, + minecraftAuthenticated = minecraftAuthenticated, + ingress = ingress, + directPeerId = directPeerId, + ), + ) + } catch (cancellation: CancellationException) { + future.cancel(false) + throw cancellation + } catch (_: Exception) { + future.complete(AdmissionAnswer.DENY) + } finally { + active.remove(future) + } + } + active[future] = job + job.invokeOnCompletion { failure -> + active.remove(future) + if (failure is CancellationException && !future.isDone) { + future.cancel(false) + } + } + future.whenComplete { _, _ -> + if (future.isCancelled) { + job.cancel() + } + } + if (stopped.get()) { + active.remove(future) + future.cancel(false) + job.cancel() + } else { + job.start() + } + return future + } + + fun stop() { + if (!stopped.compareAndSet(false, true)) { + return + } + active.forEach { (future, job) -> + future.cancel(false) + job.cancel() + } + active.clear() + } +} diff --git a/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FabricShareBootstrap.kt b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FabricShareBootstrap.kt new file mode 100644 index 000000000..c2ae72012 --- /dev/null +++ b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FabricShareBootstrap.kt @@ -0,0 +1,514 @@ +package com.minekube.connect.share.fabric + +import com.minekube.connect.api.logger.ConnectLogger +import com.minekube.connect.identity.EndpointTokenStore +import com.minekube.connect.share.ShareCoordinator +import com.minekube.connect.share.ShareConnectionGateway +import com.minekube.connect.share.ShareGameMode +import com.minekube.connect.share.ShareOptions +import com.minekube.connect.share.ShareState +import com.minekube.connect.share.VersionedMinecraftBridge +import com.minekube.connect.share.admission.AdmissionController +import com.minekube.connect.share.admission.AdmissionIdentity +import com.minekube.connect.share.direct.ShareInviteCodec +import com.minekube.connect.share.fabric.ui.ShareViewModel +import com.minekube.connect.share.fabric.ui.FriendsViewModel +import com.minekube.connect.share.fabric.ui.StoredEndpointIdentityUiActions +import com.minekube.connect.share.fabric.recovery.RecoveryViewModel +import com.minekube.connect.share.fabric.recovery.StoredRecoveryUiActions +import com.minekube.connect.share.friend.FriendStore +import com.minekube.connect.share.friend.FriendActivity +import com.minekube.connect.share.friend.FriendActivityKind +import com.minekube.connect.share.friend.FriendActivityRequest +import com.minekube.connect.share.friend.CompatibilityProfile +import com.minekube.connect.share.friend.ShareAccessIdentityStore +import com.minekube.connect.share.friend.SharePreferences +import com.minekube.connect.share.friend.SharePreferencesStore +import com.minekube.connect.share.identity.EndpointIdentityStore +import com.minekube.connect.share.recovery.RecoveryStore +import com.minekube.connect.tunnel.p2p.DirectP2pAuthMode +import com.minekube.connect.util.MessageFormatter +import java.nio.file.Path +import java.util.concurrent.atomic.AtomicReference +import java.util.concurrent.ThreadFactory +import java.util.UUID +import java.util.logging.Level +import java.util.logging.Logger +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.OkHttpClient + +object FabricShareBootstrap { + suspend fun create( + scope: CoroutineScope, + dataDirectory: Path, + minecraftVersion: String, + modVersion: String = "development", + worldAvailable: Boolean, + friendStore: FriendStore, + playerCount: () -> Int, + worldDisplayName: () -> String = { "Minecraft world" }, + playerDisplayName: () -> String? = { null }, + friendActivity: () -> FriendActivity = { + FriendActivity(FriendActivityKind.ONLINE) + }, + compatibilityProfile: () -> CompatibilityProfile? = { null }, + friendJoinTarget: () -> String? = { null }, + bridgeFactory: + ( + AdmissionController, + CoroutineScope, + ApprovedJoinTracker, + ShareConnectionGateway, + ) -> VersionedMinecraftBridge, + screens: ConnectShareScreenFactory, + guestScreens: ConnectShareGuestScreenFactory, + environment: Map = System.getenv(), + logger: ConnectLogger = FabricConnectLogger(), + httpClient: OkHttpClient = OkHttpClient(), + minecraftThreadFactory: ThreadFactory? = null, + ): ConnectShareInstallation { + val viewModelReference = AtomicReference() + val diagnostics = ShareJoinDiagnostics() + val approvedJoins = ApprovedJoinTracker() + val admission = AdmissionController( + scope = scope, + connectedCount = { + (playerCount() - HOST_PLAYER_COUNT).coerceAtLeast(0) + }, + maxGuests = { + viewModelReference.get()?.state?.value?.options?.maxGuests + ?: DEFAULT_MAX_GUESTS + }, + autoApprove = { identity -> + runCatching { + friendStore.all().any { friend -> + val directIdentityMatches = + friend.peerId == identity.directPeerId + val minecraftIdentityMatches = + identity is AdmissionIdentity.Authenticated && + friend.minecraftUuid == identity.uuid + friend.permissions.canJoinAutomatically && + (directIdentityMatches || + minecraftIdentityMatches) + } + }.getOrDefault(false) + }, + ) + val identityStore = EndpointIdentityStore( + directory = dataDirectory, + environment = environment, + endpointNames = RandomEndpointNameSource(httpClient), + tokenStore = EndpointTokenStore(), + ) + val preferencesStore = SharePreferencesStore(dataDirectory) + val initialPreferences = try { + preferencesStore.load() + } catch (_: Exception) { + logger.warn("Connect Share preferences could not be loaded") + SharePreferences() + } + val preferences = AtomicReference(initialPreferences) + val validator = WatchEndpointCredentialValidator( + client = httpClient, + watchUrl = watchHttpUrl(environment), + timeout = 10.seconds, + ) + val endpointIdentity = identityStore.currentOrCreate() + val ownConnectAddress = AtomicReference( + "${endpointIdentity.endpoint}.play.minekube.net", + ) + val accessIdentityStore = ShareAccessIdentityStore(dataDirectory) + val directIngressReference = AtomicReference() + val socialIdentityFile = dataDirectory.resolve( + SOCIAL_IDENTITY_FILE_NAME, + ) + val friendCardIssuer = FriendCardIssuer( + dataDirectory = dataDirectory, + displayName = playerDisplayName, + connectAddress = { ownConnectAddress.get() }, + accessIdentityStore = accessIdentityStore, + identityFile = socialIdentityFile, + directRoute = { + directIngressReference.get() + ?.awaitInvitation() + ?.let { ShareInviteCodec.decode(it).getOrNull() } + ?.payload + ?.let { payload -> + FriendDirectRoute( + internetDirectEnabled = + payload.internetDirectEnabled, + candidates = payload.directCandidates, + ) + } + }, + ) + val friendCardReceiver = FriendCardReceiver(friendStore) + val friendRequestServer = FriendRequestServer( + scope = scope, + admission = admission, + issuer = friendCardIssuer, + receiver = friendCardReceiver, + friendStore = friendStore, + approvedJoins = approvedJoins, + activity = friendActivity, + presencePrivacy = { preferences.get().presence }, + joinTarget = friendJoinTarget, + ) + val gateway = ShareConnectionGateway.bind( + minecraftThreadFactory = minecraftThreadFactory, + friendServer = friendRequestServer, + ) + var browser: FabricShareBrowser? = null + var controlPlane: ConnectControlPlane? = null + var directControlPlane: DirectControlPlane? = null + try { + val directPeer = FabricDirectPeerRuntime( + dataDirectory = dataDirectory, + displayName = worldDisplayName, + accessIdentityStore = accessIdentityStore, + ) + val activeBrowser = directPeer.browser + browser = activeBrowser + activeBrowser.start().leftOrNull()?.let { + logger.warn(it.safeMessage) + } + val bridge = bridgeFactory( + admission, + scope, + approvedJoins, + gateway, + ) + val ingress = PersistentConnectIngress( + FabricConnectIngress( + dataDirectory = dataDirectory, + platformInjector = gateway, + logger = logger, + platformUtils = FabricPlatformUtils( + minecraftVersion = minecraftVersion, + playerCount = playerCount, + ), + admission = admission, + approvedJoins = approvedJoins, + scope = scope, + worldAvailable = bridge::isInjected, + ), + ) + val directIngress = PersistentDirectIngress( + directPeer.ingress, + ) + val socialIngress = PersistentDirectIngress( + FabricDirectShareIngress( + dataDirectory = dataDirectory, + accessIdentityStore = accessIdentityStore, + displayName = worldDisplayName, + identityFile = socialIdentityFile, + ), + ) + directIngressReference.set(socialIngress) + val coordinator = ShareCoordinator( + bridge = bridge, + ingress = ingress, + identityProvider = identityStore::currentOrCreate, + admission = admission, + directIngress = directIngress, + failureReporter = logger::warn, + ) + val startedControlPlane = ConnectControlPlane( + scope = scope, + ingress = ingress, + identity = identityStore::currentOrCreate, + target = gateway.serverSocketAddress, + failureReporter = logger::warn, + ) + controlPlane = startedControlPlane + startedControlPlane.start() + val startedDirectControlPlane = DirectControlPlane( + scope = scope, + ingress = socialIngress, + options = socialControlOptions(), + target = gateway.directAddress, + connectAddress = { ownConnectAddress.get() }, + failureReporter = logger::warn, + ) + directControlPlane = startedDirectControlPlane + startedDirectControlPlane.start() + val viewModel = ShareViewModel( + scope = scope, + shareState = coordinator.state, + pendingAdmissions = admission.pending, + initialWorldAvailable = worldAvailable, + initialShareWithFriendsEnabled = + initialPreferences.shareWithFriends, + initialPresencePrivacy = initialPreferences.presence, + persistShareWithFriendsEnabled = { enabled -> + val updated = preferences.updateAndGet { + it.copy(shareWithFriends = enabled) + } + preferencesStore.save(updated) + }, + persistPresencePrivacy = { privacy -> + val updated = preferences.updateAndGet { + it.copy(presence = privacy) + } + preferencesStore.save(updated) + }, + identityActions = StoredEndpointIdentityUiActions( + store = identityStore, + validator = validator, + ), + onIdentityChanged = { + ownConnectAddress.set( + "${identityStore.currentOrCreate().endpoint}" + + ".play.minekube.net", + ) + startedControlPlane.restart() + startedDirectControlPlane.restart() + }, + startShare = coordinator::start, + stopShare = coordinator::stop, + answerAdmission = admission::answer, + currentInvitation = coordinator::currentInvitation, + ) + viewModelReference.set(viewModel) + val runtime = ConnectShareRuntime( + scope = scope, + stopShare = { + coordinator.worldReplaced() + }, + resumeShare = viewModel::resumeIfEnabled, + worldAvailabilityChanged = viewModel::setWorldAvailable, + ) + val friendRequestClient = FriendRequestClient() + val removalSync = FriendRemovalSync(friendStore) { removal -> + activeBrowser.openFriendControl( + friend = removal.friend, + authMode = DirectP2pAuthMode.OFFLINE, + ).fold( + ifLeft = { + arrow.core.Either.Left( + FriendRequestFailure.Unreachable, + ) + }, + ifRight = { target -> + friendRequestClient.remove( + target, + com.minekube.connect.share.friend.FriendRemovalRequest( + operationId = removal.operationId, + relationshipId = removal.friend.relationshipId, + ), + ) + }, + ) + } + val friendsViewModel = FriendsViewModel( + store = friendStore, + onPeerRemoved = { peerId -> + val minecraftUuid = friendStore.pendingRemovals() + .lastOrNull { it.friend.peerId == peerId } + ?.friend + ?.minecraftUuid + admission.revokeDirectPeer(peerId, minecraftUuid) + approvedJoins.revokeDirectPeer(peerId, minecraftUuid) + }, + onRemovalQueued = { + scope.launch(Dispatchers.IO) { + removalSync.sync() + } + }, + ) + val recoveryViewModel = RecoveryViewModel( + scope = scope, + actions = StoredRecoveryUiActions( + RecoveryStore(dataDirectory), + ), + restoreAllowed = { + viewModel.state.value.shareState is ShareState.Idle + }, + ) + val activityMonitor = FriendActivityMonitor( + store = friendStore, + query = { friend -> + activeBrowser.openFriendControl( + friend = friend, + authMode = DirectP2pAuthMode.OFFLINE, + ).fold( + ifLeft = { + arrow.core.Either.Left( + FriendRequestFailure.Unreachable, + ) + }, + ifRight = { target -> + friendRequestClient.activity( + target, + FriendActivityRequest(UUID.randomUUID()), + ) + }, + ) + }, + ) + scope.launch(Dispatchers.IO) { + while (isActive) { + activityMonitor.refresh() + delay(ACTIVITY_REFRESH_MILLIS) + } + } + scope.launch(Dispatchers.IO) { + while (isActive) { + removalSync.sync() + delay(REMOVAL_SYNC_MILLIS) + } + } + val friendPairingClient = FriendPairingClient( + store = friendStore, + issuer = friendCardIssuer, + receiver = friendCardReceiver, + requestClient = friendRequestClient, + ) + val friendJoinOrchestrator = FriendJoinOrchestrator.create( + friends = friendsViewModel, + browser = activeBrowser, + requestClient = friendRequestClient, + ownConnectAddress = ownConnectAddress::get, + gameplayAuthMode = { DirectP2pAuthMode.ONLINE }, + localCompatibility = compatibilityProfile, + diagnostics = diagnostics, + ) + return ConnectShareInstallation( + viewModel = viewModel, + friendsViewModel = friendsViewModel, + recoveryViewModel = recoveryViewModel, + runtime = runtime, + friendCardIssuer = friendCardIssuer, + friendCardReceiver = friendCardReceiver, + friendRequestClient = friendRequestClient, + friendPairingClient = friendPairingClient, + friendJoinOrchestrator = friendJoinOrchestrator, + diagnostics = diagnostics, + minecraftVersion = minecraftVersion, + modVersion = modVersion, + approvedJoins = approvedJoins, + controlPlane = startedControlPlane, + directControlPlane = startedDirectControlPlane, + browser = activeBrowser, + friendActivity = activityMonitor, + gateway = gateway, + ownConnectAddress = ownConnectAddress::get, + screens = screens, + guestScreens = guestScreens, + ) + } catch (failure: Throwable) { + try { + withContext(NonCancellable) { + directControlPlane?.shutdown() + controlPlane?.shutdown() + browser?.close() + gateway.close() + } + } catch (cleanupFailure: Throwable) { + if (cleanupFailure !== failure) { + failure.addSuppressed(cleanupFailure) + } + } + throw failure + } + } + + internal fun watchHttpUrl(environment: Map) = + normalizeWebSocketScheme( + environment[WATCH_URL_ENV] ?: DEFAULT_WATCH_URL, + ).toHttpUrlOrNull() + ?: normalizeWebSocketScheme(DEFAULT_WATCH_URL).toHttpUrl() + + internal fun socialControlOptions(): ShareOptions = ShareOptions( + gameMode = ShareGameMode.SURVIVAL, + allowCheats = false, + allowInternetDirect = true, + ) + + private fun normalizeWebSocketScheme(value: String): String = when { + value.startsWith("wss://", ignoreCase = true) -> + "https://${value.substring(WSS_SCHEME_LENGTH)}" + + value.startsWith("ws://", ignoreCase = true) -> + "http://${value.substring(WS_SCHEME_LENGTH)}" + + else -> value + } + + private const val WATCH_URL_ENV = "CONNECT_WATCH_URL" + private const val DEFAULT_WATCH_URL = "wss://watch-connect.minekube.net" + private const val WSS_SCHEME_LENGTH = 6 + private const val WS_SCHEME_LENGTH = 5 + private const val HOST_PLAYER_COUNT = 1 + private const val DEFAULT_MAX_GUESTS = 8 + private const val REMOVAL_SYNC_MILLIS = 10_000L + private const val ACTIVITY_REFRESH_MILLIS = 10_000L + private const val SOCIAL_IDENTITY_FILE_NAME = + "share-libp2p-social-identity.key" +} + +private class FabricConnectLogger( + private val delegate: Logger = Logger.getLogger(ConnectLogger.LOGGER_NAME), +) : ConnectLogger { + @Volatile + private var debugEnabled = false + + override fun error(message: String, vararg args: Any?) { + delegate.severe(MessageFormatter.format(message, *args)) + } + + override fun error( + message: String, + throwable: Throwable, + vararg args: Any?, + ) { + delegate.log( + Level.SEVERE, + MessageFormatter.format(message, *args), + throwable, + ) + } + + override fun warn(message: String, vararg args: Any?) { + delegate.warning(MessageFormatter.format(message, *args)) + } + + override fun info(message: String, vararg args: Any?) { + delegate.info(MessageFormatter.format(message, *args)) + } + + override fun translatedInfo(message: String, vararg args: Any?) { + info(message, *args) + } + + override fun debug(message: String, vararg args: Any?) { + if (debugEnabled) { + delegate.fine(MessageFormatter.format(message, *args)) + } + } + + override fun trace(message: String, vararg args: Any?) { + if (debugEnabled) { + delegate.finer(MessageFormatter.format(message, *args)) + } + } + + override fun enableDebug() { + debugEnabled = true + } + + override fun disableDebug() { + debugEnabled = false + } + + override fun isDebug(): Boolean = debugEnabled +} diff --git a/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FabricShareBrowser.kt b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FabricShareBrowser.kt new file mode 100644 index 000000000..e5afab5a5 --- /dev/null +++ b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FabricShareBrowser.kt @@ -0,0 +1,585 @@ +package com.minekube.connect.share.fabric + +import arrow.core.Either +import arrow.core.left +import arrow.core.right +import com.minekube.connect.share.direct.ShareInviteCodec +import com.minekube.connect.share.direct.ShareInviteError +import com.minekube.connect.share.direct.ShareJoinError +import com.minekube.connect.share.direct.ShareRoute +import com.minekube.connect.share.direct.SignedShareInvite +import com.minekube.connect.share.direct.TransportSelector +import com.minekube.connect.share.friend.SavedFriend +import com.minekube.connect.tunnel.p2p.DirectP2pAuthMode +import com.minekube.connect.tunnel.p2p.DirectP2pDiscoveredShare +import com.minekube.connect.tunnel.p2p.DirectP2pDiscoveryListener +import com.minekube.connect.tunnel.p2p.DirectP2pNode +import com.minekube.connect.tunnel.p2p.DirectP2pProxy +import java.net.InetSocketAddress +import java.nio.file.Path +import java.time.Duration +import java.time.Instant +import java.util.Base64 +import java.util.concurrent.atomic.AtomicBoolean +import java.util.logging.Logger +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.withContext + +class DiscoveredLanShare( + val displayName: String, + val invitationUri: String, + val invitation: SignedShareInvite, + val lanAddress: String, +) { + override fun toString(): String = + "DiscoveredLanShare(displayName=$displayName, " + + "invitationUri=, invitation=, " + + "lanAddress=)" +} + +sealed interface GuestJoinTarget : AutoCloseable { + val route: ShareRoute + + data class Connect( + val publicAddress: String, + ) : GuestJoinTarget { + override val route: ShareRoute = ShareRoute.CONNECT + override fun close() = Unit + } + + class Direct( + override val route: ShareRoute, + val localAddress: InetSocketAddress, + private val proxy: DirectP2pProxy, + ) : GuestJoinTarget { + override fun close() { + proxy.close() + } + + override fun toString(): String = + "Direct(route=$route, localAddress=$localAddress)" + } +} + +sealed interface GuestJoinFailure { + val safeMessage: String + + data class InvalidInvitation( + val error: ShareInviteError, + ) : GuestJoinFailure { + override val safeMessage: String = error.safeMessage + } + + data object PeerMismatch : GuestJoinFailure { + override val safeMessage = + "The discovered host does not match this Connect Share invitation" + } + + data object DiscoveryUnavailable : GuestJoinFailure { + override val safeMessage = + "Automatic LAN discovery is unavailable; paste a Connect Share invitation" + } + + data object NoRoute : GuestJoinFailure { + override val safeMessage: String = ShareJoinError.NoRoute.safeMessage + } + + data object EndpointConflict : GuestJoinFailure { + override val safeMessage = + "This profile uses the same Connect endpoint as your friend; reset one profile's Connect identity" + } +} + +class FabricShareBrowser private constructor( + private val node: FabricGuestDirectNode, + private val now: () -> Instant, + private val ioDispatcher: CoroutineDispatcher, + private val routeReporter: (String) -> Unit, +) : AutoCloseable { + constructor() : this( + node = CoreFabricGuestDirectNode(DirectP2pNode()), + now = Instant::now, + ioDispatcher = Dispatchers.IO, + routeReporter = LOGGER::info, + ) + + constructor(dataDirectory: Path) : this( + node = CoreFabricGuestDirectNode( + DirectP2pNode(dataDirectory.resolve(IDENTITY_FILE_NAME)), + ), + now = Instant::now, + ioDispatcher = Dispatchers.IO, + routeReporter = LOGGER::info, + ) + + internal constructor(node: FabricGuestDirectNode) : this( + node = node, + now = Instant::now, + ioDispatcher = Dispatchers.IO, + routeReporter = LOGGER::info, + ) + + private val mutableDiscovered = + MutableStateFlow>(emptyList()) + private val started = AtomicBoolean() + private val closed = AtomicBoolean() + + val discovered: StateFlow> = + mutableDiscovered.asStateFlow() + val peerId: String + get() = node.peerId() + + fun start(): Either { + if (started.get()) { + return Unit.right() + } + return Either.catch { + node.startDiscovery(::onDiscovered) + started.set(true) + }.mapLeft { + GuestJoinFailure.DiscoveryUnavailable + } + } + + fun parse( + invitationUri: String, + ): Either = + ShareInviteCodec.decode(invitationUri.trim(), now()) + .mapLeft(GuestJoinFailure::InvalidInvitation) + + suspend fun join( + invitationUri: String, + lanAddress: String?, + internetOptIn: Boolean, + authMode: DirectP2pAuthMode, + ): Either { + val invitation = parse(invitationUri).fold( + ifLeft = { return it.left() }, + ifRight = { it }, + ) + val payload = invitation.payload + val effectiveLanAddress = + lanAddress ?: matchingLanAddress(invitation) + val routes = TransportSelector.plan( + sameLan = effectiveLanAddress != null, + hostInternetOptIn = payload.internetDirectEnabled, + guestInternetOptIn = internetOptIn, + connectAddress = payload.connectAddress, + ) + return withContext(ioDispatcher) { + for (route in routes.distinct()) { + when (route) { + ShareRoute.DIRECT_LAN -> { + val address = effectiveLanAddress ?: continue + val direct = openDirect( + route, + address, + invitation, + authMode, + LAN_TIMEOUT, + ) + if (direct != null) { + reportRoute(ROUTE_DIRECT_LAN) + return@withContext direct.right() + } + reportRoute(ROUTE_DIRECT_LAN_UNAVAILABLE) + } + + ShareRoute.DIRECT_INTERNET -> { + var attempted = false + for (address in payload.directCandidates) { + attempted = true + val direct = openDirect( + route, + address, + invitation, + authMode, + INTERNET_TIMEOUT, + ) + if (direct != null) { + reportRoute(ROUTE_DIRECT_INTERNET) + return@withContext direct.right() + } + } + if (attempted) { + reportRoute( + ROUTE_DIRECT_INTERNET_UNAVAILABLE, + ) + } + } + + ShareRoute.CONNECT -> { + payload.connectAddress?.let { + reportRoute(ROUTE_CONNECT_FALLBACK) + return@withContext GuestJoinTarget.Connect(it).right() + } + } + } + } + GuestJoinFailure.NoRoute.left() + } + } + + suspend fun join( + friend: SavedFriend, + authMode: DirectP2pAuthMode, + ownConnectAddress: String? = null, + ): Either = + withContext(ioDispatcher) { + val discovered = matchingLanShare(friend) + if (discovered != null) { + val direct = openDirect( + route = ShareRoute.DIRECT_LAN, + address = discovered.lanAddress, + shareId = friend.shareId.toString(), + capability = friend.capability, + authMode = authMode, + timeout = LAN_TIMEOUT, + ) + if (direct != null) { + reportRoute(ROUTE_DIRECT_LAN) + return@withContext direct.right() + } + reportRoute(ROUTE_DIRECT_LAN_UNAVAILABLE) + } else { + reportRoute(ROUTE_DIRECT_LAN_UNAVAILABLE) + } + if (friend.internetDirectEnabled && friend.internetDirectGuestOptIn) { + var attempted = false + for (address in friend.directCandidates) { + attempted = true + val direct = openDirect( + route = ShareRoute.DIRECT_INTERNET, + address = address, + shareId = friend.shareId.toString(), + capability = friend.capability, + authMode = authMode, + timeout = INTERNET_TIMEOUT, + ) + if (direct != null) { + reportRoute(ROUTE_DIRECT_INTERNET) + return@withContext direct.right() + } + } + if (attempted) { + reportRoute(ROUTE_DIRECT_INTERNET_UNAVAILABLE) + } + } + if ( + connectAddressesMatch( + friend.connectAddress, + ownConnectAddress, + ) + ) { + reportRoute(ROUTE_ENDPOINT_CONFLICT) + return@withContext GuestJoinFailure.EndpointConflict.left() + } + friend.connectAddress?.let { + reportRoute(ROUTE_CONNECT_FALLBACK) + return@withContext GuestJoinTarget.Connect(it).right() + } + GuestJoinFailure.NoRoute.left() + } + + suspend fun openFriendControl( + friend: SavedFriend, + authMode: DirectP2pAuthMode, + ): Either = + withContext(ioDispatcher) { + matchingLanShare(friend)?.let { discovered -> + openDirect( + route = ShareRoute.DIRECT_LAN, + address = discovered.lanAddress, + shareId = friend.shareId.toString(), + capability = friend.capability, + authMode = authMode, + timeout = LAN_TIMEOUT, + )?.let { return@withContext it.right() } + } + if (friend.internetDirectEnabled && friend.internetDirectGuestOptIn) { + for (address in friend.directCandidates) { + openDirect( + route = ShareRoute.DIRECT_INTERNET, + address = address, + shareId = friend.shareId.toString(), + capability = friend.capability, + authMode = authMode, + timeout = INTERNET_TIMEOUT, + )?.let { return@withContext it.right() } + } + } + GuestJoinFailure.NoRoute.left() + } + + suspend fun probeLan( + friend: SavedFriend, + authMode: DirectP2pAuthMode, + probe: FriendStatusProbe, + ): ServerPresence? = withContext(ioDispatcher) { + val discovered = matchingLanShare(friend) + ?: return@withContext null + val direct = openDirect( + route = ShareRoute.DIRECT_LAN, + address = discovered.lanAddress, + shareId = friend.shareId.toString(), + capability = friend.capability, + authMode = authMode, + timeout = LAN_TIMEOUT, + ) ?: return@withContext null + direct.use { + probe.probe(direct.localAddress.statusAddress()).getOrNull() + } + } + + suspend fun probeDirect( + friend: SavedFriend, + authMode: DirectP2pAuthMode, + probe: FriendStatusProbe, + ): ServerPresence? = withContext(ioDispatcher) { + matchingLanShare(friend)?.let { discovered -> + val direct = openDirect( + route = ShareRoute.DIRECT_LAN, + address = discovered.lanAddress, + shareId = friend.shareId.toString(), + capability = friend.capability, + authMode = authMode, + timeout = LAN_TIMEOUT, + ) + if (direct != null) { + val presence = direct.use { + probe.probe(direct.localAddress.statusAddress()).getOrNull() + } + if (presence != null) { + return@withContext presence.copy(route = ShareRoute.DIRECT_LAN) + } + } + } + if (friend.internetDirectEnabled && friend.internetDirectGuestOptIn) { + for (address in friend.directCandidates) { + val direct = openDirect( + route = ShareRoute.DIRECT_INTERNET, + address = address, + shareId = friend.shareId.toString(), + capability = friend.capability, + authMode = authMode, + timeout = INTERNET_TIMEOUT, + ) ?: continue + val presence = direct.use { + probe.probe(direct.localAddress.statusAddress()).getOrNull() + } + if (presence != null) { + return@withContext presence.copy( + route = ShareRoute.DIRECT_INTERNET, + ) + } + } + } + null + } + + override fun close() { + if (closed.compareAndSet(false, true)) { + node.close() + mutableDiscovered.value = emptyList() + } + } + + private fun onDiscovered(discovered: DirectP2pDiscoveredShare) { + val invitation = ShareInviteCodec.decode( + discovered.invitation(), + now(), + ).getOrNull() ?: return + if (invitation.payload.peerId != discovered.peerId()) { + return + } + val found = DiscoveredLanShare( + displayName = discovered.displayName(), + invitationUri = discovered.invitation(), + invitation = invitation, + lanAddress = discovered.address(), + ) + mutableDiscovered.update { current -> + ( + current.filterNot { + val existing = it.invitation.payload + existing.shareId == invitation.payload.shareId && + existing.peerId == invitation.payload.peerId + } + found + ).takeLast(MAX_DISCOVERED_SHARES) + } + } + + private fun matchingLanAddress( + invitation: SignedShareInvite, + ): String? { + val payload = invitation.payload + return mutableDiscovered.value.firstOrNull { + val discovered = it.invitation.payload + discovered.shareId == payload.shareId && + discovered.peerId == payload.peerId + }?.lanAddress + } + + private fun matchingLanShare(friend: SavedFriend): DiscoveredLanShare? = + mutableDiscovered.value.firstOrNull { + val invitation = it.invitation + val payload = invitation.payload + payload.shareId == friend.shareId && + payload.peerId == friend.peerId && + payload.capability == friend.capability && + Base64.getEncoder().encodeToString(invitation.publicKey) == + friend.publicKeyBase64 + } + + private fun openDirect( + route: ShareRoute, + address: String, + invitation: SignedShareInvite, + authMode: DirectP2pAuthMode, + timeout: Duration, + ): GuestJoinTarget.Direct? = openDirect( + route = route, + address = address, + shareId = invitation.payload.shareId.toString(), + capability = invitation.payload.capability, + authMode = authMode, + timeout = timeout, + ) + + private fun openDirect( + route: ShareRoute, + address: String, + shareId: String, + capability: String, + authMode: DirectP2pAuthMode, + timeout: Duration, + ): GuestJoinTarget.Direct? = try { + val proxy = node.openProxy( + address = address, + shareId = shareId, + capability = capability, + authMode = authMode, + timeout = timeout, + ) + GuestJoinTarget.Direct( + route = route, + localAddress = proxy.localAddress(), + proxy = proxy, + ) + } catch (_: RuntimeException) { + null + } + + private fun reportRoute(message: String) { + try { + routeReporter(message) + } catch (_: RuntimeException) { + // Diagnostics must never alter route selection. + } + } + + private fun InetSocketAddress.statusAddress(): String { + val host = hostString + return if (host.contains(':')) { + "[$host]:$port" + } else { + "$host:$port" + } + } + + companion object { + internal fun testing( + node: FabricGuestDirectNode, + now: () -> Instant, + ioDispatcher: CoroutineDispatcher, + routeReporter: (String) -> Unit = {}, + ) = FabricShareBrowser( + node, + now, + ioDispatcher, + routeReporter, + ) + + private val LAN_TIMEOUT = Duration.ofSeconds(3) + private val INTERNET_TIMEOUT = Duration.ofSeconds(5) + private const val MAX_DISCOVERED_SHARES = 32 + private const val IDENTITY_FILE_NAME = "share-libp2p-identity.key" + private val LOGGER = Logger.getLogger("Connect") + private const val ROUTE_DIRECT_LAN = + "Connect Share route: direct LAN" + private const val ROUTE_DIRECT_LAN_UNAVAILABLE = + "Connect Share route: direct LAN unavailable" + private const val ROUTE_DIRECT_INTERNET = + "Connect Share route: direct internet" + private const val ROUTE_DIRECT_INTERNET_UNAVAILABLE = + "Connect Share route: direct internet unavailable" + private const val ROUTE_CONNECT_FALLBACK = + "Connect Share route: using Connect fallback" + private const val ROUTE_ENDPOINT_CONFLICT = + "Connect Share route: blocked copied Connect endpoint" + } +} + +internal fun connectAddressesMatch( + first: String?, + second: String?, +): Boolean { + val normalizedFirst = normalizeConnectAddress(first) + val normalizedSecond = normalizeConnectAddress(second) + return normalizedFirst != null && normalizedFirst == normalizedSecond +} + +private fun normalizeConnectAddress(value: String?): String? = + value + ?.trim() + ?.lowercase() + ?.removeSuffix(".") + ?.removeSuffix(":25565") + ?.takeIf(String::isNotEmpty) + +internal interface FabricGuestDirectNode : AutoCloseable { + fun peerId(): String + + fun startDiscovery(listener: DirectP2pDiscoveryListener) + + fun openProxy( + address: String, + shareId: String, + capability: String, + authMode: DirectP2pAuthMode, + timeout: Duration, + ): DirectP2pProxy +} + +private class CoreFabricGuestDirectNode( + private val node: DirectP2pNode, +) : FabricGuestDirectNode { + override fun peerId(): String = node.peerId() + + override fun startDiscovery(listener: DirectP2pDiscoveryListener) { + node.startDiscovery(listener) + } + + override fun openProxy( + address: String, + shareId: String, + capability: String, + authMode: DirectP2pAuthMode, + timeout: Duration, + ): DirectP2pProxy = node.openProxy( + address, + shareId, + capability, + authMode, + timeout, + ) + + override fun close() { + node.close() + } +} diff --git a/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FollowNextSessionController.kt b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FollowNextSessionController.kt new file mode 100644 index 000000000..a1cdf893a --- /dev/null +++ b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FollowNextSessionController.kt @@ -0,0 +1,137 @@ +package com.minekube.connect.share.fabric + +import com.minekube.connect.share.friend.FriendActivity +import com.minekube.connect.share.friend.FriendActivityKind +import java.time.Instant +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +data class FollowIntent( + val peerId: String, + val displayName: String, + val expiresAt: Instant, + val emittedEpoch: String? = null, +) + +sealed interface FollowAction { + val peerId: String + val displayName: String + + data class RequestJoin( + override val peerId: String, + override val displayName: String, + val sessionEpoch: String, + ) : FollowAction + + data class OfferJoinNow( + override val peerId: String, + override val displayName: String, + val sessionEpoch: String, + ) : FollowAction + + data class Expired( + override val peerId: String, + override val displayName: String, + ) : FollowAction + + data class Cancelled( + override val peerId: String, + override val displayName: String, + ) : FollowAction +} + +class FollowNextSessionController( + private val now: () -> Instant = Instant::now, + private val lifetimeSeconds: Long = DEFAULT_LIFETIME_SECONDS, +) { + private val mutableState = MutableStateFlow>( + emptyMap(), + ) + val state: StateFlow> = mutableState.asStateFlow() + + @Synchronized + fun follow(peerId: String, displayName: String) { + val normalizedName = displayName.trim().ifEmpty { "Friend" } + mutableState.value = mutableState.value + ( + peerId to FollowIntent( + peerId = peerId, + displayName = normalizedName, + expiresAt = now().plusSeconds(lifetimeSeconds), + ) + ) + } + + @Synchronized + fun cancel(peerId: String): Boolean { + if (peerId !in mutableState.value) return false + mutableState.value = mutableState.value - peerId + return true + } + + @Synchronized + fun complete(peerId: String): Boolean = cancel(peerId) + + @Synchronized + fun update( + activities: Map, + activeGameplay: Boolean, + confirmedPeerIds: Set, + ): List { + val instant = now() + val actions = mutableListOf() + val retained = linkedMapOf() + mutableState.value.values.forEach { intent -> + when { + intent.peerId !in confirmedPeerIds -> + actions += FollowAction.Cancelled( + intent.peerId, + intent.displayName, + ) + + !instant.isBefore(intent.expiresAt) -> + actions += FollowAction.Expired( + intent.peerId, + intent.displayName, + ) + + else -> { + val activity = activities[intent.peerId] + val epoch = activity?.takeIf { + it.joinable && it.kind != FriendActivityKind.ONLINE + }?.effectiveEpoch() + if (epoch != null && epoch != intent.emittedEpoch) { + actions += if (activeGameplay) { + FollowAction.OfferJoinNow( + intent.peerId, + intent.displayName, + epoch, + ) + } else { + FollowAction.RequestJoin( + intent.peerId, + intent.displayName, + epoch, + ) + } + retained[intent.peerId] = intent.copy( + emittedEpoch = epoch, + ) + } else { + retained[intent.peerId] = intent + } + } + } + } + mutableState.value = retained + return actions + } + + private fun FriendActivity.effectiveEpoch(): String = + sessionEpoch ?: listOf(kind.name, description.orEmpty(), joinable) + .joinToString(":") + + private companion object { + const val DEFAULT_LIFETIME_SECONDS = 30 * 60L + } +} diff --git a/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FriendActivityMonitor.kt b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FriendActivityMonitor.kt new file mode 100644 index 000000000..fbd1523fe --- /dev/null +++ b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FriendActivityMonitor.kt @@ -0,0 +1,58 @@ +package com.minekube.connect.share.fabric + +import arrow.core.Either +import arrow.fx.coroutines.parMap +import com.minekube.connect.share.friend.FriendActivity +import com.minekube.connect.share.friend.FriendStore +import com.minekube.connect.share.friend.SavedFriend +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.withContext + +class FriendActivityMonitor private constructor( + private val friends: () -> List, + private val query: suspend (SavedFriend) -> + Either, + private val ioDispatcher: CoroutineDispatcher, +) { + constructor( + store: FriendStore, + query: suspend (SavedFriend) -> + Either, + ioDispatcher: CoroutineDispatcher = Dispatchers.IO, + ) : this(store::all, query, ioDispatcher) + + private val mutableState = + MutableStateFlow>(emptyMap()) + val state: StateFlow> = + mutableState.asStateFlow() + + suspend fun refresh() = withContext(ioDispatcher) { + mutableState.value = runCatching(friends) + .getOrDefault(emptyList()) + .take(MAX_QUERIED_FRIENDS) + .parMap( + context = ioDispatcher, + concurrency = MAX_CONCURRENT_QUERIES, + ) { friend -> + query(friend).getOrNull()?.let { friend.peerId to it } + } + .filterNotNull() + .toMap() + } + + companion object { + internal fun testing( + friends: () -> List, + query: suspend (SavedFriend) -> + Either, + ioDispatcher: CoroutineDispatcher, + ) = FriendActivityMonitor(friends, query, ioDispatcher) + + private const val MAX_QUERIED_FRIENDS = 32 + private const val MAX_CONCURRENT_QUERIES = 4 + } +} diff --git a/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FriendActivityResolver.kt b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FriendActivityResolver.kt new file mode 100644 index 000000000..6874c1481 --- /dev/null +++ b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FriendActivityResolver.kt @@ -0,0 +1,33 @@ +package com.minekube.connect.share.fabric + +import com.minekube.connect.share.friend.FriendActivity +import com.minekube.connect.share.friend.FriendActivityKind +import com.minekube.connect.share.friend.CompatibilityProfile + +object FriendActivityResolver { + fun resolve( + worldAvailable: Boolean, + worldSharingActive: Boolean, + worldName: String?, + externalServerName: String?, + sessionEpoch: String? = null, + compatibility: CompatibilityProfile? = null, + ): FriendActivity = when { + externalServerName != null -> FriendActivity( + FriendActivityKind.PLAYING_SERVER, + externalServerName, + sessionEpoch = sessionEpoch, + compatibility = compatibility, + ) + worldAvailable && worldSharingActive -> FriendActivity( + FriendActivityKind.HOSTING_WORLD, + worldName?.takeIf(String::isNotBlank) ?: "Minecraft world", + sessionEpoch = sessionEpoch, + compatibility = compatibility, + ) + else -> FriendActivity( + FriendActivityKind.ONLINE, + compatibility = compatibility, + ) + } +} diff --git a/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FriendCardExchangeConsent.kt b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FriendCardExchangeConsent.kt new file mode 100644 index 000000000..26224c86b --- /dev/null +++ b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FriendCardExchangeConsent.kt @@ -0,0 +1,53 @@ +package com.minekube.connect.share.fabric + +import java.util.UUID + +data class FriendCardExchangeProof( + val peerId: String, + val relationshipId: UUID, +) + +class FriendCardExchangeConsent( + private val nowMillis: () -> Long = System::currentTimeMillis, +) { + private var armed: TimedExchange? = null + + @Synchronized + fun arm(peerId: String, relationshipId: UUID = UUID.randomUUID()) { + require(peerId.isNotBlank()) + armed = TimedExchange( + proof = FriendCardExchangeProof(peerId, relationshipId), + armedAtMillis = nowMillis(), + ) + } + + @Synchronized + fun consume(): FriendCardExchangeProof? { + val exchange = armed ?: return null + armed = null + return exchange.proof.takeIf { + nowMillis() - exchange.armedAtMillis <= + CONSENT_LIFETIME_MILLIS + } + } + + @Synchronized + fun cancel() { + armed = null + } + + private data class TimedExchange( + val proof: FriendCardExchangeProof, + val armedAtMillis: Long, + ) + + companion object { + const val CONSENT_LIFETIME_MILLIS = 120_000L + + fun shouldArm( + savedFriendJoin: Boolean, + canSeeMyWorlds: Boolean?, + ): Boolean = + savedFriendJoin && canSeeMyWorlds == true + } +} diff --git a/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FriendCardIssuer.kt b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FriendCardIssuer.kt new file mode 100644 index 000000000..5fd6eb965 --- /dev/null +++ b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FriendCardIssuer.kt @@ -0,0 +1,130 @@ +package com.minekube.connect.share.fabric + +import arrow.core.Either +import arrow.core.flatMap +import arrow.core.raise.either +import arrow.core.raise.ensure +import com.minekube.connect.share.direct.ShareInviteCodec +import com.minekube.connect.share.direct.ShareInvitePayload +import com.minekube.connect.share.direct.SignedShareInvite +import com.minekube.connect.share.friend.ShareAccessIdentityStore +import com.minekube.connect.share.friend.FriendStore +import com.minekube.connect.share.friend.FriendStoreError +import com.minekube.connect.share.friend.SavedFriend +import com.minekube.connect.tunnel.p2p.DirectP2pNode +import java.nio.file.Path +import java.time.Instant +import java.util.UUID + +data object FriendCardIssueFailure + +data class FriendDirectRoute( + val internetDirectEnabled: Boolean, + val candidates: List, +) + +class FriendCardReceiver( + private val store: FriendStore, +) { + fun confirmOutgoing( + peerId: String, + ): Either = + store.confirmOutgoing(peerId) + + fun receive( + invitation: String, + displayName: String, + authenticatedMinecraftUuid: UUID?, + allowAutomaticJoin: Boolean = false, + relationshipId: UUID? = null, + now: Instant = Instant.now(), + ): Either = + (if (allowAutomaticJoin) { + store.acceptAndAllowJoin( + invitation, + displayName, + now, + relationshipId, + ) + } else { + store.accept( + invitation, + displayName, + now, + relationshipId, + ) + }).flatMap { friend -> + authenticatedMinecraftUuid?.let { minecraftUuid -> + store.linkMinecraftIdentity( + friend.peerId, + minecraftUuid, + ) + } ?: Either.Right(friend) + } +} + +class FriendCardIssuer( + private val dataDirectory: Path, + private val displayName: () -> String? = { null }, + private val accessIdentityStore: ShareAccessIdentityStore = + ShareAccessIdentityStore(dataDirectory), + private val directRoute: suspend () -> FriendDirectRoute? = { null }, + private val identityFile: Path = + dataDirectory.resolve(IDENTITY_FILE_NAME), + private val connectAddress: suspend () -> String?, +) { + suspend fun issue( + now: Instant = Instant.now(), + ): Either = either { + val normalizedDisplayName = displayName()?.trim() + ensure( + normalizedDisplayName == null || + normalizedDisplayName.length in 1..MAX_DISPLAY_NAME_LENGTH, + ) { + FriendCardIssueFailure + } + Either.catch { + val access = accessIdentityStore.currentOrCreate() + DirectP2pNode( + identityFile, + ).use { node -> + val route = directRoute() + val payload = ShareInvitePayload( + wireVersion = ShareInviteCodec.WIRE_VERSION, + shareId = access.shareId, + expiresAtEpochMillis = now + .plusSeconds(CARD_LIFETIME_SECONDS) + .toEpochMilli(), + connectAddress = connectAddress(), + peerId = node.peerId(), + internetDirectEnabled = + route?.internetDirectEnabled == true, + directCandidates = route?.candidates.orEmpty(), + capability = access.capability, + displayName = normalizedDisplayName, + ) + val publicKey = node.publicKey() + val unsigned = ShareInviteCodec.unsignedBytes( + payload, + publicKey, + ) + ShareInviteCodec.encode( + SignedShareInvite( + payload = payload, + publicKey = publicKey, + signature = node.sign(unsigned), + ), + ) + } + }.mapLeft { + FriendCardIssueFailure + }.bind() + } + + private companion object { + private const val IDENTITY_FILE_NAME = + "share-libp2p-identity.key" + private const val CARD_LIFETIME_SECONDS = 24 * 60 * 60L + private const val MAX_DISPLAY_NAME_LENGTH = 64 + } +} diff --git a/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FriendJoinOrchestrator.kt b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FriendJoinOrchestrator.kt new file mode 100644 index 000000000..8d34797cf --- /dev/null +++ b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FriendJoinOrchestrator.kt @@ -0,0 +1,164 @@ +package com.minekube.connect.share.fabric + +import arrow.core.Either +import arrow.core.flatMap +import arrow.core.left +import arrow.core.right +import com.minekube.connect.share.fabric.ui.FriendsViewModel +import com.minekube.connect.share.friend.FriendJoinRequest +import com.minekube.connect.share.friend.CompatibilityProfile +import com.minekube.connect.share.friend.CompatibilityReport +import com.minekube.connect.tunnel.p2p.DirectP2pAuthMode + +sealed interface FriendJoinAttemptFailure { + val safeMessage: String + + data class Control( + val failure: GuestJoinFailure, + ) : FriendJoinAttemptFailure { + override val safeMessage: String = failure.safeMessage + } + + data class Request( + val failure: FriendRequestFailure, + ) : FriendJoinAttemptFailure { + override val safeMessage: String = failure.safeMessage + } + + data class Gameplay( + val failure: GuestJoinFailure, + ) : FriendJoinAttemptFailure { + override val safeMessage: String = failure.safeMessage + } + + data class Compatibility( + val report: CompatibilityReport.Mismatch, + ) : FriendJoinAttemptFailure { + override val safeMessage: String = report.safeMessage + val canTryAnyway: Boolean = !report.hasHardBlock + } +} + +class FriendJoinOrchestrator private constructor( + private val requestApproval: suspend ( + String, + FriendJoinRequest, + ) -> Either, + private val openSharedWorld: suspend (String) -> + Either, + private val localCompatibility: () -> CompatibilityProfile?, + private val remoteCompatibility: (String) -> CompatibilityProfile?, + private val diagnostics: ShareJoinDiagnostics, +) { + suspend fun request( + peerId: String, + request: FriendJoinRequest, + allowModMismatch: Boolean = false, + ): Either { + diagnostics.record(JoinStage.COMPATIBILITY, JoinOutcome.STARTED) + val mismatch = compatibilityMismatch(peerId) + if ( + mismatch != null && + (mismatch.hasHardBlock || !allowModMismatch) + ) { + diagnostics.record(JoinStage.COMPATIBILITY, JoinOutcome.FAILED) + return FriendJoinAttemptFailure.Compatibility(mismatch).left() + } + diagnostics.record(JoinStage.COMPATIBILITY, JoinOutcome.SUCCEEDED) + diagnostics.record(JoinStage.APPROVAL, JoinOutcome.STARTED) + val approval = requestApproval(peerId, request).fold( + ifLeft = { failure -> + diagnostics.record(JoinStage.APPROVAL, JoinOutcome.FAILED) + return failure.left() + }, + ifRight = { it }, + ) + diagnostics.record(JoinStage.APPROVAL, JoinOutcome.SUCCEEDED) + val target = when (approval) { + is FriendJoinApproval.ExternalServer -> + GuestJoinTarget.Connect(approval.address).right() + + FriendJoinApproval.SharedWorld -> openSharedWorld(peerId) + } + target.fold( + ifLeft = { + diagnostics.record(JoinStage.DIRECT, JoinOutcome.FAILED) + }, + ifRight = { joined -> + diagnostics.record( + when (joined) { + is GuestJoinTarget.Connect -> JoinStage.CONNECT_FALLBACK + is GuestJoinTarget.Direct -> JoinStage.DIRECT + }, + JoinOutcome.SUCCEEDED, + ) + }, + ) + return target + } + + private fun compatibilityMismatch( + peerId: String, + ): CompatibilityReport.Mismatch? { + val local = localCompatibility() ?: return null + val remote = remoteCompatibility(peerId) ?: return null + return local.compareTo(remote) as? CompatibilityReport.Mismatch + } + + companion object { + fun create( + friends: FriendsViewModel, + browser: FabricShareBrowser, + requestClient: FriendRequestClient, + ownConnectAddress: () -> String?, + gameplayAuthMode: () -> DirectP2pAuthMode, + localCompatibility: () -> CompatibilityProfile?, + diagnostics: ShareJoinDiagnostics, + ) = FriendJoinOrchestrator( + requestApproval = { peerId, request -> + friends.routeFriendControl( + peerId, + browser, + DirectP2pAuthMode.OFFLINE, + ).mapLeft(FriendJoinAttemptFailure::Control) + .flatMap { target -> + requestClient.requestJoin(target, request) + .mapLeft(FriendJoinAttemptFailure::Request) + } + }, + openSharedWorld = { peerId -> + friends.join( + peerId = peerId, + browser = browser, + authMode = gameplayAuthMode(), + ownConnectAddress = ownConnectAddress(), + ).mapLeft(FriendJoinAttemptFailure::Gameplay) + }, + localCompatibility = localCompatibility, + remoteCompatibility = friends::compatibilityFor, + diagnostics = diagnostics, + ) + + internal fun testing( + requestApproval: suspend (FriendJoinRequest) -> + Either, + openSharedWorld: suspend (String) -> + Either, + localCompatibility: () -> CompatibilityProfile? = { null }, + remoteCompatibility: (String) -> CompatibilityProfile? = { null }, + diagnostics: ShareJoinDiagnostics = ShareJoinDiagnostics(), + ) = FriendJoinOrchestrator( + requestApproval = { _, request -> + requestApproval(request) + .mapLeft(FriendJoinAttemptFailure::Request) + }, + openSharedWorld = { peerId -> + openSharedWorld(peerId) + .mapLeft(FriendJoinAttemptFailure::Gameplay) + }, + localCompatibility = localCompatibility, + remoteCompatibility = remoteCompatibility, + diagnostics = diagnostics, + ) + } +} diff --git a/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FriendPairingClient.kt b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FriendPairingClient.kt new file mode 100644 index 000000000..44179d314 --- /dev/null +++ b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FriendPairingClient.kt @@ -0,0 +1,95 @@ +package com.minekube.connect.share.fabric + +import arrow.core.Either +import arrow.core.raise.either +import arrow.core.raise.ensure +import com.minekube.connect.share.friend.FriendControlRequest +import com.minekube.connect.share.friend.FriendStore +import com.minekube.connect.share.friend.FriendStoreError +import com.minekube.connect.share.friend.SavedFriend +import java.time.Instant +import java.util.UUID +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +sealed interface FriendPairingFailure { + val safeMessage: String + + data class Store( + val error: FriendStoreError, + ) : FriendPairingFailure { + override val safeMessage: String = error.safeMessage + } + + data object CardIssue : FriendPairingFailure { + override val safeMessage = + "Your Connect Share friend card could not be created" + } + + data class Route( + val error: GuestJoinFailure, + ) : FriendPairingFailure { + override val safeMessage: String = error.safeMessage + } + + data class Delivery( + val error: FriendRequestFailure, + ) : FriendPairingFailure { + override val safeMessage: String = error.safeMessage + } +} + +class FriendPairingClient( + private val store: FriendStore, + private val issuer: FriendCardIssuer, + private val receiver: FriendCardReceiver, + private val requestClient: FriendRequestClient, + private val now: () -> Instant = Instant::now, + private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, +) { + suspend fun send( + invitation: String, + friendDisplayName: String, + senderDisplayName: String, + route: suspend (SavedFriend) -> + Either, + onReceived: () -> Unit, + ): Either = + withContext(ioDispatcher) { + either { + val pending = store.sendRequest( + invitationUri = invitation, + displayName = friendDisplayName, + now = now(), + ).mapLeft(FriendPairingFailure::Store).bind() + val senderCard = issuer.issue(now()) + .mapLeft { FriendPairingFailure.CardIssue } + .bind() + val target = route(pending) + .mapLeft(FriendPairingFailure::Route) + .bind() + ensure(target is GuestJoinTarget.Direct) { + target.close() + FriendPairingFailure.Route(GuestJoinFailure.NoRoute) + } + val hostCard = requestClient.exchange( + target = target, + request = FriendControlRequest( + requestId = UUID.randomUUID(), + relationshipId = pending.relationshipId, + displayName = senderDisplayName, + invitation = senderCard, + ), + onReceived = onReceived, + ).mapLeft(FriendPairingFailure::Delivery).bind() + receiver.receive( + invitation = hostCard, + displayName = friendDisplayName, + authenticatedMinecraftUuid = null, + relationshipId = pending.relationshipId, + now = now(), + ).mapLeft(FriendPairingFailure::Store).bind() + } + } +} diff --git a/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FriendPresenceMonitor.kt b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FriendPresenceMonitor.kt new file mode 100644 index 000000000..a67841339 --- /dev/null +++ b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FriendPresenceMonitor.kt @@ -0,0 +1,107 @@ +package com.minekube.connect.share.fabric + +import arrow.fx.coroutines.parMap +import com.minekube.connect.share.direct.ShareRoute +import com.minekube.connect.share.friend.FriendStore +import com.minekube.connect.share.friend.SavedFriend +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.withContext + +data class RemoteFriendPresence( + val peerId: String, + val displayName: String, + val online: Boolean, + val description: String? = null, + val notifyWhenOnline: Boolean, + val route: ShareRoute? = null, +) + +class FriendOnlineTracker { + private var onlinePeerIds: Set = emptySet() + + fun update( + presence: Map, + ): List { + val currentlyOnline = presence.values + .filter(RemoteFriendPresence::online) + val notifications = currentlyOnline.filter { + it.notifyWhenOnline && it.peerId !in onlinePeerIds + } + onlinePeerIds = currentlyOnline.mapTo(mutableSetOf()) { + it.peerId + } + return notifications + } +} + +class FriendPresenceMonitor private constructor( + private val friends: () -> List, + private val directProbe: suspend (SavedFriend) -> ServerPresence?, + private val ioDispatcher: CoroutineDispatcher, +) { + constructor( + store: FriendStore, + directProbe: suspend (SavedFriend) -> ServerPresence? = { null }, + ioDispatcher: CoroutineDispatcher = Dispatchers.IO, + ) : this( + friends = store::all, + directProbe = directProbe, + ioDispatcher = ioDispatcher, + ) + + private val mutableState = + MutableStateFlow>(emptyMap()) + + val state: StateFlow> = + mutableState.asStateFlow() + + suspend fun refresh() = withContext(ioDispatcher) { + val saved = runCatching(friends) + .getOrDefault(emptyList()) + .take(MAX_PROBED_FRIENDS) + val results = saved.parMap( + context = ioDispatcher, + concurrency = MAX_CONCURRENT_PROBES, + ) { friend -> + val directPresence = try { + directProbe(friend) + } catch (cancellation: CancellationException) { + throw cancellation + } catch (_: Exception) { + null + } + friend.peerId to RemoteFriendPresence( + peerId = friend.peerId, + displayName = friend.displayName, + online = directPresence != null, + description = directPresence?.description, + notifyWhenOnline = + friend.permissions.notifyWhenOnline, + route = directPresence?.route ?: ShareRoute.DIRECT_LAN, + ) + } + mutableState.value = results.toMap() + } + + companion object { + internal fun testing( + friends: () -> List, + directProbe: suspend (SavedFriend) -> ServerPresence? = { + null + }, + ioDispatcher: CoroutineDispatcher = Dispatchers.IO, + ) = FriendPresenceMonitor( + friends, + directProbe, + ioDispatcher, + ) + + private const val MAX_PROBED_FRIENDS = 32 + private const val MAX_CONCURRENT_PROBES = 4 + } +} diff --git a/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FriendRemovalSync.kt b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FriendRemovalSync.kt new file mode 100644 index 000000000..6a3f7a85c --- /dev/null +++ b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FriendRemovalSync.kt @@ -0,0 +1,43 @@ +package com.minekube.connect.share.fabric + +import arrow.core.Either +import arrow.fx.coroutines.parMap +import com.minekube.connect.share.friend.FriendStore +import com.minekube.connect.share.friend.PendingFriendRemoval + +data class RemovalSyncSummary( + val delivered: Int, + val pending: Int, +) + +fun interface FriendRemovalDelivery { + suspend fun deliver( + removal: PendingFriendRemoval, + ): Either +} + +class FriendRemovalSync( + private val store: FriendStore, + private val delivery: FriendRemovalDelivery, +) { + suspend fun sync(): RemovalSyncSummary { + val pending = store.pendingRemovals() + val delivered = pending.parMap(concurrency = MAX_CONCURRENT_DELIVERIES) { + removal -> + delivery.deliver(removal).fold( + ifLeft = { false }, + ifRight = { + store.acknowledgeRemoval(removal.operationId) + }, + ) + }.count { it } + return RemovalSyncSummary( + delivered = delivered, + pending = store.pendingRemovals().size, + ) + } + + private companion object { + const val MAX_CONCURRENT_DELIVERIES = 4 + } +} diff --git a/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FriendRequestClient.kt b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FriendRequestClient.kt new file mode 100644 index 000000000..d85457ac6 --- /dev/null +++ b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FriendRequestClient.kt @@ -0,0 +1,323 @@ +package com.minekube.connect.share.fabric + +import arrow.core.Either +import arrow.core.left +import arrow.core.right +import arrow.core.flatMap +import com.minekube.connect.share.friend.FriendControlDecode +import com.minekube.connect.share.friend.FriendControlRequest +import com.minekube.connect.share.friend.FriendControlResponse +import com.minekube.connect.share.friend.FriendControlWire +import com.minekube.connect.share.friend.FriendRemovalRequest +import com.minekube.connect.share.friend.FriendActivity +import com.minekube.connect.share.friend.FriendActivityRequest +import com.minekube.connect.share.friend.FriendJoinRequest +import java.io.ByteArrayOutputStream +import java.io.InputStream +import java.net.Socket +import java.net.SocketTimeoutException +import java.time.Duration +import kotlin.coroutines.coroutineContext +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.withContext + +sealed interface FriendRequestFailure { + val safeMessage: String + + data object Unreachable : FriendRequestFailure { + override val safeMessage = + "Your friend is not reachable right now" + } + + data object Declined : FriendRequestFailure { + override val safeMessage = + "Your friend declined this request" + } + + data object TimedOut : FriendRequestFailure { + override val safeMessage = + "Your friend did not answer in time" + } + + data object InvalidResponse : FriendRequestFailure { + override val safeMessage = + "The friend request response was invalid" + } +} + +sealed interface FriendJoinApproval { + data object SharedWorld : FriendJoinApproval + + data class ExternalServer(val address: String) : FriendJoinApproval +} + +class FriendRequestClient( + private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, + private val connectTimeout: Duration = Duration.ofSeconds(5), + private val decisionTimeout: Duration = Duration.ofSeconds(35), +) { + suspend fun exchange( + target: GuestJoinTarget.Direct, + request: FriendControlRequest, + onReceived: () -> Unit, + ): Either = withContext(ioDispatcher) { + target.use { + val socket = Socket() + val cancellation = coroutineContext[Job] + ?.invokeOnCompletion { socket.close() } + try { + socket.connect( + target.localAddress, + connectTimeout.toMillis().toInt(), + ) + socket.soTimeout = READ_POLL_MILLIS + socket.getOutputStream().apply { + write( + FriendControlWire.encodeRequest( + request = request, + ), + ) + flush() + } + val deadline = System.nanoTime() + decisionTimeout.toNanos() + var received = false + var outcome: Either? = null + while (outcome == null) { + coroutineContext.ensureActive() + when ( + val response = + socket.getInputStream().readResponse(deadline) + ) { + FriendControlResponse.Received -> { + if (!received) { + received = true + onReceived() + } + } + + is FriendControlResponse.Accepted -> + outcome = response.invitation.right() + + FriendControlResponse.Declined -> + outcome = FriendRequestFailure.Declined.left() + + FriendControlResponse.TimedOut -> + outcome = FriendRequestFailure.TimedOut.left() + + FriendControlResponse.Invalid -> + outcome = + FriendRequestFailure.InvalidResponse.left() + + FriendControlResponse.Removed -> + outcome = + FriendRequestFailure.InvalidResponse.left() + + is FriendControlResponse.Activity, + is FriendControlResponse.JoinAccepted, + FriendControlResponse.SharedWorldJoinAccepted, + -> outcome = + FriendRequestFailure.InvalidResponse.left() + } + } + outcome + } catch (cancellationFailure: CancellationException) { + throw cancellationFailure + } catch (_: SocketTimeoutException) { + FriendRequestFailure.TimedOut.left() + } catch (_: Exception) { + FriendRequestFailure.Unreachable.left() + } finally { + cancellation?.dispose() + socket.close() + } + } + } + + suspend fun remove( + target: GuestJoinTarget.Direct, + request: FriendRemovalRequest, + ): Either = withContext(ioDispatcher) { + target.use { + val socket = Socket() + val cancellation = coroutineContext[Job] + ?.invokeOnCompletion { socket.close() } + try { + socket.connect( + target.localAddress, + connectTimeout.toMillis().toInt(), + ) + socket.soTimeout = READ_POLL_MILLIS + socket.getOutputStream().apply { + write(FriendControlWire.encodeRemoval(request)) + flush() + } + val deadline = System.nanoTime() + decisionTimeout.toNanos() + while (true) { + coroutineContext.ensureActive() + when (socket.getInputStream().readResponse(deadline)) { + FriendControlResponse.Received -> Unit + FriendControlResponse.Removed -> return@withContext Unit.right() + FriendControlResponse.Declined -> + return@withContext FriendRequestFailure.Declined.left() + FriendControlResponse.TimedOut -> + return@withContext FriendRequestFailure.TimedOut.left() + FriendControlResponse.Invalid, + is FriendControlResponse.Accepted, + is FriendControlResponse.Activity, + is FriendControlResponse.JoinAccepted, + FriendControlResponse.SharedWorldJoinAccepted, + -> return@withContext FriendRequestFailure.InvalidResponse.left() + } + } + @Suppress("UNREACHABLE_CODE") + FriendRequestFailure.InvalidResponse.left() + } catch (cancellationFailure: CancellationException) { + throw cancellationFailure + } catch (_: SocketTimeoutException) { + FriendRequestFailure.TimedOut.left() + } catch (_: Exception) { + FriendRequestFailure.Unreachable.left() + } finally { + cancellation?.dispose() + socket.close() + } + } + } + + suspend fun activity( + target: GuestJoinTarget.Direct, + request: FriendActivityRequest, + ): Either = + exchangeControl( + target, + FriendControlWire.encodeActivityRequest(request), + ).flatMap { response -> + when (response) { + is FriendControlResponse.Activity -> response.activity.right() + FriendControlResponse.Declined -> FriendRequestFailure.Declined.left() + FriendControlResponse.TimedOut -> FriendRequestFailure.TimedOut.left() + else -> FriendRequestFailure.InvalidResponse.left() + } + } + + suspend fun requestJoin( + target: GuestJoinTarget.Direct, + request: FriendJoinRequest, + ): Either = + exchangeControl( + target, + FriendControlWire.encodeJoinRequest(request), + ).flatMap { response -> + when (response) { + is FriendControlResponse.JoinAccepted -> + FriendJoinApproval.ExternalServer(response.address).right() + FriendControlResponse.SharedWorldJoinAccepted -> + FriendJoinApproval.SharedWorld.right() + FriendControlResponse.Declined -> FriendRequestFailure.Declined.left() + FriendControlResponse.TimedOut -> FriendRequestFailure.TimedOut.left() + else -> FriendRequestFailure.InvalidResponse.left() + } + } + + private suspend fun exchangeControl( + target: GuestJoinTarget.Direct, + encoded: ByteArray, + ): Either = + withContext(ioDispatcher) { + target.use { + val socket = Socket() + val cancellation = coroutineContext[Job] + ?.invokeOnCompletion { socket.close() } + try { + socket.connect( + target.localAddress, + connectTimeout.toMillis().toInt(), + ) + socket.soTimeout = READ_POLL_MILLIS + socket.getOutputStream().apply { + write(encoded) + flush() + } + val deadline = System.nanoTime() + decisionTimeout.toNanos() + var response: FriendControlResponse + do { + coroutineContext.ensureActive() + response = socket.getInputStream().readResponse(deadline) + } while (response == FriendControlResponse.Received) + response.right() + } catch (cancellationFailure: CancellationException) { + throw cancellationFailure + } catch (_: SocketTimeoutException) { + FriendRequestFailure.TimedOut.left() + } catch (_: Exception) { + FriendRequestFailure.Unreachable.left() + } finally { + cancellation?.dispose() + socket.close() + } + } + } + + private suspend fun InputStream.readResponse( + deadlineNanos: Long, + ): FriendControlResponse { + val frame = ByteArrayOutputStream() + var length = 0 + var shift = 0 + while (shift < 35) { + val byte = readByte(deadlineNanos) + frame.write(byte) + length = length or ((byte and 0x7f) shl shift) + if (byte and 0x80 == 0) { + break + } + shift += 7 + } + if (shift >= 35 || length !in 1..FriendControlWire.MAX_REQUEST_BYTES) { + throw IllegalStateException("Friend response frame is invalid") + } + repeat(length) { + frame.write(readByte(deadlineNanos)) + } + return when ( + val decoded = + FriendControlWire.decodeResponse(frame.toByteArray()) + ) { + is FriendControlDecode.Decoded -> decoded.value + FriendControlDecode.Incomplete, + FriendControlDecode.Invalid, + -> throw IllegalStateException( + "Friend response frame is invalid", + ) + } + } + + private suspend fun InputStream.readByte( + deadlineNanos: Long, + ): Int { + while (true) { + coroutineContext.ensureActive() + if (System.nanoTime() >= deadlineNanos) { + throw SocketTimeoutException( + "Friend request decision timed out", + ) + } + try { + return read().takeIf { it >= 0 } + ?: throw IllegalStateException( + "Friend request connection closed", + ) + } catch (_: SocketTimeoutException) { + // Poll cancellation and the overall decision deadline. + } + } + } + + private companion object { + const val READ_POLL_MILLIS = 250 + } +} diff --git a/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FriendRequestServer.kt b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FriendRequestServer.kt new file mode 100644 index 000000000..587a80d1c --- /dev/null +++ b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FriendRequestServer.kt @@ -0,0 +1,334 @@ +package com.minekube.connect.share.fabric + +import com.minekube.connect.share.admission.AdmissionAnswer +import com.minekube.connect.share.admission.AdmissionController +import com.minekube.connect.share.admission.AdmissionIdentity +import com.minekube.connect.share.admission.AdmissionPurpose +import com.minekube.connect.share.admission.Ingress +import com.minekube.connect.share.direct.ShareInviteCodec +import com.minekube.connect.share.friend.FriendControlContext +import com.minekube.connect.share.friend.FriendControlRequest +import com.minekube.connect.share.friend.FriendControlResponse +import com.minekube.connect.share.friend.FriendRemovalRequest +import com.minekube.connect.share.friend.FriendActivity +import com.minekube.connect.share.friend.FriendActivityKind +import com.minekube.connect.share.friend.FriendActivityRequest +import com.minekube.connect.share.friend.FriendJoinRequest +import com.minekube.connect.share.friend.FriendControlServer +import com.minekube.connect.share.friend.FriendAccessPolicy +import com.minekube.connect.share.friend.FriendRelationshipStatus +import com.minekube.connect.share.friend.FriendStore +import com.minekube.connect.share.friend.PresencePrivacy +import java.time.Instant +import java.util.Base64 +import java.util.concurrent.CompletableFuture +import java.util.concurrent.CompletionStage +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch + +class FriendRequestServer( + private val scope: CoroutineScope, + private val admission: AdmissionController, + private val issuer: FriendCardIssuer, + private val receiver: FriendCardReceiver, + private val friendStore: FriendStore, + private val approvedJoins: ApprovedJoinTracker? = null, + private val now: () -> Instant = Instant::now, + private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, + private val onRelationshipChanged: () -> Unit = {}, + private val activity: () -> FriendActivity = { + FriendActivity(FriendActivityKind.ONLINE) + }, + private val presencePrivacy: () -> PresencePrivacy = { + PresencePrivacy() + }, + private val joinTarget: () -> String? = { null }, +) : FriendControlServer { + override fun allowsMinecraftStatus( + context: FriendControlContext, + ): Boolean { + val privacy = presencePrivacy() + if ( + !privacy.showOnline || + !privacy.showPlaying || + !privacy.showCurrentServer + ) return false + val peerId = context.directPeerId ?: return true + val friend = authenticatedFriend(context) ?: return false + return friend.peerId == peerId && + friend.permissions.canSeeMyWorlds + } + + override fun handle( + context: FriendControlContext, + request: FriendControlRequest, + ): CompletionStage { + val result = CompletableFuture() + val job = scope.launch(ioDispatcher) { + try { + result.complete(process(context, request)) + } catch (cancellation: CancellationException) { + result.cancel(false) + throw cancellation + } catch (_: Exception) { + result.complete(FriendControlResponse.Invalid) + } + } + result.cancelJobWhenCancelled(job) + return result + } + + override fun handleRemoval( + context: FriendControlContext, + request: FriendRemovalRequest, + ): CompletionStage { + val result = CompletableFuture() + val job = scope.launch(ioDispatcher) { + try { + val peerId = context.directPeerId + val response = if ( + context.ingress == Ingress.CONNECT || peerId == null + ) { + FriendControlResponse.Invalid + } else { + val removed = friendStore.applyRemoteRemoval( + peerId, + request.relationshipId, + ) + if (removed != null) { + admission.revokeDirectPeer( + peerId, + removed.minecraftUuid, + ) + approvedJoins?.revokeDirectPeer( + peerId, + removed.minecraftUuid, + ) + notifyRelationshipChanged() + } + FriendControlResponse.Removed + } + result.complete(response) + } catch (cancellation: CancellationException) { + result.cancel(false) + throw cancellation + } catch (_: Exception) { + result.complete(FriendControlResponse.Invalid) + } + } + result.cancelJobWhenCancelled(job) + return result + } + + override fun handleActivity( + context: FriendControlContext, + request: FriendActivityRequest, + ): CompletionStage = launchResponse { + val friend = authenticatedFriend(context) + ?: return@launchResponse FriendControlResponse.Invalid + val privacy = presencePrivacy() + if (!privacy.showOnline) { + return@launchResponse FriendControlResponse.Invalid + } + val current = activity() + val visible = when { + !friend.permissions.canSeeMyWorlds || !privacy.showPlaying -> + FriendActivity(FriendActivityKind.ONLINE) + + else -> current.copy( + description = current.description.takeIf { + ( + current.kind != FriendActivityKind.PLAYING_SERVER && + current.kind != FriendActivityKind.HOSTING_WORLD + ) || + privacy.showCurrentServer + }, + joinable = current.joinable && privacy.showJoinable && + friend.permissions.accessPolicy != + FriendAccessPolicy.NEVER_ALLOW, + ) + } + FriendControlResponse.Activity(visible) + } + + override fun handleJoin( + context: FriendControlContext, + request: FriendJoinRequest, + ): CompletionStage = launchResponse { + val friend = authenticatedFriend(context) + ?: return@launchResponse FriendControlResponse.Invalid + if (friend.permissions.accessPolicy == FriendAccessPolicy.NEVER_ALLOW) { + return@launchResponse FriendControlResponse.Declined + } + if (!friend.permissions.canSeeMyWorlds) { + return@launchResponse FriendControlResponse.Invalid + } + val currentActivity = activity() + if ( + currentActivity.kind != FriendActivityKind.HOSTING_WORLD && + currentActivity.kind != FriendActivityKind.PLAYING_SERVER + ) return@launchResponse FriendControlResponse.Invalid + val identity = AdmissionIdentity.UnverifiedOffline( + name = request.playerName, + uuid = request.playerUuid, + connectionId = "friend-join:${request.requestId}", + ingress = context.ingress, + directPeerId = context.directPeerId, + ) + when (admission.request(identity, AdmissionPurpose.JOIN)) { + AdmissionAnswer.ALLOW -> when (currentActivity.kind) { + FriendActivityKind.HOSTING_WORLD -> { + admission.approveNextJoin(identity) + FriendControlResponse.SharedWorldJoinAccepted + } + FriendActivityKind.PLAYING_SERVER -> joinTarget() + ?.takeIf(String::isNotBlank) + ?.let(FriendControlResponse::JoinAccepted) + ?: FriendControlResponse.Invalid + FriendActivityKind.ONLINE -> FriendControlResponse.Invalid + } + AdmissionAnswer.DENY -> FriendControlResponse.Declined + AdmissionAnswer.TIMEOUT -> FriendControlResponse.TimedOut + AdmissionAnswer.STOPPED, + AdmissionAnswer.CAPACITY, + -> FriendControlResponse.Invalid + } + } + + private fun authenticatedFriend( + context: FriendControlContext, + ) = context.directPeerId + ?.takeIf { context.ingress != Ingress.CONNECT } + ?.let(friendStore::relationship) + ?.getOrNull() + ?.takeIf { + it.relationshipStatus == FriendRelationshipStatus.CONFIRMED + } + + private fun launchResponse( + operation: suspend () -> FriendControlResponse, + ): CompletionStage { + val result = CompletableFuture() + val job = scope.launch(ioDispatcher) { + try { + result.complete(operation()) + } catch (cancellation: CancellationException) { + result.cancel(false) + throw cancellation + } catch (_: Exception) { + result.complete(FriendControlResponse.Invalid) + } + } + result.cancelJobWhenCancelled(job) + return result + } + + private suspend fun process( + context: FriendControlContext, + request: FriendControlRequest, + ): FriendControlResponse { + val authenticatedPeerId = context.directPeerId + ?: return FriendControlResponse.Invalid + if (context.ingress == Ingress.CONNECT) { + return FriendControlResponse.Invalid + } + val instant = now() + val invitation = ShareInviteCodec.decode( + request.invitation, + instant, + ).getOrNull() ?: return FriendControlResponse.Invalid + val senderPeerId = invitation.payload.peerId + if (authenticatedPeerId != senderPeerId) { + return FriendControlResponse.Invalid + } + if (friendStore.isBlocked(senderPeerId)) { + return FriendControlResponse.Declined + } + val senderKey = Base64.getEncoder() + .encodeToString(invitation.publicKey) + val existing = friendStore.relationship(senderPeerId).getOrNull() + if (existing != null) { + if (existing.publicKeyBase64 != senderKey) { + return FriendControlResponse.Invalid + } + val accepted = receiver.receive( + invitation = request.invitation, + displayName = request.displayName, + authenticatedMinecraftUuid = null, + relationshipId = request.relationshipId, + now = instant, + ) + if (accepted.isLeft()) { + return FriendControlResponse.Invalid + } + notifyRelationshipChanged() + return issueHostCard(instant) + } + + val identity = AdmissionIdentity.UnverifiedOffline( + name = request.displayName, + uuid = invitation.payload.shareId, + connectionId = "friend:${request.requestId}", + ingress = context.ingress, + directPeerId = context.directPeerId, + ) + return when ( + admission.request( + identity, + purpose = AdmissionPurpose.FRIEND, + ) + ) { + AdmissionAnswer.ALLOW -> { + val received = receiver.receive( + invitation = request.invitation, + displayName = request.displayName, + authenticatedMinecraftUuid = null, + relationshipId = request.relationshipId, + now = instant, + ) + if (received.isLeft()) { + FriendControlResponse.Invalid + } else { + notifyRelationshipChanged() + issueHostCard(instant) + } + } + + AdmissionAnswer.DENY -> FriendControlResponse.Declined + AdmissionAnswer.TIMEOUT -> FriendControlResponse.TimedOut + AdmissionAnswer.STOPPED, + AdmissionAnswer.CAPACITY, + -> FriendControlResponse.Invalid + } + } + + private suspend fun issueHostCard( + now: Instant, + ): FriendControlResponse = + issuer.issue(now).fold( + ifLeft = { FriendControlResponse.Invalid }, + ifRight = FriendControlResponse::Accepted, + ) + + private fun notifyRelationshipChanged() { + try { + onRelationshipChanged() + } catch (_: RuntimeException) { + // A UI refresh must not undo an accepted friendship. + } + } + + private fun CompletableFuture.cancelJobWhenCancelled( + job: Job, + ) { + whenComplete { _, _ -> + if (isCancelled) { + job.cancel() + } + } + } +} diff --git a/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/LoadedCompatibilityProfileFactory.kt b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/LoadedCompatibilityProfileFactory.kt new file mode 100644 index 000000000..2ce8b435c --- /dev/null +++ b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/LoadedCompatibilityProfileFactory.kt @@ -0,0 +1,91 @@ +package com.minekube.connect.share.fabric + +import arrow.core.Either +import com.minekube.connect.share.friend.CompatibilityProfile +import com.minekube.connect.share.friend.ModLoader +import com.minekube.connect.share.friend.PackPlatform +import com.minekube.connect.share.friend.PackReference +import com.minekube.connect.share.friend.RequiredMod +import java.net.URI + +enum class ModSide { + UNIVERSAL, + CLIENT, + SERVER, +} + +data class LoadedMod( + val id: String, + val version: String, + val side: ModSide, + val builtIn: Boolean = false, +) + +object LoadedCompatibilityProfileFactory { + fun create( + minecraftVersion: String, + loader: ModLoader, + mods: Collection, + packEnvironment: Map = emptyMap(), + ): CompatibilityProfile = CompatibilityProfile( + minecraftVersion = minecraftVersion, + loader = loader, + requiredMods = mods.asSequence() + .filterNot(LoadedMod::builtIn) + .filter { it.side != ModSide.CLIENT } + .filterNot { it.id.lowercase() in LOADER_COMPONENT_IDS } + .filter { it.id.isNotBlank() && it.version.isNotBlank() } + .map { RequiredMod(it.id, it.version) } + .distinctBy { it.id.lowercase() } + .sortedBy { it.id.lowercase() } + .toList(), + pack = packReference(packEnvironment), + ) + + private fun packReference( + environment: Map, + ): PackReference? { + val rawUrl = environment[PACK_URL_ENV] + ?.trim() + ?.takeIf(String::isNotEmpty) + ?: return null + val project = environment[PACK_PROJECT_ENV] + ?.trim() + ?.takeIf(String::isNotEmpty) + ?: return null + val version = environment[PACK_VERSION_ENV] + ?.trim() + ?.takeIf(String::isNotEmpty) + ?: return null + val uri = Either.catch { URI(rawUrl) }.getOrNull() + ?.takeIf { + it.scheme.equals("https", ignoreCase = true) && + !it.host.isNullOrBlank() && + it.userInfo == null + } + ?: return null + val platform = when (uri.host.lowercase()) { + "modrinth.com", "www.modrinth.com" -> PackPlatform.MODRINTH + "curseforge.com", "www.curseforge.com" -> PackPlatform.CURSEFORGE + else -> PackPlatform.OTHER + } + return PackReference( + platform = platform, + projectId = project, + versionId = version, + url = uri.toASCIIString(), + ) + } + + private val LOADER_COMPONENT_IDS = setOf( + "java", + "minecraft", + "fabricloader", + "fabric-language-kotlin", + "forge", + "neoforge", + ) + private const val PACK_URL_ENV = "CONNECT_SHARE_PACK_URL" + private const val PACK_PROJECT_ENV = "CONNECT_SHARE_PACK_PROJECT" + private const val PACK_VERSION_ENV = "CONNECT_SHARE_PACK_VERSION" +} diff --git a/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/MinecraftStatusProbe.kt b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/MinecraftStatusProbe.kt new file mode 100644 index 000000000..30457c195 --- /dev/null +++ b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/MinecraftStatusProbe.kt @@ -0,0 +1,207 @@ +package com.minekube.connect.share.fabric + +import arrow.core.Either +import arrow.core.raise.either +import arrow.core.raise.ensure +import com.google.gson.JsonElement +import com.google.gson.JsonParser +import com.minekube.connect.share.direct.ShareRoute +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.DataInputStream +import java.net.InetSocketAddress +import java.net.Socket +import java.nio.charset.StandardCharsets +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +data class ServerPresence( + val description: String, + val route: ShareRoute? = null, +) + +sealed interface StatusProbeError { + data object InvalidAddress : StatusProbeError + + data object Unreachable : StatusProbeError + + data object InvalidResponse : StatusProbeError + + data object EndpointOffline : StatusProbeError +} + +fun interface FriendStatusProbe { + suspend fun probe( + address: String, + ): Either +} + +class MinecraftStatusProbe( + private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, +) : FriendStatusProbe { + override suspend fun probe( + address: String, + ): Either = + withContext(ioDispatcher) { + either { + val target = parseAddress(address).bind() + val json = Either.catch { + requestStatus(target) + }.mapLeft { + StatusProbeError.Unreachable + }.bind() + val description = Either.catch { + flattenDescription( + JsonParser.parseString(json) + .asJsonObject + .get("description"), + ) + }.mapLeft { + StatusProbeError.InvalidResponse + }.bind() + ensure(!description.isOfflineFallback()) { + StatusProbeError.EndpointOffline + } + ServerPresence(description) + } + } + + private fun requestStatus(target: InetSocketAddress): String { + Socket().use { socket -> + socket.soTimeout = TIMEOUT_MILLIS + socket.connect(target, TIMEOUT_MILLIS) + val output = socket.getOutputStream() + val handshake = ByteArrayOutputStream().apply { + writeVarInt(0) + writeVarInt(0) + writeString(target.hostString) + write(target.port ushr 8) + write(target.port and 0xff) + writeVarInt(1) + }.toByteArray() + output.writePacket(handshake) + output.writePacket(byteArrayOf(0)) + output.flush() + + val input = DataInputStream(socket.getInputStream()) + val packetLength = input.readVarInt() + require(packetLength in 1..MAX_PACKET_BYTES) + val packet = DataInputStream( + ByteArrayInputStream(input.readNBytes(packetLength)), + ) + require(packet.readVarInt() == 0) + val jsonLength = packet.readVarInt() + require(jsonLength in 1..MAX_PACKET_BYTES) + return String( + packet.readNBytes(jsonLength), + StandardCharsets.UTF_8, + ) + } + } + + private fun parseAddress( + value: String, + ): Either = + Either.catch { + val trimmed = value.trim() + require(trimmed.isNotEmpty()) + val host: String + val port: Int + if (trimmed.startsWith("[")) { + val closing = trimmed.indexOf(']') + require(closing > 1) + host = trimmed.substring(1, closing) + port = if (closing + 1 < trimmed.length) { + require(trimmed[closing + 1] == ':') + trimmed.substring(closing + 2).toInt() + } else { + DEFAULT_PORT + } + } else if (trimmed.count { it == ':' } == 1) { + host = trimmed.substringBeforeLast(':') + port = trimmed.substringAfterLast(':').toInt() + } else { + host = trimmed + port = DEFAULT_PORT + } + require(host.isNotBlank() && port in 1..65_535) + InetSocketAddress(host, port) + }.mapLeft { + StatusProbeError.InvalidAddress + } + + private fun flattenDescription(element: JsonElement?): String = when { + element == null || element.isJsonNull -> "" + element.isJsonPrimitive -> element.asString + element.isJsonArray -> element.asJsonArray.joinToString("") { + flattenDescription(it) + } + else -> { + val json = element.asJsonObject + buildString { + json.get("text")?.let { + append(flattenDescription(it)) + } + json.get("translate")?.let { + append(flattenDescription(it)) + } + json.get("extra")?.let { + append(flattenDescription(it)) + } + } + } + } + + private fun String.isOfflineFallback(): Boolean { + val normalized = lowercase() + return OFFLINE_MARKERS.any(normalized::contains) + } + + private fun java.io.OutputStream.writePacket(payload: ByteArray) { + writeVarInt(payload.size) + write(payload) + } + + private fun java.io.OutputStream.writeString(value: String) { + val bytes = value.toByteArray(StandardCharsets.UTF_8) + writeVarInt(bytes.size) + write(bytes) + } + + private fun java.io.OutputStream.writeVarInt(value: Int) { + var remaining = value + while (true) { + if (remaining and -128 == 0) { + write(remaining) + return + } + write(remaining and 127 or 128) + remaining = remaining ushr 7 + } + } + + private fun DataInputStream.readVarInt(): Int { + var value = 0 + var position = 0 + while (position < 32) { + val current = readUnsignedByte() + value = value or ((current and 0x7f) shl position) + if (current and 0x80 == 0) { + return value + } + position += 7 + } + throw IllegalArgumentException("VarInt is too large") + } + + private companion object { + const val DEFAULT_PORT = 25_565 + const val TIMEOUT_MILLIS = 2_500 + const val MAX_PACKET_BYTES = 1024 * 1024 + val OFFLINE_MARKERS = listOf( + " is currently not available.", + " could not be pinged", + ) + } +} diff --git a/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/PersistentConnectIngress.kt b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/PersistentConnectIngress.kt new file mode 100644 index 000000000..e7c93740c --- /dev/null +++ b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/PersistentConnectIngress.kt @@ -0,0 +1,162 @@ +package com.minekube.connect.share.fabric + +import arrow.core.Either +import arrow.core.left +import arrow.core.right +import com.minekube.connect.share.ConnectShareHandle +import com.minekube.connect.share.ConnectShareIngress +import com.minekube.connect.share.identity.EndpointIdentity +import java.net.SocketAddress +import java.util.concurrent.CancellationException +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +sealed interface PersistentConnectState { + data object Idle : PersistentConnectState + + data object Starting : PersistentConnectState + + data class Available( + val endpoint: String, + val publicAddress: String, + ) : PersistentConnectState + + data class Failed( + val safeMessage: String, + ) : PersistentConnectState + + data object Closed : PersistentConnectState +} + +sealed interface PersistentConnectFailure { + val safeMessage: String + + data object StartFailed : PersistentConnectFailure { + override val safeMessage = + "Connect friend delivery is temporarily unavailable" + } + + data object Closed : PersistentConnectFailure { + override val safeMessage = + "Connect friend delivery has stopped" + } +} + +class PersistentConnectIngress( + private val delegate: ConnectShareIngress, +) : ConnectShareIngress { + private val lifecycle = Mutex() + private var active: Active? = null + private val mutableState = MutableStateFlow( + PersistentConnectState.Idle, + ) + + val state: StateFlow = + mutableState.asStateFlow() + + suspend fun startControl( + identity: EndpointIdentity, + target: SocketAddress, + ): Either = + lifecycle.withLock { + when { + mutableState.value == PersistentConnectState.Closed -> + PersistentConnectFailure.Closed.left() + + active != null -> + checkNotNull(active) + .borrow(identity, target) + .right() + + else -> { + mutableState.value = PersistentConnectState.Starting + try { + val acquired = delegate.start(identity, target) + val installed = Active(identity, target, acquired) + active = installed + mutableState.value = + PersistentConnectState.Available( + endpoint = acquired.endpoint, + publicAddress = acquired.publicAddress, + ) + installed.borrow(identity, target).right() + } catch (cancellation: CancellationException) { + throw cancellation + } catch (_: Exception) { + mutableState.value = + PersistentConnectState.Failed( + PersistentConnectFailure.StartFailed + .safeMessage, + ) + PersistentConnectFailure.StartFailed.left() + } + } + } + } + + override suspend fun start( + identity: EndpointIdentity, + target: SocketAddress, + ): ConnectShareHandle = startControl(identity, target).fold( + ifLeft = { + throw IllegalStateException(it.safeMessage) + }, + ifRight = { it }, + ) + + suspend fun shutdown() { + lifecycle.withLock { + if (mutableState.value == PersistentConnectState.Closed) { + return@withLock + } + val acquired = active + active = null + try { + acquired?.handle?.close?.invoke() + } finally { + mutableState.value = PersistentConnectState.Closed + } + } + } + + suspend fun restart() { + lifecycle.withLock { + if (mutableState.value == PersistentConnectState.Closed) { + return@withLock + } + val acquired = active + active = null + try { + acquired?.handle?.close?.invoke() + } finally { + mutableState.value = PersistentConnectState.Idle + } + } + } + + private data class Active( + val identity: EndpointIdentity, + val target: SocketAddress, + val handle: ConnectShareHandle, + ) { + fun borrow( + requestedIdentity: EndpointIdentity, + requestedTarget: SocketAddress, + ): ConnectShareHandle { + check(requestedIdentity == identity) { + "Persistent Connect endpoint identity changed" + } + check(requestedTarget == target) { + "Persistent Connect gateway target changed" + } + return ConnectShareHandle( + endpoint = handle.endpoint, + publicAddress = handle.publicAddress, + close = {}, + ) + } + } +} diff --git a/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/PersistentDirectIngress.kt b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/PersistentDirectIngress.kt new file mode 100644 index 000000000..c13c68864 --- /dev/null +++ b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/PersistentDirectIngress.kt @@ -0,0 +1,196 @@ +package com.minekube.connect.share.fabric + +import arrow.core.Either +import arrow.core.left +import arrow.core.right +import com.minekube.connect.share.DirectShareHandle +import com.minekube.connect.share.DirectShareIngress +import com.minekube.connect.share.ShareOptions +import java.net.SocketAddress +import java.util.concurrent.CancellationException +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withTimeoutOrNull +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +sealed interface PersistentDirectState { + data object Idle : PersistentDirectState + + data object Starting : PersistentDirectState + + data class Available( + val lanAvailable: Boolean, + val internetAvailable: Boolean, + ) : PersistentDirectState + + data class Failed( + val safeMessage: String, + ) : PersistentDirectState + + data object Closed : PersistentDirectState +} + +sealed interface PersistentDirectFailure { + val safeMessage: String + + data object StartFailed : PersistentDirectFailure { + override val safeMessage = + "Direct friend delivery is temporarily unavailable" + } + + data object Closed : PersistentDirectFailure { + override val safeMessage = + "Direct friend delivery has stopped" + } +} + +class PersistentDirectIngress( + private val delegate: DirectShareIngress, +) : DirectShareIngress { + private val lifecycle = Mutex() + private var active: Active? = null + private val mutableState = MutableStateFlow( + PersistentDirectState.Idle, + ) + + val state: StateFlow = + mutableState.asStateFlow() + + suspend fun currentInvitation(): String? = lifecycle.withLock { + active?.handle?.invitation + } + + suspend fun awaitInvitation( + timeout: Duration = 3.seconds, + ): String? { + currentInvitation()?.let { return it } + return withTimeoutOrNull(timeout) { + state.first { + it is PersistentDirectState.Available || + it is PersistentDirectState.Failed || + it is PersistentDirectState.Closed + } + currentInvitation() + } + } + + suspend fun startControl( + options: ShareOptions, + target: SocketAddress, + connectAddress: String?, + ): Either = + lifecycle.withLock { + when { + mutableState.value == PersistentDirectState.Closed -> + PersistentDirectFailure.Closed.left() + + active != null -> + checkNotNull(active) + .borrow(target, connectAddress) + .right() + + else -> startFresh(options, target, connectAddress) + } + } + + suspend fun restart() { + lifecycle.withLock { + if (mutableState.value == PersistentDirectState.Closed) { + return@withLock + } + val acquired = active + active = null + try { + acquired?.handle?.close?.invoke() + } finally { + mutableState.value = PersistentDirectState.Idle + } + } + } + + override suspend fun start( + options: ShareOptions, + target: SocketAddress, + connectAddress: String?, + ): DirectShareHandle = lifecycle.withLock { + check(mutableState.value != PersistentDirectState.Closed) { + PersistentDirectFailure.Closed.safeMessage + } + active?.handle?.close?.invoke() + active = null + startFresh(options, target, connectAddress) + }.fold( + ifLeft = { + throw IllegalStateException(it.safeMessage) + }, + ifRight = { it }, + ) + + suspend fun shutdown() { + lifecycle.withLock { + if (mutableState.value == PersistentDirectState.Closed) { + return@withLock + } + val acquired = active + active = null + try { + acquired?.handle?.close?.invoke() + } finally { + mutableState.value = PersistentDirectState.Closed + } + } + } + + private data class Active( + val target: SocketAddress, + val connectAddress: String?, + val handle: DirectShareHandle, + ) { + fun borrow( + requestedTarget: SocketAddress, + requestedConnectAddress: String?, + ): DirectShareHandle { + check(requestedTarget == target) { + "Persistent direct gateway target changed" + } + check(requestedConnectAddress == connectAddress) { + "Persistent direct Connect fallback changed" + } + return handle.copy(close = {}) + } + } + + private suspend fun startFresh( + options: ShareOptions, + target: SocketAddress, + connectAddress: String?, + ): Either { + mutableState.value = PersistentDirectState.Starting + return try { + val acquired = delegate.start(options, target, connectAddress) + val installed = Active( + target = target, + connectAddress = connectAddress, + handle = acquired, + ) + active = installed + mutableState.value = PersistentDirectState.Available( + lanAvailable = acquired.lanAvailable, + internetAvailable = acquired.internetAvailable, + ) + installed.borrow(target, connectAddress).right() + } catch (cancellation: CancellationException) { + throw cancellation + } catch (_: Exception) { + mutableState.value = PersistentDirectState.Failed( + PersistentDirectFailure.StartFailed.safeMessage, + ) + PersistentDirectFailure.StartFailed.left() + } + } +} diff --git a/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/RandomEndpointNameSource.kt b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/RandomEndpointNameSource.kt new file mode 100644 index 000000000..da7a8e792 --- /dev/null +++ b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/RandomEndpointNameSource.kt @@ -0,0 +1,80 @@ +package com.minekube.connect.share.fabric + +import arrow.core.Either +import com.minekube.connect.share.identity.EndpointNameSource +import java.io.IOException +import java.security.SecureRandom +import java.util.random.RandomGenerator +import kotlin.coroutines.resume +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds +import kotlin.time.toJavaDuration +import kotlinx.coroutines.suspendCancellableCoroutine +import okhttp3.Call +import okhttp3.Callback +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response + +class RandomEndpointNameSource( + client: OkHttpClient, + private val url: HttpUrl = DEFAULT_URL, + timeout: Duration = 5.seconds, + private val random: RandomGenerator = SecureRandom(), +) : EndpointNameSource { + private val client = client.newBuilder() + .callTimeout(timeout.toJavaDuration()) + .build() + + override suspend fun create(): String = + fetch().fold( + ifLeft = { fallback() }, + ifRight = { remote -> + remote.takeIf(ENDPOINT_PATTERN::matches) ?: fallback() + }, + ) + + private suspend fun fetch(): Either = + suspendCancellableCoroutine { continuation -> + val call = client.newCall(Request.Builder().url(url).build()) + continuation.invokeOnCancellation { call.cancel() } + call.enqueue( + object : Callback { + override fun onFailure(call: Call, e: IOException) { + if (continuation.isActive) { + continuation.resume(Either.Left(e)) + } + } + + override fun onResponse(call: Call, response: Response) { + val result = Either.catch { + response.use { + if (it.code != 200) { + throw IOException("Random endpoint service returned non-200") + } + it.body?.string() + ?: throw IOException("Random endpoint service returned no body") + } + } + if (continuation.isActive) { + continuation.resume(result) + } + } + }, + ) + } + + private fun fallback(): String = buildString(FALLBACK_LENGTH) { + repeat(FALLBACK_LENGTH) { + append('a' + random.nextInt(26)) + } + } + + private companion object { + val DEFAULT_URL: HttpUrl = "https://randomname.minekube.net".toHttpUrl() + val ENDPOINT_PATTERN = Regex("^[a-z0-9][a-z0-9-]{2,62}$") + const val FALLBACK_LENGTH = 5 + } +} diff --git a/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/ShareJoinDiagnostics.kt b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/ShareJoinDiagnostics.kt new file mode 100644 index 000000000..1dd1dd906 --- /dev/null +++ b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/ShareJoinDiagnostics.kt @@ -0,0 +1,65 @@ +package com.minekube.connect.share.fabric + +import java.time.Instant +import java.util.ArrayDeque + +enum class JoinStage { + COMPATIBILITY, + FRIEND_CONTROL, + APPROVAL, + DIRECT, + CONNECT_FALLBACK, + MINECRAFT_LOGIN, +} + +enum class JoinOutcome { + STARTED, + SUCCEEDED, + FAILED, + CANCELLED, +} + +data class JoinDiagnosticEvent( + val at: Instant, + val stage: JoinStage, + val outcome: JoinOutcome, +) + +class ShareJoinDiagnostics( + private val now: () -> Instant = Instant::now, +) { + private val events = ArrayDeque() + + @Synchronized + fun record(stage: JoinStage, outcome: JoinOutcome) { + while (events.size >= MAX_EVENTS) { + events.removeFirst() + } + events.addLast(JoinDiagnosticEvent(now(), stage, outcome)) + } + + @Synchronized + fun bundle( + minecraftVersion: String, + modVersion: String, + ): String = buildString { + appendLine("Connect Share diagnostic bundle") + appendLine("Minecraft: ${minecraftVersion.safeField()}") + appendLine("Connect Share: ${modVersion.safeField()}") + appendLine("Generated: ${now()}") + appendLine("Events (oldest first):") + events.forEach { event -> + appendLine("${event.at}: ${event.stage}: ${event.outcome}") + } + append("No addresses, names, invitations, tokens, or keys are included.") + } + + private fun String.safeField(): String = filter { + it.isLetterOrDigit() || it in ".+-_" + }.take(MAX_FIELD_LENGTH).ifBlank { "unknown" } + + private companion object { + const val MAX_EVENTS = 50 + const val MAX_FIELD_LENGTH = 64 + } +} diff --git a/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/SocialEventTracker.kt b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/SocialEventTracker.kt new file mode 100644 index 000000000..0f97f2ad3 --- /dev/null +++ b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/SocialEventTracker.kt @@ -0,0 +1,74 @@ +package com.minekube.connect.share.fabric + +import com.minekube.connect.share.fabric.ui.FriendSummary +import com.minekube.connect.share.fabric.ui.FriendsUiState +import com.minekube.connect.share.friend.FriendActivityKind + +sealed interface SocialEvent { + val displayName: String + + data class FriendAccepted( + override val displayName: String, + ) : SocialEvent + + data class FriendRemoved( + override val displayName: String, + ) : SocialEvent + + data class PlayingServer( + override val displayName: String, + val serverName: String, + ) : SocialEvent + + data class WorldReady( + override val displayName: String, + val worldName: String?, + ) : SocialEvent +} + +class SocialEventTracker { + private var previous: Map? = null + + fun update(state: FriendsUiState): List { + val current = state.friends.associateBy(FriendSummary::peerId) + val before = previous + previous = current + if (before == null) return emptyList() + + val events = mutableListOf() + current.values.forEach { friend -> + val old = before[friend.peerId] + when { + old == null -> events += + SocialEvent.FriendAccepted(friend.displayName) + + friend.activityKind == FriendActivityKind.PLAYING_SERVER && + old.activityKind != FriendActivityKind.PLAYING_SERVER -> + events += SocialEvent.PlayingServer( + friend.displayName, + friend.activityDescription ?: "Minecraft server", + ) + + friend.activityKind == FriendActivityKind.HOSTING_WORLD && + friend.canRequestJoin && + !old.canRequestJoin -> + events += SocialEvent.WorldReady( + friend.displayName, + friend.activityDescription, + ) + + friend.canJoinNow && !old.canJoinNow -> + events += SocialEvent.WorldReady( + friend.displayName, + friend.worldName, + ) + } + } + before.values + .filter { it.peerId !in current } + .forEach { + events += SocialEvent.FriendRemoved(it.displayName) + } + return events + } +} diff --git a/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/WatchEndpointCredentialValidator.kt b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/WatchEndpointCredentialValidator.kt new file mode 100644 index 000000000..2c572eae7 --- /dev/null +++ b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/WatchEndpointCredentialValidator.kt @@ -0,0 +1,131 @@ +package com.minekube.connect.share.fabric + +import arrow.core.Either +import com.minekube.connect.share.identity.CredentialValidationError +import com.minekube.connect.share.identity.EndpointCredentialValidator +import com.minekube.connect.share.identity.EndpointIdentity +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicReference +import kotlin.coroutines.resume +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withTimeoutOrNull +import minekube.connect.v1alpha1.WatchServiceOuterClass.SessionRejection +import minekube.connect.v1alpha1.WatchServiceOuterClass.WatchRequest +import minekube.connect.v1alpha1.WatchServiceOuterClass.WatchResponse +import okhttp3.HttpUrl +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import okio.ByteString + +class WatchEndpointCredentialValidator( + private val client: OkHttpClient, + private val watchUrl: HttpUrl, + private val timeout: Duration = 10.seconds, +) : EndpointCredentialValidator { + override suspend fun validate( + identity: EndpointIdentity, + ): Either = + withTimeoutOrNull(timeout) { + awaitValidation(identity) + } ?: Either.Left( + CredentialValidationError.Network( + "Connect credential validation timed out", + ), + ) + + private suspend fun awaitValidation( + identity: EndpointIdentity, + ): Either = suspendCancellableCoroutine { continuation -> + val completed = AtomicBoolean() + val socketReference = AtomicReference() + val request = Request.Builder() + .url(watchUrl) + .header("Authorization", "Bearer ${identity.token}") + .header("Connect-Endpoint", identity.endpoint) + .header("Connect-Platform", "Fabric") + .build() + + fun complete(result: Either) { + if (completed.compareAndSet(false, true) && continuation.isActive) { + continuation.resume(result) + } + } + + val listener = object : WebSocketListener() { + override fun onOpen(webSocket: WebSocket, response: Response) { + webSocket.close(NORMAL_CLOSE, "credentials validated") + complete(Either.Right(Unit)) + } + + override fun onMessage(webSocket: WebSocket, bytes: ByteString) { + rejectProposal(webSocket, bytes) + } + + override fun onClosing(webSocket: WebSocket, code: Int, reason: String) { + webSocket.close(NORMAL_CLOSE, null) + } + + override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { + complete( + Either.Left( + CredentialValidationError.Network( + "Connect credential validation closed before authentication", + ), + ), + ) + } + + override fun onFailure( + webSocket: WebSocket, + t: Throwable, + response: Response?, + ) { + val error = when (response?.code) { + 401, 403 -> CredentialValidationError.Rejected( + "Connect rejected the endpoint credentials", + ) + + else -> CredentialValidationError.Network( + "Could not reach Connect to validate the endpoint credentials", + ) + } + response?.close() + complete(Either.Left(error)) + } + } + + val socket = client.newWebSocket(request, listener) + socketReference.set(socket) + continuation.invokeOnCancellation { + completed.set(true) + socketReference.get()?.cancel() + } + } + + internal fun rejectProposal(webSocket: WebSocket, bytes: ByteString) { + val response = runCatching { + WatchResponse.parseFrom(bytes.toByteArray()) + }.getOrElse { + webSocket.close(PROTOCOL_ERROR_CLOSE, "invalid watch response") + return + } + val rejection = SessionRejection.newBuilder() + .setId(response.session.id) + .build() + val request = WatchRequest.newBuilder() + .setSessionRejection(rejection) + .build() + webSocket.send(ByteString.of(*request.toByteArray())) + webSocket.close(NORMAL_CLOSE, "credential validation rejects proposals") + } + + private companion object { + const val NORMAL_CLOSE = 1000 + const val PROTOCOL_ERROR_CLOSE = 1002 + } +} diff --git a/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/recovery/RecoveryViewModel.kt b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/recovery/RecoveryViewModel.kt new file mode 100644 index 000000000..cbf023a9f --- /dev/null +++ b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/recovery/RecoveryViewModel.kt @@ -0,0 +1,325 @@ +package com.minekube.connect.share.fabric.recovery + +import arrow.core.Either +import com.minekube.connect.share.fabric.ui.ShareUiMessage +import com.minekube.connect.share.recovery.RecoveryArchiveError +import com.minekube.connect.share.recovery.RecoveryStore +import com.minekube.connect.share.recovery.RecoveryStoreError +import com.minekube.connect.share.recovery.RecoverySummary +import java.nio.file.Path +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicLong +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +enum class RecoveryPhase { + IDLE, + EXPORTED, + IMPORT_PREVIEW, + RESTORED, +} + +data class RecoveryUiState( + val phase: RecoveryPhase = RecoveryPhase.IDLE, + val operationInProgress: Boolean = false, + val summary: RecoverySummary? = null, + val importConfirmationRequired: Boolean = false, + val safeMessage: ShareUiMessage? = null, +) + +interface RecoveryUiActions { + suspend fun export( + target: Path, + passphrase: CharArray, + ): Either + + suspend fun preview( + source: Path, + passphrase: CharArray, + ): Either + + suspend fun import( + source: Path, + passphrase: CharArray, + ): Either +} + +class StoredRecoveryUiActions( + private val store: RecoveryStore, +) : RecoveryUiActions { + override suspend fun export( + target: Path, + passphrase: CharArray, + ): Either = + store.exportTo(target, passphrase) + + override suspend fun preview( + source: Path, + passphrase: CharArray, + ): Either = + store.preview(source, passphrase) + + override suspend fun import( + source: Path, + passphrase: CharArray, + ): Either = + store.importFrom(source, passphrase) +} + +class RecoveryViewModel( + private val scope: CoroutineScope, + private val actions: RecoveryUiActions, + private val operationDispatcher: CoroutineDispatcher = Dispatchers.IO, + private val restoreAllowed: () -> Boolean = { true }, +) : AutoCloseable { + private val working = AtomicBoolean(false) + private val generation = AtomicLong() + private val mutableState = MutableStateFlow(RecoveryUiState()) + @Volatile + private var pendingImport: PendingImport? = null + + val state: StateFlow = mutableState.asStateFlow() + + fun export( + target: Path, + passphrase: CharArray, + confirmation: CharArray, + ) { + if (!passphrase.contentEquals(confirmation)) { + passphrase.fill('\u0000') + confirmation.fill('\u0000') + update { + copy( + safeMessage = ShareUiMessage( + "connect_share.recovery.error.secret_mismatch", + ), + ) + } + return + } + val owned = passphrase.copyOf() + passphrase.fill('\u0000') + confirmation.fill('\u0000') + if (!beginOperation()) { + owned.fill('\u0000') + return + } + scope.launch(operationDispatcher) { + try { + actions.export(target, owned).fold( + ifLeft = { failure -> showFailure(failure) }, + ifRight = { summary -> + update { + copy( + phase = RecoveryPhase.EXPORTED, + summary = summary, + safeMessage = ShareUiMessage( + "connect_share.recovery.exported", + ), + ) + } + }, + ) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + showFailure(RecoveryStoreError.BackupWriteFailed) + } finally { + owned.fill('\u0000') + endOperation() + } + } + } + + fun previewImport(source: Path, passphrase: CharArray) { + val owned = passphrase.copyOf() + passphrase.fill('\u0000') + clearPendingImport() + val operationGeneration = generation.incrementAndGet() + if (!beginOperation()) { + owned.fill('\u0000') + return + } + scope.launch(operationDispatcher) { + var retained = false + try { + actions.preview(source, owned).fold( + ifLeft = { failure -> showFailure(failure) }, + ifRight = { summary -> + if (generation.get() == operationGeneration) { + pendingImport = PendingImport(source, owned) + retained = true + update { + copy( + phase = RecoveryPhase.IMPORT_PREVIEW, + summary = summary, + importConfirmationRequired = true, + safeMessage = null, + ) + } + } + }, + ) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + showFailure(RecoveryStoreError.BackupReadFailed) + } finally { + if (!retained) { + owned.fill('\u0000') + } + endOperation() + } + } + } + + fun confirmImport() { + val pending = pendingImport ?: return + if (working.get()) { + return + } + if (!restoreAllowed()) { + clearPendingImport() + update { + copy( + phase = RecoveryPhase.IDLE, + summary = null, + safeMessage = ShareUiMessage( + "connect_share.recovery.error.stop_sharing", + ), + ) + } + return + } + if (!beginOperation()) { + return + } + if (pendingImport !== pending) { + endOperation() + return + } + pendingImport = null + update { copy(importConfirmationRequired = false) } + scope.launch(operationDispatcher) { + try { + actions.import(pending.source, pending.passphrase).fold( + ifLeft = { failure -> showFailure(failure) }, + ifRight = { summary -> + update { + copy( + phase = RecoveryPhase.RESTORED, + summary = summary, + safeMessage = ShareUiMessage( + "connect_share.recovery.restored_restart", + ), + ) + } + }, + ) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + showFailure(RecoveryStoreError.ReplacementFailed) + } finally { + pending.passphrase.fill('\u0000') + endOperation() + } + } + } + + fun cancelImport() { + generation.incrementAndGet() + clearPendingImport() + if (!working.get()) { + mutableState.value = RecoveryUiState() + } + } + + fun clearMessage() { + update { copy(safeMessage = null) } + } + + override fun close() { + cancelImport() + } + + private fun beginOperation(): Boolean { + if (!working.compareAndSet(false, true)) { + return false + } + update { + copy( + operationInProgress = true, + safeMessage = null, + ) + } + return true + } + + private fun endOperation() { + working.set(false) + update { copy(operationInProgress = false) } + } + + private fun clearPendingImport() { + pendingImport?.passphrase?.fill('\u0000') + pendingImport = null + update { copy(importConfirmationRequired = false) } + } + + private fun showFailure(failure: RecoveryStoreError) { + update { + copy( + phase = RecoveryPhase.IDLE, + summary = null, + importConfirmationRequired = false, + safeMessage = failure.uiMessage(), + ) + } + } + + private fun update(transform: RecoveryUiState.() -> RecoveryUiState) { + mutableState.value = mutableState.value.transform() + } + + private data class PendingImport( + val source: Path, + val passphrase: CharArray, + ) +} + +fun RecoveryStoreError.uiMessage(): ShareUiMessage = when (this) { + is RecoveryStoreError.ArchiveFailure -> when (reason) { + RecoveryArchiveError.WeakPassphrase -> + ShareUiMessage("connect_share.recovery.error.weak_secret") + RecoveryArchiveError.AuthenticationFailed -> + ShareUiMessage("connect_share.recovery.error.authentication") + RecoveryArchiveError.UnsupportedVersion -> + ShareUiMessage("connect_share.recovery.error.unsupported") + RecoveryArchiveError.ArchiveTooLarge, + RecoveryArchiveError.EntryTooLarge, + -> ShareUiMessage("connect_share.recovery.error.too_large") + RecoveryArchiveError.InvalidArchive, + RecoveryArchiveError.UnknownEntry, + RecoveryArchiveError.DuplicateEntry, + RecoveryArchiveError.MissingRequiredEntry, + RecoveryArchiveError.IncompleteEndpointIdentity, + -> ShareUiMessage("connect_share.recovery.error.invalid") + } + RecoveryStoreError.MissingRequiredMaterial -> + ShareUiMessage("connect_share.recovery.error.nothing_to_export") + RecoveryStoreError.UnsafeMaterial -> + ShareUiMessage("connect_share.recovery.error.unsafe_files") + RecoveryStoreError.BackupReadFailed -> + ShareUiMessage("connect_share.recovery.error.read") + RecoveryStoreError.BackupWriteFailed -> + ShareUiMessage("connect_share.recovery.error.write") + RecoveryStoreError.ReplacementFailed -> + ShareUiMessage("connect_share.recovery.error.restore") +} diff --git a/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/ui/AdaptiveShareLayout.kt b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/ui/AdaptiveShareLayout.kt new file mode 100644 index 000000000..9e1a1030a --- /dev/null +++ b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/ui/AdaptiveShareLayout.kt @@ -0,0 +1,128 @@ +package com.minekube.connect.share.fabric.ui + +data class FriendsScreenLayout( + val contentX: Int, + val contentWidth: Int, + val headerY: Int, + val subtitleY: Int, + val rowsTop: Int, + val rowHeight: Int, + val rowGap: Int, + val visibleRows: Int, + val rowsBottom: Int, + val messageY: Int, + val footerTop: Int, + val footerBottom: Int, +) { + val halfButtonWidth: Int = (contentWidth - BUTTON_GAP) / 2 + + fun rowY(index: Int): Int = rowsTop + index * (rowHeight + rowGap) + + private companion object { + const val BUTTON_GAP = 6 + } +} + +data class FormScreenLayout( + val contentX: Int, + val contentWidth: Int, + val headerY: Int, + val subtitleY: Int, + val bodyTop: Int, + val availableBodyHeight: Int, + val footerTop: Int, + val footerBottom: Int, +) { + val halfButtonWidth: Int = (contentWidth - BUTTON_GAP) / 2 + + private companion object { + const val BUTTON_GAP = 6 + } +} + +data class ManageFriendFormLayout( + val nameLabelY: Int, + val nameInputY: Int, + val notifyY: Int, + val shareWorldsY: Int, + val accessPolicyY: Int, + val internetDirectY: Int, +) + +object AdaptiveShareLayout { + const val EDGE_MARGIN: Int = 12 + const val MAX_CONTENT_WIDTH: Int = 360 + const val BUTTON_HEIGHT: Int = 20 + const val BUTTON_GAP: Int = 6 + const val FOOTER_ROW_GAP: Int = 4 + + fun friends( + screenWidth: Int, + screenHeight: Int, + ): FriendsScreenLayout { + val contentWidth = contentWidth(screenWidth) + val contentX = (screenWidth - contentWidth) / 2 + val footerBottom = screenHeight - EDGE_MARGIN + val footerTop = footerBottom - BUTTON_HEIGHT * 2 - FOOTER_ROW_GAP + val messageY = footerTop - 16 + val rowsTop = 56 + val rowHeight = 24 + val rowGap = 4 + val visibleRows = ( + (messageY - rowsTop + rowGap) / (rowHeight + rowGap) + ).coerceIn(1, 6) + val rowsBottom = rowsTop + visibleRows * rowHeight + + (visibleRows - 1) * rowGap + return FriendsScreenLayout( + contentX = contentX, + contentWidth = contentWidth, + headerY = 14, + subtitleY = 32, + rowsTop = rowsTop, + rowHeight = rowHeight, + rowGap = rowGap, + visibleRows = visibleRows, + rowsBottom = rowsBottom, + messageY = messageY, + footerTop = footerTop, + footerBottom = footerBottom, + ) + } + + fun form( + screenWidth: Int, + screenHeight: Int, + fieldCount: Int, + ): FormScreenLayout { + val contentWidth = contentWidth(screenWidth) + val contentX = (screenWidth - contentWidth) / 2 + val footerBottom = screenHeight - EDGE_MARGIN + val footerTop = footerBottom - BUTTON_HEIGHT * 2 - FOOTER_ROW_GAP + val bodyTop = if (fieldCount >= 5) 52 else 58 + return FormScreenLayout( + contentX = contentX, + contentWidth = contentWidth, + headerY = 14, + subtitleY = 32, + bodyTop = bodyTop, + availableBodyHeight = footerTop - bodyTop, + footerTop = footerTop, + footerBottom = footerBottom, + ) + } + + fun manageFriendForm(bodyTop: Int): ManageFriendFormLayout = + ManageFriendFormLayout( + nameLabelY = bodyTop, + nameInputY = bodyTop + 12, + notifyY = bodyTop + 34, + shareWorldsY = bodyTop + 56, + accessPolicyY = bodyTop + 78, + internetDirectY = bodyTop + 100, + ) + + private fun contentWidth(screenWidth: Int): Int = + (screenWidth - EDGE_MARGIN * 2) + .coerceAtLeast(1) + .coerceAtMost(MAX_CONTENT_WIDTH) +} diff --git a/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/ui/FriendFormDraft.kt b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/ui/FriendFormDraft.kt new file mode 100644 index 000000000..911a75c0e --- /dev/null +++ b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/ui/FriendFormDraft.kt @@ -0,0 +1,15 @@ +package com.minekube.connect.share.fabric.ui + +data class FriendFormDraft( + val displayName: String = "", + val invitation: String = "", + val offlineMode: Boolean = false, + val internetDirect: Boolean = false, +) { + fun newRequest(): FriendFormDraft = FriendFormDraft() + + companion object { + fun forManage(displayName: String): FriendFormDraft = + FriendFormDraft(displayName = displayName) + } +} diff --git a/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/ui/FriendsViewModel.kt b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/ui/FriendsViewModel.kt new file mode 100644 index 000000000..e9c39237c --- /dev/null +++ b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/ui/FriendsViewModel.kt @@ -0,0 +1,430 @@ +package com.minekube.connect.share.fabric.ui + +import arrow.core.Either +import arrow.core.Option +import arrow.core.left +import arrow.core.toOption +import com.minekube.connect.share.admission.AdmissionPurpose +import com.minekube.connect.share.admission.AdmissionIdentity +import com.minekube.connect.share.admission.Ingress +import com.minekube.connect.share.admission.PendingAdmission +import com.minekube.connect.share.fabric.DiscoveredLanShare +import com.minekube.connect.share.fabric.FabricShareBrowser +import com.minekube.connect.share.fabric.GuestJoinFailure +import com.minekube.connect.share.fabric.GuestJoinTarget +import com.minekube.connect.share.fabric.RemoteFriendPresence +import com.minekube.connect.share.fabric.FollowAction +import com.minekube.connect.share.fabric.FollowNextSessionController +import com.minekube.connect.share.direct.ShareRoute +import com.minekube.connect.share.direct.ShareInviteCodec +import com.minekube.connect.share.friend.FriendPermissions +import com.minekube.connect.share.friend.FriendStore +import com.minekube.connect.share.friend.SavedFriend +import com.minekube.connect.share.friend.FriendActivity +import com.minekube.connect.share.friend.FriendActivityKind +import com.minekube.connect.share.friend.CompatibilityProfile +import com.minekube.connect.tunnel.p2p.DirectP2pAuthMode +import java.time.Instant +import java.util.UUID +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +data class FriendSummary( + val peerId: String, + val displayName: String, + val connectAvailable: Boolean, + val permissions: FriendPermissions, + val internetDirectGuestOptIn: Boolean = false, + val onlineViaLan: Boolean = false, + val onlineViaConnect: Boolean = false, + val worldName: String? = null, + val activityKind: FriendActivityKind? = null, + val activityDescription: String? = null, + val canRequestJoin: Boolean = false, + val canJoinNow: Boolean = false, + val following: Boolean = false, +) + +data class OutgoingFriendRequestSummary( + val peerId: String, + val displayName: String, + val relationshipId: UUID = UUID.randomUUID(), +) + +data class IncomingFriendRequestSummary( + val requestId: UUID, + val displayName: String, + val ingress: Ingress, + val purpose: AdmissionPurpose, +) + +data class BlockedFriendSummary( + val peerId: String, + val displayName: String, +) + +data class FriendsUiState( + val friends: List = emptyList(), + val outgoingRequests: List = emptyList(), + val incomingRequests: List = emptyList(), + val blocked: List = emptyList(), + val safeMessage: ShareUiMessage? = null, +) + +class FriendsViewModel( + private val store: FriendStore, + private val followController: FollowNextSessionController = + FollowNextSessionController(), + private val onPeerRemoved: (String) -> Unit = {}, + private val onRemovalQueued: () -> Unit = {}, +) { + private var discovered: List = emptyList() + private var remotePresence: Map = emptyMap() + private var activities: Map = emptyMap() + private var incomingRequests: List = + emptyList() + private val mutableState = MutableStateFlow(loadInitialState()) + + val state: StateFlow = mutableState.asStateFlow() + + fun sendRequest( + invitationUri: String, + displayName: String, + now: Instant = Instant.now(), + internetDirectGuestOptIn: Boolean = false, + ): String? = + store.sendRequest( + invitationUri = invitationUri, + displayName = displayName, + now = now, + internetDirectGuestOptIn = internetDirectGuestOptIn, + ).fold( + ifLeft = { failure -> + update { copy(safeMessage = failure.uiMessage()) } + null + }, + ifRight = { request -> + refresh() + request.peerId + }, + ) + + fun suggestedDisplayName( + invitationUri: String, + now: Instant = Instant.now(), + ): Option = ShareInviteCodec.decode( + invitationUri.trim(), + now, + ).getOrNull()?.payload?.displayName.toOption() + + fun rename(peerId: String, displayName: String) { + store.rename(peerId, displayName).fold( + ifLeft = { failure -> + update { copy(safeMessage = failure.uiMessage()) } + }, + ifRight = { + refresh() + }, + ) + } + + fun updatePermissions( + peerId: String, + permissions: FriendPermissions, + ) { + store.updatePermissions(peerId, permissions).fold( + ifLeft = { failure -> + update { copy(safeMessage = failure.uiMessage()) } + }, + ifRight = { + refresh() + }, + ) + } + + fun updateInternetDirectGuestOptIn( + peerId: String, + enabled: Boolean, + ) { + store.setInternetDirectGuestOptIn(peerId, enabled).fold( + ifLeft = { failure -> + update { copy(safeMessage = failure.uiMessage()) } + }, + ifRight = { refresh() }, + ) + } + + internal fun relationshipId(peerId: String): UUID? = + store.relationship(peerId).getOrNull()?.relationshipId + + fun remove(peerId: String): Boolean = + Either.catch { + store.remove(peerId) + }.fold( + ifLeft = { + update { copy(safeMessage = FRIEND_REMOVE_FAILURE) } + false + }, + ifRight = { removed -> + refresh() + if (removed) { + notifyPeerRemoved(peerId) + onRemovalQueued() + } + removed + }, + ) + + fun block(peerId: String): Boolean = + Either.catch { store.block(peerId) }.fold( + ifLeft = { + update { copy(safeMessage = FRIEND_BLOCK_FAILURE) } + false + }, + ifRight = { blocked -> + refresh() + if (blocked) { + notifyPeerRemoved(peerId) + onRemovalQueued() + } + blocked + }, + ) + + fun unblock(peerId: String): Boolean = + Either.catch { store.unblock(peerId) }.fold( + ifLeft = { + update { copy(safeMessage = FRIEND_UNBLOCK_FAILURE) } + false + }, + ifRight = { unblocked -> + refresh() + unblocked + }, + ) + + fun follow(peerId: String): Boolean { + val friend = savedFriend(peerId) ?: return false + followController.follow(peerId, friend.displayName) + refresh(preserveSafeMessage = true) + return true + } + + fun cancelFollow(peerId: String): Boolean = + followController.cancel(peerId).also { + if (it) refresh(preserveSafeMessage = true) + } + + fun completeFollow(peerId: String): Boolean = + followController.complete(peerId).also { + if (it) refresh(preserveSafeMessage = true) + } + + fun followActions(activeGameplay: Boolean): List = + followController.update( + activities = activities, + activeGameplay = activeGameplay, + confirmedPeerIds = runCatching { + store.all().mapTo(mutableSetOf(), SavedFriend::peerId) + }.getOrDefault(emptySet()), + ).also { + if (it.isNotEmpty()) refresh(preserveSafeMessage = true) + } + + fun updatePresence(discovered: List) { + if (this.discovered == discovered) { + return + } + this.discovered = discovered + refresh(preserveSafeMessage = true) + } + + fun updateRemotePresence( + presence: Map, + ) { + if (remotePresence == presence) { + return + } + remotePresence = presence + refresh(preserveSafeMessage = true) + } + + fun updateActivities(activity: Map) { + if (activities == activity) return + activities = activity + refresh(preserveSafeMessage = true) + } + + fun updateIncoming(pending: List) { + val next = pending + .asSequence() + .map { + IncomingFriendRequestSummary( + requestId = it.requestId, + displayName = it.identity.name, + ingress = when (val identity = it.identity) { + is AdmissionIdentity.Authenticated -> + identity.ingress + + is AdmissionIdentity.UnverifiedOffline -> + identity.ingress + }, + purpose = it.purpose, + ) + } + .toList() + if (incomingRequests == next) { + refresh(preserveSafeMessage = true) + return + } + incomingRequests = next + refresh(preserveSafeMessage = true) + } + + suspend fun join( + peerId: String, + browser: FabricShareBrowser, + authMode: DirectP2pAuthMode, + ownConnectAddress: String? = null, + ): Either { + val friend = savedFriend(peerId) + ?: return GuestJoinFailure.NoRoute.left() + return browser.join(friend, authMode, ownConnectAddress) + } + + suspend fun routeOutgoing( + peerId: String, + browser: FabricShareBrowser, + authMode: DirectP2pAuthMode, + ): Either { + val request = outgoingRequest(peerId) + ?: return GuestJoinFailure.NoRoute.left() + return browser.openFriendControl(request, authMode) + } + + suspend fun routeFriendControl( + peerId: String, + browser: FabricShareBrowser, + authMode: DirectP2pAuthMode, + ): Either { + val friend = savedFriend(peerId) + ?: return GuestJoinFailure.NoRoute.left() + return browser.openFriendControl(friend, authMode) + } + + fun reload() { + refresh() + } + + internal fun savedFriend(peerId: String): SavedFriend? = + runCatching { + store.all().firstOrNull { it.peerId == peerId } + }.getOrNull() + + internal fun outgoingRequest(peerId: String): SavedFriend? = + runCatching { + store.outgoingRequests().firstOrNull { + it.peerId == peerId + } + }.getOrNull() + + internal fun compatibilityFor(peerId: String): CompatibilityProfile? = + activities[peerId]?.compatibility + + private fun refresh( + preserveSafeMessage: Boolean = false, + ) { + mutableState.value = try { + currentState().let { next -> + if (preserveSafeMessage) { + next.copy( + safeMessage = + mutableState.value.safeMessage, + ) + } else { + next + } + } + } catch (_: Exception) { + mutableState.value.copy( + safeMessage = FRIENDS_LOAD_FAILURE, + ) + } + } + + private fun loadInitialState(): FriendsUiState = try { + currentState() + } catch (_: Exception) { + FriendsUiState(safeMessage = FRIENDS_LOAD_FAILURE) + } + + private fun currentState(): FriendsUiState = + FriendsUiState( + friends = store.all().map { it.summary() }, + outgoingRequests = store.outgoingRequests().map { + OutgoingFriendRequestSummary( + peerId = it.peerId, + displayName = it.displayName, + relationshipId = it.relationshipId, + ) + }, + incomingRequests = incomingRequests, + blocked = store.blocked().map { + BlockedFriendSummary( + peerId = it.peerId, + displayName = it.displayName, + ) + }, + ) + + private fun update(transform: FriendsUiState.() -> FriendsUiState) { + mutableState.value = mutableState.value.transform() + } + + private fun notifyPeerRemoved(peerId: String) { + try { + onPeerRemoved(peerId) + } catch (_: RuntimeException) { + } + } + + private fun SavedFriend.summary(): FriendSummary { + val activity = activities[peerId] + val remote = remotePresence[peerId] + ?.takeIf { it.online && activity != null } + return FriendSummary( + peerId = peerId, + displayName = displayName, + connectAvailable = connectAddress != null, + permissions = permissions, + internetDirectGuestOptIn = internetDirectGuestOptIn, + onlineViaLan = remote?.route == ShareRoute.DIRECT_LAN, + onlineViaConnect = remote?.route == ShareRoute.CONNECT, + worldName = activity?.description, + activityKind = activity?.kind, + activityDescription = activity?.description, + canRequestJoin = activity?.joinable == true && + ( + activity.kind == FriendActivityKind.PLAYING_SERVER || + activity.kind == FriendActivityKind.HOSTING_WORLD + ), + canJoinNow = activity?.joinable == true && + remote != null && + activity.kind != FriendActivityKind.PLAYING_SERVER && + activity.kind != FriendActivityKind.HOSTING_WORLD, + following = peerId in followController.state.value, + ) + } + + private companion object { + val FRIENDS_LOAD_FAILURE = + ShareUiMessage("connect_share.error.friends_load") + val FRIEND_REMOVE_FAILURE = + ShareUiMessage("connect_share.error.friend_remove") + val FRIEND_BLOCK_FAILURE = + ShareUiMessage("connect_share.error.friend_block") + val FRIEND_UNBLOCK_FAILURE = + ShareUiMessage("connect_share.error.friend_unblock") + } +} diff --git a/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/ui/ListPage.kt b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/ui/ListPage.kt new file mode 100644 index 000000000..56c52d35b --- /dev/null +++ b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/ui/ListPage.kt @@ -0,0 +1,35 @@ +package com.minekube.connect.share.fabric.ui + +data class ListPage( + val items: List, + val offset: Int, + val previousOffset: Int?, + val nextOffset: Int?, + val pageNumber: Int, + val pageCount: Int, +) { + val hasPrevious: Boolean = previousOffset != null + val hasNext: Boolean = nextOffset != null +} + +fun List.page( + offset: Int, + size: Int, +): ListPage { + require(size > 0) { "Page size must be positive" } + val pageCount = ((this.size + size - 1) / size).coerceAtLeast(1) + val requestedPage = offset.coerceAtLeast(0) / size + val pageIndex = requestedPage.coerceAtMost(pageCount - 1) + val normalizedOffset = pageIndex * size + val items = drop(normalizedOffset).take(size) + return ListPage( + items = items, + offset = normalizedOffset, + previousOffset = normalizedOffset.takeIf { it > 0 } + ?.minus(size) + ?.coerceAtLeast(0), + nextOffset = (normalizedOffset + size).takeIf { it < this.size }, + pageNumber = pageIndex + 1, + pageCount = pageCount, + ) +} diff --git a/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/ui/ShareScreenPresentation.kt b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/ui/ShareScreenPresentation.kt new file mode 100644 index 000000000..7f995d736 --- /dev/null +++ b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/ui/ShareScreenPresentation.kt @@ -0,0 +1,224 @@ +package com.minekube.connect.share.fabric.ui + +import com.minekube.connect.share.fabric.FollowAction +import com.minekube.connect.share.friend.CompatibilityDifference +import com.minekube.connect.share.friend.FriendActivityKind + +enum class FriendPresenceTone { + JOINABLE, + ONLINE, + SAVED, + OFFLINE, +} + +enum class FriendPrimaryAction( + val translationKey: String, +) { + JOIN_NOW("connect_share.friends.action.join_now"), + ASK_TO_JOIN("connect_share.friends.action.ask_to_join"), + CANCEL_FOLLOW("connect_share.friends.action.cancel_follow"), + JOIN_WHEN_READY("connect_share.friends.action.join_when_ready"), +} + +data class FriendRowPresentation( + val tone: FriendPresenceTone, + val statusKey: String, + val statusArguments: List, + val action: FriendPrimaryAction, +) + +data class FriendsOverview( + val friendCount: Int, + val onlineCount: Int, + val joinableCount: Int, + val incomingCount: Int, + val outgoingCount: Int, +) + +enum class FriendsSummaryTone { + ATTENTION, + READY, + ONLINE, + MUTED, +} + +data class FriendsSummaryPresentation( + val translationKey: String, + val count: Int?, + val tone: FriendsSummaryTone, +) + +data class MenuFriendsPresentation( + val translationKey: String, + val count: Int?, +) + +data class CompatibilityLine( + val translationKey: String, + val arguments: List, +) + +data class FollowTerminalNotification( + val titleKey: String, + val detailKey: String, + val displayName: String, +) + +fun FollowAction.terminalNotification(): FollowTerminalNotification? = + when (this) { + is FollowAction.Expired -> FollowTerminalNotification( + titleKey = "connect_share.notification.follow_expired", + detailKey = "connect_share.notification.follow_expired_detail", + displayName = displayName, + ) + is FollowAction.Cancelled -> FollowTerminalNotification( + titleKey = "connect_share.notification.follow_cancelled", + detailKey = "connect_share.notification.follow_cancelled_detail", + displayName = displayName, + ) + is FollowAction.RequestJoin, + is FollowAction.OfferJoinNow -> null + } + +fun FriendSummary.presentation(): FriendRowPresentation { + val action = when { + canJoinNow -> FriendPrimaryAction.JOIN_NOW + canRequestJoin -> FriendPrimaryAction.ASK_TO_JOIN + following -> FriendPrimaryAction.CANCEL_FOLLOW + else -> FriendPrimaryAction.JOIN_WHEN_READY + } + val tone = when { + canJoinNow || canRequestJoin || + activityKind == FriendActivityKind.HOSTING_WORLD -> + FriendPresenceTone.JOINABLE + + activityKind == FriendActivityKind.PLAYING_SERVER || + activityKind == FriendActivityKind.ONLINE || + onlineViaLan || onlineViaConnect -> FriendPresenceTone.ONLINE + + connectAvailable -> FriendPresenceTone.SAVED + else -> FriendPresenceTone.OFFLINE + } + val status = when { + activityKind == FriendActivityKind.HOSTING_WORLD -> + "connect_share.friends.status.world" to + listOf(activityDescription ?: worldName ?: "Minecraft world") + + activityKind == FriendActivityKind.PLAYING_SERVER -> + "connect_share.friends.status.server" to + listOf(activityDescription ?: "Minecraft server") + + canJoinNow -> + "connect_share.friends.status.ready" to emptyList() + + activityKind == FriendActivityKind.ONLINE || onlineViaConnect -> + "connect_share.friends.status.online" to emptyList() + + connectAvailable -> + "connect_share.friends.status.saved" to emptyList() + + else -> "connect_share.friends.status.offline" to emptyList() + } + return FriendRowPresentation( + tone = tone, + statusKey = status.first, + statusArguments = status.second, + action = action, + ) +} + +fun FriendsUiState.overview(): FriendsOverview { + val presentations = friends.map(FriendSummary::presentation) + return FriendsOverview( + friendCount = friends.size, + onlineCount = presentations.count { + it.tone == FriendPresenceTone.JOINABLE || + it.tone == FriendPresenceTone.ONLINE + }, + joinableCount = presentations.count { + it.tone == FriendPresenceTone.JOINABLE + }, + incomingCount = incomingRequests.size, + outgoingCount = outgoingRequests.size, + ) +} + +fun FriendsOverview.summary(): FriendsSummaryPresentation { + val baseKey: String + val count: Int? + val tone: FriendsSummaryTone + when { + incomingCount > 0 -> { + baseKey = "connect_share.friends.summary.requests" + count = incomingCount + tone = FriendsSummaryTone.ATTENTION + } + friendCount == 0 -> return FriendsSummaryPresentation( + translationKey = "connect_share.friends.summary.welcome", + count = null, + tone = FriendsSummaryTone.MUTED, + ) + joinableCount > 0 -> { + baseKey = "connect_share.friends.summary.joinable" + count = joinableCount + tone = FriendsSummaryTone.READY + } + onlineCount > 0 -> { + baseKey = "connect_share.friends.summary.online" + count = onlineCount + tone = FriendsSummaryTone.ONLINE + } + else -> { + baseKey = "connect_share.friends.summary.saved" + count = friendCount + tone = FriendsSummaryTone.MUTED + } + } + return FriendsSummaryPresentation( + translationKey = "$baseKey.${if (count == 1) "one" else "many"}", + count = count, + tone = tone, + ) +} + +fun FriendsOverview.menuLabel(): MenuFriendsPresentation = when { + incomingCount > 0 -> MenuFriendsPresentation( + translationKey = "connect_share.menu.requests", + count = incomingCount, + ) + joinableCount > 0 -> MenuFriendsPresentation( + translationKey = "connect_share.menu.ready", + count = joinableCount, + ) + else -> MenuFriendsPresentation( + translationKey = "connect_share.menu.join", + count = null, + ) +} + +fun CompatibilityDifference.presentation(): CompatibilityLine = when (this) { + is CompatibilityDifference.MinecraftVersion -> CompatibilityLine( + "connect_share.compatibility.minecraft", + listOf(local, remote), + ) + + is CompatibilityDifference.Loader -> CompatibilityLine( + "connect_share.compatibility.loader", + listOf(local.name.lowercase(), remote.name.lowercase()), + ) + + is CompatibilityDifference.MissingLocal -> CompatibilityLine( + "connect_share.compatibility.install", + listOf(modId, remoteVersion), + ) + + is CompatibilityDifference.MissingRemote -> CompatibilityLine( + "connect_share.compatibility.host_missing", + listOf(modId, localVersion), + ) + + is CompatibilityDifference.ModVersion -> CompatibilityLine( + "connect_share.compatibility.mod_version", + listOf(modId, local, remote), + ) +} diff --git a/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/ui/ShareUiMessage.kt b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/ui/ShareUiMessage.kt new file mode 100644 index 000000000..c60e50432 --- /dev/null +++ b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/ui/ShareUiMessage.kt @@ -0,0 +1,141 @@ +package com.minekube.connect.share.fabric.ui + +import com.minekube.connect.share.ShareLifecycleError +import com.minekube.connect.share.admission.AdmissionAnswer +import com.minekube.connect.share.direct.ShareInviteError +import com.minekube.connect.share.fabric.FriendJoinAttemptFailure +import com.minekube.connect.share.fabric.FriendRequestFailure +import com.minekube.connect.share.fabric.GuestJoinFailure +import com.minekube.connect.share.friend.CompatibilityDifference +import com.minekube.connect.share.friend.FriendStoreError +import com.minekube.connect.share.identity.CredentialValidationError + +data class ShareUiMessage( + val translationKey: String, + val arguments: List = emptyList(), +) + +data class RemoteLoginMessage( + val translationKey: String, + val fallback: String, +) + +object ShareLoginMessages { + val AUTHENTICATION_REQUIRED = RemoteLoginMessage( + "connect_share.login.authentication_required", + "This connection needs a valid Minecraft account.", + ) + + fun denial(answer: AdmissionAnswer?): RemoteLoginMessage = when (answer) { + AdmissionAnswer.TIMEOUT -> RemoteLoginMessage( + "connect_share.login.approval_timed_out", + "The host did not approve this join in time. Try again.", + ) + AdmissionAnswer.CAPACITY -> RemoteLoginMessage( + "connect_share.login.share_full", + "This shared world is full. Ask the host to make room.", + ) + AdmissionAnswer.STOPPED -> RemoteLoginMessage( + "connect_share.login.sharing_stopped", + "This world is not available right now. " + + "Ask the host to share it again.", + ) + else -> RemoteLoginMessage( + "connect_share.login.host_denied", + "The host declined this join. Request access again when ready.", + ) + } +} + +fun ShareInviteError.uiMessage(): ShareUiMessage = when (this) { + ShareInviteError.Malformed -> + ShareUiMessage("connect_share.error.invitation_malformed") + is ShareInviteError.UnsupportedVersion -> ShareUiMessage( + "connect_share.error.invitation_unsupported_version", + listOf(version.toString()), + ) + ShareInviteError.Expired -> + ShareUiMessage("connect_share.error.invitation_expired") + ShareInviteError.InvalidSignature -> + ShareUiMessage("connect_share.error.invitation_invalid_signature") + ShareInviteError.RelayCandidateForbidden -> + ShareUiMessage("connect_share.error.invitation_relay_forbidden") + ShareInviteError.PeerMismatch -> + ShareUiMessage("connect_share.error.invitation_peer_mismatch") +} + +fun FriendStoreError.uiMessage(): ShareUiMessage = when (this) { + is FriendStoreError.InvalidInvitation -> + reason.uiMessage() + FriendStoreError.InvalidDisplayName -> + ShareUiMessage("connect_share.error.invalid_friend_name") + FriendStoreError.IdentityConflict -> + ShareUiMessage("connect_share.error.friend_identity_conflict") + FriendStoreError.NotFound -> + ShareUiMessage("connect_share.error.friend_not_saved") + FriendStoreError.Blocked -> + ShareUiMessage("connect_share.error.friend_blocked") +} + +fun CredentialValidationError.uiMessage(): ShareUiMessage = when (this) { + is CredentialValidationError.InvalidInput -> + ShareUiMessage("connect_share.error.identity_invalid") + is CredentialValidationError.Rejected -> + ShareUiMessage("connect_share.error.identity_rejected") + is CredentialValidationError.Network -> + ShareUiMessage("connect_share.error.identity_network") + is CredentialValidationError.ManagedByEnvironment -> + ShareUiMessage("connect_share.error.identity_managed") +} + +fun ShareLifecycleError.uiMessage(): ShareUiMessage = when (this) { + ShareLifecycleError.AlreadyActive -> + ShareUiMessage("connect_share.error.share_already_active") + ShareLifecycleError.StartFailed -> + ShareUiMessage("connect_share.error.share_start_failed") + ShareLifecycleError.StopFailed -> + ShareUiMessage("connect_share.error.share_stop_failed") +} + +fun GuestJoinFailure.uiMessage(): ShareUiMessage = when (this) { + is GuestJoinFailure.InvalidInvitation -> + error.uiMessage() + GuestJoinFailure.PeerMismatch -> + ShareUiMessage("connect_share.error.join_peer_mismatch") + GuestJoinFailure.DiscoveryUnavailable -> + ShareUiMessage("connect_share.error.join_discovery_unavailable") + GuestJoinFailure.NoRoute -> + ShareUiMessage("connect_share.error.join_no_route") + GuestJoinFailure.EndpointConflict -> + ShareUiMessage("connect_share.error.identity_endpoint_conflict") +} + +fun FriendRequestFailure.uiMessage(): ShareUiMessage = when (this) { + FriendRequestFailure.Unreachable -> + ShareUiMessage("connect_share.error.friend_unreachable") + FriendRequestFailure.Declined -> + ShareUiMessage("connect_share.error.friend_declined") + FriendRequestFailure.TimedOut -> + ShareUiMessage("connect_share.error.friend_timed_out") + FriendRequestFailure.InvalidResponse -> + ShareUiMessage("connect_share.error.friend_invalid_response") +} + +fun FriendJoinAttemptFailure.uiMessage(): ShareUiMessage = when (this) { + is FriendJoinAttemptFailure.Control -> failure.uiMessage() + is FriendJoinAttemptFailure.Request -> failure.uiMessage() + is FriendJoinAttemptFailure.Gameplay -> failure.uiMessage() + is FriendJoinAttemptFailure.Compatibility -> + when { + report.differences.any { + it is CompatibilityDifference.MinecraftVersion + } -> ShareUiMessage("connect_share.error.minecraft_version") + report.differences.any { + it is CompatibilityDifference.Loader + } -> ShareUiMessage("connect_share.error.mod_loader") + else -> ShareUiMessage("connect_share.error.required_mods") + } +} + +val GENERIC_SHARE_UI_MESSAGE = + ShareUiMessage("connect_share.error.generic") diff --git a/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/ui/ShareViewModel.kt b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/ui/ShareViewModel.kt new file mode 100644 index 000000000..82087d606 --- /dev/null +++ b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/ui/ShareViewModel.kt @@ -0,0 +1,471 @@ +package com.minekube.connect.share.fabric.ui + +import arrow.core.Either +import com.minekube.connect.share.ShareGameMode +import com.minekube.connect.share.ShareLifecycleError +import com.minekube.connect.share.ShareOptions +import com.minekube.connect.share.ShareState +import com.minekube.connect.share.admission.PendingAdmission +import com.minekube.connect.share.identity.CredentialSource +import com.minekube.connect.share.identity.CredentialValidationError +import com.minekube.connect.share.identity.EndpointCredentialValidator +import com.minekube.connect.share.identity.EndpointIdentity +import com.minekube.connect.share.identity.EndpointIdentityStore +import com.minekube.connect.share.friend.PresencePrivacy +import java.nio.file.Path +import java.util.UUID +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +data class EndpointIdentitySummary( + val endpoint: String, + val endpointSource: CredentialSource, + val tokenSource: CredentialSource, +) { + val endpointManagedByEnvironment: Boolean = + endpointSource == CredentialSource.ENVIRONMENT + val tokenManagedByEnvironment: Boolean = + tokenSource == CredentialSource.ENVIRONMENT +} + +data class IdentityImportDraft( + val endpoint: String = "", + val token: String = "", + val endpointEditable: Boolean = true, + val tokenEditable: Boolean = true, +) { + override fun toString(): String = + "IdentityImportDraft(endpoint=$endpoint, token=, " + + "endpointEditable=$endpointEditable, tokenEditable=$tokenEditable)" +} + +data class ShareUiState( + val worldAvailable: Boolean, + val shareState: ShareState, + val options: ShareOptions, + val pendingAdmissions: List, + val shareWithFriendsEnabled: Boolean = false, + val presencePrivacy: PresencePrivacy = PresencePrivacy(), + val identity: EndpointIdentitySummary? = null, + val importDraft: IdentityImportDraft = IdentityImportDraft(), + val operationInProgress: Boolean = false, + val safeMessage: ShareUiMessage? = null, +) { + val startEnabled: Boolean + get() = worldAvailable && + shareState is ShareState.Idle && + !operationInProgress +} + +interface EndpointIdentityUiActions { + suspend fun current(): EndpointIdentitySummary + + suspend fun import( + endpoint: String, + token: String, + ): Either + + suspend fun importTokenFile( + endpoint: String, + tokenFile: Path, + ): Either + + suspend fun reset(): Either +} + +class StoredEndpointIdentityUiActions( + private val store: EndpointIdentityStore, + private val validator: EndpointCredentialValidator, +) : EndpointIdentityUiActions { + override suspend fun current(): EndpointIdentitySummary = + store.currentOrCreate().redactedSummary() + + override suspend fun import( + endpoint: String, + token: String, + ): Either = + store.import(endpoint, token, validator).map(EndpointIdentity::redactedSummary) + + override suspend fun importTokenFile( + endpoint: String, + tokenFile: Path, + ): Either = + store.importTokenFile(endpoint, tokenFile, validator) + .map(EndpointIdentity::redactedSummary) + + override suspend fun reset(): + Either = + store.resetConfirmed().map(EndpointIdentity::redactedSummary) +} + +class ShareViewModel( + private val scope: CoroutineScope, + shareState: StateFlow, + pendingAdmissions: StateFlow>, + initialWorldAvailable: Boolean, + private val identityActions: EndpointIdentityUiActions, + initialShareWithFriendsEnabled: Boolean = false, + private val persistShareWithFriendsEnabled: (Boolean) -> Unit = {}, + initialPresencePrivacy: PresencePrivacy = PresencePrivacy(), + private val persistPresencePrivacy: (PresencePrivacy) -> Unit = {}, + private val startShare: + suspend (ShareOptions) -> Either, + private val stopShare: suspend () -> Either, + private val answerAdmission: (UUID, Boolean) -> Unit, + private val operationDispatcher: CoroutineDispatcher = Dispatchers.IO, + private val onIdentityChanged: suspend () -> Unit = {}, + private val currentInvitation: () -> String? = { null }, +) { + private val operationMutex = Mutex() + + fun currentInvitation(): String? = currentInvitation.invoke() + private val mutableState = MutableStateFlow( + ShareUiState( + worldAvailable = initialWorldAvailable, + shareState = shareState.value, + options = ShareOptions( + gameMode = ShareGameMode.SURVIVAL, + allowCheats = false, + ), + pendingAdmissions = pendingAdmissions.value, + shareWithFriendsEnabled = initialShareWithFriendsEnabled, + presencePrivacy = initialPresencePrivacy, + ), + ) + + val state: StateFlow = mutableState.asStateFlow() + + init { + scope.launch(start = CoroutineStart.UNDISPATCHED) { + shareState.collectLatest { next -> + update { copy(shareState = next) } + } + } + scope.launch(start = CoroutineStart.UNDISPATCHED) { + pendingAdmissions.collectLatest { next -> + update { copy(pendingAdmissions = next) } + } + } + scope.launch(context = operationDispatcher) { + runOperation { + val identity = identityActions.current() + update { + copy( + identity = identity, + importDraft = importDraft.withEditability(identity), + ) + } + } + } + } + + fun setWorldAvailable(available: Boolean) { + update { copy(worldAvailable = available) } + } + + fun setGameMode(gameMode: ShareGameMode) { + update { copy(options = options.copy(gameMode = gameMode)) } + } + + fun setAllowCheats(allowCheats: Boolean) { + update { copy(options = options.copy(allowCheats = allowCheats)) } + } + + fun setMaxGuests(maxGuests: Int) { + update { + copy( + options = options.copy( + maxGuests = maxGuests.coerceIn( + ShareOptions.MIN_GUESTS, + ShareOptions.MAX_GUESTS, + ), + ), + ) + } + } + + fun setAllowInternetDirect(allowed: Boolean) { + update { + copy(options = options.copy(allowInternetDirect = allowed)) + } + } + + fun setPresencePrivacy(privacy: PresencePrivacy) { + update { copy(presencePrivacy = privacy) } + scope.launch(context = operationDispatcher) { + try { + persistPresencePrivacy(privacy) + } catch (_: Exception) { + update { copy(safeMessage = PREFERENCES_FAILURE_MESSAGE) } + } + } + } + + fun start() { + if (!state.value.startEnabled) return + scope.launch(context = operationDispatcher) { + runOperation { + if (!canStartCurrentWorld()) return@runOperation + setShareWithFriendsEnabled(true) + startCurrentWorld() + } + } + } + + fun stop() { + scope.launch(context = operationDispatcher) { + runOperation { + if (!canStopCurrentWorld()) return@runOperation + try { + setShareWithFriendsEnabled(false) + } finally { + stopCurrentWorld() + } + } + } + } + + suspend fun resumeIfEnabled() { + if ( + !state.value.shareWithFriendsEnabled || + !state.value.startEnabled + ) { + return + } + kotlinx.coroutines.withContext(operationDispatcher) { + runOperation { + if (!canStartCurrentWorld()) return@runOperation + startCurrentWorld() + } + } + } + + fun allow(requestId: UUID) { + answerAdmission(requestId, true) + } + + fun deny(requestId: UUID) { + answerAdmission(requestId, false) + } + + fun setImportEndpoint(endpoint: String) { + if (!identityChangesAllowed()) { + rejectIdentityChange() + return + } + update { + if (!importDraft.endpointEditable) { + this + } else { + copy(importDraft = importDraft.copy(endpoint = endpoint)) + } + } + } + + fun setImportToken(token: String) { + if (!identityChangesAllowed()) { + rejectIdentityChange() + return + } + update { + if (!importDraft.tokenEditable) { + this + } else { + copy(importDraft = importDraft.copy(token = token)) + } + } + } + + fun importIdentity() { + if (!identityChangesAllowed()) { + rejectIdentityChange() + return + } + val draft = state.value.importDraft + if (!draft.endpointEditable || !draft.tokenEditable) { + update { copy(safeMessage = MANAGED_MESSAGE) } + return + } + scope.launch(context = operationDispatcher) { + runOperation { + applyIdentityResult( + identityActions.import(draft.endpoint, draft.token), + ) + } + } + } + + fun importTokenFile(tokenFile: Path) { + if (!identityChangesAllowed()) { + rejectIdentityChange() + return + } + val draft = state.value.importDraft + if (!draft.endpointEditable || !draft.tokenEditable) { + update { copy(safeMessage = MANAGED_MESSAGE) } + return + } + scope.launch(context = operationDispatcher) { + runOperation { + applyIdentityResult( + identityActions.importTokenFile(draft.endpoint, tokenFile), + ) + } + } + } + + fun resetIdentity() { + if (!identityChangesAllowed()) { + rejectIdentityChange() + return + } + scope.launch(context = operationDispatcher) { + runOperation { + applyIdentityResult(identityActions.reset()) + } + } + } + + private suspend fun applyIdentityResult( + result: Either, + ) { + result.fold( + ifLeft = { failure -> + update { copy(safeMessage = failure.uiMessage()) } + }, + ifRight = { identity -> + onIdentityChanged() + update { + copy( + identity = identity, + importDraft = IdentityImportDraft() + .withEditability(identity), + safeMessage = null, + ) + } + }, + ) + } + + private fun setShareWithFriendsEnabled(enabled: Boolean) { + persistShareWithFriendsEnabled(enabled) + update { copy(shareWithFriendsEnabled = enabled) } + } + + private suspend fun startCurrentWorld() { + startShare(state.value.options).fold( + ifLeft = { failure -> + update { copy(safeMessage = failure.uiMessage()) } + }, + ifRight = { + update { + copy( + shareState = it, + safeMessage = null, + ) + } + }, + ) + } + + private suspend fun stopCurrentWorld() { + stopShare().fold( + ifLeft = { failure -> + update { copy(safeMessage = failure.uiMessage()) } + }, + ifRight = { + update { + copy( + shareState = ShareState.Idle, + safeMessage = null, + ) + } + }, + ) + } + + private suspend fun runOperation(operation: suspend () -> Unit) { + operationMutex.withLock { + update { copy(operationInProgress = true) } + try { + operation() + } catch (cancellation: CancellationException) { + throw cancellation + } catch (_: Exception) { + update { copy(safeMessage = GENERIC_FAILURE_MESSAGE) } + } finally { + update { copy(operationInProgress = false) } + } + } + } + + private fun canStartCurrentWorld(): Boolean = + state.value.worldAvailable && state.value.shareState is ShareState.Idle + + private fun canStopCurrentWorld(): Boolean = when (state.value.shareState) { + ShareState.Idle -> false + + ShareState.Starting, + is ShareState.Sharing, + ShareState.Stopping, + is ShareState.Failed, + -> true + } + + private fun update(transform: ShareUiState.() -> ShareUiState) { + mutableState.value = mutableState.value.transform() + } + + private fun identityChangesAllowed(): Boolean = when ( + state.value.shareState + ) { + ShareState.Idle, + is ShareState.Failed, + -> true + + ShareState.Starting, + is ShareState.Sharing, + ShareState.Stopping, + -> false + } + + private fun rejectIdentityChange() { + update { + copy(safeMessage = IDENTITY_ACTIVE_MESSAGE) + } + } + + private fun IdentityImportDraft.withEditability( + identity: EndpointIdentitySummary, + ): IdentityImportDraft = copy( + endpointEditable = !identity.endpointManagedByEnvironment, + tokenEditable = !identity.tokenManagedByEnvironment, + ) + + private companion object { + val MANAGED_MESSAGE = + ShareUiMessage("connect_share.error.identity_managed") + val GENERIC_FAILURE_MESSAGE = + ShareUiMessage("connect_share.error.generic") + val IDENTITY_ACTIVE_MESSAGE = + ShareUiMessage("connect_share.error.identity_active") + val PREFERENCES_FAILURE_MESSAGE = + ShareUiMessage("connect_share.error.preferences_save") + } +} + +private fun EndpointIdentity.redactedSummary() = EndpointIdentitySummary( + endpoint = endpoint, + endpointSource = endpointSource, + tokenSource = tokenSource, +) diff --git a/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/ApprovedJoinTrackerTest.kt b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/ApprovedJoinTrackerTest.kt new file mode 100644 index 000000000..0e8bc6a3d --- /dev/null +++ b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/ApprovedJoinTrackerTest.kt @@ -0,0 +1,149 @@ +package com.minekube.connect.share.fabric + +import com.minekube.connect.share.admission.AdmissionAnswer +import com.minekube.connect.share.admission.AdmissionIdentity +import com.minekube.connect.share.admission.AuthSource +import com.minekube.connect.share.admission.Ingress +import com.minekube.connect.share.direct.ShareInviteCodec +import java.time.Instant +import java.util.UUID +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.io.TempDir + +class ApprovedJoinTrackerTest { + @TempDir + lateinit var tempDir: java.nio.file.Path + + private var nowMillis = 1_000L + private val tracker = ApprovedJoinTracker { nowMillis } + + @Test + fun `approved authenticated identity can be consumed once`() = runTest { + val (invitation, peerId) = invitationAndPeer() + tracker.record( + AUTHENTICATED.copy(directPeerId = peerId), + AdmissionAnswer.ALLOW, + ) + + assertEquals(true, tracker.hasProof("Robin", PLAYER_UUID)) + assertEquals( + PLAYER_UUID, + tracker.consume("Robin", PLAYER_UUID, invitation) + ?.authenticatedMinecraftUuid, + ) + assertNull(tracker.consume("Robin", PLAYER_UUID, invitation)) + } + + @Test + fun `approved offline identity proves pairing without trusting its uuid`() = runTest { + val (invitation, peerId) = invitationAndPeer() + tracker.record( + OFFLINE.copy(directPeerId = peerId), + AdmissionAnswer.ALLOW, + ) + + val proof = tracker.consume("Robin", PLAYER_UUID, invitation) + + assertNotNull(proof) + assertNull(proof.authenticatedMinecraftUuid) + } + + @Test + fun `denied identities cannot trigger a friend card exchange`() { + tracker.record(AUTHENTICATED, AdmissionAnswer.DENY) + + assertEquals(false, tracker.hasProof("Robin", PLAYER_UUID)) + assertNull(tracker.consume("Robin", PLAYER_UUID, "invalid")) + } + + @Test + fun `authentication proof expires before an unrelated later join`() = runTest { + val (invitation, peerId) = invitationAndPeer() + tracker.record( + AUTHENTICATED.copy(directPeerId = peerId), + AdmissionAnswer.ALLOW, + ) + nowMillis += 121_000 + + assertNull(tracker.consume("Robin", PLAYER_UUID, invitation)) + } + + @Test + fun `removing a peer revokes its direct and linked uuid proofs`() { + val peerId = "12D3KooWRemovedFriend" + tracker.record( + AUTHENTICATED.copy(directPeerId = peerId), + AdmissionAnswer.ALLOW, + ) + tracker.record( + AUTHENTICATED.copy(name = "LinkedConnectPlayer"), + AdmissionAnswer.ALLOW, + ) + + assertEquals(2, tracker.revokeDirectPeer(peerId, PLAYER_UUID)) + assertEquals(false, tracker.hasProof("Robin", PLAYER_UUID)) + assertEquals(false, tracker.hasProof("LinkedConnectPlayer", PLAYER_UUID)) + } + + @Test + fun `automatic friendship proof requires the matching direct peer`() = runTest { + val (expectedInvitation, expectedPeerId) = invitationAndPeer() + val (otherInvitation, _) = invitationAndPeer("other") + tracker.record( + OFFLINE.copy(directPeerId = expectedPeerId), + AdmissionAnswer.ALLOW, + ) + + assertNull( + tracker.consume("Robin", PLAYER_UUID, otherInvitation), + "a different signed peer must not consume another peer's approval", + ) + assertNull( + tracker.consume("Robin", PLAYER_UUID, expectedInvitation), + "a rejected proof remains one-shot", + ) + } + + @Test + fun `unbound Connect proof cannot enable automatic friendship`() = runTest { + val (invitation, _) = invitationAndPeer() + tracker.record(OFFLINE, AdmissionAnswer.ALLOW) + + assertEquals(false, tracker.hasProof("Robin", PLAYER_UUID)) + assertNull(tracker.consume("Robin", PLAYER_UUID, invitation)) + } + + private suspend fun invitationAndPeer( + suffix: String = "expected", + ): Pair { + val invitation = FriendCardIssuer( + dataDirectory = tempDir.resolve(suffix), + connectAddress = { null }, + ).issue(Instant.ofEpochMilli(nowMillis)).getOrNull()!! + val peerId = ShareInviteCodec.decode( + invitation, + Instant.ofEpochMilli(nowMillis), + ).getOrNull()!!.payload.peerId + return invitation to peerId + } + + private companion object { + val PLAYER_UUID: UUID = + UUID.fromString("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee") + val AUTHENTICATED = AdmissionIdentity.Authenticated( + name = "Robin", + uuid = PLAYER_UUID, + source = AuthSource.CONNECT, + ) + val OFFLINE = AdmissionIdentity.UnverifiedOffline( + name = "Robin", + uuid = PLAYER_UUID, + connectionId = "offline-connection", + ingress = Ingress.CONNECT, + ) + } +} diff --git a/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/ConnectControlPlaneTest.kt b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/ConnectControlPlaneTest.kt new file mode 100644 index 000000000..c0af097a5 --- /dev/null +++ b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/ConnectControlPlaneTest.kt @@ -0,0 +1,128 @@ +package com.minekube.connect.share.fabric + +import com.minekube.connect.share.ConnectShareHandle +import com.minekube.connect.share.ConnectShareIngress +import com.minekube.connect.share.identity.CredentialSource +import com.minekube.connect.share.identity.EndpointIdentity +import io.netty.channel.local.LocalAddress +import java.net.SocketAddress +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest + +@OptIn(ExperimentalCoroutinesApi::class) +class ConnectControlPlaneTest { + @Test + fun `startup and shutdown are scheduled off the caller dispatcher`() = + runTest { + val io = StandardTestDispatcher(testScheduler) + val delegate = RecordingIngress() + val persistent = PersistentConnectIngress(delegate) + var identitiesLoaded = 0 + val control = ConnectControlPlane( + scope = backgroundScope, + ingress = persistent, + identity = { + identitiesLoaded++ + IDENTITY + }, + target = TARGET, + ioDispatcher = io, + ) + + control.start() + + assertEquals(0, identitiesLoaded) + assertEquals(0, delegate.starts) + runCurrent() + assertEquals(1, identitiesLoaded) + assertEquals(1, delegate.starts) + assertIs( + control.state.value, + ) + + control.shutdown() + + assertEquals(1, delegate.closes) + assertEquals(PersistentConnectState.Closed, control.state.value) + } + + @Test + fun `repeated starts share one in-flight title connector`() = runTest { + val io = StandardTestDispatcher(testScheduler) + val delegate = RecordingIngress() + val control = ConnectControlPlane( + scope = backgroundScope, + ingress = PersistentConnectIngress(delegate), + identity = { IDENTITY }, + target = TARGET, + ioDispatcher = io, + ) + + repeat(8) { control.start() } + runCurrent() + + assertEquals(1, delegate.starts) + control.shutdown() + } + + @Test + fun `shutdown cancels an in-flight connector startup`() = runTest { + val io = StandardTestDispatcher(testScheduler) + var cancellations = 0 + val delegate = ConnectShareIngress { _, _ -> + try { + awaitCancellation() + } finally { + cancellations++ + } + } + val control = ConnectControlPlane( + scope = backgroundScope, + ingress = PersistentConnectIngress(delegate), + identity = { IDENTITY }, + target = TARGET, + ioDispatcher = io, + ) + control.start() + runCurrent() + + control.shutdown() + + assertEquals(1, cancellations) + assertEquals(PersistentConnectState.Closed, control.state.value) + } + + private class RecordingIngress : ConnectShareIngress { + var starts = 0 + var closes = 0 + + override suspend fun start( + identity: EndpointIdentity, + target: SocketAddress, + ): ConnectShareHandle { + starts++ + return ConnectShareHandle( + endpoint = identity.endpoint, + publicAddress = + "${identity.endpoint}.play.minekube.net", + close = { closes++ }, + ) + } + } + + private companion object { + val IDENTITY = EndpointIdentity( + endpoint = "control", + token = "T-controlplanetoken", + endpointSource = CredentialSource.GENERATED, + tokenSource = CredentialSource.GENERATED, + ) + val TARGET: SocketAddress = LocalAddress("control-target") + } +} diff --git a/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/ConnectShareRuntimeTest.kt b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/ConnectShareRuntimeTest.kt new file mode 100644 index 000000000..d9a6cc44a --- /dev/null +++ b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/ConnectShareRuntimeTest.kt @@ -0,0 +1,98 @@ +package com.minekube.connect.share.fabric + +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals + +@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) +class ConnectShareRuntimeTest { + @Test + fun `world lifecycle work is scheduled off the caller dispatcher`() = + runTest { + val lifecycleDispatcher = + StandardTestDispatcher(testScheduler) + var resumeCalls = 0 + val runtime = ConnectShareRuntime( + scope = this, + stopShare = {}, + resumeShare = { resumeCalls++ }, + lifecycleDispatcher = lifecycleDispatcher, + ) + + runtime.integratedWorldChanged(worldAvailable = true) + + assertEquals(0, resumeCalls) + runCurrent() + assertEquals(1, resumeCalls) + } + + @Test + fun `leaving a world stops the active share exactly once`() = runTest { + var stopCalls = 0 + val runtime = ConnectShareRuntime( + scope = this, + stopShare = { + stopCalls++ + }, + lifecycleDispatcher = + StandardTestDispatcher(testScheduler), + ) + + runtime.integratedWorldChanged(worldAvailable = true) + runtime.integratedWorldChanged(worldAvailable = false) + runtime.integratedWorldChanged(worldAvailable = false) + advanceUntilIdle() + + assertEquals(1, stopCalls) + } + + @Test + fun `replacing an integrated world stops the previous share`() = runTest { + var stopCalls = 0 + val runtime = ConnectShareRuntime( + scope = this, + stopShare = { + stopCalls++ + }, + lifecycleDispatcher = + StandardTestDispatcher(testScheduler), + ) + + runtime.integratedWorldChanged(worldAvailable = true, identity = "one") + runtime.integratedWorldChanged(worldAvailable = true, identity = "two") + advanceUntilIdle() + + assertEquals(1, stopCalls) + } + + @Test + fun `enabled sharing resumes when the host enters or switches worlds`() = runTest { + val lifecycle = mutableListOf() + val runtime = ConnectShareRuntime( + scope = this, + stopShare = { + lifecycle += "stop" + }, + resumeShare = { + lifecycle += "resume" + }, + lifecycleDispatcher = + StandardTestDispatcher(testScheduler), + ) + + runtime.integratedWorldChanged(worldAvailable = true, identity = "one") + advanceUntilIdle() + runtime.integratedWorldChanged(worldAvailable = true, identity = "two") + advanceUntilIdle() + runtime.integratedWorldChanged(worldAvailable = false) + advanceUntilIdle() + + assertEquals( + listOf("resume", "stop", "resume", "stop"), + lifecycle, + ) + } +} diff --git a/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/DirectControlPlaneTest.kt b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/DirectControlPlaneTest.kt new file mode 100644 index 000000000..887dcbe46 --- /dev/null +++ b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/DirectControlPlaneTest.kt @@ -0,0 +1,171 @@ +package com.minekube.connect.share.fabric + +import com.minekube.connect.share.DirectShareHandle +import com.minekube.connect.share.DirectShareIngress +import com.minekube.connect.share.ShareGameMode +import com.minekube.connect.share.ShareOptions +import java.net.InetAddress +import java.net.InetSocketAddress +import java.net.SocketAddress +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.async +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest + +@OptIn(ExperimentalCoroutinesApi::class) +class DirectControlPlaneTest { + @Test + fun `startup and shutdown are scheduled off the caller dispatcher`() = + runTest { + val io = StandardTestDispatcher(testScheduler) + val delegate = RecordingIngress() + val persistent = PersistentDirectIngress(delegate) + var addressesLoaded = 0 + val control = DirectControlPlane( + scope = backgroundScope, + ingress = persistent, + options = OPTIONS, + target = TARGET, + connectAddress = { + addressesLoaded++ + CONNECT_ADDRESS + }, + ioDispatcher = io, + ) + + control.start() + + assertEquals(0, addressesLoaded) + assertEquals(0, delegate.starts) + runCurrent() + assertEquals(1, addressesLoaded) + assertEquals(1, delegate.starts) + assertIs( + control.state.value, + ) + + control.shutdown() + + assertEquals(1, delegate.closes) + assertEquals(PersistentDirectState.Closed, control.state.value) + } + + @Test + fun `repeated starts share one in-flight title direct host`() = runTest { + val io = StandardTestDispatcher(testScheduler) + val delegate = RecordingIngress() + val control = DirectControlPlane( + scope = backgroundScope, + ingress = PersistentDirectIngress(delegate), + options = OPTIONS, + target = TARGET, + connectAddress = { CONNECT_ADDRESS }, + ioDispatcher = io, + ) + + repeat(8) { control.start() } + runCurrent() + + assertEquals(1, delegate.starts) + control.shutdown() + } + + @Test + fun `restart republishes the fallback after the Connect address changes`() = + runTest { + val io = StandardTestDispatcher(testScheduler) + val delegate = RecordingIngress() + var address = CONNECT_ADDRESS + val control = DirectControlPlane( + scope = backgroundScope, + ingress = PersistentDirectIngress(delegate), + options = OPTIONS, + target = TARGET, + connectAddress = { address }, + ioDispatcher = io, + ) + + control.start() + runCurrent() + + address = "new-control.play.minekube.net" + val restart = async { control.restart() } + runCurrent() + restart.await() + + assertEquals(2, delegate.starts) + assertEquals( + listOf( + CONNECT_ADDRESS, + "new-control.play.minekube.net", + ), + delegate.startedAddresses, + ) + control.shutdown() + } + + @Test + fun `shutdown cancels an in-flight direct host startup`() = runTest { + val io = StandardTestDispatcher(testScheduler) + var cancellations = 0 + val delegate = DirectShareIngress { _, _, _ -> + try { + awaitCancellation() + } finally { + cancellations++ + } + } + val control = DirectControlPlane( + scope = backgroundScope, + ingress = PersistentDirectIngress(delegate), + options = OPTIONS, + target = TARGET, + connectAddress = { CONNECT_ADDRESS }, + ioDispatcher = io, + ) + control.start() + runCurrent() + + control.shutdown() + + assertEquals(1, cancellations) + assertEquals(PersistentDirectState.Closed, control.state.value) + } + + private class RecordingIngress : DirectShareIngress { + var starts = 0 + var closes = 0 + val startedAddresses = mutableListOf() + + override suspend fun start( + options: ShareOptions, + target: SocketAddress, + connectAddress: String?, + ): DirectShareHandle { + starts++ + startedAddresses += connectAddress + return DirectShareHandle( + invitation = "minekube://share/persistent-control", + lanAvailable = true, + internetAvailable = false, + close = { closes++ }, + ) + } + } + + private companion object { + const val CONNECT_ADDRESS = "control.play.minekube.net" + val TARGET: SocketAddress = + InetSocketAddress(InetAddress.getLoopbackAddress(), 25_565) + val OPTIONS = ShareOptions( + gameMode = ShareGameMode.SURVIVAL, + allowCheats = false, + allowInternetDirect = false, + ) + } +} diff --git a/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FabricConnectIngressTest.kt b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FabricConnectIngressTest.kt new file mode 100644 index 000000000..b44534593 --- /dev/null +++ b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FabricConnectIngressTest.kt @@ -0,0 +1,107 @@ +package com.minekube.connect.share.fabric + +import com.minekube.connect.identity.EndpointTokenStore +import com.minekube.connect.share.admission.AdmissionController +import com.minekube.connect.share.identity.CredentialSource +import com.minekube.connect.share.identity.EndpointIdentity +import com.minekube.connect.share.identity.EndpointIdentityStore +import com.minekube.connect.watch.SessionAdmissionGate +import java.net.InetSocketAddress +import java.nio.file.Files +import java.nio.file.Path +import java.util.concurrent.atomic.AtomicInteger +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.io.TempDir + +class FabricConnectIngressTest { + @TempDir + lateinit var tempDir: Path + + @Test + fun `start reuses token file and returns stable public address`() = runTest { + val tokenFile = tempDir.resolve(EndpointIdentityStore.TOKEN_FILE_NAME) + EndpointTokenStore().save(tokenFile, IDENTITY.token) + val before = Files.readAllBytes(tokenFile) + val closes = AtomicInteger() + val factory = RecordingRuntimeFactory(closes) + val ingress = ingress(factory) + + val handle = ingress.start(IDENTITY, TARGET) + + assertEquals("amber-fox", handle.endpoint) + assertEquals("amber-fox.play.minekube.net", handle.publicAddress) + assertContentEquals(before, Files.readAllBytes(tokenFile)) + assertEquals(TARGET, factory.target) + assertEquals(IDENTITY, factory.identity) + handle.close() + handle.close() + assertEquals(1, closes.get()) + } + + @Test + fun `start refuses to create a missing token`() = runTest { + val factory = RecordingRuntimeFactory(AtomicInteger()) + val ingress = ingress(factory) + + assertFailsWith { + ingress.start(IDENTITY, TARGET) + } + + assertEquals(0, factory.starts) + assertEquals(false, Files.exists(tempDir.resolve(EndpointIdentityStore.TOKEN_FILE_NAME))) + } + + private fun kotlinx.coroutines.test.TestScope.ingress( + factory: FabricConnectRuntimeFactory, + ): FabricConnectIngress { + val admission = AdmissionController( + scope = backgroundScope, + timeout = 30.seconds, + maxPending = 16, + connectedCount = { 0 }, + maxGuests = { 8 }, + ) + return FabricConnectIngress.testing( + dataDirectory = tempDir, + admission = admission, + scope = backgroundScope, + runtimeFactory = factory, + ) + } + + private class RecordingRuntimeFactory( + private val closes: AtomicInteger, + ) : FabricConnectRuntimeFactory { + var starts: Int = 0 + var identity: EndpointIdentity? = null + var target: java.net.SocketAddress? = null + + override fun start( + identity: EndpointIdentity, + target: java.net.SocketAddress, + admissionGate: SessionAdmissionGate, + ): FabricConnectRuntime { + starts++ + this.identity = identity + this.target = target + return FabricConnectRuntime { + closes.incrementAndGet() + } + } + } + + private companion object { + val TARGET = InetSocketAddress.createUnresolved("127.0.0.1", 25565) + val IDENTITY = EndpointIdentity( + endpoint = "amber-fox", + token = "T-AAAAAAAAAAAAAAAAAAAA", + endpointSource = CredentialSource.GENERATED, + tokenSource = CredentialSource.GENERATED, + ) + } +} diff --git a/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FabricDirectAuthenticationPolicyTest.kt b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FabricDirectAuthenticationPolicyTest.kt new file mode 100644 index 000000000..895f523fd --- /dev/null +++ b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FabricDirectAuthenticationPolicyTest.kt @@ -0,0 +1,49 @@ +package com.minekube.connect.share.fabric + +import arrow.core.Either +import com.minekube.connect.tunnel.p2p.DirectP2pAuthMode +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs + +class FabricDirectAuthenticationPolicyTest { + @Test + fun `failed online authentication never downgrades to offline`() { + assertIs>( + FabricDirectAuthenticationPolicy.validate( + DirectP2pAuthMode.ONLINE, + minecraftAuthenticated = false, + ), + ) + } + + @Test + fun `explicit offline mode may proceed as unverified`() { + assertIs>( + FabricDirectAuthenticationPolicy.validate( + DirectP2pAuthMode.OFFLINE, + minecraftAuthenticated = false, + ), + ) + } + + @Test + fun `explicit offline tunnel bypasses Mojang login with an offline profile`() { + assertEquals( + DirectMinecraftAuthentication.OFFLINE_PROFILE, + FabricDirectAuthenticationPolicy.minecraftAuthentication( + DirectP2pAuthMode.OFFLINE, + ), + ) + } + + @Test + fun `online tunnel retains Mojang login`() { + assertEquals( + DirectMinecraftAuthentication.MOJANG, + FabricDirectAuthenticationPolicy.minecraftAuthentication( + DirectP2pAuthMode.ONLINE, + ), + ) + } +} diff --git a/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FabricDirectPeerRuntimeTest.kt b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FabricDirectPeerRuntimeTest.kt new file mode 100644 index 000000000..c12461304 --- /dev/null +++ b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FabricDirectPeerRuntimeTest.kt @@ -0,0 +1,159 @@ +package com.minekube.connect.share.fabric + +import com.minekube.connect.share.ShareGameMode +import com.minekube.connect.share.ShareOptions +import com.minekube.connect.tunnel.p2p.DirectP2pAuthMode +import com.minekube.connect.tunnel.p2p.DirectP2pDiscoveryListener +import com.minekube.connect.tunnel.p2p.DirectP2pHostConfig +import com.minekube.connect.tunnel.p2p.DirectP2pHostHandler +import com.minekube.connect.tunnel.p2p.DirectP2pHostInfo +import com.minekube.connect.tunnel.p2p.DirectP2pProxy +import java.net.InetAddress +import java.net.InetSocketAddress +import java.nio.file.Path +import java.security.KeyPairGenerator +import java.security.Signature +import java.time.Duration +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.io.TempDir + +class FabricDirectPeerRuntimeTest { + @TempDir + lateinit var tempDir: Path + + @Test + fun `title host and browser share one libp2p node`() = runTest { + val node = RecordingPeerNode() + val runtime = FabricDirectPeerRuntime.testing( + node = node, + dataDirectory = tempDir, + displayName = { "Title friend host" }, + ) + + assertTrue(runtime.browser.start().isRight()) + val handle = runtime.ingress.start( + options = ShareOptions( + gameMode = ShareGameMode.SURVIVAL, + allowCheats = false, + ), + target = InetSocketAddress( + InetAddress.getLoopbackAddress(), + 25_565, + ), + connectAddress = "title.play.minekube.net", + ) + + assertEquals(1, node.discoveryStarts) + assertEquals(1, node.hostStarts) + assertEquals(1, node.publishes) + + handle.close() + runtime.browser.close() + } + + @Test + fun `world refresh keeps the shared peer alive until the runtime closes`() = + runTest { + val node = RecordingPeerNode() + val runtime = FabricDirectPeerRuntime.testing( + node = node, + dataDirectory = tempDir, + displayName = { "Current world" }, + ) + + assertTrue(runtime.browser.start().isRight()) + val first = runtime.ingress.start( + options = ShareOptions( + gameMode = ShareGameMode.SURVIVAL, + allowCheats = false, + ), + target = InetSocketAddress( + InetAddress.getLoopbackAddress(), + 25_565, + ), + connectAddress = "stable.play.minekube.net", + ) + first.close() + val refreshed = runtime.ingress.start( + options = ShareOptions( + gameMode = ShareGameMode.SURVIVAL, + allowCheats = false, + allowInternetDirect = true, + ), + target = InetSocketAddress( + InetAddress.getLoopbackAddress(), + 25_565, + ), + connectAddress = "stable.play.minekube.net", + ) + refreshed.close() + + assertEquals(2, node.hostStarts) + assertEquals(2, node.publishes) + assertEquals(0, node.closes) + runtime.browser.close() + assertEquals(1, node.closes) + } + + private class RecordingPeerNode : FabricDirectPeerNode { + private val keyPair = + KeyPairGenerator.getInstance("Ed25519").generateKeyPair() + var discoveryStarts = 0 + var hostStarts = 0 + var publishes = 0 + var closes = 0 + + override fun peerId(): String = PEER_ID + + override fun startDiscovery(listener: DirectP2pDiscoveryListener) { + discoveryStarts++ + } + + override fun startHost( + config: DirectP2pHostConfig, + handler: DirectP2pHostHandler, + ): DirectP2pHostInfo { + hostStarts++ + return DirectP2pHostInfo( + PEER_ID, + keyPair.public.encoded, + listOf("/ip4/127.0.0.1/tcp/4001/p2p/$PEER_ID"), + emptyList(), + ) + } + + override fun sign(payload: ByteArray): ByteArray = + Signature.getInstance("Ed25519").run { + initSign(keyPair.private) + update(payload) + sign() + } + + override fun publish( + invitation: String, + discoveryInvitation: String, + ) { + publishes++ + } + + override fun openProxy( + address: String, + shareId: String, + capability: String, + authMode: DirectP2pAuthMode, + timeout: Duration, + ): DirectP2pProxy = error("not used") + + override fun close() { + closes++ + } + } + + private companion object { + const val PEER_ID = + "12D3KooWEHeJnnq1Rfwt679bTyTxkEdtyTC8peAJWsWCxtAJ4s9y" + } +} diff --git a/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FabricDirectShareIngressTest.kt b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FabricDirectShareIngressTest.kt new file mode 100644 index 000000000..3c0472abd --- /dev/null +++ b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FabricDirectShareIngressTest.kt @@ -0,0 +1,273 @@ +package com.minekube.connect.share.fabric + +import arrow.core.Either +import com.minekube.connect.share.ShareGameMode +import com.minekube.connect.share.ShareOptions +import com.minekube.connect.share.direct.ShareInviteCodec +import com.minekube.connect.share.direct.SignedShareInvite +import com.minekube.connect.tunnel.p2p.DirectP2pHostConfig +import com.minekube.connect.tunnel.p2p.DirectP2pHostHandler +import com.minekube.connect.tunnel.p2p.DirectP2pHostInfo +import com.minekube.connect.tunnel.p2p.Libp2pRuntime +import java.net.InetSocketAddress +import java.nio.file.Path +import java.security.KeyPair +import java.security.KeyPairGenerator +import java.security.Signature +import java.time.Instant +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertTrue +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.io.TempDir + +@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) +class FabricDirectShareIngressTest { + @TempDir + lateinit var tempDir: Path + + @Test + fun `publishes a signed invitation with Connect fallback and opted-in candidates`() = + runTest { + val node = FakeDirectNode() + val ingress = FabricDirectShareIngress.testing( + nodeFactory = { node }, + now = { Instant.ofEpochMilli(NOW) }, + shareId = { SHARE_ID }, + capability = { CAPABILITY }, + displayName = { "Robin's World" }, + localSocket = { _, _ -> error("not opened during setup") }, + ) + + val handle = ingress.start( + options = OPTIONS.copy(allowInternetDirect = true), + target = InetSocketAddress( + java.net.InetAddress.getLoopbackAddress(), + 25_565, + ), + connectAddress = "amber-fox.play.minekube.net", + ) + val decoded = ShareInviteCodec.decode( + handle.invitation, + Instant.ofEpochMilli(NOW), + ) + val invite = assertIs>(decoded).value + + assertEquals(SHARE_ID, invite.payload.shareId) + assertEquals("amber-fox.play.minekube.net", invite.payload.connectAddress) + assertEquals(node.hostInfo.peerId(), invite.payload.peerId) + assertEquals(node.hostInfo.internetAddresses(), invite.payload.directCandidates) + assertEquals(CAPABILITY, invite.payload.capability) + assertTrue(invite.payload.internetDirectEnabled) + assertTrue(handle.lanAvailable) + assertTrue(handle.internetAvailable) + assertEquals(handle.invitation, node.published) + val discoveryInvite = assertIs>( + ShareInviteCodec.decode( + node.publishedDiscoveryInvitation!!, + Instant.ofEpochMilli(NOW), + ), + ).value + assertFalse(discoveryInvite.payload.internetDirectEnabled) + assertTrue(discoveryInvite.payload.directCandidates.isEmpty()) + assertFalse(handle.toString().contains(CAPABILITY)) + + handle.close() + assertTrue(node.closed) + } + + @Test + fun `internet candidates are absent until the host opts in`() = runTest { + val node = FakeDirectNode() + val ingress = FabricDirectShareIngress.testing( + nodeFactory = { node }, + now = { Instant.ofEpochMilli(NOW) }, + shareId = { SHARE_ID }, + capability = { CAPABILITY }, + displayName = { "World" }, + localSocket = { _, _ -> error("not opened during setup") }, + ) + + val handle = ingress.start( + options = OPTIONS, + target = InetSocketAddress( + java.net.InetAddress.getLoopbackAddress(), + 25_565, + ), + connectAddress = null, + ) + val invite = assertIs>( + ShareInviteCodec.decode( + handle.invitation, + Instant.ofEpochMilli(NOW), + ), + ).value + + assertFalse(invite.payload.internetDirectEnabled) + assertTrue(invite.payload.directCandidates.isEmpty()) + assertEquals(null, invite.payload.connectAddress) + assertFalse(handle.internetAvailable) + handle.close() + } + + @Test + fun `persistent direct host republishes before its invitation expires`() = runTest { + val node = FakeDirectNode() + var currentTime = Instant.ofEpochMilli(NOW) + val ingress = FabricDirectShareIngress.testing( + nodeFactory = { node }, + now = { currentTime }, + shareId = { SHARE_ID }, + capability = { CAPABILITY }, + displayName = { "World" }, + localSocket = { _, _ -> error("not opened during setup") }, + renewalDispatcher = StandardTestDispatcher(testScheduler), + ) + + val handle = ingress.start( + OPTIONS, + InetSocketAddress( + java.net.InetAddress.getLoopbackAddress(), + 25_565, + ), + null, + ) + val originalInvitation = handle.invitation + runCurrent() + currentTime = currentTime.plusSeconds(12 * 60 * 60L) + advanceTimeBy(12 * 60 * 60 * 1_000L) + runCurrent() + + assertTrue(node.publishedInvitations.size >= 2) + assertTrue(handle.invitation != originalInvitation) + assertEquals(node.publishedInvitations.last(), handle.invitation) + handle.close() + } + + @Test + fun `partial startup closes the isolated node`() = runTest { + val node = FakeDirectNode(failPublish = true) + val ingress = FabricDirectShareIngress.testing( + nodeFactory = { node }, + now = { Instant.ofEpochMilli(NOW) }, + shareId = { SHARE_ID }, + capability = { CAPABILITY }, + displayName = { "World" }, + localSocket = { _, _ -> error("not opened during setup") }, + ) + + kotlin.test.assertFailsWith { + ingress.start( + OPTIONS, + InetSocketAddress( + java.net.InetAddress.getLoopbackAddress(), + 25_565, + ), + null, + ) + } + + assertTrue(node.closed) + } + + @Test + fun `production ingress keeps its peer identity across share restarts`() = runTest { + val target = InetSocketAddress( + java.net.InetAddress.getLoopbackAddress(), + 25_565, + ) + val firstIngress = FabricDirectShareIngress( + dataDirectory = tempDir, + displayName = { "First World" }, + ) + val first = firstIngress.start(OPTIONS, target, null) + val firstInvite = assertIs>( + ShareInviteCodec.decode(first.invitation), + ).value + first.close() + Libp2pRuntime.close() + + val secondIngress = FabricDirectShareIngress( + dataDirectory = tempDir, + displayName = { "Second World" }, + ) + val second = secondIngress.start(OPTIONS, target, null) + val secondInvite = assertIs>( + ShareInviteCodec.decode(second.invitation), + ).value + + assertEquals(firstInvite.payload.peerId, secondInvite.payload.peerId) + assertEquals(firstInvite.payload.shareId, secondInvite.payload.shareId) + assertEquals( + firstInvite.payload.capability, + secondInvite.payload.capability, + ) + second.close() + Libp2pRuntime.close() + } + + private class FakeDirectNode( + private val failPublish: Boolean = false, + ) : FabricDirectNode { + private val keyPair: KeyPair = + KeyPairGenerator.getInstance("Ed25519").generateKeyPair() + val hostInfo = DirectP2pHostInfo( + "12D3KooWHost", + keyPair.public.encoded, + listOf( + "/ip4/192.168.1.20/tcp/4001/p2p/12D3KooWHost", + ), + listOf( + "/ip6/2001:db8::20/tcp/4001/p2p/12D3KooWHost", + ), + ) + var published: String? = null + var publishedDiscoveryInvitation: String? = null + val publishedInvitations = mutableListOf() + var closed = false + + override fun startHost( + config: DirectP2pHostConfig, + handler: DirectP2pHostHandler, + ): DirectP2pHostInfo = hostInfo + + override fun sign(payload: ByteArray): ByteArray = + Signature.getInstance("Ed25519").run { + initSign(keyPair.private) + update(payload) + sign() + } + + override fun publish( + invitation: String, + discoveryInvitation: String, + ) { + if (failPublish) { + error("publish failed") + } + published = invitation + publishedDiscoveryInvitation = discoveryInvitation + publishedInvitations += invitation + } + + override fun close() { + closed = true + } + } + + private companion object { + const val NOW = 1_785_384_000_000L + val SHARE_ID: java.util.UUID = + java.util.UUID.fromString("9e511188-31a9-43ac-9107-29d94410d554") + const val CAPABILITY = "capability-123456789" + val OPTIONS = ShareOptions( + gameMode = ShareGameMode.SURVIVAL, + allowCheats = false, + ) + } +} diff --git a/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FabricLocalLoginAdmissionGateTest.kt b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FabricLocalLoginAdmissionGateTest.kt new file mode 100644 index 000000000..1762dd2be --- /dev/null +++ b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FabricLocalLoginAdmissionGateTest.kt @@ -0,0 +1,109 @@ +package com.minekube.connect.share.fabric + +import com.minekube.connect.share.admission.AdmissionAnswer +import com.minekube.connect.share.admission.AdmissionController +import com.minekube.connect.share.admission.AdmissionIdentity +import com.minekube.connect.share.admission.Ingress +import java.util.UUID +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest + +@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) +class FabricLocalLoginAdmissionGateTest { + @Test + fun `authenticated direct approval retains its ingress`() = runTest { + val admission = admission() + val gate = FabricLocalLoginAdmissionGate( + FabricLocalLoginAdmission(admission), + backgroundScope, + ) + + gate.request( + name = "Alex", + uuid = PLAYER_UUID, + connectionId = "connection-1", + minecraftAuthenticated = true, + ingress = Ingress.DIRECT_INTERNET, + ) + runCurrent() + + val identity = assertIs( + admission.pending.value.single().identity, + ) + assertEquals(Ingress.DIRECT_INTERNET, identity.ingress) + admission.resetShare() + } + + @Test + fun `exposes offline login approval as a cancellable Java stage`() = runTest { + val admission = admission() + val gate = FabricLocalLoginAdmissionGate( + FabricLocalLoginAdmission(admission), + backgroundScope, + ) + + val result = gate.request( + name = "Alex", + uuid = PLAYER_UUID, + connectionId = "connection-1", + minecraftAuthenticated = false, + ingress = Ingress.DIRECT_LAN, + ).toCompletableFuture() + runCurrent() + + val pending = admission.pending.value.single() + val identity = assertIs(pending.identity) + assertEquals("connection-1", identity.connectionId) + assertEquals(Ingress.DIRECT_LAN, identity.ingress) + admission.answer(pending.requestId, allow = true) + runCurrent() + + assertEquals(AdmissionAnswer.ALLOW, result.getNow(null)) + } + + @Test + fun `stop cancels pending and future login requests`() = runTest { + val admission = admission() + val gate = FabricLocalLoginAdmissionGate( + FabricLocalLoginAdmission(admission), + backgroundScope, + ) + val pending = gate.request( + name = "Alex", + uuid = PLAYER_UUID, + connectionId = "connection-1", + minecraftAuthenticated = false, + ).toCompletableFuture() + runCurrent() + + gate.stop() + runCurrent() + val afterStop = gate.request( + name = "Steve", + uuid = UUID.randomUUID(), + connectionId = "connection-2", + minecraftAuthenticated = false, + ).toCompletableFuture() + + assertTrue(pending.isCancelled) + assertTrue(afterStop.isCancelled) + admission.resetShare() + } + + private fun kotlinx.coroutines.test.TestScope.admission() = AdmissionController( + scope = backgroundScope, + timeout = 30.seconds, + maxPending = 16, + connectedCount = { 0 }, + maxGuests = { 8 }, + ) + + private companion object { + val PLAYER_UUID: UUID = UUID.fromString("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee") + } +} diff --git a/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FabricSessionAdmissionGateTest.kt b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FabricSessionAdmissionGateTest.kt new file mode 100644 index 000000000..fbd95eb0c --- /dev/null +++ b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FabricSessionAdmissionGateTest.kt @@ -0,0 +1,287 @@ +package com.minekube.connect.share.fabric + +import com.minekube.connect.share.admission.AdmissionAnswer +import com.minekube.connect.share.admission.AdmissionController +import com.minekube.connect.share.admission.AdmissionIdentity +import com.minekube.connect.share.admission.AuthSource +import com.minekube.connect.share.admission.Ingress +import com.minekube.connect.watch.SessionProposal +import java.util.UUID +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.async +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import minekube.connect.v1alpha1.WatchServiceOuterClass.Authentication +import minekube.connect.v1alpha1.WatchServiceOuterClass.GameProfile +import minekube.connect.v1alpha1.WatchServiceOuterClass.Player +import minekube.connect.v1alpha1.WatchServiceOuterClass.Session + +@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) +class FabricSessionAdmissionGateTest { + @Test + fun `title control stays reachable while player sessions require a world`() = + runTest { + val admission = admission() + var worldAvailable = false + val gate = FabricSessionAdmissionGate( + admission = admission, + scope = backgroundScope, + worldAvailable = { worldAvailable }, + ) + val unavailable = gate.request( + proposal(passthrough = false), + ).toCompletableFuture().getNow(null) + + assertFalse(unavailable.isAllowed) + assertEquals( + "No shared world is active", + unavailable.safeMessage, + ) + assertTrue(admission.pending.value.isEmpty()) + + worldAvailable = true + val available = gate.request( + proposal(passthrough = false), + ).toCompletableFuture() + runCurrent() + assertEquals(1, admission.pending.value.size) + admission.answer( + admission.pending.value.single().requestId, + allow = false, + ) + runCurrent() + assertFalse(available.getNow(null).isAllowed) + } + + @Test + fun `status probe bypasses player admission for control routing`() = runTest { + val admission = admission() + val gate = FabricSessionAdmissionGate(admission, backgroundScope) + val ping = Session.newBuilder() + .setId("status-session") + .setAuth(Authentication.newBuilder().setPassthrough(false)) + .setPlayer( + Player.newBuilder() + .setAddr("127.0.0.1") + .setProfile(GameProfile.getDefaultInstance()), + ) + .build() + + val decision = gate.request(SessionProposal(ping) {}) + .toCompletableFuture() + .getNow(null) + + assertTrue(decision.isAllowed) + assertTrue(admission.pending.value.isEmpty()) + } + + @Test + fun `Connect authenticated profile waits for host approval`() = runTest { + val admission = admission() + val approvedJoins = ApprovedJoinTracker() + val gate = FabricSessionAdmissionGate( + admission, + backgroundScope, + approvedJoins, + ) + + val result = gate.request(proposal(passthrough = false)).toCompletableFuture() + runCurrent() + val pending = admission.pending.value.single() + val identity = assertIs(pending.identity) + + assertEquals("Alex", identity.name) + assertEquals(PLAYER_UUID, identity.uuid) + assertEquals(AuthSource.CONNECT, identity.source) + admission.answer(pending.requestId, allow = true) + runCurrent() + assertTrue(result.getNow(null).isAllowed) + assertFalse( + approvedJoins.hasProof("Alex", PLAYER_UUID), + "a Connect-only identity cannot authorize automatic friendship", + ) + } + + @Test + fun `passthrough profile defers approval to local login`() = runTest { + val admission = admission() + val gate = FabricSessionAdmissionGate(admission, backgroundScope) + + val result = gate.request(proposal(passthrough = true)) + .toCompletableFuture() + .getNow(null) + + assertTrue(result.isDeferredToLocalLogin) + assertTrue(admission.pending.value.isEmpty()) + } + + @Test + fun `host denial becomes a safe Core denial`() = runTest { + val admission = admission() + val gate = FabricSessionAdmissionGate(admission, backgroundScope) + val result = gate.request(proposal(passthrough = false)).toCompletableFuture() + runCurrent() + + admission.answer(admission.pending.value.single().requestId, allow = false) + runCurrent() + + val decision = result.getNow(null) + assertFalse(decision.isAllowed) + assertFalse(decision.isDeferredToLocalLogin) + assertEquals( + "The host declined this join. Request access again when ready.", + decision.safeMessage, + ) + } + + @Test + fun `Connect approval timeout leaves time for an actionable disconnect`() = + runTest { + val admission = admission() + val gate = FabricSessionAdmissionGate( + admission = admission, + scope = backgroundScope, + decisionTimeout = 20.seconds, + ) + val result = gate.request(proposal(passthrough = false)) + .toCompletableFuture() + runCurrent() + + advanceTimeBy(19_999) + assertFalse(result.isDone) + advanceTimeBy(1) + runCurrent() + + assertTrue(result.isDone) + assertFalse(result.getNow(null).isAllowed) + assertEquals( + "The host did not approve this join in time. Try again.", + result.getNow(null).safeMessage, + ) + assertTrue(admission.pending.value.isEmpty()) + } + + @Test + fun `stopping gate cancels pending Core stages`() = runTest { + val admission = admission() + val gate = FabricSessionAdmissionGate(admission, backgroundScope) + val result = gate.request(proposal(passthrough = false)).toCompletableFuture() + runCurrent() + + gate.stop() + runCurrent() + + assertTrue(result.isCancelled) + admission.resetShare() + } + + @Test + fun `malformed Connect profile is denied without pending approval`() = runTest { + val admission = admission() + val gate = FabricSessionAdmissionGate(admission, backgroundScope) + val malformed = Session.newBuilder() + .setAuth(Authentication.newBuilder().setPassthrough(false)) + .setPlayer( + Player.newBuilder() + .setAddr("127.0.0.1") + .setProfile( + GameProfile.newBuilder() + .setName("Alex") + .setId("not-a-uuid"), + ), + ) + .build() + + val decision = gate.request(SessionProposal(malformed) {}).toCompletableFuture() + .getNow(null) + + assertFalse(decision.isAllowed) + assertEquals("Connect profile is invalid", decision.safeMessage) + assertTrue(admission.pending.value.isEmpty()) + } + + @Test + fun `local login maps authenticated and offline identities separately`() = runTest { + val admission = admission() + val approvedJoins = ApprovedJoinTracker() + val local = FabricLocalLoginAdmission( + admission, + approvedJoins, + ) + val authenticated = async { + local.request( + name = "Alex", + uuid = PLAYER_UUID, + connectionId = "connection-authenticated", + minecraftAuthenticated = true, + directPeerId = "12D3KooWAuthenticated", + ) + } + runCurrent() + val authenticatedIdentity = assertIs( + admission.pending.value.single().identity, + ) + assertEquals(AuthSource.MOJANG, authenticatedIdentity.source) + assertEquals( + "12D3KooWAuthenticated", + authenticatedIdentity.directPeerId, + ) + admission.answer(admission.pending.value.single().requestId, allow = true) + assertEquals(AdmissionAnswer.ALLOW, authenticated.await()) + assertTrue(approvedJoins.hasProof("Alex", PLAYER_UUID)) + + val offline = async { + local.request( + name = "Alex", + uuid = PLAYER_UUID, + connectionId = "connection-offline", + minecraftAuthenticated = false, + directPeerId = "12D3KooWOffline", + ) + } + runCurrent() + val offlineIdentity = assertIs( + admission.pending.value.single().identity, + ) + assertEquals("connection-offline", offlineIdentity.connectionId) + assertEquals(Ingress.CONNECT, offlineIdentity.ingress) + assertEquals("12D3KooWOffline", offlineIdentity.directPeerId) + admission.answer(admission.pending.value.single().requestId, allow = false) + assertEquals(AdmissionAnswer.DENY, offline.await()) + } + + private fun kotlinx.coroutines.test.TestScope.admission() = AdmissionController( + scope = backgroundScope, + timeout = 30.seconds, + maxPending = 16, + connectedCount = { 0 }, + maxGuests = { 8 }, + ) + + private fun proposal(passthrough: Boolean): SessionProposal { + val session = Session.newBuilder() + .setId("session-1") + .setAuth(Authentication.newBuilder().setPassthrough(passthrough)) + .setPlayer( + Player.newBuilder() + .setAddr("127.0.0.1") + .setProfile( + GameProfile.newBuilder() + .setName("Alex") + .setId(PLAYER_UUID.toString()), + ), + ) + .build() + return SessionProposal(session) {} + } + + private companion object { + val PLAYER_UUID: UUID = UUID.fromString("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee") + } +} diff --git a/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FabricShareBootstrapTest.kt b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FabricShareBootstrapTest.kt new file mode 100644 index 000000000..f65afe4c2 --- /dev/null +++ b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FabricShareBootstrapTest.kt @@ -0,0 +1,28 @@ +package com.minekube.connect.share.fabric + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class FabricShareBootstrapTest { + @Test + fun `websocket watch URLs are normalized for OkHttp`() { + assertEquals( + "https://watch-connect.minekube.net/", + FabricShareBootstrap.watchHttpUrl(emptyMap()).toString(), + ) + assertEquals( + "http://localhost:8080/watch", + FabricShareBootstrap.watchHttpUrl( + mapOf("CONNECT_WATCH_URL" to "ws://localhost:8080/watch"), + ).toString(), + ) + } + + @Test + fun `social control hosting always exposes direct internet candidates`() { + assertTrue( + FabricShareBootstrap.socialControlOptions().allowInternetDirect, + ) + } +} diff --git a/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FabricShareBrowserTest.kt b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FabricShareBrowserTest.kt new file mode 100644 index 000000000..7487232d0 --- /dev/null +++ b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FabricShareBrowserTest.kt @@ -0,0 +1,625 @@ +package com.minekube.connect.share.fabric + +import arrow.core.Either +import com.minekube.connect.share.direct.ShareInviteCodec +import com.minekube.connect.share.direct.ShareInvitePayload +import com.minekube.connect.share.direct.ShareRoute +import com.minekube.connect.share.direct.SignedShareInvite +import com.minekube.connect.share.friend.SavedFriend +import com.minekube.connect.tunnel.p2p.DirectP2pAuthMode +import com.minekube.connect.tunnel.p2p.DirectP2pDiscoveredShare +import com.minekube.connect.tunnel.p2p.DirectP2pDiscoveryListener +import com.minekube.connect.tunnel.p2p.DirectP2pProxy +import java.net.InetAddress +import java.net.InetSocketAddress +import java.nio.file.Path +import java.security.KeyPairGenerator +import java.security.Signature +import java.time.Duration +import java.time.Instant +import java.util.Base64 +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.UUID +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.io.TempDir + +class FabricShareBrowserTest { + @TempDir + lateinit var tempDir: Path + + @Test + fun `guest peer identity survives browser restarts`() { + val first = FabricShareBrowser(tempDir) + val firstPeerId = first.peerId + first.close() + + val second = FabricShareBrowser(tempDir) + + assertEquals(firstPeerId, second.peerId) + assertTrue(firstPeerId.isNotBlank()) + second.close() + } + + @Test + fun `valid mDNS metadata becomes a LAN share without exposing secrets`() = + runTest { + val node = FakeGuestNode() + val browser = browser(node) + browser.start() + val invitation = invitation() + + node.discover( + DirectP2pDiscoveredShare( + "Robin's World", + PEER_ID, + LAN_ADDRESS, + invitation, + ), + ) + + val discovered = browser.discovered.value.single() + assertEquals("Robin's World", discovered.displayName) + assertEquals(SHARE_ID, discovered.invitation.payload.shareId) + assertTrue(discovered.toString().contains("")) + browser.close() + } + + @Test + fun `LAN is selected before internet and Connect`() = runTest { + val node = FakeGuestNode() + val browser = browser(node) + + val result = browser.join( + invitationUri = invitation(), + lanAddress = LAN_ADDRESS, + internetOptIn = true, + authMode = DirectP2pAuthMode.OFFLINE, + ) + + val target = assertIs>(result).value + assertEquals(ShareRoute.DIRECT_LAN, target.route) + assertEquals(listOf(LAN_ADDRESS), node.openedAddresses) + target.close() + browser.close() + } + + @Test + fun `pasted invitation uses its matching discovered LAN address`() = runTest { + val node = FakeGuestNode() + val browser = browser(node) + browser.start() + val invitation = invitation() + node.discover( + DirectP2pDiscoveredShare( + "Robin's World", + PEER_ID, + LAN_ADDRESS, + invitation, + ), + ) + + val result = browser.join( + invitationUri = invitation, + lanAddress = null, + internetOptIn = false, + authMode = DirectP2pAuthMode.OFFLINE, + ) + + val target = assertIs>(result).value + assertEquals(ShareRoute.DIRECT_LAN, target.route) + assertEquals(listOf(LAN_ADDRESS), node.openedAddresses) + target.close() + browser.close() + } + + @Test + fun `saved friend resolves a fresh LAN address without another link`() = runTest { + val node = FakeGuestNode() + val browser = browser(node) + browser.start() + val invitation = invitation() + val friend = savedFriend(invitation) + node.discover( + DirectP2pDiscoveredShare( + "Robin's New World", + PEER_ID, + LAN_ADDRESS, + invitation, + ), + ) + + val result = browser.join( + friend = friend, + authMode = DirectP2pAuthMode.OFFLINE, + ) + + val target = assertIs>(result).value + assertEquals(ShareRoute.DIRECT_LAN, target.route) + assertEquals(listOf(LAN_ADDRESS), node.openedAddresses) + target.close() + browser.close() + } + + @Test + fun `saved friend route survives another peer advertising the same share`() = + runTest { + val node = FakeGuestNode() + val browser = browser(node) + browser.start() + val friendLink = invitation() + val friend = savedFriend(friendLink) + node.discover( + DirectP2pDiscoveredShare( + "Robin's friend control", + PEER_ID, + LAN_ADDRESS, + friendLink, + ), + ) + val worldPeer = "12D3KooWWorld" + node.discover( + DirectP2pDiscoveredShare( + "Robin's active world", + worldPeer, + lanAddress(worldPeer), + invitation(peerId = worldPeer), + ), + ) + + val result = browser.openFriendControl( + friend = friend, + authMode = DirectP2pAuthMode.OFFLINE, + ) + + val target = assertIs>( + result, + ).value + assertEquals(ShareRoute.DIRECT_LAN, target.route) + assertEquals(listOf(LAN_ADDRESS), node.openedAddresses) + assertEquals(2, browser.discovered.value.size) + target.close() + browser.close() + } + + @Test + fun `concurrent discoveries retain social and active-world routes`() = runTest { + val node = FakeGuestNode() + val browser = browser(node) + browser.start() + val worldPeer = "12D3KooWWorld" + + node.discoverConcurrently( + listOf( + DirectP2pDiscoveredShare( + "Robin's friend control", + PEER_ID, + LAN_ADDRESS, + invitation(), + ), + DirectP2pDiscoveredShare( + "Robin's active world", + worldPeer, + lanAddress(worldPeer), + invitation(peerId = worldPeer), + ), + ), + ) + + assertEquals( + setOf(PEER_ID, worldPeer), + browser.discovered.value.map { it.invitation.payload.peerId }.toSet(), + ) + browser.close() + } + + @Test + fun `friend control uses saved direct internet route outside the LAN`() = runTest { + val node = FakeGuestNode() + val browser = browser(node) + val friend = savedFriend(invitation()) + + val result = browser.openFriendControl( + friend = friend, + authMode = DirectP2pAuthMode.OFFLINE, + ) + + val target = assertIs>(result).value + assertEquals(ShareRoute.DIRECT_INTERNET, target.route) + assertEquals(listOf(INTERNET_ADDRESS), node.openedAddresses) + target.close() + browser.close() + } + + @Test + fun `saved friend never probes persisted internet without guest consent`() = runTest { + val node = FakeGuestNode() + val browser = browser(node) + val friend = savedFriend(invitation()).copy( + internetDirectGuestOptIn = false, + ) + + val result = browser.openFriendControl( + friend = friend, + authMode = DirectP2pAuthMode.OFFLINE, + ) + + assertEquals(GuestJoinFailure.NoRoute, result.leftOrNull()) + assertTrue(node.openedAddresses.isEmpty()) + browser.close() + } + + @Test + fun `saved friend ignores LAN metadata signed by a different identity`() = runTest { + val node = FakeGuestNode() + val reports = mutableListOf() + val browser = browser(node, reports::add) + browser.start() + val friend = savedFriend(invitation()) + node.discover( + DirectP2pDiscoveredShare( + "Impostor World", + PEER_ID, + LAN_ADDRESS, + invitation(), + ), + ) + + val result = browser.join( + friend = friend, + authMode = DirectP2pAuthMode.OFFLINE, + ) + + val target = assertIs>(result).value + assertEquals(ShareRoute.DIRECT_INTERNET, target.route) + assertEquals(listOf(INTERNET_ADDRESS), node.openedAddresses) + assertEquals( + listOf( + "Connect Share route: direct LAN unavailable", + "Connect Share route: direct internet", + ), + reports, + ) + target.close() + browser.close() + } + + @Test + fun `saved friend never falls back through this profiles own Connect endpoint`() = + runTest { + val node = FakeGuestNode(failDirect = true) + val browser = browser(node) + val friend = savedFriend(invitation()) + + val result = browser.join( + friend = friend, + authMode = DirectP2pAuthMode.OFFLINE, + ownConnectAddress = friend.connectAddress, + ) + + assertEquals( + GuestJoinFailure.EndpointConflict, + result.leftOrNull(), + ) + assertEquals(listOf(INTERNET_ADDRESS), node.openedAddresses) + browser.close() + } + + @Test + fun `saved friend falls back to Connect after persisted direct route fails`() = + runTest { + val node = FakeGuestNode(failDirect = true) + val reports = mutableListOf() + val browser = browser(node, reports::add) + val friend = savedFriend(invitation()) + + val result = browser.join( + friend = friend, + authMode = DirectP2pAuthMode.OFFLINE, + ) + + assertIs>(result) + assertEquals(listOf(INTERNET_ADDRESS), node.openedAddresses) + assertEquals( + listOf( + "Connect Share route: direct LAN unavailable", + "Connect Share route: direct internet unavailable", + "Connect Share route: using Connect fallback", + ), + reports, + ) + browser.close() + } + + @Test + fun `LAN discovery is world ready only after status succeeds through proxy`() = + runTest { + val node = FakeGuestNode() + val browser = browser(node) + val link = invitation() + val friend = savedFriend(link) + browser.start() + node.discover( + DirectP2pDiscoveredShare( + "Robin's LAN World", + PEER_ID, + lanAddress(PEER_ID), + link, + ), + ) + val probed = mutableListOf() + + val presence = browser.probeLan( + friend = friend, + authMode = DirectP2pAuthMode.OFFLINE, + probe = FriendStatusProbe { address -> + probed += address + Either.Right(ServerPresence("Robin's LAN World")) + }, + ) + + assertEquals(ServerPresence("Robin's LAN World"), presence) + assertEquals(1, probed.size) + assertTrue(probed.single().endsWith(":41234")) + browser.close() + } + + @Test + fun `presence probes persisted internet routes after LAN`() = runTest { + val node = FakeGuestNode() + val browser = browser(node) + val friend = savedFriend(invitation()) + val probed = mutableListOf() + + val presence = browser.probeDirect( + friend = friend, + authMode = DirectP2pAuthMode.OFFLINE, + probe = FriendStatusProbe { address -> + probed += address + Either.Right(ServerPresence("Robin's World")) + }, + ) + + assertEquals( + ServerPresence("Robin's World", ShareRoute.DIRECT_INTERNET), + presence, + ) + assertEquals(1, probed.size) + assertEquals(listOf(INTERNET_ADDRESS), node.openedAddresses) + browser.close() + } + + @Test + fun `pasted invitation ignores discovery with a different peer`() = runTest { + val node = FakeGuestNode() + val browser = browser(node) + browser.start() + val otherPeer = "12D3KooWOther" + node.discover( + DirectP2pDiscoveredShare( + "Other World", + otherPeer, + lanAddress(otherPeer), + invitation(peerId = otherPeer), + ), + ) + + val result = browser.join( + invitationUri = invitation(), + lanAddress = null, + internetOptIn = false, + authMode = DirectP2pAuthMode.OFFLINE, + ) + + assertIs>(result) + assertTrue(node.openedAddresses.isEmpty()) + browser.close() + } + + @Test + fun `pasted invitation ignores discovery with a different share`() = runTest { + val node = FakeGuestNode() + val browser = browser(node) + browser.start() + val otherShare = UUID.fromString( + "72a5d404-0ef9-48bc-882b-a2ec896afbe5", + ) + node.discover( + DirectP2pDiscoveredShare( + "Other World", + PEER_ID, + LAN_ADDRESS, + invitation(shareId = otherShare), + ), + ) + + val result = browser.join( + invitationUri = invitation(), + lanAddress = null, + internetOptIn = false, + authMode = DirectP2pAuthMode.OFFLINE, + ) + + assertIs>(result) + assertTrue(node.openedAddresses.isEmpty()) + browser.close() + } + + @Test + fun `failed direct reachability falls back to Connect exactly once`() = + runTest { + val node = FakeGuestNode(failDirect = true) + val reports = mutableListOf() + val browser = browser(node, reports::add) + + val result = browser.join( + invitationUri = invitation(), + lanAddress = LAN_ADDRESS, + internetOptIn = true, + authMode = DirectP2pAuthMode.ONLINE, + ) + + val target = assertIs>(result).value + assertEquals("amber-fox.play.minekube.net", target.publicAddress) + assertEquals( + listOf(LAN_ADDRESS, INTERNET_ADDRESS), + node.openedAddresses, + ) + assertEquals( + listOf( + "Connect Share route: direct LAN unavailable", + "Connect Share route: direct internet unavailable", + "Connect Share route: using Connect fallback", + ), + reports, + ) + browser.close() + } + + @Test + fun `guest internet opt in is required even when host enabled it`() = + runTest { + val node = FakeGuestNode(failDirect = true) + val browser = browser(node) + + val result = browser.join( + invitationUri = invitation(), + lanAddress = null, + internetOptIn = false, + authMode = DirectP2pAuthMode.ONLINE, + ) + + assertIs>(result) + assertTrue(node.openedAddresses.isEmpty()) + browser.close() + } + + private fun kotlinx.coroutines.test.TestScope.browser( + node: FakeGuestNode, + routeReporter: (String) -> Unit = {}, + ) = + FabricShareBrowser.testing( + node = node, + now = { Instant.ofEpochMilli(NOW) }, + ioDispatcher = StandardTestDispatcher(testScheduler), + routeReporter = routeReporter, + ) + + private fun invitation( + shareId: UUID = SHARE_ID, + peerId: String = PEER_ID, + ): String { + val pair = KeyPairGenerator.getInstance("Ed25519").generateKeyPair() + val payload = ShareInvitePayload( + wireVersion = ShareInviteCodec.WIRE_VERSION, + shareId = shareId, + expiresAtEpochMillis = NOW + 60_000, + connectAddress = "amber-fox.play.minekube.net", + peerId = peerId, + internetDirectEnabled = true, + directCandidates = listOf(internetAddress(peerId)), + capability = CAPABILITY, + ) + val unsigned = ShareInviteCodec.unsignedBytes(payload, pair.public.encoded) + val signature = Signature.getInstance("Ed25519").run { + initSign(pair.private) + update(unsigned) + sign() + } + return ShareInviteCodec.encode( + SignedShareInvite(payload, pair.public.encoded, signature), + ) + } + + private fun savedFriend(invitationUri: String): SavedFriend { + val invitation = ShareInviteCodec.decode( + invitationUri, + Instant.ofEpochMilli(NOW), + ).getOrNull()!! + return SavedFriend( + peerId = invitation.payload.peerId, + publicKeyBase64 = Base64.getEncoder() + .encodeToString(invitation.publicKey), + shareId = invitation.payload.shareId, + capability = invitation.payload.capability, + connectAddress = invitation.payload.connectAddress, + internetDirectEnabled = invitation.payload.internetDirectEnabled, + directCandidates = invitation.payload.directCandidates, + internetDirectGuestOptIn = true, + displayName = "Robin", + ) + } + + private fun lanAddress(peerId: String) = + "/ip4/192.168.1.20/tcp/4001/p2p/$peerId" + + private fun internetAddress(peerId: String) = + "/ip6/2001:db8::20/tcp/4001/p2p/$peerId" + + private class FakeGuestNode( + private val failDirect: Boolean = false, + ) : FabricGuestDirectNode { + private var listener: DirectP2pDiscoveryListener? = null + val openedAddresses = mutableListOf() + + override fun peerId(): String = "12D3KooWGuest" + + override fun startDiscovery(listener: DirectP2pDiscoveryListener) { + this.listener = listener + } + + fun discover(share: DirectP2pDiscoveredShare) { + listener?.onDiscovered(share) + } + + fun discoverConcurrently(shares: List) { + val ready = CountDownLatch(shares.size) + val start = CountDownLatch(1) + val threads = shares.map { share -> + Thread { + ready.countDown() + start.await() + discover(share) + }.also(Thread::start) + } + assertTrue(ready.await(10, TimeUnit.SECONDS)) + start.countDown() + threads.forEach { it.join(10_000) } + } + + override fun openProxy( + address: String, + shareId: String, + capability: String, + authMode: DirectP2pAuthMode, + timeout: Duration, + ): DirectP2pProxy { + openedAddresses += address + if (failDirect) { + error("unreachable") + } + return DirectP2pProxy( + InetSocketAddress(InetAddress.getLoopbackAddress(), 41_234), + ) {} + } + + override fun close() = Unit + } + + private companion object { + const val NOW = 1_785_384_000_000L + val SHARE_ID: UUID = + UUID.fromString("9e511188-31a9-43ac-9107-29d94410d554") + const val PEER_ID = "12D3KooWHost" + const val CAPABILITY = "capability-secret" + const val LAN_ADDRESS = + "/ip4/192.168.1.20/tcp/4001/p2p/12D3KooWHost" + const val INTERNET_ADDRESS = + "/ip6/2001:db8::20/tcp/4001/p2p/12D3KooWHost" + } +} diff --git a/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FollowNextSessionControllerTest.kt b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FollowNextSessionControllerTest.kt new file mode 100644 index 000000000..c94cbec91 --- /dev/null +++ b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FollowNextSessionControllerTest.kt @@ -0,0 +1,151 @@ +package com.minekube.connect.share.fabric + +import com.minekube.connect.share.friend.FriendActivity +import com.minekube.connect.share.friend.FriendActivityKind +import java.time.Instant +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class FollowNextSessionControllerTest { + @Test + fun `joinable epoch emits one request and duplicate presence cannot storm`() { + val controller = FollowNextSessionController(now = { NOW }) + controller.follow(ROBIN, "Robin") + val activity = mapOf( + ROBIN to FriendActivity( + FriendActivityKind.HOSTING_WORLD, + "Survival", + joinable = true, + sessionEpoch = "world-1", + ), + ) + + assertEquals( + listOf(FollowAction.RequestJoin(ROBIN, "Robin", "world-1")), + controller.update(activity, activeGameplay = false, setOf(ROBIN)), + ) + assertTrue( + controller.update(activity, activeGameplay = false, setOf(ROBIN)) + .isEmpty(), + ) + } + + @Test + fun `active gameplay is never interrupted and receives one join offer`() { + val controller = FollowNextSessionController(now = { NOW }) + controller.follow(ROBIN, "Robin") + + assertEquals( + listOf(FollowAction.OfferJoinNow(ROBIN, "Robin", "server-1")), + controller.update( + mapOf( + ROBIN to FriendActivity( + FriendActivityKind.PLAYING_SERVER, + "Friends server", + sessionEpoch = "server-1", + ), + ), + activeGameplay = true, + confirmedPeerIds = setOf(ROBIN), + ), + ) + } + + @Test + fun `expiry cancellation and removal clear follow intent`() { + var now = NOW + val controller = FollowNextSessionController( + now = { now }, + lifetimeSeconds = 60, + ) + controller.follow(ROBIN, "Robin") + assertTrue(controller.cancel(ROBIN)) + assertTrue(controller.state.value.isEmpty()) + + controller.follow(ROBIN, "Robin") + now = NOW.plusSeconds(61) + assertEquals( + listOf(FollowAction.Expired(ROBIN, "Robin")), + controller.update(emptyMap(), false, setOf(ROBIN)), + ) + + controller.follow(ROBIN, "Robin") + assertEquals( + listOf(FollowAction.Cancelled(ROBIN, "Robin")), + controller.update(emptyMap(), false, emptySet()), + ) + } + + @Test + fun `simultaneous follows remain independent`() { + val controller = FollowNextSessionController(now = { NOW }) + controller.follow(ROBIN, "Robin") + controller.follow(ALEX, "Alex") + + val actions = controller.update( + mapOf( + ROBIN to FriendActivity( + FriendActivityKind.HOSTING_WORLD, + sessionEpoch = "r1", + ), + ALEX to FriendActivity( + FriendActivityKind.HOSTING_WORLD, + sessionEpoch = "a1", + ), + ), + activeGameplay = false, + confirmedPeerIds = setOf(ROBIN, ALEX), + ) + + assertEquals(2, actions.size) + assertEquals(setOf(ROBIN, ALEX), actions.map { it.peerId }.toSet()) + } + + @Test + fun `blocking a friend cancels follow before any join request`() { + val controller = FollowNextSessionController(now = { NOW }) + controller.follow(ROBIN, "Robin") + + val actions = controller.update( + activities = mapOf( + ROBIN to FriendActivity( + FriendActivityKind.HOSTING_WORLD, + sessionEpoch = "blocked-world", + ), + ), + activeGameplay = false, + confirmedPeerIds = emptySet(), + ) + + assertEquals( + listOf(FollowAction.Cancelled(ROBIN, "Robin")), + actions, + ) + assertTrue(controller.state.value.isEmpty()) + } + + @Test + fun `reconnect with a new world epoch can retry without duplicating either epoch`() { + val controller = FollowNextSessionController(now = { NOW }) + controller.follow(ROBIN, "Robin") + + fun activity(epoch: String) = mapOf( + ROBIN to FriendActivity( + FriendActivityKind.HOSTING_WORLD, + sessionEpoch = epoch, + ), + ) + + assertEquals(1, controller.update(activity("world-1"), false, setOf(ROBIN)).size) + assertTrue(controller.update(activity("world-1"), false, setOf(ROBIN)).isEmpty()) + assertEquals(1, controller.update(activity("world-2"), false, setOf(ROBIN)).size) + assertTrue(controller.update(activity("world-2"), false, setOf(ROBIN)).isEmpty()) + } + + private companion object { + val NOW: Instant = Instant.parse("2026-08-01T12:00:00Z") + const val ROBIN = "12D3KooWRobin" + const val ALEX = "12D3KooWAlex" + } +} diff --git a/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FriendActivityMonitorTest.kt b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FriendActivityMonitorTest.kt new file mode 100644 index 000000000..b5a827142 --- /dev/null +++ b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FriendActivityMonitorTest.kt @@ -0,0 +1,56 @@ +package com.minekube.connect.share.fabric + +import arrow.core.left +import arrow.core.right +import com.minekube.connect.share.friend.FriendActivity +import com.minekube.connect.share.friend.FriendActivityKind +import com.minekube.connect.share.friend.FriendStore +import com.minekube.connect.share.friend.SavedFriend +import java.nio.file.Path +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.io.TempDir + +class FriendActivityMonitorTest { + @TempDir + lateinit var tempDir: Path + + @Test + fun `refresh keeps only reachable confirmed friend activity`() = runTest { + val playing = friend("playing", "Robin") + val unreachable = friend("offline", "Bob") + val monitor = FriendActivityMonitor.testing( + friends = { listOf(playing, unreachable) }, + query = { friend -> + if (friend.peerId == playing.peerId) { + FriendActivity( + FriendActivityKind.PLAYING_SERVER, + "Hypixel", + ).right() + } else { + FriendRequestFailure.Unreachable.left() + } + }, + ioDispatcher = StandardTestDispatcher(testScheduler), + ) + + monitor.refresh() + + assertEquals( + FriendActivity(FriendActivityKind.PLAYING_SERVER, "Hypixel"), + monitor.state.value.getValue("playing"), + ) + assertEquals(setOf("playing"), monitor.state.value.keys) + } + + private fun friend(peerId: String, name: String) = SavedFriend( + peerId = peerId, + publicKeyBase64 = "key", + shareId = java.util.UUID.randomUUID(), + capability = "friend-capability-123456789", + connectAddress = null, + displayName = name, + ) +} diff --git a/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FriendActivityResolverTest.kt b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FriendActivityResolverTest.kt new file mode 100644 index 000000000..fc54847c3 --- /dev/null +++ b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FriendActivityResolverTest.kt @@ -0,0 +1,47 @@ +package com.minekube.connect.share.fabric + +import com.minekube.connect.share.friend.FriendActivity +import com.minekube.connect.share.friend.FriendActivityKind +import kotlin.test.Test +import kotlin.test.assertEquals + +class FriendActivityResolverTest { + @Test + fun `enabled singleplayer sharing publishes the world as playing`() { + assertEquals( + FriendActivity(FriendActivityKind.HOSTING_WORLD, "Survival"), + FriendActivityResolver.resolve( + worldAvailable = true, + worldSharingActive = true, + worldName = "Survival", + externalServerName = null, + ), + ) + } + + @Test + fun `singleplayer stays private until sharing is enabled`() { + assertEquals( + FriendActivity(FriendActivityKind.ONLINE), + FriendActivityResolver.resolve( + worldAvailable = true, + worldSharingActive = false, + worldName = "Private World", + externalServerName = null, + ), + ) + } + + @Test + fun `external multiplayer remains requestable without exposing its address`() { + assertEquals( + FriendActivity(FriendActivityKind.PLAYING_SERVER, "Hypixel"), + FriendActivityResolver.resolve( + worldAvailable = false, + worldSharingActive = true, + worldName = null, + externalServerName = "Hypixel", + ), + ) + } +} diff --git a/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FriendCardExchangeConsentTest.kt b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FriendCardExchangeConsentTest.kt new file mode 100644 index 000000000..f9b60ab67 --- /dev/null +++ b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FriendCardExchangeConsentTest.kt @@ -0,0 +1,77 @@ +package com.minekube.connect.share.fabric + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue +import java.util.UUID + +class FriendCardExchangeConsentTest { + private var nowMillis = 1_000L + private val consent = FriendCardExchangeConsent { nowMillis } + + @Test + fun `armed Share join returns its peer exactly once`() { + consent.arm(PEER_ID) + + assertEquals(PEER_ID, consent.consume()?.peerId) + assertNull(consent.consume()) + } + + @Test + fun `stale Share join cannot leak a card to a later server`() { + consent.arm(PEER_ID) + nowMillis += 121_000 + + assertNull(consent.consume()) + } + + @Test + fun `cancel removes pending consent`() { + consent.arm(PEER_ID) + consent.cancel() + + assertNull(consent.consume()) + } + + @Test + fun `consent carries the relationship generation`() { + val relationshipId = UUID.randomUUID() + consent.arm(PEER_ID, relationshipId) + + assertEquals(relationshipId, consent.consume()!!.relationshipId) + } + + @Test + fun `reciprocal pairing requires explicit saved friend permission`() { + assertFalse( + FriendCardExchangeConsent.shouldArm( + savedFriendJoin = true, + canSeeMyWorlds = null, + ), + ) + assertFalse( + FriendCardExchangeConsent.shouldArm( + savedFriendJoin = true, + canSeeMyWorlds = false, + ), + ) + assertFalse( + FriendCardExchangeConsent.shouldArm( + savedFriendJoin = false, + canSeeMyWorlds = true, + ), + ) + assertTrue( + FriendCardExchangeConsent.shouldArm( + savedFriendJoin = true, + canSeeMyWorlds = true, + ), + ) + } + + private companion object { + const val PEER_ID = "12D3KooWPendingFriend" + } +} diff --git a/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FriendCardIssuerTest.kt b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FriendCardIssuerTest.kt new file mode 100644 index 000000000..3fbf9ae27 --- /dev/null +++ b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FriendCardIssuerTest.kt @@ -0,0 +1,179 @@ +package com.minekube.connect.share.fabric + +import arrow.core.Either +import com.minekube.connect.share.direct.ShareInviteCodec +import com.minekube.connect.share.friend.FriendStore +import java.nio.file.Path +import java.time.Instant +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.yield +import org.junit.jupiter.api.io.TempDir + +class FriendCardIssuerTest { + @TempDir + lateinit var tempDir: Path + + @Test + fun `friend card uses stable signed identity without an open world`() = + runBlocking { + val issuer = FriendCardIssuer( + dataDirectory = tempDir, + connectAddress = { "purple-del.play.minekube.net" }, + displayName = { "RoboFlax2" }, + ) + + val first = assertIs>( + issuer.issue(NOW), + ).value + val second = assertIs>( + issuer.issue(NOW.plusSeconds(60)), + ).value + val firstInvite = + ShareInviteCodec.decode(first, NOW).getOrNull()!! + val secondInvite = ShareInviteCodec.decode( + second, + NOW.plusSeconds(60), + ).getOrNull()!! + + assertEquals( + firstInvite.payload.peerId, + secondInvite.payload.peerId, + ) + assertEquals( + firstInvite.payload.shareId, + secondInvite.payload.shareId, + ) + assertEquals( + firstInvite.payload.capability, + secondInvite.payload.capability, + ) + assertEquals( + "purple-del.play.minekube.net", + firstInvite.payload.connectAddress, + ) + assertEquals("RoboFlax2", firstInvite.payload.displayName) + assertTrue(firstInvite.payload.directCandidates.isEmpty()) + } + + @Test + fun `receiving a card completes reciprocal pairing after approval`() = + runBlocking { + val issuer = FriendCardIssuer( + dataDirectory = tempDir.resolve("sender"), + connectAddress = { "sender.play.minekube.net" }, + ) + val card = issuer.issue(NOW).getOrNull()!! + val store = FriendStore(tempDir.resolve("receiver")) + val receiver = FriendCardReceiver(store) + val minecraftUuid = java.util.UUID.fromString( + "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + ) + + val result = receiver.receive( + invitation = card, + displayName = "Robin", + authenticatedMinecraftUuid = minecraftUuid, + allowAutomaticJoin = true, + now = NOW, + ) + + assertIs< + Either.Right< + com.minekube.connect.share.friend.SavedFriend, + > + >(result) + val saved = store.all().single() + assertEquals("Robin", saved.displayName) + assertEquals(minecraftUuid, saved.minecraftUuid) + assertTrue(saved.permissions.canJoinAutomatically) + } + + @Test + fun `approved exchange promotes the sender outgoing request`() = + runBlocking { + val issuer = FriendCardIssuer( + dataDirectory = tempDir.resolve("sender"), + connectAddress = { "sender.play.minekube.net" }, + ) + val card = issuer.issue(NOW).getOrNull()!! + val peerId = ShareInviteCodec.decode(card, NOW) + .getOrNull()!! + .payload + .peerId + val store = FriendStore(tempDir.resolve("accepter")) + store.sendRequest(card, "Robin", NOW) + val receiver = FriendCardReceiver(store) + + val result = receiver.confirmOutgoing(peerId) + + assertIs< + Either.Right< + com.minekube.connect.share.friend.SavedFriend, + > + >(result) + assertEquals(peerId, store.all().single().peerId) + assertTrue(store.outgoingRequests().isEmpty()) + } + + @Test + fun `card issuer resolves the persisted endpoint asynchronously`() = + runBlocking { + val issuer = FriendCardIssuer( + dataDirectory = tempDir, + connectAddress = { + yield() + "saved-endpoint.play.minekube.net" + }, + ) + + val card = issuer.issue(NOW).getOrNull()!! + val invite = ShareInviteCodec.decode(card, NOW).getOrNull()!! + + assertEquals( + "saved-endpoint.play.minekube.net", + invite.payload.connectAddress, + ) + } + + @Test + fun `friend card carries current direct internet social candidates`() = + runBlocking { + val peerId = ShareInviteCodec.decode( + FriendCardIssuer( + dataDirectory = tempDir, + connectAddress = { null }, + ).issue(NOW).getOrNull()!!, + NOW, + ).getOrNull()!!.payload.peerId + val internetAddress = internetAddress(peerId) + val issuer = FriendCardIssuer( + dataDirectory = tempDir, + connectAddress = { "saved-endpoint.play.minekube.net" }, + directRoute = { + FriendDirectRoute( + internetDirectEnabled = true, + candidates = listOf(internetAddress), + ) + }, + ) + + val card = issuer.issue(NOW).getOrNull()!! + val invite = ShareInviteCodec.decode(card, NOW).getOrNull()!! + + assertTrue(invite.payload.internetDirectEnabled) + assertEquals( + listOf(internetAddress), + invite.payload.directCandidates, + ) + } + + private companion object { + val NOW: Instant = Instant.parse("2026-07-31T00:00:00Z") + fun internetAddress(peerId: String) = + "/ip6/2001:db8::20/tcp/4001/p2p/$peerId" + } +} diff --git a/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FriendJoinOrchestratorTest.kt b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FriendJoinOrchestratorTest.kt new file mode 100644 index 000000000..8879feddb --- /dev/null +++ b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FriendJoinOrchestratorTest.kt @@ -0,0 +1,134 @@ +package com.minekube.connect.share.fabric + +import arrow.core.left +import arrow.core.right +import com.minekube.connect.share.friend.FriendJoinRequest +import com.minekube.connect.share.friend.CompatibilityProfile +import com.minekube.connect.share.friend.ModLoader +import com.minekube.connect.share.friend.RequiredMod +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlinx.coroutines.test.runTest + +class FriendJoinOrchestratorTest { + @Test + fun `external server approval becomes a normal Connect destination`() = runTest { + val orchestrator = FriendJoinOrchestrator.testing( + requestApproval = { + FriendJoinApproval.ExternalServer("friends.example.test").right() + }, + openSharedWorld = { error("shared route must not open") }, + ) + + val result = orchestrator.request(PEER_ID, REQUEST).getOrNull() + + assertEquals( + GuestJoinTarget.Connect("friends.example.test"), + result, + ) + } + + @Test + fun `shared world opens gameplay only after approval`() = runTest { + var openedPeer: String? = null + val expected = GuestJoinTarget.Connect("shared.example.test") + val orchestrator = FriendJoinOrchestrator.testing( + requestApproval = { FriendJoinApproval.SharedWorld.right() }, + openSharedWorld = { peerId -> + openedPeer = peerId + expected.right() + }, + ) + + val result = orchestrator.request(PEER_ID, REQUEST).getOrNull() + + assertEquals(PEER_ID, openedPeer) + assertEquals(expected, result) + } + + @Test + fun `approval failure stays actionable and never opens gameplay`() = runTest { + val orchestrator = FriendJoinOrchestrator.testing( + requestApproval = { FriendRequestFailure.Unreachable.left() }, + openSharedWorld = { error("gameplay must not open") }, + ) + + val failure = orchestrator.request(PEER_ID, REQUEST).leftOrNull() + + assertIs(failure) + assertEquals( + "Your friend is not reachable right now", + failure.safeMessage, + ) + } + + @Test + fun `incompatible Minecraft version blocks before requesting approval`() = runTest { + var approvalRequested = false + val orchestrator = FriendJoinOrchestrator.testing( + requestApproval = { + approvalRequested = true + FriendJoinApproval.SharedWorld.right() + }, + openSharedWorld = { error("gameplay must not open") }, + localCompatibility = { profile("1.21.1") }, + remoteCompatibility = { profile("1.20.1") }, + ) + + val failure = orchestrator.request(PEER_ID, REQUEST).leftOrNull() + + assertIs(failure) + assertEquals("Your Minecraft versions do not match.", failure.safeMessage) + assertEquals(false, failure.canTryAnyway) + assertEquals(false, approvalRequested) + } + + @Test + fun `mod mismatch requires explicit try anyway before approval`() = runTest { + var approvals = 0 + val orchestrator = FriendJoinOrchestrator.testing( + requestApproval = { + approvals++ + FriendJoinApproval.ExternalServer("friends.example.test").right() + }, + openSharedWorld = { error("gameplay must not open") }, + localCompatibility = { profile(modVersion = "1") }, + remoteCompatibility = { profile(modVersion = "2") }, + ) + + val blocked = orchestrator.request(PEER_ID, REQUEST).leftOrNull() + assertIs(blocked) + assertEquals(true, blocked.canTryAnyway) + assertEquals(0, approvals) + + val allowed = orchestrator.request( + PEER_ID, + REQUEST, + allowModMismatch = true, + ).getOrNull() + assertEquals( + GuestJoinTarget.Connect("friends.example.test"), + allowed, + ) + assertEquals(1, approvals) + } + + private fun profile( + minecraft: String = "1.21.1", + modVersion: String = "1", + ) = CompatibilityProfile( + minecraftVersion = minecraft, + loader = ModLoader.FABRIC, + requiredMods = listOf(RequiredMod("example", modVersion)), + ) + + private companion object { + const val PEER_ID = "12D3KooWRobin" + val REQUEST = FriendJoinRequest( + requestId = java.util.UUID.randomUUID(), + playerName = "Alex", + playerUuid = java.util.UUID.randomUUID(), + ) + } +} diff --git a/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FriendPairingDirectE2ETest.kt b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FriendPairingDirectE2ETest.kt new file mode 100644 index 000000000..30729ce9b --- /dev/null +++ b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FriendPairingDirectE2ETest.kt @@ -0,0 +1,392 @@ +package com.minekube.connect.share.fabric + +import com.minekube.connect.share.ShareConnectionGateway +import com.minekube.connect.share.ShareGameMode +import com.minekube.connect.share.ShareOptions +import com.minekube.connect.share.admission.AdmissionController +import com.minekube.connect.share.admission.AdmissionAnswer +import com.minekube.connect.share.admission.AdmissionIdentity +import com.minekube.connect.share.admission.AdmissionPurpose +import com.minekube.connect.share.admission.Ingress +import com.minekube.connect.share.direct.DirectSessionRegistry +import com.minekube.connect.share.friend.FriendStore +import com.minekube.connect.share.friend.FriendRemovalRequest +import com.minekube.connect.share.friend.FriendActivity +import com.minekube.connect.share.friend.FriendActivityKind +import com.minekube.connect.share.friend.FriendActivityRequest +import com.minekube.connect.share.friend.FriendJoinRequest +import com.minekube.connect.share.friend.ShareAccessIdentityStore +import com.minekube.connect.tunnel.p2p.DirectP2pAuthMode +import com.minekube.connect.tunnel.p2p.DirectP2pDiscoveryListener +import com.minekube.connect.tunnel.p2p.DirectP2pHostConfig +import com.minekube.connect.tunnel.p2p.DirectP2pHostHandler +import com.minekube.connect.tunnel.p2p.DirectP2pHostInfo +import com.minekube.connect.tunnel.p2p.DirectP2pNode +import com.minekube.connect.tunnel.p2p.DirectP2pProxy +import com.minekube.connect.tunnel.p2p.DirectP2pSession +import com.minekube.connect.tunnel.p2p.Libp2pRuntime +import java.net.InetAddress +import java.net.InetSocketAddress +import java.net.Socket +import java.net.SocketAddress +import java.nio.file.Path +import java.time.Duration +import java.time.Instant +import java.util.concurrent.atomic.AtomicReference +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.junit.jupiter.api.io.TempDir + +class FriendPairingDirectE2ETest { + @TempDir + lateinit var tempDir: Path + + @Test + fun `signed friend request traverses a real direct libp2p proxy`() = + runBlocking { + val now = Instant.parse("2026-07-31T12:00:00Z") + val hostDirectory = tempDir.resolve("host") + val senderDirectory = tempDir.resolve("sender") + val hostStore = FriendStore(hostDirectory) + val senderStore = FriendStore(senderDirectory) + val admission = AdmissionController( + scope = this, + timeout = 10.seconds, + maxPending = 8, + connectedCount = { 0 }, + maxGuests = { 8 }, + ) + val hostActivity = AtomicReference( + FriendActivity( + FriendActivityKind.PLAYING_SERVER, + "Hypixel", + ), + ) + val hostServer = FriendRequestServer( + scope = this, + admission = admission, + issuer = FriendCardIssuer(hostDirectory) { + "host.play.minekube.net" + }, + receiver = FriendCardReceiver(hostStore), + friendStore = hostStore, + now = { now }, + ioDispatcher = Dispatchers.IO, + activity = hostActivity::get, + joinTarget = { "mc.hypixel.net" }, + ) + + try { + ShareConnectionGateway.bind(hostServer).use { gateway -> + val access = ShareAccessIdentityStore( + hostDirectory, + ).currentOrCreate() + val hostNode = DirectP2pNode( + hostDirectory.resolve(IDENTITY_FILE_NAME), + ) + val hostInfo = AtomicReference() + val directIngress = FabricDirectShareIngress.testing( + nodeFactory = { + RealHostNode(hostNode, hostInfo) + }, + now = { now }, + shareId = { access.shareId }, + capability = { access.capability }, + displayName = { "Host control plane" }, + localSocket = ::openTaggedGatewaySocket, + ) + val direct = directIngress.start( + options = OPTIONS, + target = gateway.directAddress, + connectAddress = "host.play.minekube.net", + ) + val browser = FabricShareBrowser.testing( + node = RealGuestNode( + DirectP2pNode( + senderDirectory.resolve( + IDENTITY_FILE_NAME, + ), + ), + ), + now = { now }, + ioDispatcher = Dispatchers.IO, + ) + try { + val requestClient = FriendRequestClient( + ioDispatcher = Dispatchers.IO, + connectTimeout = Duration.ofSeconds(3), + decisionTimeout = Duration.ofSeconds(5), + ) + val pairing = FriendPairingClient( + store = senderStore, + issuer = FriendCardIssuer(senderDirectory) { + "sender.play.minekube.net" + }, + receiver = FriendCardReceiver(senderStore), + requestClient = requestClient, + now = { now }, + ioDispatcher = Dispatchers.IO, + ) + val received = CompletableDeferred() + val result = async { + pairing.send( + invitation = direct.invitation, + friendDisplayName = "RoboFlax2", + senderDisplayName = "bob", + route = { + browser.join( + invitationUri = + direct.invitation, + lanAddress = hostInfo.get() + .lanAddresses() + .first(), + internetOptIn = false, + authMode = + DirectP2pAuthMode.OFFLINE, + ) + }, + onReceived = { received.complete(Unit) }, + ) + } + + val pending = withTimeout(5.seconds) { + admission.pending + .first { it.isNotEmpty() } + .single() + } + withTimeout(5.seconds) { received.await() } + admission.answer(pending.requestId, allow = true) + + assertTrue(result.await().isRight()) + assertEquals( + "bob", + hostStore.all().single().displayName, + ) + assertEquals( + "RoboFlax2", + senderStore.all().single().displayName, + ) + + val activityTarget = browser.join( + invitationUri = direct.invitation, + lanAddress = hostInfo.get().lanAddresses().first(), + internetOptIn = false, + authMode = DirectP2pAuthMode.OFFLINE, + ).getOrNull() as GuestJoinTarget.Direct + assertEquals( + FriendActivity( + FriendActivityKind.PLAYING_SERVER, + "Hypixel", + ), + requestClient.activity( + activityTarget, + FriendActivityRequest(java.util.UUID.randomUUID()), + ).getOrNull(), + ) + + val joinTarget = browser.join( + invitationUri = direct.invitation, + lanAddress = hostInfo.get().lanAddresses().first(), + internetOptIn = false, + authMode = DirectP2pAuthMode.OFFLINE, + ).getOrNull() as GuestJoinTarget.Direct + val requestedJoin = async { + requestClient.requestJoin( + joinTarget, + FriendJoinRequest( + java.util.UUID.randomUUID(), + "bob", + java.util.UUID.randomUUID(), + ), + ) + } + val joinAdmission = withTimeout(5.seconds) { + admission.pending.first { + it.singleOrNull()?.purpose == + com.minekube.connect.share.admission.AdmissionPurpose.JOIN + }.single() + } + admission.answer(joinAdmission.requestId, allow = true) + assertEquals( + FriendJoinApproval.ExternalServer("mc.hypixel.net"), + requestedJoin.await().getOrNull(), + ) + + hostActivity.set( + FriendActivity( + FriendActivityKind.HOSTING_WORLD, + "Survival", + ), + ) + val playerUuid = java.util.UUID.randomUUID() + val worldJoinTarget = browser.join( + invitationUri = direct.invitation, + lanAddress = hostInfo.get().lanAddresses().first(), + internetOptIn = false, + authMode = DirectP2pAuthMode.OFFLINE, + ).getOrNull() as GuestJoinTarget.Direct + val requestedWorldJoin = async { + requestClient.requestJoin( + worldJoinTarget, + FriendJoinRequest( + java.util.UUID.randomUUID(), + "bob", + playerUuid, + ), + ) + } + val worldAdmission = withTimeout(5.seconds) { + admission.pending.first { + it.singleOrNull()?.purpose == + AdmissionPurpose.JOIN + }.single() + } + admission.answer(worldAdmission.requestId, allow = true) + assertEquals( + FriendJoinApproval.SharedWorld, + requestedWorldJoin.await().getOrNull(), + ) + assertEquals( + AdmissionAnswer.ALLOW, + admission.request( + AdmissionIdentity.UnverifiedOffline( + name = "bob", + uuid = playerUuid, + connectionId = "connect-gameplay", + ingress = Ingress.CONNECT, + ), + AdmissionPurpose.JOIN, + ), + ) + assertTrue(admission.pending.value.isEmpty()) + + val hostPeerId = senderStore.all().single().peerId + assertTrue(senderStore.remove(hostPeerId, now)) + val removal = senderStore.pendingRemovals().single() + val removalTarget = browser.join( + invitationUri = direct.invitation, + lanAddress = hostInfo.get().lanAddresses().first(), + internetOptIn = false, + authMode = DirectP2pAuthMode.OFFLINE, + ).getOrNull() as GuestJoinTarget.Direct + + assertTrue( + requestClient.remove( + removalTarget, + FriendRemovalRequest( + operationId = removal.operationId, + relationshipId = removal.friend.relationshipId, + ), + ).isRight(), + ) + senderStore.acknowledgeRemoval(removal.operationId) + + assertTrue(hostStore.all().isEmpty()) + assertTrue(senderStore.all().isEmpty()) + assertTrue(senderStore.pendingRemovals().isEmpty()) + } finally { + browser.close() + direct.close() + } + } + } finally { + Libp2pRuntime.close() + } + } + + private class RealHostNode( + private val node: DirectP2pNode, + private val hostInfo: AtomicReference, + ) : FabricDirectNode { + override fun startHost( + config: DirectP2pHostConfig, + handler: DirectP2pHostHandler, + ): DirectP2pHostInfo = node.startHost(config, handler).also( + hostInfo::set, + ) + + override fun sign(payload: ByteArray): ByteArray = + node.sign(payload) + + override fun publish( + invitation: String, + discoveryInvitation: String, + ) { + node.publish(invitation, discoveryInvitation) + } + + override fun close() { + node.close() + } + } + + private class RealGuestNode( + private val node: DirectP2pNode, + ) : FabricGuestDirectNode { + override fun peerId(): String = node.peerId() + + override fun startDiscovery( + listener: DirectP2pDiscoveryListener, + ) { + node.startDiscovery(listener) + } + + override fun openProxy( + address: String, + shareId: String, + capability: String, + authMode: DirectP2pAuthMode, + timeout: Duration, + ): DirectP2pProxy = node.openProxy( + address, + shareId, + capability, + authMode, + timeout, + ) + + override fun close() { + node.close() + } + } + + private fun openTaggedGatewaySocket( + target: SocketAddress, + session: DirectP2pSession, + ): Socket { + val socket = Socket() + socket.bind( + InetSocketAddress(InetAddress.getLoopbackAddress(), 0), + ) + val registration = DirectSessionRegistry.register( + sourcePort = socket.localPort, + session = session, + ) + return try { + socket.connect(target) + socket + } catch (failure: Throwable) { + registration.close() + socket.close() + throw failure + } + } + + private companion object { + const val IDENTITY_FILE_NAME = + "share-libp2p-identity.key" + val OPTIONS = ShareOptions( + gameMode = ShareGameMode.SURVIVAL, + allowCheats = false, + allowInternetDirect = false, + ) + } +} diff --git a/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FriendPairingE2ETest.kt b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FriendPairingE2ETest.kt new file mode 100644 index 000000000..5dc521b4e --- /dev/null +++ b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FriendPairingE2ETest.kt @@ -0,0 +1,71 @@ +package com.minekube.connect.share.fabric + +import arrow.core.Either +import arrow.core.right +import com.minekube.connect.share.friend.FriendRelationshipStatus +import com.minekube.connect.share.friend.FriendStore +import java.nio.file.Path +import java.time.Instant +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertTrue +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.io.TempDir + +class FriendPairingE2ETest { + @TempDir + lateinit var tempDir: Path + + @Test + fun `Connect Minecraft route is refused before friend delivery`() = + runBlocking { + val now = Instant.parse("2026-07-31T10:30:00Z") + val hostIssuer = FriendCardIssuer( + dataDirectory = tempDir.resolve("host"), + connectAddress = { "host.play.minekube.net" }, + ) + val invitation = hostIssuer.issue(now).getOrNull()!! + val senderStore = FriendStore(tempDir.resolve("sender")) + val pairing = FriendPairingClient( + store = senderStore, + issuer = FriendCardIssuer(tempDir.resolve("sender")) { + "sender.play.minekube.net" + }, + receiver = FriendCardReceiver(senderStore), + requestClient = FriendRequestClient( + ioDispatcher = Dispatchers.IO, + ), + now = { now }, + ioDispatcher = Dispatchers.IO, + ) + var received = false + + val result = pairing.send( + invitation = invitation, + friendDisplayName = "RoboFlax2", + senderDisplayName = "bob", + route = { + GuestJoinTarget.Connect( + "host.play.minekube.net", + ).right() + }, + onReceived = { received = true }, + ) + + val failure = assertIs< + Either.Left + >(result).value + assertEquals(GuestJoinFailure.NoRoute, failure.error) + assertFalse(received) + assertTrue(senderStore.all().isEmpty()) + assertEquals( + FriendRelationshipStatus.PENDING_OUTGOING, + senderStore.outgoingRequests() + .single() + .relationshipStatus, + ) + } +} diff --git a/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FriendPresenceMonitorTest.kt b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FriendPresenceMonitorTest.kt new file mode 100644 index 000000000..2bb9d8ae8 --- /dev/null +++ b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FriendPresenceMonitorTest.kt @@ -0,0 +1,179 @@ +package com.minekube.connect.share.fabric + +import com.minekube.connect.share.direct.ShareRoute +import com.minekube.connect.share.friend.FriendPermissions +import com.minekube.connect.share.friend.SavedFriend +import java.util.UUID +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.async +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runCurrent + +@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) +class FriendPresenceMonitorTest { + @Test + fun `refresh loads persisted friends only on its IO dispatcher`() = runTest { + val io = StandardTestDispatcher(testScheduler) + var loads = 0 + val monitor = FriendPresenceMonitor.testing( + friends = { + loads++ + emptyList() + }, + ioDispatcher = io, + ) + + val refresh = async(start = CoroutineStart.UNDISPATCHED) { + monitor.refresh() + } + + assertEquals(0, loads) + runCurrent() + assertEquals(1, loads) + refresh.await() + } + + @Test + fun `refresh projects online state without exposing saved routes`() = runTest { + val online = friend( + peerId = "12D3KooWOnline", + address = "online.play.minekube.net", + ) + val offline = friend( + peerId = "12D3KooWOffline", + address = "offline.play.minekube.net", + ) + val monitor = FriendPresenceMonitor.testing( + friends = { listOf(online, offline) }, + directProbe = { friend -> + if (friend.peerId == online.peerId) { + ServerPresence("Robin's World") + } else { + null + } + }, + ) + + monitor.refresh() + + val presence = monitor.state.value + assertTrue(presence.getValue(online.peerId).online) + assertEquals( + "Robin's World", + presence.getValue(online.peerId).description, + ) + assertFalse(presence.getValue(offline.peerId).online) + assertFalse(presence.toString().contains("capability-secret")) + } + + @Test + fun `direct LAN status is authenticated before being reported`() = runTest { + val nearby = friend( + peerId = "12D3KooWNearby", + address = "nearby.play.minekube.net", + ) + val monitor = FriendPresenceMonitor.testing( + friends = { listOf(nearby) }, + directProbe = { + ServerPresence("Robin's LAN World") + }, + ) + + monitor.refresh() + + val presence = monitor.state.value.getValue(nearby.peerId) + assertTrue(presence.online) + assertEquals(ShareRoute.DIRECT_LAN, presence.route) + assertEquals("Robin's LAN World", presence.description) + } + + @Test + fun `failed direct status does not fall back to Connect`() = runTest { + val friend = friend( + peerId = "12D3KooWDirectUnavailable", + address = "friend.play.minekube.net", + ) + val monitor = FriendPresenceMonitor.testing( + friends = { listOf(friend) }, + directProbe = { null }, + ) + + monitor.refresh() + + assertFalse(monitor.state.value.getValue(friend.peerId).online) + } + + @Test + fun `direct presence probing preserves coroutine cancellation`() = runTest { + val monitor = FriendPresenceMonitor.testing( + friends = { + listOf( + friend( + peerId = "12D3KooWCancelled", + address = "cancelled.play.minekube.net", + ), + ) + }, + directProbe = { + throw CancellationException("cancelled") + }, + ) + + assertFailsWith { + monitor.refresh() + } + } + + @Test + fun `online notification fires once per transition and respects preference`() { + val tracker = FriendOnlineTracker() + val online = RemoteFriendPresence( + peerId = "peer-online", + displayName = "Robin", + online = true, + description = "Robin's World", + notifyWhenOnline = true, + ) + val muted = online.copy( + peerId = "peer-muted", + displayName = "Muted", + notifyWhenOnline = false, + ) + + assertEquals( + listOf(online), + tracker.update(mapOf(online.peerId to online, muted.peerId to muted)), + ) + assertTrue( + tracker.update(mapOf(online.peerId to online)).isEmpty(), + ) + tracker.update( + mapOf(online.peerId to online.copy(online = false)), + ) + + assertEquals( + listOf(online), + tracker.update(mapOf(online.peerId to online)), + ) + } + + private fun friend( + peerId: String, + address: String, + ) = SavedFriend( + peerId = peerId, + publicKeyBase64 = "cHVibGljLWtleQ==", + shareId = UUID.randomUUID(), + capability = "capability-secret", + connectAddress = address, + displayName = peerId.takeLast(6), + permissions = FriendPermissions(), + ) +} diff --git a/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FriendRemovalSyncTest.kt b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FriendRemovalSyncTest.kt new file mode 100644 index 000000000..6b915c519 --- /dev/null +++ b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FriendRemovalSyncTest.kt @@ -0,0 +1,72 @@ +package com.minekube.connect.share.fabric + +import arrow.core.left +import arrow.core.right +import com.minekube.connect.share.direct.ShareInviteCodec +import com.minekube.connect.share.direct.ShareInvitePayload +import com.minekube.connect.share.direct.SignedShareInvite +import com.minekube.connect.share.friend.FriendStore +import java.nio.file.Path +import java.security.KeyPairGenerator +import java.security.Signature +import java.time.Instant +import java.util.UUID +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.io.TempDir + +class FriendRemovalSyncTest { + @TempDir + lateinit var tempDir: Path + + @Test + fun `failed removal stays durable and a later sync acknowledges it`() = runTest { + val store = FriendStore(tempDir) + store.accept(signedLink(), "Robin", NOW) + store.remove(PEER_ID, NOW) + var reachable = false + var attempts = 0 + val sync = FriendRemovalSync(store) { + attempts++ + if (reachable) Unit.right() else FriendRequestFailure.Unreachable.left() + } + + assertEquals(RemovalSyncSummary(delivered = 0, pending = 1), sync.sync()) + assertEquals(1, FriendStore(tempDir).pendingRemovals().size) + + reachable = true + assertEquals(RemovalSyncSummary(delivered = 1, pending = 0), sync.sync()) + assertTrue(FriendStore(tempDir).pendingRemovals().isEmpty()) + assertEquals(2, attempts) + } + + private fun signedLink(): String { + val pair = KeyPairGenerator.getInstance("Ed25519").generateKeyPair() + val payload = ShareInvitePayload( + wireVersion = ShareInviteCodec.WIRE_VERSION, + shareId = UUID.randomUUID(), + expiresAtEpochMillis = NOW.plusSeconds(3_600).toEpochMilli(), + connectAddress = "purple-del.play.minekube.net", + peerId = PEER_ID, + internetDirectEnabled = false, + directCandidates = emptyList(), + capability = "friend-capability-123456789", + ) + val unsigned = ShareInviteCodec.unsignedBytes(payload, pair.public.encoded) + val signature = Signature.getInstance("Ed25519").run { + initSign(pair.private) + update(unsigned) + sign() + } + return ShareInviteCodec.encode( + SignedShareInvite(payload, pair.public.encoded, signature), + ) + } + + private companion object { + val NOW: Instant = Instant.parse("2026-07-31T00:00:00Z") + const val PEER_ID = "12D3KooWStableFriendPeer" + } +} diff --git a/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FriendRequestClientTest.kt b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FriendRequestClientTest.kt new file mode 100644 index 000000000..facd44fc8 --- /dev/null +++ b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FriendRequestClientTest.kt @@ -0,0 +1,291 @@ +package com.minekube.connect.share.fabric + +import arrow.core.Either +import com.minekube.connect.share.direct.ShareRoute +import com.minekube.connect.share.friend.FriendControlDecode +import com.minekube.connect.share.friend.FriendControlRequest +import com.minekube.connect.share.friend.FriendControlResponse +import com.minekube.connect.share.friend.FriendControlWire +import com.minekube.connect.share.friend.FriendRemovalRequest +import com.minekube.connect.share.friend.FriendActivity +import com.minekube.connect.share.friend.FriendActivityKind +import com.minekube.connect.share.friend.FriendActivityRequest +import com.minekube.connect.share.friend.FriendJoinRequest +import com.minekube.connect.tunnel.p2p.DirectP2pProxy +import java.io.ByteArrayOutputStream +import java.net.InetAddress +import java.net.InetSocketAddress +import java.net.ServerSocket +import java.time.Duration +import java.util.UUID +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import kotlin.concurrent.thread +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking + +class FriendRequestClientTest { + @Test + fun `libp2p control request waits for remote acceptance without joining`() = + runBlocking { + val server = ServerSocket( + 0, + 1, + InetAddress.getLoopbackAddress(), + ) + val received = CountDownLatch(1) + val remote = thread(name = "friend-control-test") { + server.use { + it.accept().use { socket -> + val request = socket.getInputStream() + .readControlRequest() + assertEquals(REQUEST, request) + socket.getOutputStream().apply { + write( + FriendControlWire.encodeResponse( + FriendControlResponse.Received, + ), + ) + flush() + } + received.countDown() + socket.getOutputStream().apply { + write( + FriendControlWire.encodeResponse( + FriendControlResponse.Accepted(HOST_CARD), + ), + ) + flush() + } + } + } + } + var acknowledged = false + val client = FriendRequestClient( + ioDispatcher = Dispatchers.IO, + ) + + val result = client.exchange( + target = directTarget(server), + request = REQUEST, + onReceived = { acknowledged = true }, + ) + + assertIs>(result) + assertEquals(HOST_CARD, result.value) + assertTrue(acknowledged) + assertTrue(received.await(1, TimeUnit.SECONDS)) + remote.join(1_000) + } + + @Test + fun `cancelling a pending request closes its control socket promptly`() = + runBlocking { + val server = ServerSocket( + 0, + 1, + InetAddress.getLoopbackAddress(), + ) + val closed = CountDownLatch(1) + val remote = thread(name = "friend-control-cancel-test") { + server.use { + it.accept().use { socket -> + socket.getInputStream().readControlRequest() + socket.getOutputStream().apply { + write( + FriendControlWire.encodeResponse( + FriendControlResponse.Received, + ), + ) + flush() + } + while (socket.getInputStream().read() != -1) { + // Wait for cancellation to close the stream. + } + closed.countDown() + } + } + } + val client = FriendRequestClient( + ioDispatcher = Dispatchers.IO, + decisionTimeout = Duration.ofSeconds(30), + ) + val pending = launch { + client.exchange( + target = directTarget(server), + request = REQUEST, + onReceived = {}, + ) + } + delay(100) + + pending.cancelAndJoin() + + assertTrue(closed.await(2, TimeUnit.SECONDS)) + remote.join(1_000) + } + + @Test + fun `removal waits for a remote acknowledgement`() = runBlocking { + val server = ServerSocket(0, 1, InetAddress.getLoopbackAddress()) + val removal = FriendRemovalRequest(UUID.randomUUID()) + val remote = thread(name = "friend-removal-test") { + server.use { + it.accept().use { socket -> + val bytes = socket.getInputStream().readNBytes( + FriendControlWire.encodeRemoval(removal).size, + ) + assertEquals( + removal, + assertIs>( + FriendControlWire.decodeRemoval(bytes), + ).value, + ) + socket.getOutputStream().apply { + write(FriendControlWire.encodeResponse(FriendControlResponse.Received)) + write(FriendControlWire.encodeResponse(FriendControlResponse.Removed)) + flush() + } + } + } + } + + val result = FriendRequestClient(ioDispatcher = Dispatchers.IO) + .remove(directTarget(server), removal) + + assertIs>(result) + remote.join(1_000) + } + + @Test + fun `activity query returns privacy safe friend activity`() = runBlocking { + val request = FriendActivityRequest(UUID.randomUUID()) + val expected = FriendActivity(FriendActivityKind.PLAYING_SERVER, "Hypixel") + val server = responseServer( + FriendControlWire.encodeActivityRequest(request), + FriendControlResponse.Activity(expected), + ) + + val result = FriendRequestClient(ioDispatcher = Dispatchers.IO) + .activity(directTarget(server), request) + + assertEquals(expected, assertIs>(result).value) + } + + @Test + fun `join request returns address only after remote acceptance`() = runBlocking { + val request = FriendJoinRequest( + UUID.randomUUID(), + "RoboFlax2", + PLAYER_UUID, + ) + val server = responseServer( + FriendControlWire.encodeJoinRequest(request), + FriendControlResponse.JoinAccepted("mc.hypixel.net"), + ) + + val result = FriendRequestClient(ioDispatcher = Dispatchers.IO) + .requestJoin(directTarget(server), request) + + assertEquals( + FriendJoinApproval.ExternalServer("mc.hypixel.net"), + assertIs>(result).value, + ) + } + + @Test + fun `shared world approval does not expose or require a server address`() = runBlocking { + val request = FriendJoinRequest( + UUID.randomUUID(), + "RoboFlax2", + PLAYER_UUID, + ) + val server = responseServer( + FriendControlWire.encodeJoinRequest(request), + FriendControlResponse.SharedWorldJoinAccepted, + ) + + val result = FriendRequestClient(ioDispatcher = Dispatchers.IO) + .requestJoin(directTarget(server), request) + + assertEquals( + FriendJoinApproval.SharedWorld, + assertIs>(result).value, + ) + } + + private fun responseServer( + expectedRequest: ByteArray, + response: FriendControlResponse, + ): ServerSocket { + val server = ServerSocket(0, 1, InetAddress.getLoopbackAddress()) + thread(name = "friend-control-response-test") { + server.use { + it.accept().use { socket -> + assertTrue( + expectedRequest.contentEquals( + socket.getInputStream().readNBytes(expectedRequest.size), + ), + ) + socket.getOutputStream().apply { + write(FriendControlWire.encodeResponse(FriendControlResponse.Received)) + write(FriendControlWire.encodeResponse(response)) + flush() + } + } + } + } + return server + } + + private fun java.io.InputStream.readControlRequest(): FriendControlRequest { + val bytes = ByteArrayOutputStream() + while (bytes.size() <= FriendControlWire.MAX_REQUEST_BYTES) { + val next = read() + check(next >= 0) { "Friend control request ended early" } + bytes.write(next) + when ( + val decoded = + FriendControlWire.decodeRequest(bytes.toByteArray()) + ) { + is FriendControlDecode.Decoded -> return decoded.value + FriendControlDecode.Incomplete -> Unit + FriendControlDecode.Invalid -> + error("Friend control request was invalid") + } + } + error("Friend control request exceeded its limit") + } + + private fun directTarget(server: ServerSocket): GuestJoinTarget.Direct { + val address = InetSocketAddress( + InetAddress.getLoopbackAddress(), + server.localPort, + ) + return GuestJoinTarget.Direct( + ShareRoute.DIRECT_LAN, + address, + DirectP2pProxy(address) {}, + ) + } + + private companion object { + val REQUEST = FriendControlRequest( + requestId = UUID.fromString( + "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + ), + displayName = "bob", + invitation = "minekube://share/sender-card", + ) + const val HOST_CARD = "minekube://share/host-card" + val PLAYER_UUID: UUID = + UUID.fromString("11111111-2222-3333-4444-555555555555") + } +} diff --git a/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FriendRequestServerTest.kt b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FriendRequestServerTest.kt new file mode 100644 index 000000000..29e94c51a --- /dev/null +++ b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FriendRequestServerTest.kt @@ -0,0 +1,602 @@ +package com.minekube.connect.share.fabric + +import com.minekube.connect.share.admission.AdmissionController +import com.minekube.connect.share.admission.AdmissionIdentity +import com.minekube.connect.share.admission.AdmissionAnswer +import com.minekube.connect.share.admission.AuthSource +import com.minekube.connect.share.admission.AdmissionPurpose +import com.minekube.connect.share.admission.Ingress +import com.minekube.connect.share.direct.ShareInviteCodec +import com.minekube.connect.share.friend.FriendControlContext +import com.minekube.connect.share.friend.FriendControlRequest +import com.minekube.connect.share.friend.FriendControlResponse +import com.minekube.connect.share.friend.FriendRemovalRequest +import com.minekube.connect.share.friend.FriendActivity +import com.minekube.connect.share.friend.FriendActivityKind +import com.minekube.connect.share.friend.FriendActivityRequest +import com.minekube.connect.share.friend.FriendJoinRequest +import com.minekube.connect.share.friend.FriendStore +import com.minekube.connect.share.friend.FriendAccessPolicy +import com.minekube.connect.share.friend.PresencePrivacy +import java.nio.file.Path +import java.time.Instant +import java.util.UUID +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.async +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.future.await +import org.junit.jupiter.api.io.TempDir + +@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) +class FriendRequestServerTest { + @TempDir + lateinit var tempDir: Path + + @Test + fun `remote acceptance stores sender and returns signed host card`() = runTest { + val senderIssuer = issuer("sender") + val hostIssuer = issuer("host") + val senderCard = senderIssuer.issue(NOW).getOrNull()!! + val senderPeerId = ShareInviteCodec.decode(senderCard, NOW) + .getOrNull()!! + .payload.peerId + val admission = admission() + val hostStore = FriendStore(tempDir.resolve("host-store")) + val server = FriendRequestServer( + scope = backgroundScope, + admission = admission, + issuer = hostIssuer, + receiver = FriendCardReceiver(hostStore), + friendStore = hostStore, + now = { NOW }, + ioDispatcher = StandardTestDispatcher(testScheduler), + ) + + val response = server.handle( + FriendControlContext( + ingress = Ingress.DIRECT_LAN, + directPeerId = senderPeerId, + ), + request(senderCard), + ).toCompletableFuture() + runCurrent() + + val pending = admission.pending.value.single() + assertEquals(AdmissionPurpose.FRIEND, pending.purpose) + assertEquals("bob", pending.identity.name) + admission.answer(pending.requestId, allow = true) + runCurrent() + + val accepted = assertIs( + response.getNow(null), + ) + assertTrue( + ShareInviteCodec.decode(accepted.invitation, NOW).isRight(), + ) + assertEquals(senderPeerId, hostStore.all().single().peerId) + assertFalse(hostStore.all().single().permissions.canJoinAutomatically) + } + + @Test + fun `non libp2p ingress and direct identity mismatch never create trust`() = + runTest { + val senderCard = issuer("sender").issue(NOW).getOrNull()!! + val admission = admission() + val hostStore = FriendStore(tempDir.resolve("host-store")) + val server = FriendRequestServer( + scope = backgroundScope, + admission = admission, + issuer = issuer("host"), + receiver = FriendCardReceiver(hostStore), + friendStore = hostStore, + now = { NOW }, + ioDispatcher = StandardTestDispatcher(testScheduler), + ) + + val mismatch = server.handle( + FriendControlContext( + ingress = Ingress.DIRECT_LAN, + directPeerId = "12D3KooWWrong", + ), + request(senderCard), + ).toCompletableFuture() + runCurrent() + assertEquals(FriendControlResponse.Invalid, mismatch.getNow(null)) + assertTrue(admission.pending.value.isEmpty()) + + val connect = server.handle( + FriendControlContext(Ingress.CONNECT, directPeerId = null), + request(senderCard), + ).toCompletableFuture() + runCurrent() + + assertEquals(FriendControlResponse.Invalid, connect.getNow(null)) + assertTrue(admission.pending.value.isEmpty()) + assertTrue(hostStore.all().isEmpty()) + } + + @Test + fun `blocked libp2p identity cannot create another friend prompt`() = runTest { + val senderCard = issuer("sender").issue(NOW).getOrNull()!! + val senderPeerId = ShareInviteCodec.decode(senderCard, NOW) + .getOrNull()!!.payload.peerId + val admission = admission() + val hostStore = FriendStore(tempDir.resolve("host-store")) + hostStore.accept(senderCard, "bob", NOW) + hostStore.block(senderPeerId, NOW) + val server = FriendRequestServer( + scope = backgroundScope, + admission = admission, + issuer = issuer("host"), + receiver = FriendCardReceiver(hostStore), + friendStore = hostStore, + now = { NOW.plusSeconds(1) }, + ioDispatcher = StandardTestDispatcher(testScheduler), + ) + + val response = server.handle( + FriendControlContext(Ingress.DIRECT_LAN, senderPeerId), + request(senderCard), + ).await() + + assertEquals(FriendControlResponse.Declined, response) + assertTrue(admission.pending.value.isEmpty()) + assertTrue(hostStore.all().isEmpty()) + } + + @Test + fun `crossed outgoing request confirms friendship without another prompt`() = + runTest { + val senderCard = issuer("sender").issue(NOW).getOrNull()!! + val senderPeerId = ShareInviteCodec.decode(senderCard, NOW) + .getOrNull()!! + .payload.peerId + val admission = admission() + val hostStore = FriendStore(tempDir.resolve("host-store")) + hostStore.sendRequest(senderCard, "bob", NOW) + var relationshipsChanged = 0 + val server = FriendRequestServer( + scope = backgroundScope, + admission = admission, + issuer = issuer("host"), + receiver = FriendCardReceiver(hostStore), + friendStore = hostStore, + now = { NOW }, + ioDispatcher = StandardTestDispatcher(testScheduler), + onRelationshipChanged = { relationshipsChanged++ }, + ) + + val response = server.handle( + FriendControlContext( + ingress = Ingress.DIRECT_LAN, + directPeerId = senderPeerId, + ), + request(senderCard), + ).toCompletableFuture() + + assertIs(response.await()) + assertTrue(admission.pending.value.isEmpty()) + val confirmed = hostStore.all().single() + assertEquals(senderPeerId, confirmed.peerId) + assertFalse(confirmed.permissions.canJoinAutomatically) + assertTrue(hostStore.outgoingRequests().isEmpty()) + assertEquals(1, relationshipsChanged) + } + + @Test + fun `authenticated removal converges locally and is idempotent`() = runTest { + val senderCard = issuer("sender").issue(NOW).getOrNull()!! + val senderPeerId = ShareInviteCodec.decode(senderCard, NOW) + .getOrNull()!!.payload.peerId + val hostStore = FriendStore(tempDir.resolve("host-store")) + hostStore.accept(senderCard, "bob", NOW) + val admission = admission() + val authenticated = AdmissionIdentity.Authenticated( + name = "bob", + uuid = PLAYER_UUID, + source = AuthSource.MOJANG, + ingress = Ingress.DIRECT_LAN, + directPeerId = senderPeerId, + ) + val approval = async { + admission.request(authenticated) + } + runCurrent() + admission.answer(admission.pending.value.single().requestId, true) + assertEquals(AdmissionAnswer.ALLOW, approval.await()) + admission.approveNextJoin( + AdmissionIdentity.UnverifiedOffline( + name = "bob", + uuid = PLAYER_UUID, + connectionId = "friend-join", + ingress = Ingress.DIRECT_LAN, + directPeerId = senderPeerId, + ), + ) + val approvedJoins = ApprovedJoinTracker() + approvedJoins.record(authenticated, AdmissionAnswer.ALLOW) + val server = FriendRequestServer( + scope = backgroundScope, + admission = admission, + issuer = issuer("host"), + receiver = FriendCardReceiver(hostStore), + friendStore = hostStore, + approvedJoins = approvedJoins, + now = { NOW }, + ioDispatcher = StandardTestDispatcher(testScheduler), + ) + val context = FriendControlContext( + ingress = Ingress.DIRECT_LAN, + directPeerId = senderPeerId, + ) + val removal = FriendRemovalRequest( + operationId = UUID.randomUUID(), + relationshipId = hostStore.relationship(senderPeerId) + .getOrNull()!!.relationshipId, + ) + + assertEquals( + FriendControlResponse.Removed, + server.handleRemoval(context, removal).await(), + ) + assertEquals( + FriendControlResponse.Removed, + server.handleRemoval(context, removal).await(), + ) + assertTrue(hostStore.all().isEmpty()) + assertTrue(hostStore.pendingRemovals().isEmpty()) + assertFalse(approvedJoins.hasProof("bob", PLAYER_UUID)) + val afterRemoval = async { + admission.request(authenticated) + } + runCurrent() + assertEquals(1, admission.pending.value.size) + admission.resetShare() + assertEquals(AdmissionAnswer.STOPPED, afterRemoval.await()) + } + + @Test + fun `removal never accepts Connect ingress`() = runTest { + val hostStore = FriendStore(tempDir.resolve("host-store")) + val server = FriendRequestServer( + scope = backgroundScope, + admission = admission(), + issuer = issuer("host"), + receiver = FriendCardReceiver(hostStore), + friendStore = hostStore, + ioDispatcher = StandardTestDispatcher(testScheduler), + ) + + assertEquals( + FriendControlResponse.Invalid, + server.handleRemoval( + FriendControlContext(Ingress.CONNECT, null), + FriendRemovalRequest(UUID.randomUUID()), + ).await(), + ) + } + + @Test + fun `confirmed friend can see server activity but not its address`() = runTest { + val senderCard = issuer("sender").issue(NOW).getOrNull()!! + val senderPeerId = ShareInviteCodec.decode(senderCard, NOW) + .getOrNull()!!.payload.peerId + val hostStore = FriendStore(tempDir.resolve("host-store")) + hostStore.accept(senderCard, "bob", NOW) + val server = FriendRequestServer( + scope = backgroundScope, + admission = admission(), + issuer = issuer("host"), + receiver = FriendCardReceiver(hostStore), + friendStore = hostStore, + activity = { + FriendActivity(FriendActivityKind.PLAYING_SERVER, "Hypixel") + }, + joinTarget = { "mc.hypixel.net" }, + ioDispatcher = StandardTestDispatcher(testScheduler), + ) + + assertEquals( + FriendControlResponse.Activity( + FriendActivity(FriendActivityKind.PLAYING_SERVER, "Hypixel"), + ), + server.handleActivity( + FriendControlContext(Ingress.DIRECT_LAN, senderPeerId), + FriendActivityRequest(UUID.randomUUID()), + ).await(), + ) + } + + @Test + fun `join target is disclosed only after friend request is approved`() = runTest { + val senderCard = issuer("sender").issue(NOW).getOrNull()!! + val senderPeerId = ShareInviteCodec.decode(senderCard, NOW) + .getOrNull()!!.payload.peerId + val admission = admission() + val hostStore = FriendStore(tempDir.resolve("host-store")) + hostStore.accept(senderCard, "bob", NOW) + val server = FriendRequestServer( + scope = backgroundScope, + admission = admission, + issuer = issuer("host"), + receiver = FriendCardReceiver(hostStore), + friendStore = hostStore, + activity = { + FriendActivity(FriendActivityKind.PLAYING_SERVER, "Hypixel") + }, + joinTarget = { "mc.hypixel.net" }, + ioDispatcher = StandardTestDispatcher(testScheduler), + ) + val response = server.handleJoin( + FriendControlContext(Ingress.DIRECT_LAN, senderPeerId), + FriendJoinRequest( + UUID.randomUUID(), + "RoboFlax2", + PLAYER_UUID, + ), + ).toCompletableFuture() + runCurrent() + + val pending = admission.pending.value.single() + assertEquals(AdmissionPurpose.JOIN, pending.purpose) + assertEquals("RoboFlax2", pending.identity.name) + assertEquals(PLAYER_UUID, pending.identity.uuid) + admission.answer(pending.requestId, allow = true) + runCurrent() + + assertEquals( + FriendControlResponse.JoinAccepted("mc.hypixel.net"), + response.getNow(null), + ) + } + + @Test + fun `confirmed friend can request to join a shared singleplayer world`() = runTest { + val senderCard = issuer("sender").issue(NOW).getOrNull()!! + val senderPeerId = ShareInviteCodec.decode(senderCard, NOW) + .getOrNull()!!.payload.peerId + val admission = admission() + val hostStore = FriendStore(tempDir.resolve("host-store")) + hostStore.accept(senderCard, "bob", NOW) + val server = FriendRequestServer( + scope = backgroundScope, + admission = admission, + issuer = issuer("host"), + receiver = FriendCardReceiver(hostStore), + friendStore = hostStore, + activity = { + FriendActivity( + FriendActivityKind.HOSTING_WORLD, + "Survival", + ) + }, + ioDispatcher = StandardTestDispatcher(testScheduler), + ) + val response = server.handleJoin( + FriendControlContext(Ingress.DIRECT_LAN, senderPeerId), + FriendJoinRequest( + UUID.randomUUID(), + "RoboFlax2", + PLAYER_UUID, + ), + ).toCompletableFuture() + runCurrent() + + val pending = admission.pending.value.single() + assertEquals(AdmissionPurpose.JOIN, pending.purpose) + admission.answer(pending.requestId, allow = true) + runCurrent() + + assertEquals( + FriendControlResponse.SharedWorldJoinAccepted, + response.getNow(null), + ) + } + + @Test + fun `never allow declines join without notifying the host`() = runTest { + val senderCard = issuer("sender").issue(NOW).getOrNull()!! + val senderPeerId = ShareInviteCodec.decode(senderCard, NOW) + .getOrNull()!!.payload.peerId + val admission = admission() + val hostStore = FriendStore(tempDir.resolve("host-store")) + val friend = hostStore.accept(senderCard, "bob", NOW).getOrNull()!! + hostStore.updatePermissions( + senderPeerId, + friend.permissions.copy( + accessPolicy = FriendAccessPolicy.NEVER_ALLOW, + ), + ) + val server = FriendRequestServer( + scope = backgroundScope, + admission = admission, + issuer = issuer("host"), + receiver = FriendCardReceiver(hostStore), + friendStore = hostStore, + activity = { + FriendActivity(FriendActivityKind.HOSTING_WORLD, "Survival") + }, + ioDispatcher = StandardTestDispatcher(testScheduler), + ) + + val response = server.handleJoin( + FriendControlContext(Ingress.DIRECT_LAN, senderPeerId), + FriendJoinRequest(UUID.randomUUID(), "RoboFlax2", PLAYER_UUID), + ).await() + + assertEquals(FriendControlResponse.Declined, response) + assertTrue(admission.pending.value.isEmpty()) + } + + @Test + fun `presence privacy can hide playing details without hiding online state`() = runTest { + val senderCard = issuer("sender").issue(NOW).getOrNull()!! + val senderPeerId = ShareInviteCodec.decode(senderCard, NOW) + .getOrNull()!!.payload.peerId + val hostStore = FriendStore(tempDir.resolve("host-store")) + hostStore.accept(senderCard, "bob", NOW) + val server = FriendRequestServer( + scope = backgroundScope, + admission = admission(), + issuer = issuer("host"), + receiver = FriendCardReceiver(hostStore), + friendStore = hostStore, + activity = { + FriendActivity(FriendActivityKind.PLAYING_SERVER, "Private") + }, + presencePrivacy = { + PresencePrivacy( + showOnline = true, + showPlaying = false, + showCurrentServer = false, + showJoinable = false, + ) + }, + ioDispatcher = StandardTestDispatcher(testScheduler), + ) + + assertEquals( + FriendControlResponse.Activity( + FriendActivity(FriendActivityKind.ONLINE), + ), + server.handleActivity( + FriendControlContext(Ingress.DIRECT_LAN, senderPeerId), + FriendActivityRequest(UUID.randomUUID()), + ).await(), + ) + } + + @Test + fun `presence privacy hides both world names and raw Minecraft status`() = runTest { + val senderCard = issuer("sender-private-world").issue(NOW).getOrNull()!! + val senderPeerId = ShareInviteCodec.decode(senderCard, NOW) + .getOrNull()!!.payload.peerId + val hostStore = FriendStore(tempDir.resolve("host-private-world-store")) + hostStore.accept(senderCard, "bob", NOW) + val server = FriendRequestServer( + scope = backgroundScope, + admission = admission(), + issuer = issuer("host-private-world"), + receiver = FriendCardReceiver(hostStore), + friendStore = hostStore, + activity = { + FriendActivity( + FriendActivityKind.HOSTING_WORLD, + "Secret Survival", + ) + }, + presencePrivacy = { + PresencePrivacy( + showOnline = false, + showPlaying = true, + showCurrentServer = false, + showJoinable = true, + ) + }, + ioDispatcher = StandardTestDispatcher(testScheduler), + ) + val context = FriendControlContext(Ingress.DIRECT_LAN, senderPeerId) + + assertFalse(server.allowsMinecraftStatus(context)) + assertEquals( + FriendControlResponse.Invalid, + server.handleActivity( + context, + FriendActivityRequest(UUID.randomUUID()), + ).await(), + ) + } + + @Test + fun `current activity privacy hides singleplayer world name`() = runTest { + val senderCard = issuer("sender-hidden-name").issue(NOW).getOrNull()!! + val senderPeerId = ShareInviteCodec.decode(senderCard, NOW) + .getOrNull()!!.payload.peerId + val hostStore = FriendStore(tempDir.resolve("host-hidden-name-store")) + hostStore.accept(senderCard, "bob", NOW) + val server = FriendRequestServer( + scope = backgroundScope, + admission = admission(), + issuer = issuer("host-hidden-name"), + receiver = FriendCardReceiver(hostStore), + friendStore = hostStore, + activity = { + FriendActivity( + FriendActivityKind.HOSTING_WORLD, + "Secret Survival", + ) + }, + presencePrivacy = { + PresencePrivacy(showCurrentServer = false) + }, + ioDispatcher = StandardTestDispatcher(testScheduler), + ) + val context = FriendControlContext(Ingress.DIRECT_LAN, senderPeerId) + + assertFalse(server.allowsMinecraftStatus(context)) + assertEquals( + FriendControlResponse.Activity( + FriendActivity(FriendActivityKind.HOSTING_WORLD), + ), + server.handleActivity( + context, + FriendActivityRequest(UUID.randomUUID()), + ).await(), + ) + } + + @Test + fun `fully visible privacy permits status on a capability route`() = runTest { + val hostStore = FriendStore(tempDir.resolve("visible-status-store")) + val server = FriendRequestServer( + scope = backgroundScope, + admission = admission(), + issuer = issuer("visible-status-host"), + receiver = FriendCardReceiver(hostStore), + friendStore = hostStore, + presencePrivacy = { PresencePrivacy() }, + ioDispatcher = StandardTestDispatcher(testScheduler), + ) + + assertTrue( + server.allowsMinecraftStatus( + FriendControlContext(Ingress.CONNECT, null), + ), + ) + } + + private fun kotlinx.coroutines.test.TestScope.admission() = + AdmissionController( + scope = backgroundScope, + timeout = 30.seconds, + maxPending = 16, + connectedCount = { 0 }, + maxGuests = { 8 }, + ) + + private fun issuer(name: String) = FriendCardIssuer( + dataDirectory = tempDir.resolve(name), + connectAddress = { "$name.play.minekube.net" }, + ) + + private fun request(card: String) = FriendControlRequest( + requestId = UUID.fromString( + "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + ), + displayName = "bob", + invitation = card, + ) + + private companion object { + val NOW: Instant = Instant.parse("2026-07-31T00:00:00Z") + val PLAYER_UUID: UUID = + UUID.fromString("11111111-2222-3333-4444-555555555555") + } +} diff --git a/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/GuestConnectionLeaseTest.kt b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/GuestConnectionLeaseTest.kt new file mode 100644 index 000000000..0746526a9 --- /dev/null +++ b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/GuestConnectionLeaseTest.kt @@ -0,0 +1,63 @@ +package com.minekube.connect.share.fabric + +import kotlin.test.Test +import kotlin.test.assertEquals + +class GuestConnectionLeaseTest { + @Test + fun `lease survives connect screen and closes after disconnect`() { + val closed = mutableListOf() + var now = 0L + val lease = GuestConnectionLease(nowNanos = { now }) + + lease.hold(closeable("proxy", closed), closeable("browser", closed)) + lease.connectionChanged(false) + lease.connectionChanged(true) + assertEquals(emptyList(), closed) + + lease.connectionChanged(false) + + assertEquals(listOf("proxy", "browser"), closed) + } + + @Test + fun `lease closes when Minecraft never establishes a connection`() { + val closed = mutableListOf() + var now = 0L + val lease = GuestConnectionLease( + nowNanos = { now }, + connectTimeoutNanos = 30, + ) + + lease.hold(closeable("proxy", closed), closeable("browser", closed)) + now = 31 + lease.connectionChanged(false) + + assertEquals(listOf("proxy", "browser"), closed) + } + + @Test + fun `replacement closes the previous lease in ownership order`() { + val closed = mutableListOf() + val lease = GuestConnectionLease(nowNanos = { 0 }) + + lease.hold(closeable("first-proxy", closed), closeable("first-browser", closed)) + lease.hold(closeable("second-proxy", closed), closeable("second-browser", closed)) + lease.close() + + assertEquals( + listOf( + "first-proxy", + "first-browser", + "second-proxy", + "second-browser", + ), + closed, + ) + } + + private fun closeable( + name: String, + closed: MutableList, + ) = AutoCloseable { closed += name } +} diff --git a/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/LoadedCompatibilityProfileFactoryTest.kt b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/LoadedCompatibilityProfileFactoryTest.kt new file mode 100644 index 000000000..05f7e3757 --- /dev/null +++ b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/LoadedCompatibilityProfileFactoryTest.kt @@ -0,0 +1,93 @@ +package com.minekube.connect.share.fabric + +import com.minekube.connect.share.friend.ModLoader +import com.minekube.connect.share.friend.PackPlatform +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class LoadedCompatibilityProfileFactoryTest { + @Test + fun `profile contains only universal or server gameplay mods`() { + val profile = LoadedCompatibilityProfileFactory.create( + minecraftVersion = "1.21.1", + loader = ModLoader.FABRIC, + mods = listOf( + LoadedMod("minecraft", "1.21.1", ModSide.UNIVERSAL, true), + LoadedMod("fabricloader", "0.16", ModSide.UNIVERSAL), + LoadedMod("connect-share", "1", ModSide.CLIENT), + LoadedMod("sodium", "1", ModSide.CLIENT), + LoadedMod("world-mod", "2", ModSide.UNIVERSAL), + LoadedMod("server-rules", "3", ModSide.SERVER), + ), + ) + + assertEquals(ModLoader.FABRIC, profile.loader) + assertEquals( + listOf("server-rules", "world-mod"), + profile.requiredMods.map { it.id }, + ) + } + + @Test + fun `optional Modrinth pack metadata becomes a recovery link`() { + val profile = LoadedCompatibilityProfileFactory.create( + minecraftVersion = "1.21.1", + loader = ModLoader.FABRIC, + mods = emptyList(), + packEnvironment = mapOf( + "CONNECT_SHARE_PACK_URL" to + "https://modrinth.com/modpack/adventure/version/v4", + "CONNECT_SHARE_PACK_PROJECT" to "adventure", + "CONNECT_SHARE_PACK_VERSION" to "v4", + ), + ) + + assertEquals(PackPlatform.MODRINTH, profile.pack?.platform) + assertEquals("adventure", profile.pack?.projectId) + assertEquals("v4", profile.pack?.versionId) + } + + @Test + fun `CurseForge pack metadata becomes a safe recovery link`() { + val profile = LoadedCompatibilityProfileFactory.create( + minecraftVersion = "1.20.1", + loader = ModLoader.FORGE, + mods = emptyList(), + packEnvironment = mapOf( + "CONNECT_SHARE_PACK_URL" to + "https://www.curseforge.com/minecraft/modpacks/adventure/files/7", + "CONNECT_SHARE_PACK_PROJECT" to "adventure", + "CONNECT_SHARE_PACK_VERSION" to "7", + ), + ) + + assertEquals(PackPlatform.CURSEFORGE, profile.pack?.platform) + assertEquals( + "https://www.curseforge.com/minecraft/modpacks/adventure/files/7", + profile.pack?.url, + ) + } + + @Test + fun `unsafe pack metadata is never exposed as a recovery link`() { + listOf( + "http://modrinth.com/modpack/adventure", + "https://user:password@modrinth.com/modpack/adventure", + "file:///tmp/adventure.mrpack", + ).forEach { url -> + val profile = LoadedCompatibilityProfileFactory.create( + minecraftVersion = "1.21.1", + loader = ModLoader.FABRIC, + mods = emptyList(), + packEnvironment = mapOf( + "CONNECT_SHARE_PACK_URL" to url, + "CONNECT_SHARE_PACK_PROJECT" to "adventure", + "CONNECT_SHARE_PACK_VERSION" to "v4", + ), + ) + + assertNull(profile.pack) + } + } +} diff --git a/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/MinecraftStatusProbeTest.kt b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/MinecraftStatusProbeTest.kt new file mode 100644 index 000000000..a648bbf88 --- /dev/null +++ b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/MinecraftStatusProbeTest.kt @@ -0,0 +1,114 @@ +package com.minekube.connect.share.fabric + +import arrow.core.Either +import java.io.ByteArrayOutputStream +import java.io.DataInputStream +import java.io.DataOutputStream +import java.net.InetAddress +import java.net.ServerSocket +import java.nio.charset.StandardCharsets +import java.util.concurrent.CompletableFuture +import java.util.concurrent.TimeUnit +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlinx.coroutines.test.runTest + +class MinecraftStatusProbeTest { + @Test + fun `status response from a live endpoint is online`() = runTest { + fakeStatusServer( + """{"version":{"name":"test","protocol":1},"players":{"max":8,"online":1},"description":{"text":"Robin's World"}}""", + ).use { server -> + val result = MinecraftStatusProbe().probe(server.address) + + val presence = assertIs>(result).value + assertEquals("Robin's World", presence.description) + } + } + + @Test + fun `Connect fallback MOTD is recognized as offline`() = runTest { + fakeStatusServer( + """{"version":{"name":"test","protocol":1},"players":{"max":0,"online":0},"description":{"extra":[{"text":"purple-del"},{"text":" is currently not available."}]}}""", + ).use { server -> + val result = MinecraftStatusProbe().probe(server.address) + + assertIs>(result) + } + } + + private fun fakeStatusServer(json: String): FakeStatusServer { + val listener = ServerSocket( + 0, + 1, + InetAddress.getLoopbackAddress(), + ) + val completed = CompletableFuture() + val thread = Thread { + try { + listener.accept().use { socket -> + val input = DataInputStream(socket.getInputStream()) + input.readNBytes(readVarInt(input)) + input.readNBytes(readVarInt(input)) + val response = ByteArrayOutputStream().also { packet -> + writeVarInt(packet, 0) + val jsonBytes = json.toByteArray(StandardCharsets.UTF_8) + writeVarInt(packet, jsonBytes.size) + packet.write(jsonBytes) + }.toByteArray() + val output = socket.getOutputStream() + writeVarInt(output, response.size) + output.write(response) + output.flush() + } + completed.complete(Unit) + } catch (failure: Throwable) { + completed.completeExceptionally(failure) + } + } + thread.isDaemon = true + thread.start() + return FakeStatusServer( + address = "127.0.0.1:${listener.localPort}", + close = { + listener.close() + completed.get(3, TimeUnit.SECONDS) + }, + ) + } + + private fun readVarInt(input: DataInputStream): Int { + var value = 0 + var position = 0 + while (position < 32) { + val current = input.readUnsignedByte() + value = value or ((current and 0x7f) shl position) + if (current and 0x80 == 0) return value + position += 7 + } + error("VarInt is too large") + } + + private fun writeVarInt( + output: java.io.OutputStream, + value: Int, + ) { + var remaining = value + while (true) { + if (remaining and -128 == 0) { + output.write(remaining) + return + } + output.write(remaining and 127 or 128) + remaining = remaining ushr 7 + } + } + + private class FakeStatusServer( + val address: String, + private val close: () -> Unit, + ) : AutoCloseable { + override fun close() = close.invoke() + } +} diff --git a/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/PersistentConnectIngressTest.kt b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/PersistentConnectIngressTest.kt new file mode 100644 index 000000000..8b489680b --- /dev/null +++ b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/PersistentConnectIngressTest.kt @@ -0,0 +1,150 @@ +package com.minekube.connect.share.fabric + +import com.minekube.connect.share.ConnectShareHandle +import com.minekube.connect.share.ConnectShareIngress +import com.minekube.connect.share.identity.CredentialSource +import com.minekube.connect.share.identity.EndpointIdentity +import io.netty.channel.local.LocalAddress +import java.net.SocketAddress +import java.util.concurrent.atomic.AtomicInteger +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.test.assertTrue +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.runBlocking + +class PersistentConnectIngressTest { + @Test + fun `title startup and world leases share one connector until shutdown`() = + runBlocking { + val delegate = FakeIngress() + val persistent = PersistentConnectIngress(delegate) + + val starts = List(8) { + async { + persistent.startControl(IDENTITY, TARGET) + } + }.awaitAll() + + assertTrue(starts.all { it.isRight() }) + assertEquals(1, delegate.starts.get()) + assertIs( + persistent.state.value, + ) + + val firstWorld = persistent.start(IDENTITY, TARGET) + val secondWorld = persistent.start(IDENTITY, TARGET) + firstWorld.close() + secondWorld.close() + + assertEquals(0, delegate.closes.get()) + assertEquals( + "stable.play.minekube.net", + firstWorld.publicAddress, + ) + + persistent.shutdown() + persistent.shutdown() + + assertEquals(1, delegate.closes.get()) + assertEquals(PersistentConnectState.Closed, persistent.state.value) + } + + @Test + fun `failed title startup can retry without leaking a connector`() = + runBlocking { + val delegate = FakeIngress(failuresBeforeSuccess = 1) + val persistent = PersistentConnectIngress(delegate) + + val failed = persistent.startControl(IDENTITY, TARGET) + + assertTrue(failed.isLeft()) + assertIs( + persistent.state.value, + ) + val recovered = persistent.startControl(IDENTITY, TARGET) + assertTrue(recovered.isRight()) + assertEquals(2, delegate.starts.get()) + persistent.shutdown() + assertEquals(1, delegate.closes.get()) + } + + @Test + fun `active connector rejects identity or target drift`() = runBlocking { + val persistent = PersistentConnectIngress(FakeIngress()) + persistent.startControl(IDENTITY, TARGET).getOrNull()!! + + assertFailsWith { + persistent.start( + IDENTITY.copy(endpoint = "other"), + TARGET, + ) + } + assertFailsWith { + persistent.start( + IDENTITY, + LocalAddress("other-target"), + ) + } + + persistent.shutdown() + } + + @Test + fun `restart releases the captured identity before the next control start`() = + runBlocking { + val delegate = FakeIngress() + val persistent = PersistentConnectIngress(delegate) + persistent.startControl(IDENTITY, TARGET).getOrNull()!! + + persistent.restart() + + assertEquals(1, delegate.closes.get()) + assertIs(persistent.state.value) + persistent.startControl( + IDENTITY.copy(endpoint = "replacement"), + TARGET, + ).getOrNull()!! + assertEquals(2, delegate.starts.get()) + + persistent.shutdown() + } + + private class FakeIngress( + private val failuresBeforeSuccess: Int = 0, + ) : ConnectShareIngress { + val starts = AtomicInteger() + val closes = AtomicInteger() + + override suspend fun start( + identity: EndpointIdentity, + target: SocketAddress, + ): ConnectShareHandle { + val attempt = starts.incrementAndGet() + if (attempt <= failuresBeforeSuccess) { + error("simulated Connect startup failure") + } + return ConnectShareHandle( + endpoint = identity.endpoint, + publicAddress = + "${identity.endpoint}.play.minekube.net", + close = { + closes.incrementAndGet() + }, + ) + } + } + + private companion object { + val IDENTITY = EndpointIdentity( + endpoint = "stable", + token = "T-persistenttesttoken", + endpointSource = CredentialSource.GENERATED, + tokenSource = CredentialSource.GENERATED, + ) + val TARGET: SocketAddress = LocalAddress("persistent-target") + } +} diff --git a/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/PersistentDirectIngressTest.kt b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/PersistentDirectIngressTest.kt new file mode 100644 index 000000000..8287a1251 --- /dev/null +++ b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/PersistentDirectIngressTest.kt @@ -0,0 +1,166 @@ +package com.minekube.connect.share.fabric + +import com.minekube.connect.share.DirectShareHandle +import com.minekube.connect.share.DirectShareIngress +import com.minekube.connect.share.ShareGameMode +import com.minekube.connect.share.ShareOptions +import java.net.InetAddress +import java.net.InetSocketAddress +import java.net.SocketAddress +import java.util.concurrent.atomic.AtomicInteger +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.test.assertTrue +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.runBlocking + +class PersistentDirectIngressTest { + @Test + fun `world starts replace the title host and publish current invitations`() = + runBlocking { + val delegate = FakeIngress() + val persistent = PersistentDirectIngress(delegate) + + val starts = List(8) { + async { + persistent.startControl( + CONTROL_OPTIONS, + TARGET, + CONNECT_ADDRESS, + ) + } + }.awaitAll() + + assertTrue(starts.all { it.isRight() }) + assertEquals(1, delegate.starts.get()) + assertIs( + persistent.state.value, + ) + + val firstWorld = persistent.start( + CONTROL_OPTIONS, + TARGET, + CONNECT_ADDRESS, + ) + val secondWorld = persistent.start( + CONTROL_OPTIONS.copy(allowInternetDirect = true), + TARGET, + CONNECT_ADDRESS, + ) + firstWorld.close() + secondWorld.close() + + assertEquals(2, delegate.closes.get()) + assertEquals("$INVITATION-2", firstWorld.invitation) + assertTrue(firstWorld.lanAvailable) + assertEquals("$INVITATION-3", secondWorld.invitation) + assertEquals(3, delegate.starts.get()) + assertEquals( + listOf(false, false, true), + delegate.startedOptions.map(ShareOptions::allowInternetDirect), + ) + + persistent.shutdown() + persistent.shutdown() + + assertEquals(3, delegate.closes.get()) + assertEquals(PersistentDirectState.Closed, persistent.state.value) + } + + @Test + fun `failed title startup can retry without leaking a direct host`() = + runBlocking { + val delegate = FakeIngress(failuresBeforeSuccess = 1) + val persistent = PersistentDirectIngress(delegate) + + val failed = persistent.startControl( + CONTROL_OPTIONS, + TARGET, + CONNECT_ADDRESS, + ) + + assertTrue(failed.isLeft()) + assertIs(persistent.state.value) + val recovered = persistent.startControl( + CONTROL_OPTIONS, + TARGET, + CONNECT_ADDRESS, + ) + assertTrue(recovered.isRight()) + assertEquals(2, delegate.starts.get()) + + persistent.shutdown() + assertEquals(1, delegate.closes.get()) + } + + @Test + fun `active direct host rejects target or Connect address drift`() = + runBlocking { + val persistent = PersistentDirectIngress(FakeIngress()) + persistent.startControl( + CONTROL_OPTIONS, + TARGET, + CONNECT_ADDRESS, + ).getOrNull()!! + + assertFailsWith { + persistent.startControl( + CONTROL_OPTIONS, + InetSocketAddress(InetAddress.getLoopbackAddress(), 25_566), + CONNECT_ADDRESS, + ) + } + assertFailsWith { + persistent.startControl( + CONTROL_OPTIONS, + TARGET, + "other.play.minekube.net", + ) + } + + persistent.shutdown() + } + + private class FakeIngress( + private val failuresBeforeSuccess: Int = 0, + ) : DirectShareIngress { + val starts = AtomicInteger() + val closes = AtomicInteger() + val startedOptions = mutableListOf() + + override suspend fun start( + options: ShareOptions, + target: SocketAddress, + connectAddress: String?, + ): DirectShareHandle { + val attempt = starts.incrementAndGet() + startedOptions += options + if (attempt <= failuresBeforeSuccess) { + error("simulated direct startup failure") + } + return DirectShareHandle( + invitation = "$INVITATION-$attempt", + lanAvailable = true, + internetAvailable = false, + close = { + closes.incrementAndGet() + }, + ) + } + } + + private companion object { + const val CONNECT_ADDRESS = "stable.play.minekube.net" + const val INVITATION = "minekube://share/signed-persistent" + val TARGET: SocketAddress = + InetSocketAddress(InetAddress.getLoopbackAddress(), 25_565) + val CONTROL_OPTIONS = ShareOptions( + gameMode = ShareGameMode.SURVIVAL, + allowCheats = false, + allowInternetDirect = false, + ) + } +} diff --git a/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/PrismFriendJoinE2ETest.kt b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/PrismFriendJoinE2ETest.kt new file mode 100644 index 000000000..b22f6c306 --- /dev/null +++ b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/PrismFriendJoinE2ETest.kt @@ -0,0 +1,287 @@ +package com.minekube.connect.share.fabric + +import com.minekube.connect.share.friend.FriendActivityKind +import com.minekube.connect.share.friend.FriendJoinRequest +import com.minekube.connect.share.friend.FriendStore +import com.minekube.connect.tunnel.p2p.DirectP2pAuthMode +import com.minekube.connect.tunnel.p2p.DirectP2pNode +import java.io.IOException +import java.nio.charset.StandardCharsets +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.attribute.BasicFileAttributes +import java.util.UUID +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.test.fail +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.junit.jupiter.api.Assumptions.assumeTrue +import org.junit.jupiter.api.io.TempDir + +/** + * Opt-in bridge between the deterministic friend tests and a real Prism host + * plus guest. See share/AGENTS.md for the launch sequence. + */ +class PrismFriendJoinE2ETest { + @TempDir + lateinit var tempDir: Path + + @Test + fun `rotated guest log counts fresh advancement evidence`() { + val guestLog = tempDir.resolve("latest.log") + Files.writeString( + guestLog, + "[old] [Render thread/INFO]: Loaded 41 advancements\n", + ) + val beforeAppend = snapshotLog(guestLog) + + Files.writeString( + guestLog, + "[old] [Render thread/INFO]: Loaded 41 advancements\n" + + "[new] [Render thread/INFO]: Loaded 41 advancements\n", + ) + + assertTrue(hasNewLoadedAdvancements(guestLog, beforeAppend)) + + Files.writeString( + guestLog, + "[before] [Render thread/INFO]: Loaded 41 advancements\n", + ) + val beforeRotation = snapshotLog(guestLog) + Files.move(guestLog, guestLog.resolveSibling("latest.log.1")) + Files.writeString( + guestLog, + "[startup] [Render thread/INFO]: Loaded 41 advancements\n", + ) + assertTrue(hasNewLoadedAdvancements(guestLog, beforeRotation)) + } + + @Test + fun `first poll after rotation keeps an already logged successful join`() { + val guestLog = tempDir.resolve("latest.log") + Files.writeString( + guestLog, + "[previous] [Render thread/INFO]: Loaded 41 advancements\n", + ) + val beforeLaunch = snapshotLog(guestLog) + + Files.move(guestLog, guestLog.resolveSibling("latest.log.1")) + Files.writeString( + guestLog, + "[join] [Render thread/INFO]: Loaded 41 advancements\n", + ) + + assertTrue(hasNewLoadedAdvancements(guestLog, beforeLaunch)) + } + + @Test + fun `saved friend requests and joins a live singleplayer world`() = + runBlocking { + val dataValue = System.getenv("LIVE_DATA") + val targetValue = System.getenv("LIVE_TARGET_FILE") + ?: System.getenv("LIVE_PORT_FILE") + val hostLogValue = System.getenv("LIVE_HOST_LOG") + assumeTrue( + dataValue != null && targetValue != null && hostLogValue != null, + "LIVE_DATA, LIVE_TARGET_FILE, and LIVE_HOST_LOG enable this E2E", + ) + val dataDirectory = Path.of(checkNotNull(dataValue)) + val targetFile = Path.of(checkNotNull(targetValue)) + val hostLog = Path.of(checkNotNull(hostLogValue)) + val guestLog = System.getenv("LIVE_GUEST_LOG")?.let(Path::of) + val playerName = System.getenv("LIVE_PLAYER_NAME") ?: "bob" + val forceConnectFallback = + System.getenv("LIVE_FORCE_CONNECT_FALLBACK") == "true" + val joinedLine = "] $playerName joined the game" + val joinsBefore = Files.readString(hostLog) + .lineSequence() + .count { joinedLine in it } + val guestLogBefore = guestLog?.let(::snapshotLog) + val friend = FriendStore(dataDirectory).all().single() + System.getenv("LIVE_HOST_DATA")?.let { hostDataValue -> + val guestPeerId = DirectP2pNode( + dataDirectory.resolve("share-libp2p-identity.key"), + ).use(DirectP2pNode::peerId) + assertTrue( + FriendStore(Path.of(hostDataValue)).relationship(guestPeerId) + .isSome(), + "The live host has not confirmed this guest peer identity", + ) + } + val browser = FabricShareBrowser(dataDirectory) + try { + assertTrue(browser.start().isRight()) + withTimeout(30_000) { + browser.discovered.first { discovered -> + discovered.any { + it.invitation.payload.peerId == friend.peerId + } + } + } + val client = FriendRequestClient() + val activityTarget = browser.openFriendControl( + friend, + DirectP2pAuthMode.OFFLINE, + ).getOrNull()!! + val activityResult = + activityTarget.use { + client.activity( + it, + com.minekube.connect.share.friend + .FriendActivityRequest(UUID.randomUUID()), + ) + } + val activity = activityResult.getOrNull() + ?: fail( + activityResult.leftOrNull()?.safeMessage + ?: "Host returned no friend activity", + ) + assertEquals(FriendActivityKind.HOSTING_WORLD, activity.kind) + + // A hidden world name intentionally skips raw Minecraft status. + // Authenticated activity remains the privacy-safe authority, + // and gameplay admission is independent. + if (activity.description != null) { + // Status and gameplay require different one-shot proxies. + withTimeout(30_000) { + while ( + browser.probeLan( + friend, + DirectP2pAuthMode.OFFLINE, + MinecraftStatusProbe(), + ) == null + ) { + delay(250) + } + } + } + val playerUuid = UUID.nameUUIDFromBytes( + "OfflinePlayer:$playerName".toByteArray( + StandardCharsets.UTF_8, + ), + ) + val requestTarget = browser.openFriendControl( + friend, + DirectP2pAuthMode.OFFLINE, + ).getOrNull()!! + assertEquals( + FriendJoinApproval.SharedWorld, + requestTarget.use { + client.requestJoin( + it, + FriendJoinRequest( + UUID.randomUUID(), + playerName, + playerUuid, + ), + ).getOrNull() + }, + ) + if (forceConnectFallback) { + forceDirectFailure(browser) + } + val gameplay = browser.join( + friend, + DirectP2pAuthMode.OFFLINE, + ).getOrNull() ?: fail("No gameplay route was available") + gameplay.use { + val target = when (gameplay) { + is GuestJoinTarget.Direct -> { + assertTrue(!forceConnectFallback) + gameplay.localAddress.port.toString() + } + + is GuestJoinTarget.Connect -> { + assertTrue(forceConnectFallback) + gameplay.publicAddress + } + } + Files.writeString(targetFile, target) + withTimeout(180_000) { + while (Files.readString(hostLog) + .lineSequence() + .count { joinedLine in it } <= joinsBefore + ) { + delay(100) + } + } + if (guestLog != null && guestLogBefore != null) { + withTimeout(180_000) { + while ( + !hasNewLoadedAdvancements( + guestLog, + guestLogBefore, + ) + ) { + delay(100) + } + } + } + } + } finally { + browser.close() + } + } + + private fun forceDirectFailure(browser: FabricShareBrowser) { + val field = FabricShareBrowser::class.java + .getDeclaredField("node") + .apply { isAccessible = true } + (field.get(browser) as AutoCloseable).close() + } + + private fun snapshotLog(path: Path): LogSnapshot = + readLog(path) ?: LogSnapshot( + exists = false, + fileKey = null, + contents = "", + loadedAdvancements = 0, + ) + + private fun hasNewLoadedAdvancements( + path: Path, + before: LogSnapshot, + ): Boolean { + val current = readLog(path) ?: return false + val sameFile = before.exists && + if (before.fileKey != null && current.fileKey != null) { + before.fileKey == current.fileKey + } else { + current.contents.startsWith(before.contents) + } + return if (sameFile && current.contents.startsWith(before.contents)) { + current.loadedAdvancements > before.loadedAdvancements + } else { + current.loadedAdvancements > 0 + } + } + + private fun readLog(path: Path): LogSnapshot? = try { + val attributes = Files.readAttributes(path, BasicFileAttributes::class.java) + val contents = Files.readString(path) + LogSnapshot( + exists = true, + fileKey = attributes.fileKey(), + contents = contents, + loadedAdvancements = loadedAdvancementsCount(contents), + ) + } catch (_: IOException) { + null + } + + private fun loadedAdvancementsCount(contents: String): Int = + contents.lineSequence().count { + "Loaded " in it && " advancements" in it + } + + private data class LogSnapshot( + val exists: Boolean, + val fileKey: Any?, + val contents: String, + val loadedAdvancements: Int, + ) +} diff --git a/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/RandomEndpointNameSourceTest.kt b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/RandomEndpointNameSourceTest.kt new file mode 100644 index 000000000..6159e0df1 --- /dev/null +++ b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/RandomEndpointNameSourceTest.kt @@ -0,0 +1,70 @@ +package com.minekube.connect.share.fabric + +import java.util.Random +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.test.runTest +import okhttp3.OkHttpClient +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer + +class RandomEndpointNameSourceTest { + @Test + fun `returns a valid remote endpoint`() = runTest { + MockWebServer().use { server -> + server.enqueue(MockResponse().setBody("amber-fox")) + server.start() + val source = RandomEndpointNameSource( + client = OkHttpClient(), + url = server.url("/"), + timeout = 2.seconds, + random = Random(7), + ) + + assertEquals("amber-fox", source.create()) + } + } + + @Test + fun `invalid empty and non-200 responses use lowercase fallback`() = runTest { + MockWebServer().use { server -> + server.enqueue(MockResponse().setBody("INVALID ENDPOINT")) + server.enqueue(MockResponse().setBody("")) + server.enqueue(MockResponse().setResponseCode(503).setBody("secret response")) + server.start() + val source = RandomEndpointNameSource( + client = OkHttpClient(), + url = server.url("/"), + timeout = 2.seconds, + random = Random(7), + ) + + repeat(3) { + assertTrue(source.create().matches(Regex("^[a-z]{5}$"))) + } + } + } + + @Test + fun `timeout uses lowercase fallback`() = runTest { + MockWebServer().use { server -> + server.enqueue( + MockResponse() + .setBody("amber-fox") + .setBodyDelay(2, java.util.concurrent.TimeUnit.SECONDS), + ) + server.start() + val source = RandomEndpointNameSource( + client = OkHttpClient(), + url = server.url("/"), + timeout = 50.milliseconds, + random = Random(7), + ) + + assertTrue(source.create().matches(Regex("^[a-z]{5}$"))) + } + } +} diff --git a/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/SecretRedactionTest.kt b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/SecretRedactionTest.kt new file mode 100644 index 000000000..5f93d7358 --- /dev/null +++ b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/SecretRedactionTest.kt @@ -0,0 +1,43 @@ +package com.minekube.connect.share.fabric + +import com.minekube.connect.share.ShareGameMode +import com.minekube.connect.share.ShareOptions +import com.minekube.connect.share.ShareState +import com.minekube.connect.share.fabric.ui.IdentityImportDraft +import com.minekube.connect.share.fabric.ui.ShareUiState +import com.minekube.connect.share.identity.CredentialSource +import com.minekube.connect.share.identity.EndpointIdentity +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertFalse + +class SecretRedactionTest { + @Test + fun `identity and screen models redact entered endpoint tokens`() { + val rawToken = "connect-secret-token" + val identity = EndpointIdentity( + endpoint = "friends", + token = rawToken, + endpointSource = CredentialSource.IMPORTED, + tokenSource = CredentialSource.IMPORTED, + ) + val screen = ShareUiState( + worldAvailable = true, + shareState = ShareState.Idle, + options = ShareOptions( + gameMode = ShareGameMode.SURVIVAL, + allowCheats = false, + ), + pendingAdmissions = emptyList(), + importDraft = IdentityImportDraft( + endpoint = "friends", + token = rawToken, + ), + ) + + listOf(identity.toString(), screen.toString()).forEach { rendered -> + assertContains(rendered, "") + assertFalse(rendered.contains(rawToken)) + } + } +} diff --git a/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/ShareJoinDiagnosticsTest.kt b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/ShareJoinDiagnosticsTest.kt new file mode 100644 index 000000000..dad04346a --- /dev/null +++ b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/ShareJoinDiagnosticsTest.kt @@ -0,0 +1,33 @@ +package com.minekube.connect.share.fabric + +import java.time.Instant +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertEquals +import kotlin.test.assertFalse + +class ShareJoinDiagnosticsTest { + @Test + fun `bundle is bounded stage-only and contains no connection secrets`() { + val diagnostics = ShareJoinDiagnostics( + now = { Instant.parse("2026-08-01T10:00:00Z") }, + ) + repeat(80) { + diagnostics.record( + JoinStage.DIRECT, + if (it == 79) JoinOutcome.FAILED else JoinOutcome.STARTED, + ) + } + + val bundle = diagnostics.bundle( + minecraftVersion = "1.21.1", + modVersion = "0.1.0", + ) + + assertContains(bundle, "Minecraft: 1.21.1") + assertContains(bundle, "DIRECT: FAILED") + assertFalse("/ip4/" in bundle) + assertFalse("play.minekube.net" in bundle) + assertEquals(50, bundle.lineSequence().count { ": DIRECT: " in it }) + } +} diff --git a/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/SocialEventTrackerTest.kt b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/SocialEventTrackerTest.kt new file mode 100644 index 000000000..86dcbc7c7 --- /dev/null +++ b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/SocialEventTrackerTest.kt @@ -0,0 +1,78 @@ +package com.minekube.connect.share.fabric + +import com.minekube.connect.share.fabric.ui.FriendSummary +import com.minekube.connect.share.fabric.ui.FriendsUiState +import com.minekube.connect.share.fabric.ui.OutgoingFriendRequestSummary +import com.minekube.connect.share.friend.FriendActivityKind +import com.minekube.connect.share.friend.FriendPermissions +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class SocialEventTrackerTest { + @Test + fun `accepted removed and server activity transitions each emit once`() { + val tracker = SocialEventTracker() + val outgoing = FriendsUiState( + outgoingRequests = listOf( + OutgoingFriendRequestSummary("peer", "Robin"), + ), + ) + assertTrue(tracker.update(outgoing).isEmpty()) + + val confirmed = FriendsUiState(friends = listOf(friend())) + assertEquals( + listOf(SocialEvent.FriendAccepted("Robin")), + tracker.update(confirmed), + ) + assertTrue(tracker.update(confirmed).isEmpty()) + + val playing = FriendsUiState( + friends = listOf( + friend().copy( + activityKind = FriendActivityKind.PLAYING_SERVER, + activityDescription = "Hypixel", + canRequestJoin = true, + ), + ), + ) + assertEquals( + listOf(SocialEvent.PlayingServer("Robin", "Hypixel")), + tracker.update(playing), + ) + assertEquals( + listOf(SocialEvent.FriendRemoved("Robin")), + tracker.update(FriendsUiState()), + ) + } + + @Test + fun `shared world becoming reachable emits one ready notification`() { + val tracker = SocialEventTracker() + val online = FriendsUiState(friends = listOf(friend())) + tracker.update(online) + + val hosting = FriendsUiState( + friends = listOf( + friend().copy( + activityKind = FriendActivityKind.HOSTING_WORLD, + activityDescription = "Survival", + canRequestJoin = true, + ), + ), + ) + + assertEquals( + listOf(SocialEvent.WorldReady("Robin", "Survival")), + tracker.update(hosting), + ) + assertTrue(tracker.update(hosting).isEmpty()) + } + + private fun friend() = FriendSummary( + peerId = "peer", + displayName = "Robin", + connectAvailable = true, + permissions = FriendPermissions(), + ) +} diff --git a/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/WatchEndpointCredentialValidatorTest.kt b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/WatchEndpointCredentialValidatorTest.kt new file mode 100644 index 000000000..b3c3d7f7c --- /dev/null +++ b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/WatchEndpointCredentialValidatorTest.kt @@ -0,0 +1,186 @@ +package com.minekube.connect.share.fabric + +import arrow.core.Either +import com.minekube.connect.share.identity.CredentialSource +import com.minekube.connect.share.identity.CredentialValidationError +import com.minekube.connect.share.identity.EndpointIdentity +import java.util.concurrent.CancellationException +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.async +import kotlinx.coroutines.runBlocking +import minekube.connect.v1alpha1.WatchServiceOuterClass.Session +import minekube.connect.v1alpha1.WatchServiceOuterClass.WatchRequest +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.SocketPolicy +import okio.ByteString + +class WatchEndpointCredentialValidatorTest { + @Test + fun `successful validation sends credential headers and closes immediately`() = runBlocking { + MockWebServer().use { server -> + val closed = CountDownLatch(1) + server.enqueue( + MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onClosing(webSocket: WebSocket, code: Int, reason: String) { + closed.countDown() + webSocket.close(code, reason) + } + }, + ), + ) + server.start() + + val result = validator(server).validate(identity) + val request = assertNotNull(server.takeRequest(2, TimeUnit.SECONDS)) + + assertIs>(result) + assertEquals("Bearer ${identity.token}", request.getHeader("Authorization")) + assertEquals(identity.endpoint, request.getHeader("Connect-Endpoint")) + assertEquals("Fabric", request.getHeader("Connect-Platform")) + assertEquals(true, closed.await(2, TimeUnit.SECONDS)) + } + } + + @Test + fun `unexpected proposal is rejected without opening a local tunnel`() = runBlocking { + MockWebServer().use { server -> + server.start() + val socket = RecordingWebSocket() + val proposal = minekube.connect.v1alpha1.WatchServiceOuterClass.WatchResponse + .newBuilder() + .setSession(Session.newBuilder().setId("proposal-1")) + .build() + + validator(server).rejectProposal( + socket, + ByteString.of(*proposal.toByteArray()), + ) + + val rejection = WatchRequest.parseFrom(assertNotNull(socket.binary).toByteArray()) + assertEquals("proposal-1", rejection.sessionRejection.id) + assertEquals(1000, socket.closeCode) + } + } + + @Test + fun `unauthorized response is sanitized`() = runBlocking { + MockWebServer().use { server -> + server.enqueue(MockResponse().setResponseCode(401).setBody(identity.token)) + server.start() + + val result = validator(server).validate(identity) + val error = assertIs>(result).value + + assertIs(error) + assertFalse(error.safeMessage.contains(identity.token)) + assertFalse(error.toString().contains(identity.token)) + } + } + + @Test + fun `transport failure returns a safe network error`() = runBlocking { + MockWebServer().use { server -> + server.enqueue(MockResponse().setSocketPolicy(SocketPolicy.DISCONNECT_AT_START)) + server.start() + + val result = validator(server).validate(identity) + val error = assertIs>(result).value + + assertIs(error) + assertFalse(error.safeMessage.contains(identity.token)) + assertFalse(error.toString().contains(identity.token)) + } + } + + @Test + fun `validation timeout returns a safe network error`() = runBlocking { + MockWebServer().use { server -> + server.enqueue(MockResponse().setSocketPolicy(SocketPolicy.NO_RESPONSE)) + server.start() + + val result = validator(server, 100.milliseconds).validate(identity) + val error = assertIs>(result).value + + assertIs(error) + assertFalse(error.safeMessage.contains(identity.token)) + } + } + + @Test + fun `caller cancellation remains cancellation`() = runBlocking { + MockWebServer().use { server -> + server.enqueue(MockResponse().setSocketPolicy(SocketPolicy.NO_RESPONSE)) + server.start() + val validation = async { + validator(server, 30.seconds).validate(identity) + } + assertNotNull(server.takeRequest(2, TimeUnit.SECONDS)) + + validation.cancel(CancellationException("screen closed")) + + assertFailsWith { + validation.await() + } + } + } + + private fun validator( + server: MockWebServer, + timeout: Duration = 2.seconds, + ) = WatchEndpointCredentialValidator( + client = OkHttpClient(), + watchUrl = server.url("/watch"), + timeout = timeout, + ) + + private class RecordingWebSocket : WebSocket { + var binary: ByteString? = null + var closeCode: Int? = null + + override fun request(): Request = Request.Builder() + .url("http://localhost/") + .build() + + override fun queueSize(): Long = 0 + + override fun send(text: String): Boolean = false + + override fun send(bytes: ByteString): Boolean { + binary = bytes + return true + } + + override fun close(code: Int, reason: String?): Boolean { + closeCode = code + return true + } + + override fun cancel() = Unit + } + + private companion object { + val identity = EndpointIdentity( + endpoint = "amber-fox", + token = "T-AAAAAAAAAAAAAAAAAAAA", + endpointSource = CredentialSource.IMPORTED, + tokenSource = CredentialSource.IMPORTED, + ) + } +} diff --git a/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/recovery/RecoveryViewModelTest.kt b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/recovery/RecoveryViewModelTest.kt new file mode 100644 index 000000000..00af33836 --- /dev/null +++ b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/recovery/RecoveryViewModelTest.kt @@ -0,0 +1,215 @@ +package com.minekube.connect.share.fabric.recovery + +import arrow.core.Either +import arrow.core.left +import arrow.core.right +import com.minekube.connect.share.recovery.RecoveryArchiveError +import com.minekube.connect.share.recovery.RecoveryStoreError +import com.minekube.connect.share.recovery.RecoverySummary +import java.nio.file.Path +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest + +@OptIn(ExperimentalCoroutinesApi::class) +class RecoveryViewModelTest { + @Test + fun `export mismatch clears both secrets without starting work`() = runTest { + val actions = FakeRecoveryActions() + val viewModel = viewModel(actions) + val passphrase = PASSPHRASE.copyOf() + val confirmation = "different recovery secret".toCharArray() + + viewModel.export(BACKUP, passphrase, confirmation) + runCurrent() + + assertTrue(passphrase.all { it == '\u0000' }) + assertTrue(confirmation.all { it == '\u0000' }) + assertEquals(0, actions.exports) + assertEquals( + "connect_share.recovery.error.secret_mismatch", + viewModel.state.value.safeMessage?.translationKey, + ) + } + + @Test + fun `file work is nonblocking and reports a safe export summary`() = runTest { + val gate = CompletableDeferred() + val actions = FakeRecoveryActions(exportGate = gate) + val viewModel = viewModel(actions) + + viewModel.export(BACKUP, PASSPHRASE.copyOf(), PASSPHRASE.copyOf()) + runCurrent() + + assertTrue(viewModel.state.value.operationInProgress) + assertEquals(RecoveryPhase.IDLE, viewModel.state.value.phase) + + gate.complete(Unit) + advanceUntilIdle() + + assertFalse(viewModel.state.value.operationInProgress) + assertEquals(RecoveryPhase.EXPORTED, viewModel.state.value.phase) + assertEquals(SUMMARY, viewModel.state.value.summary) + assertEquals( + "connect_share.recovery.exported", + viewModel.state.value.safeMessage?.translationKey, + ) + assertTrue(actions.lastExportSecret!!.all { it == '\u0000' }) + } + + @Test + fun `authenticated preview requires confirmation then clears retained secret`() = runTest { + val actions = FakeRecoveryActions() + val viewModel = viewModel(actions) + val passphrase = PASSPHRASE.copyOf() + + viewModel.previewImport(BACKUP, passphrase) + advanceUntilIdle() + + assertTrue(passphrase.all { it == '\u0000' }) + assertEquals(RecoveryPhase.IMPORT_PREVIEW, viewModel.state.value.phase) + assertEquals(SUMMARY, viewModel.state.value.summary) + assertTrue(viewModel.state.value.importConfirmationRequired) + val retained = actions.lastPreviewSecret!! + assertFalse(retained.all { it == '\u0000' }) + + viewModel.confirmImport() + advanceUntilIdle() + + assertEquals(1, actions.imports) + assertTrue(retained.all { it == '\u0000' }) + assertEquals(RecoveryPhase.RESTORED, viewModel.state.value.phase) + assertFalse(viewModel.state.value.importConfirmationRequired) + assertEquals( + "connect_share.recovery.restored_restart", + viewModel.state.value.safeMessage?.translationKey, + ) + } + + @Test + fun `cancelled preview and closed screen clear the retained secret`() = runTest { + val actions = FakeRecoveryActions() + val viewModel = viewModel(actions) + + viewModel.previewImport(BACKUP, PASSPHRASE.copyOf()) + advanceUntilIdle() + val cancelled = actions.lastPreviewSecret!! + viewModel.cancelImport() + + assertTrue(cancelled.all { it == '\u0000' }) + assertEquals(RecoveryPhase.IDLE, viewModel.state.value.phase) + + viewModel.previewImport(BACKUP, PASSPHRASE.copyOf()) + advanceUntilIdle() + val closed = actions.lastPreviewSecret!! + viewModel.close() + + assertTrue(closed.all { it == '\u0000' }) + assertEquals(RecoveryPhase.IDLE, viewModel.state.value.phase) + } + + @Test + fun `wrong secret uses recovery wording distinct from dashboard import`() = runTest { + val actions = FakeRecoveryActions( + previewResult = RecoveryStoreError.ArchiveFailure( + RecoveryArchiveError.AuthenticationFailed, + ).left(), + ) + val viewModel = viewModel(actions) + + viewModel.previewImport(BACKUP, PASSPHRASE.copyOf()) + advanceUntilIdle() + + val key = viewModel.state.value.safeMessage?.translationKey.orEmpty() + assertEquals("connect_share.recovery.error.authentication", key) + assertFalse(key.contains("identity")) + assertTrue(actions.lastPreviewSecret!!.all { it == '\u0000' }) + } + + @Test + fun `active sharing refuses restore and clears the retained secret`() = runTest { + val actions = FakeRecoveryActions() + val viewModel = RecoveryViewModel( + scope = this, + actions = actions, + operationDispatcher = StandardTestDispatcher(testScheduler), + restoreAllowed = { false }, + ) + + viewModel.previewImport(BACKUP, PASSPHRASE.copyOf()) + advanceUntilIdle() + val retained = actions.lastPreviewSecret!! + viewModel.confirmImport() + advanceUntilIdle() + + assertEquals(0, actions.imports) + assertTrue(retained.all { it == '\u0000' }) + assertEquals( + "connect_share.recovery.error.stop_sharing", + viewModel.state.value.safeMessage?.translationKey, + ) + } + + private fun kotlinx.coroutines.test.TestScope.viewModel( + actions: RecoveryUiActions, + ) = RecoveryViewModel( + scope = this, + actions = actions, + operationDispatcher = StandardTestDispatcher(testScheduler), + ) + + private class FakeRecoveryActions( + private val exportGate: CompletableDeferred? = null, + private val previewResult: + Either = SUMMARY.right(), + ) : RecoveryUiActions { + var exports = 0 + var imports = 0 + var lastExportSecret: CharArray? = null + var lastPreviewSecret: CharArray? = null + + override suspend fun export( + target: Path, + passphrase: CharArray, + ): Either { + exports++ + lastExportSecret = passphrase + exportGate?.await() + return SUMMARY.right() + } + + override suspend fun preview( + source: Path, + passphrase: CharArray, + ): Either { + lastPreviewSecret = passphrase + return previewResult + } + + override suspend fun import( + source: Path, + passphrase: CharArray, + ): Either { + imports++ + assertEquals(lastPreviewSecret, passphrase) + return SUMMARY.right() + } + } + + private companion object { + val BACKUP: Path = Path.of("friends.connect-share-backup") + val PASSPHRASE = "correct horse battery staple".toCharArray() + val SUMMARY = RecoverySummary( + entryCount = 7, + includesPreferences = true, + includesEndpointIdentity = true, + ) + } +} diff --git a/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/ui/AdaptiveShareLayoutTest.kt b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/ui/AdaptiveShareLayoutTest.kt new file mode 100644 index 000000000..a2fdfbd22 --- /dev/null +++ b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/ui/AdaptiveShareLayoutTest.kt @@ -0,0 +1,65 @@ +package com.minekube.connect.share.fabric.ui + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class AdaptiveShareLayoutTest { + @Test + fun `compact screens keep content and footer separated`() { + val layout = AdaptiveShareLayout.friends( + screenWidth = 320, + screenHeight = 240, + ) + + assertEquals(296, layout.contentWidth) + assertEquals(12, layout.contentX) + assertTrue(layout.visibleRows >= 3) + assertTrue(layout.rowsBottom <= layout.messageY) + assertTrue(layout.messageY < layout.footerTop) + assertTrue(layout.footerBottom <= 240 - AdaptiveShareLayout.EDGE_MARGIN) + } + + @Test + fun `wide screens cap line length and show at most six relationships`() { + val layout = AdaptiveShareLayout.friends( + screenWidth = 1_920, + screenHeight = 1_080, + ) + + assertEquals(360, layout.contentWidth) + assertEquals(6, layout.visibleRows) + assertEquals(780, layout.contentX) + } + + @Test + fun `form layout remains usable at the minimum supported height`() { + val layout = AdaptiveShareLayout.form( + screenWidth = 320, + screenHeight = 240, + fieldCount = 4, + ) + + assertEquals(296, layout.contentWidth) + assertTrue(layout.bodyTop < layout.footerTop) + assertTrue(layout.availableBodyHeight >= 112) + assertTrue(layout.footerBottom <= 228) + } + + @Test + fun `manage friend form keeps its persistent name label and controls separated`() { + val layout = AdaptiveShareLayout.form( + screenWidth = 640, + screenHeight = 240, + fieldCount = 5, + ) + val form = AdaptiveShareLayout.manageFriendForm(layout.bodyTop) + + assertTrue(form.nameLabelY + 11 <= form.nameInputY) + assertTrue(form.nameInputY + 20 <= form.notifyY) + assertTrue(form.notifyY + 20 <= form.shareWorldsY) + assertTrue(form.shareWorldsY + 20 <= form.accessPolicyY) + assertTrue(form.accessPolicyY + 20 <= form.internetDirectY) + assertTrue(form.internetDirectY + 20 <= layout.footerTop) + } +} diff --git a/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/ui/FriendFormDraftTest.kt b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/ui/FriendFormDraftTest.kt new file mode 100644 index 000000000..62b8ebf8d --- /dev/null +++ b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/ui/FriendFormDraftTest.kt @@ -0,0 +1,30 @@ +package com.minekube.connect.share.fabric.ui + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class FriendFormDraftTest { + @Test + fun `a new friend request never inherits another relationship`() { + val previous = FriendFormDraft.forManage("Existing friend") + .copy( + invitation = "old invitation", + offlineMode = true, + internetDirect = true, + ) + + assertEquals(FriendFormDraft(), previous.newRequest()) + } + + @Test + fun `managing a friend carries only that confirmed display name`() { + val draft = FriendFormDraft.forManage("Robin") + + assertEquals("Robin", draft.displayName) + assertTrue(draft.invitation.isEmpty()) + assertFalse(draft.offlineMode) + assertFalse(draft.internetDirect) + } +} diff --git a/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/ui/FriendsViewModelTest.kt b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/ui/FriendsViewModelTest.kt new file mode 100644 index 000000000..357f8a7eb --- /dev/null +++ b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/ui/FriendsViewModelTest.kt @@ -0,0 +1,687 @@ +package com.minekube.connect.share.fabric.ui + +import com.minekube.connect.share.direct.ShareInviteCodec +import com.minekube.connect.share.direct.ShareInvitePayload +import com.minekube.connect.share.direct.SignedShareInvite +import com.minekube.connect.share.admission.AdmissionIdentity +import com.minekube.connect.share.admission.AdmissionPurpose +import com.minekube.connect.share.admission.Ingress +import com.minekube.connect.share.admission.PendingAdmission +import com.minekube.connect.share.fabric.DiscoveredLanShare +import com.minekube.connect.share.fabric.FabricGuestDirectNode +import com.minekube.connect.share.fabric.FabricShareBrowser +import com.minekube.connect.share.fabric.GuestJoinFailure +import com.minekube.connect.share.fabric.GuestJoinTarget +import com.minekube.connect.share.fabric.RemoteFriendPresence +import com.minekube.connect.share.direct.ShareRoute +import com.minekube.connect.share.friend.FriendPermissions +import com.minekube.connect.share.friend.FriendStore +import com.minekube.connect.share.friend.FriendActivity +import com.minekube.connect.share.friend.FriendActivityKind +import com.minekube.connect.tunnel.p2p.DirectP2pAuthMode +import com.minekube.connect.tunnel.p2p.DirectP2pDiscoveredShare +import com.minekube.connect.tunnel.p2p.DirectP2pDiscoveryListener +import com.minekube.connect.tunnel.p2p.DirectP2pProxy +import java.net.InetAddress +import java.net.InetSocketAddress +import java.nio.file.Path +import java.security.KeyPairGenerator +import java.security.Signature +import java.time.Instant +import java.util.UUID +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertTrue +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.io.TempDir + +class FriendsViewModelTest { + @TempDir + lateinit var tempDir: Path + + @Test + fun `sending one link exposes only an outgoing request`() { + val viewModel = FriendsViewModel(FriendStore(tempDir)) + + assertEquals( + PEER_ID, + viewModel.sendRequest(signedLink(), "Robin", NOW), + ) + + val request = viewModel.state.value.outgoingRequests.single() + assertEquals(PEER_ID, request.peerId) + assertEquals("Robin", request.displayName) + assertTrue(viewModel.state.value.friends.isEmpty()) + assertFalse(viewModel.state.value.toString().contains(CAPABILITY)) + assertEquals(null, viewModel.state.value.safeMessage) + } + + @Test + fun `sending a request forwards explicit internet consent`() { + val store = FriendStore(tempDir) + val viewModel = FriendsViewModel(store) + + viewModel.sendRequest( + signedLink(), + "Robin", + NOW, + internetDirectGuestOptIn = true, + ) + + assertTrue( + store.outgoingRequests().single().internetDirectGuestOptIn, + ) + } + + @Test + fun `signed friend link suggests its sender username`() { + val viewModel = FriendsViewModel(FriendStore(tempDir)) + + val suggested = viewModel.suggestedDisplayName( + signedLink(displayName = "RoboFlax2"), + NOW, + ) + + assertEquals("RoboFlax2", suggested.getOrNull()) + } + + @Test + fun `outgoing request never exposes presence as a friend`() { + val viewModel = FriendsViewModel(FriendStore(tempDir)) + viewModel.sendRequest(signedLink(), "Robin", NOW) + + viewModel.updateRemotePresence( + mapOf( + PEER_ID to RemoteFriendPresence( + peerId = PEER_ID, + displayName = "Robin", + online = true, + description = "Robin's World", + notifyWhenOnline = true, + ), + ), + ) + assertTrue(viewModel.state.value.friends.isEmpty()) + assertEquals( + PEER_ID, + viewModel.state.value.outgoingRequests.single().peerId, + ) + } + + @Test + fun `friends state exposes both friend and join approvals`() { + val viewModel = FriendsViewModel(FriendStore(tempDir)) + val friendRequestId = UUID.randomUUID() + val joinRequestId = UUID.randomUUID() + + viewModel.updateIncoming( + listOf( + PendingAdmission( + requestId = friendRequestId, + identity = AdmissionIdentity.UnverifiedOffline( + name = "bob", + uuid = UUID.randomUUID(), + connectionId = "friend:bob", + ingress = Ingress.CONNECT, + ), + purpose = AdmissionPurpose.FRIEND, + ), + PendingAdmission( + requestId = joinRequestId, + identity = AdmissionIdentity.UnverifiedOffline( + name = "visitor", + uuid = UUID.randomUUID(), + connectionId = "join:visitor", + ingress = Ingress.DIRECT_LAN, + ), + purpose = AdmissionPurpose.JOIN, + ), + ), + ) + + val incoming = viewModel.state.value.incomingRequests + assertEquals(2, incoming.size) + assertEquals(friendRequestId, incoming[0].requestId) + assertEquals(AdmissionPurpose.FRIEND, incoming[0].purpose) + assertEquals("bob", incoming[0].displayName) + assertEquals(Ingress.CONNECT, incoming[0].ingress) + assertEquals(joinRequestId, incoming[1].requestId) + assertEquals(AdmissionPurpose.JOIN, incoming[1].purpose) + assertTrue(viewModel.state.value.friends.isEmpty()) + assertTrue(viewModel.state.value.outgoingRequests.isEmpty()) + } + + @Test + fun `unchanged incoming tick observes an accepted relationship`() { + val store = FriendStore(tempDir) + val viewModel = FriendsViewModel(store) + viewModel.updateIncoming(emptyList()) + + store.accept(signedLink(), "Robin", NOW) + viewModel.updateIncoming(emptyList()) + + assertEquals("Robin", viewModel.state.value.friends.single().displayName) + assertTrue(viewModel.state.value.incomingRequests.isEmpty()) + } + + @Test + fun `invalid friend link stays on the add flow with a useful message`() { + val viewModel = FriendsViewModel(FriendStore(tempDir)) + + val accepted = viewModel.sendRequest( + "minekube://share/not-a-valid-link", + "Robin", + NOW, + ) + + assertEquals(null, accepted) + assertTrue(viewModel.state.value.friends.isEmpty()) + assertTrue( + viewModel.state.value.safeMessage?.translationKey?.isNotBlank() == + true, + ) + } + + @Test + fun `unchanged presence ticks do not erase an operation error`() { + val viewModel = FriendsViewModel(FriendStore(tempDir)) + viewModel.sendRequest( + "minekube://share/not-a-valid-link", + "Robin", + NOW, + ) + val message = viewModel.state.value.safeMessage + + repeat(20) { + viewModel.updatePresence(emptyList()) + viewModel.updateRemotePresence(emptyMap()) + } + + assertEquals(message, viewModel.state.value.safeMessage) + } + + @Test + fun `saved friend can be renamed configured and removed`() { + val store = FriendStore(tempDir) + store.accept(signedLink(), "Robin", NOW) + val viewModel = FriendsViewModel(store) + + viewModel.rename(PEER_ID, "Robin from Discord") + viewModel.updatePermissions( + PEER_ID, + FriendPermissions( + notifyWhenOnline = false, + canSeeMyWorlds = true, + canJoinAutomatically = true, + ), + ) + + val managed = viewModel.state.value.friends.single() + assertEquals("Robin from Discord", managed.displayName) + assertFalse(managed.permissions.notifyWhenOnline) + assertTrue(managed.permissions.canJoinAutomatically) + + assertTrue(viewModel.remove(PEER_ID)) + + assertTrue(viewModel.state.value.friends.isEmpty()) + assertTrue(FriendStore(tempDir).all().isEmpty()) + assertFalse(viewModel.remove(PEER_ID)) + } + + @Test + fun `blocked identity is manageable without restoring friendship`() { + val store = FriendStore(tempDir) + store.accept(signedLink(), "Robin", NOW) + var removalsQueued = 0 + val viewModel = FriendsViewModel(store) { removalsQueued++ } + + assertTrue(viewModel.block(PEER_ID)) + + assertTrue(viewModel.state.value.friends.isEmpty()) + assertEquals("Robin", viewModel.state.value.blocked.single().displayName) + assertEquals(1, removalsQueued) + + assertTrue(viewModel.unblock(PEER_ID)) + assertTrue(viewModel.state.value.blocked.isEmpty()) + assertTrue(viewModel.state.value.friends.isEmpty()) + } + + @Test + fun `matching discovery marks a saved friend world ready to join`() { + val link = signedLink() + val invitation = ShareInviteCodec.decode(link, NOW).getOrNull()!! + val store = FriendStore(tempDir) + store.accept(link, "Robin", NOW) + val viewModel = FriendsViewModel(store) + + viewModel.updatePresence( + listOf( + DiscoveredLanShare( + displayName = "Robin's New World", + invitationUri = link, + invitation = invitation, + lanAddress = + "/ip4/192.168.1.25/tcp/4001/p2p/$PEER_ID", + ), + ), + ) + viewModel.updateRemotePresence( + mapOf( + PEER_ID to RemoteFriendPresence( + peerId = PEER_ID, + displayName = "Robin", + online = true, + description = "Robin's New World", + notifyWhenOnline = true, + route = ShareRoute.DIRECT_LAN, + ), + ), + ) + viewModel.updateActivities( + mapOf( + PEER_ID to FriendActivity( + FriendActivityKind.HOSTING_WORLD, + "Robin's New World", + ), + ), + ) + + val online = viewModel.state.value.friends.single() + assertTrue(online.onlineViaLan) + assertEquals("Robin's New World", online.worldName) + + viewModel.updatePresence(emptyList()) + viewModel.updateRemotePresence(emptyMap()) + + assertFalse(viewModel.state.value.friends.single().onlineViaLan) + } + + @Test + fun `cancelling an outgoing request removes only pending state`() { + val store = FriendStore(tempDir) + val viewModel = FriendsViewModel(store) + viewModel.sendRequest(signedLink(), "Robin", NOW) + + assertTrue(viewModel.remove(PEER_ID)) + + assertTrue(viewModel.state.value.outgoingRequests.isEmpty()) + assertTrue(viewModel.state.value.friends.isEmpty()) + assertTrue(FriendStore(tempDir).outgoingRequests().isEmpty()) + } + + @Test + fun `Connect presence marks a saved friend online across networks`() { + val store = FriendStore(tempDir) + store.accept(signedLink(), "Robin", NOW) + val viewModel = FriendsViewModel(store) + + viewModel.updateRemotePresence( + mapOf( + PEER_ID to RemoteFriendPresence( + peerId = PEER_ID, + displayName = "Robin", + online = true, + description = "Robin's Remote World", + notifyWhenOnline = true, + route = ShareRoute.CONNECT, + ), + ), + ) + viewModel.updateActivities( + mapOf( + PEER_ID to FriendActivity( + FriendActivityKind.HOSTING_WORLD, + "Robin's Remote World", + ), + ), + ) + + val online = viewModel.state.value.friends.single() + assertTrue(online.onlineViaConnect) + assertEquals("Robin's Remote World", online.worldName) + } + + @Test + fun `direct status presence cannot make a privacy-hidden world joinable`() { + val store = FriendStore(tempDir) + store.accept(signedLink(), "Robin", NOW) + val viewModel = FriendsViewModel(store) + + viewModel.updateRemotePresence( + mapOf( + PEER_ID to RemoteFriendPresence( + peerId = PEER_ID, + displayName = "Robin", + online = true, + description = "Private World", + notifyWhenOnline = true, + route = ShareRoute.DIRECT_LAN, + ), + ), + ) + viewModel.updateActivities( + mapOf(PEER_ID to FriendActivity(FriendActivityKind.ONLINE)), + ) + + val friend = viewModel.state.value.friends.single() + assertFalse(friend.canJoinNow) + assertEquals( + "connect_share.friends.status.online", + friend.presentation().statusKey, + ) + } + + @Test + fun `raw status cannot reveal presence without privacy-safe activity`() { + val store = FriendStore(tempDir) + store.accept(signedLink(), "Robin", NOW) + val viewModel = FriendsViewModel(store) + viewModel.updateRemotePresence( + mapOf( + PEER_ID to RemoteFriendPresence( + peerId = PEER_ID, + displayName = "Robin", + online = true, + description = "Secret World", + notifyWhenOnline = true, + route = ShareRoute.DIRECT_LAN, + ), + ), + ) + + val friend = viewModel.state.value.friends.single() + assertFalse(friend.onlineViaLan) + assertEquals(null, friend.worldName) + assertFalse(friend.canRequestJoin) + assertFalse(friend.canJoinNow) + } + + @Test + fun `playing on a server exposes request to join instead of direct join`() { + val store = FriendStore(tempDir) + store.accept(signedLink(), "Robin", NOW) + val viewModel = FriendsViewModel(store) + + viewModel.updateActivities( + mapOf( + PEER_ID to FriendActivity( + FriendActivityKind.PLAYING_SERVER, + "Hypixel", + ), + ), + ) + + val friend = viewModel.state.value.friends.single() + assertEquals(FriendActivityKind.PLAYING_SERVER, friend.activityKind) + assertEquals("Hypixel", friend.activityDescription) + assertTrue(friend.canRequestJoin) + assertFalse(friend.canJoinNow) + } + + @Test + fun `visible playing activity does not offer join when host hid joinability`() { + val store = FriendStore(tempDir) + store.accept(signedLink(), "Robin", NOW) + val viewModel = FriendsViewModel(store) + + viewModel.updateActivities( + mapOf( + PEER_ID to FriendActivity( + kind = FriendActivityKind.PLAYING_SERVER, + description = "Private server", + joinable = false, + ), + ), + ) + + val friend = viewModel.state.value.friends.single() + assertFalse(friend.canRequestJoin) + assertFalse(friend.canJoinNow) + } + + @Test + fun `follow next session is visible cancelable and emits once per epoch`() { + val store = FriendStore(tempDir) + store.accept(signedLink(), "Robin", NOW) + val viewModel = FriendsViewModel(store) + + assertTrue(viewModel.follow(PEER_ID)) + assertTrue(viewModel.state.value.friends.single().following) + viewModel.updateActivities( + mapOf( + PEER_ID to FriendActivity( + FriendActivityKind.HOSTING_WORLD, + "Survival", + sessionEpoch = "world-1", + ), + ), + ) + + assertEquals(1, viewModel.followActions(activeGameplay = false).size) + assertTrue(viewModel.followActions(activeGameplay = false).isEmpty()) + assertTrue(viewModel.cancelFollow(PEER_ID)) + assertFalse(viewModel.state.value.friends.single().following) + } + + @Test + fun `shared singleplayer world exposes request to join when ready`() { + val store = FriendStore(tempDir) + store.accept(signedLink(), "Robin", NOW) + val viewModel = FriendsViewModel(store) + viewModel.updateRemotePresence( + mapOf( + PEER_ID to RemoteFriendPresence( + peerId = PEER_ID, + displayName = "Robin", + online = true, + description = "Survival", + notifyWhenOnline = true, + route = ShareRoute.DIRECT_LAN, + ), + ), + ) + viewModel.updateActivities( + mapOf( + PEER_ID to FriendActivity( + FriendActivityKind.HOSTING_WORLD, + "Survival", + ), + ), + ) + + val friend = viewModel.state.value.friends.single() + assertEquals(FriendActivityKind.HOSTING_WORLD, friend.activityKind) + assertEquals("Survival", friend.activityDescription) + assertTrue(friend.canRequestJoin) + assertFalse(friend.canJoinNow) + } + + @Test + fun `privacy-safe activity is enough to request a singleplayer join`() { + val store = FriendStore(tempDir) + store.accept(signedLink(), "Robin", NOW) + val viewModel = FriendsViewModel(store) + viewModel.updateActivities( + mapOf( + PEER_ID to FriendActivity( + FriendActivityKind.HOSTING_WORLD, + joinable = true, + ), + ), + ) + + val friend = viewModel.state.value.friends.single() + assertTrue(friend.canRequestJoin) + assertFalse(friend.canJoinNow) + } + + @Test + fun `joining a saved friend does not expose its stored capability`() = runTest { + val link = signedLink() + val invitation = ShareInviteCodec.decode(link, NOW).getOrNull()!! + val node = FakeGuestNode() + val browser = FabricShareBrowser.testing( + node = node, + now = { NOW }, + ioDispatcher = StandardTestDispatcher(testScheduler), + ) + browser.start() + node.discover( + DirectP2pDiscoveredShare( + "Robin's World", + PEER_ID, + LAN_ADDRESS, + link, + ), + ) + val store = FriendStore(tempDir) + store.accept(link, "Robin", NOW) + val viewModel = FriendsViewModel(store) + viewModel.updatePresence( + listOf( + DiscoveredLanShare( + "Robin's World", + link, + invitation, + LAN_ADDRESS, + ), + ), + ) + + val result = viewModel.join( + peerId = PEER_ID, + browser = browser, + authMode = DirectP2pAuthMode.OFFLINE, + ) + + assertIs>(result) + assertEquals(listOf(LAN_ADDRESS), node.openedAddresses) + browser.close() + } + + @Test + fun `retrying an outgoing request can join its signed route`() = runTest { + val link = signedLink() + val node = FakeGuestNode() + val browser = FabricShareBrowser.testing( + node = node, + now = { NOW }, + ioDispatcher = StandardTestDispatcher(testScheduler), + ) + browser.start() + node.discover( + DirectP2pDiscoveredShare( + "Robin's World", + PEER_ID, + LAN_ADDRESS, + link, + ), + ) + val viewModel = FriendsViewModel(FriendStore(tempDir)) + viewModel.sendRequest(link, "Robin", NOW) + + val result = viewModel.routeOutgoing( + peerId = PEER_ID, + browser = browser, + authMode = DirectP2pAuthMode.OFFLINE, + ) + + assertIs>(result) + assertEquals(listOf(LAN_ADDRESS), node.openedAddresses) + assertTrue(viewModel.state.value.friends.isEmpty()) + browser.close() + } + + @Test + fun `outgoing friend requests never use a Connect Minecraft endpoint`() = runTest { + val link = signedLink() + val browser = FabricShareBrowser.testing( + node = FakeGuestNode(), + now = { NOW }, + ioDispatcher = StandardTestDispatcher(testScheduler), + ) + val viewModel = FriendsViewModel(FriendStore(tempDir)) + viewModel.sendRequest(link, "Robin", NOW) + + val result = viewModel.routeOutgoing( + peerId = PEER_ID, + browser = browser, + authMode = DirectP2pAuthMode.OFFLINE, + ) + + assertIs>(result) + browser.close() + } + + private fun signedLink( + displayName: String? = null, + ): String { + val pair = KeyPairGenerator.getInstance("Ed25519").generateKeyPair() + val payload = ShareInvitePayload( + wireVersion = ShareInviteCodec.WIRE_VERSION, + shareId = UUID.fromString( + "9e511188-31a9-43ac-9107-29d94410d554", + ), + expiresAtEpochMillis = NOW.plusSeconds(3_600).toEpochMilli(), + connectAddress = "purple-del.play.minekube.net", + peerId = PEER_ID, + internetDirectEnabled = false, + directCandidates = emptyList(), + capability = CAPABILITY, + displayName = displayName, + ) + val unsigned = ShareInviteCodec.unsignedBytes( + payload, + pair.public.encoded, + ) + val signature = Signature.getInstance("Ed25519").run { + initSign(pair.private) + update(unsigned) + sign() + } + return ShareInviteCodec.encode( + SignedShareInvite(payload, pair.public.encoded, signature), + ) + } + + private class FakeGuestNode : FabricGuestDirectNode { + private var listener: DirectP2pDiscoveryListener? = null + val openedAddresses = mutableListOf() + + override fun peerId(): String = "12D3KooWGuest" + + override fun startDiscovery(listener: DirectP2pDiscoveryListener) { + this.listener = listener + } + + fun discover(share: DirectP2pDiscoveredShare) { + listener?.onDiscovered(share) + } + + override fun openProxy( + address: String, + shareId: String, + capability: String, + authMode: DirectP2pAuthMode, + timeout: java.time.Duration, + ): DirectP2pProxy { + openedAddresses += address + return DirectP2pProxy( + InetSocketAddress(InetAddress.getLoopbackAddress(), 41_234), + ) {} + } + + override fun close() = Unit + } + + private companion object { + val NOW: Instant = Instant.parse("2026-07-31T00:00:00Z") + const val PEER_ID = "12D3KooWStableFriendPeer" + const val CAPABILITY = "friend-capability-123456789" + const val LAN_ADDRESS = + "/ip4/192.168.1.25/tcp/4001/p2p/$PEER_ID" + } +} diff --git a/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/ui/ListPageTest.kt b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/ui/ListPageTest.kt new file mode 100644 index 000000000..4cd860af5 --- /dev/null +++ b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/ui/ListPageTest.kt @@ -0,0 +1,41 @@ +package com.minekube.connect.share.fabric.ui + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ListPageTest { + @Test + fun `pages every relationship without losing rows`() { + val relationships = (1..12).toList() + + val first = relationships.page(offset = 0, size = 5) + val second = relationships.page(offset = first.nextOffset!!, size = 5) + val third = relationships.page(offset = second.nextOffset!!, size = 5) + + assertEquals((1..5).toList(), first.items) + assertEquals((6..10).toList(), second.items) + assertEquals(listOf(11, 12), third.items) + assertFalse(first.hasPrevious) + assertTrue(first.hasNext) + assertTrue(second.hasPrevious) + assertTrue(second.hasNext) + assertTrue(third.hasPrevious) + assertFalse(third.hasNext) + assertEquals(3, third.pageNumber) + assertEquals(3, third.pageCount) + } + + @Test + fun `clamps an obsolete offset after relationships disappear`() { + val page = listOf("remaining").page(offset = 10, size = 5) + + assertEquals(listOf("remaining"), page.items) + assertEquals(0, page.offset) + assertEquals(null, page.previousOffset) + assertEquals(null, page.nextOffset) + assertEquals(1, page.pageNumber) + assertEquals(1, page.pageCount) + } +} diff --git a/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/ui/ShareScreenPresentationTest.kt b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/ui/ShareScreenPresentationTest.kt new file mode 100644 index 000000000..d927958ac --- /dev/null +++ b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/ui/ShareScreenPresentationTest.kt @@ -0,0 +1,206 @@ +package com.minekube.connect.share.fabric.ui + +import com.minekube.connect.share.fabric.FollowAction +import com.minekube.connect.share.friend.CompatibilityDifference +import com.minekube.connect.share.friend.FriendActivityKind +import com.minekube.connect.share.friend.FriendPermissions +import com.minekube.connect.share.friend.ModLoader +import kotlin.test.Test +import kotlin.test.assertEquals + +class ShareScreenPresentationTest { + @Test + fun `joinable world is the strongest friend state`() { + val friend = friend( + activityKind = FriendActivityKind.HOSTING_WORLD, + activityDescription = "Cherry Grove", + canRequestJoin = true, + connectAvailable = true, + ) + + val presentation = friend.presentation() + + assertEquals(FriendPresenceTone.JOINABLE, presentation.tone) + assertEquals(FriendPrimaryAction.ASK_TO_JOIN, presentation.action) + assertEquals("connect_share.friends.status.world", presentation.statusKey) + assertEquals(listOf("Cherry Grove"), presentation.statusArguments) + } + + @Test + fun `approved friend can join now without another request`() { + val presentation = friend( + canJoinNow = true, + onlineViaLan = true, + ).presentation() + + assertEquals(FriendPresenceTone.JOINABLE, presentation.tone) + assertEquals(FriendPrimaryAction.JOIN_NOW, presentation.action) + } + + @Test + fun `following an unavailable friend offers cancellation`() { + val presentation = friend(following = true).presentation() + + assertEquals(FriendPresenceTone.OFFLINE, presentation.tone) + assertEquals(FriendPrimaryAction.CANCEL_FOLLOW, presentation.action) + } + + @Test + fun `friends overview counts only real friends as online or joinable`() { + val state = FriendsUiState( + friends = listOf( + friend(activityKind = FriendActivityKind.ONLINE), + friend(canRequestJoin = true), + friend(), + ), + incomingRequests = listOf( + IncomingFriendRequestSummary( + requestId = java.util.UUID.randomUUID(), + displayName = "Alex", + ingress = com.minekube.connect.share.admission.Ingress.DIRECT_LAN, + purpose = com.minekube.connect.share.admission.AdmissionPurpose.FRIEND, + ), + ), + outgoingRequests = listOf( + OutgoingFriendRequestSummary("pending", "Sam"), + ), + ) + + assertEquals( + FriendsOverview( + friendCount = 3, + onlineCount = 2, + joinableCount = 1, + incomingCount = 1, + outgoingCount = 1, + ), + state.overview(), + ) + } + + @Test + fun `friends summary prioritizes requests and uses singular copy`() { + val presentation = FriendsOverview( + friendCount = 4, + onlineCount = 3, + joinableCount = 2, + incomingCount = 1, + outgoingCount = 0, + ).summary() + + assertEquals( + "connect_share.friends.summary.requests.one", + presentation.translationKey, + ) + assertEquals(FriendsSummaryTone.ATTENTION, presentation.tone) + assertEquals(1, presentation.count) + } + + @Test + fun `friends summary uses plural copy for the strongest available state`() { + val presentation = FriendsOverview( + friendCount = 4, + onlineCount = 3, + joinableCount = 2, + incomingCount = 0, + outgoingCount = 0, + ).summary() + + assertEquals( + "connect_share.friends.summary.joinable.many", + presentation.translationKey, + ) + assertEquals(FriendsSummaryTone.READY, presentation.tone) + assertEquals(2, presentation.count) + } + + @Test + fun `empty friends summary welcomes instead of counting zero`() { + val presentation = FriendsOverview( + friendCount = 0, + onlineCount = 0, + joinableCount = 0, + incomingCount = 0, + outgoingCount = 0, + ).summary() + + assertEquals( + "connect_share.friends.summary.welcome", + presentation.translationKey, + ) + assertEquals(null, presentation.count) + } + + @Test + fun `menu label surfaces requests before ready friends`() { + val requests = FriendsOverview(5, 4, 3, 2, 0).menuLabel() + val ready = FriendsOverview(5, 4, 3, 0, 0).menuLabel() + val quiet = FriendsOverview(5, 0, 0, 0, 0).menuLabel() + + assertEquals("connect_share.menu.requests", requests.translationKey) + assertEquals(2, requests.count) + assertEquals("connect_share.menu.ready", ready.translationKey) + assertEquals(3, ready.count) + assertEquals("connect_share.menu.join", quiet.translationKey) + assertEquals(null, quiet.count) + } + + @Test + fun `compatibility details use localizable semantic lines`() { + val lines = listOf( + CompatibilityDifference.MinecraftVersion("26.2", "1.21.11"), + CompatibilityDifference.Loader(ModLoader.FABRIC, ModLoader.NEOFORGE), + CompatibilityDifference.MissingLocal("create", "6.0"), + CompatibilityDifference.MissingRemote("sodium", "0.9"), + CompatibilityDifference.ModVersion("voicechat", "2", "3"), + ).map(CompatibilityDifference::presentation) + + assertEquals( + listOf( + "connect_share.compatibility.minecraft", + "connect_share.compatibility.loader", + "connect_share.compatibility.install", + "connect_share.compatibility.host_missing", + "connect_share.compatibility.mod_version", + ), + lines.map(CompatibilityLine::translationKey), + ) + assertEquals(listOf("26.2", "1.21.11"), lines.first().arguments) + } + + @Test + fun `automatic follow cancellation has a visible explanation`() { + assertEquals( + FollowTerminalNotification( + titleKey = "connect_share.notification.follow_cancelled", + detailKey = + "connect_share.notification.follow_cancelled_detail", + displayName = "Robin", + ), + FollowAction.Cancelled("peer", "Robin").terminalNotification(), + ) + } + + private fun friend( + connectAvailable: Boolean = false, + onlineViaLan: Boolean = false, + onlineViaConnect: Boolean = false, + activityKind: FriendActivityKind? = null, + activityDescription: String? = null, + canRequestJoin: Boolean = false, + canJoinNow: Boolean = false, + following: Boolean = false, + ): FriendSummary = FriendSummary( + peerId = "peer", + displayName = "Robin", + connectAvailable = connectAvailable, + permissions = FriendPermissions(), + onlineViaLan = onlineViaLan, + onlineViaConnect = onlineViaConnect, + activityKind = activityKind, + activityDescription = activityDescription, + canRequestJoin = canRequestJoin, + canJoinNow = canJoinNow, + following = following, + ) +} diff --git a/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/ui/ShareUiMessageTest.kt b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/ui/ShareUiMessageTest.kt new file mode 100644 index 000000000..f5522815f --- /dev/null +++ b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/ui/ShareUiMessageTest.kt @@ -0,0 +1,86 @@ +package com.minekube.connect.share.fabric.ui + +import com.minekube.connect.share.admission.AdmissionAnswer +import com.minekube.connect.share.direct.ShareInviteError +import com.minekube.connect.share.fabric.GuestJoinFailure +import com.minekube.connect.share.friend.FriendStoreError +import kotlin.test.Test +import kotlin.test.assertEquals + +class ShareUiMessageTest { + @Test + fun `invitation failures retain their actionable reason`() { + val cases = listOf( + ShareInviteError.Malformed to ShareUiMessage( + "connect_share.error.invitation_malformed", + ), + ShareInviteError.UnsupportedVersion(9) to ShareUiMessage( + "connect_share.error.invitation_unsupported_version", + listOf("9"), + ), + ShareInviteError.Expired to ShareUiMessage( + "connect_share.error.invitation_expired", + ), + ShareInviteError.InvalidSignature to ShareUiMessage( + "connect_share.error.invitation_invalid_signature", + ), + ShareInviteError.RelayCandidateForbidden to ShareUiMessage( + "connect_share.error.invitation_relay_forbidden", + ), + ShareInviteError.PeerMismatch to ShareUiMessage( + "connect_share.error.invitation_peer_mismatch", + ), + ) + + cases.forEach { (failure, expected) -> + assertEquals(expected, failure.uiMessage()) + assertEquals( + expected, + FriendStoreError.InvalidInvitation(failure).uiMessage(), + ) + assertEquals( + expected, + GuestJoinFailure.InvalidInvitation(failure).uiMessage(), + ) + } + } + + @Test + fun `login denial messages are stable translation keys`() { + assertEquals( + RemoteLoginMessage( + "connect_share.login.authentication_required", + "This connection needs a valid Minecraft account.", + ), + ShareLoginMessages.AUTHENTICATION_REQUIRED, + ) + assertEquals( + RemoteLoginMessage( + "connect_share.login.approval_timed_out", + "The host did not approve this join in time. Try again.", + ), + ShareLoginMessages.denial(AdmissionAnswer.TIMEOUT), + ) + assertEquals( + RemoteLoginMessage( + "connect_share.login.share_full", + "This shared world is full. Ask the host to make room.", + ), + ShareLoginMessages.denial(AdmissionAnswer.CAPACITY), + ) + assertEquals( + RemoteLoginMessage( + "connect_share.login.sharing_stopped", + "This world is not available right now. Ask the host to share it again.", + ), + ShareLoginMessages.denial(AdmissionAnswer.STOPPED), + ) + assertEquals( + RemoteLoginMessage( + "connect_share.login.host_denied", + "The host declined this join. Request access again when ready.", + ), + ShareLoginMessages.denial(AdmissionAnswer.DENY), + ) + } +} diff --git a/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/ui/ShareViewModelTest.kt b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/ui/ShareViewModelTest.kt new file mode 100644 index 000000000..5b6946d29 --- /dev/null +++ b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/ui/ShareViewModelTest.kt @@ -0,0 +1,382 @@ +package com.minekube.connect.share.fabric.ui + +import arrow.core.Either +import com.minekube.connect.share.ShareLifecycleError +import com.minekube.connect.share.ShareOptions +import com.minekube.connect.share.ShareState +import com.minekube.connect.share.admission.AdmissionIdentity +import com.minekube.connect.share.admission.AuthSource +import com.minekube.connect.share.admission.PendingAdmission +import com.minekube.connect.share.identity.CredentialSource +import com.minekube.connect.share.identity.CredentialValidationError +import com.minekube.connect.share.friend.PresencePrivacy +import java.nio.file.Path +import java.util.UUID +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) +class ShareViewModelTest { + @Test + fun `start is disabled without a world and while a share is starting`() = runTest { + val shareState = MutableStateFlow(ShareState.Idle) + val viewModel = viewModel( + shareState = shareState, + worldAvailable = false, + ) + advanceUntilIdle() + + assertFalse(viewModel.state.value.startEnabled) + + viewModel.setWorldAvailable(true) + shareState.value = ShareState.Starting + runCurrent() + + assertFalse(viewModel.state.value.startEnabled) + + shareState.value = ShareState.Idle + runCurrent() + + assertTrue(viewModel.state.value.startEnabled) + } + + @Test + fun `failed sharing can be reset with stop`() = runTest { + val shareState = MutableStateFlow( + ShareState.Failed("start failed"), + ) + val viewModel = viewModel(shareState = shareState) + advanceUntilIdle() + + assertFalse(viewModel.state.value.startEnabled) + + viewModel.stop() + advanceUntilIdle() + + assertEquals(ShareState.Idle, viewModel.state.value.shareState) + assertTrue(viewModel.state.value.startEnabled) + } + + @Test + fun `capacity is clamped to supported guest range`() = runTest { + val viewModel = viewModel() + advanceUntilIdle() + + viewModel.setMaxGuests(-20) + assertEquals(ShareOptions.MIN_GUESTS, viewModel.state.value.options.maxGuests) + + viewModel.setMaxGuests(200) + assertEquals(ShareOptions.MAX_GUESTS, viewModel.state.value.options.maxGuests) + } + + @Test + fun `successful import clears token from mutable UI state`() = runTest { + val identityActions = FakeIdentityActions( + current = localIdentity(), + imported = localIdentity(endpoint = "friends"), + ) + val viewModel = viewModel(identityActions = identityActions) + advanceUntilIdle() + + viewModel.setImportEndpoint("friends") + viewModel.setImportToken("super-secret-token") + viewModel.importIdentity() + advanceUntilIdle() + + assertEquals("friends", viewModel.state.value.identity?.endpoint) + assertEquals("", viewModel.state.value.importDraft.token) + assertEquals("super-secret-token", identityActions.lastImportedToken) + assertFalse(viewModel.state.value.toString().contains("super-secret-token")) + } + + @Test + fun `environment managed identity fields cannot be edited`() = runTest { + val identityActions = FakeIdentityActions( + current = EndpointIdentitySummary( + endpoint = "managed", + endpointSource = CredentialSource.ENVIRONMENT, + tokenSource = CredentialSource.ENVIRONMENT, + ), + ) + val viewModel = viewModel(identityActions = identityActions) + advanceUntilIdle() + + viewModel.setImportEndpoint("changed") + viewModel.setImportToken("changed-token") + viewModel.importIdentity() + advanceUntilIdle() + + assertEquals("", viewModel.state.value.importDraft.endpoint) + assertEquals("", viewModel.state.value.importDraft.token) + assertFalse(viewModel.state.value.importDraft.endpointEditable) + assertFalse(viewModel.state.value.importDraft.tokenEditable) + assertEquals(0, identityActions.importCalls) + assertEquals( + "connect_share.error.identity_managed", + viewModel.state.value.safeMessage?.translationKey, + ) + } + + @Test + fun `allow and deny answer the exact pending request`() = runTest { + val first = pending("Alice") + val second = pending("Bob") + val answers = mutableListOf>() + val viewModel = viewModel( + pending = MutableStateFlow(listOf(first, second)), + answerAdmission = { requestId, allow -> + answers += requestId to allow + }, + ) + advanceUntilIdle() + + viewModel.allow(second.requestId) + viewModel.deny(first.requestId) + + assertEquals( + listOf( + second.requestId to true, + first.requestId to false, + ), + answers, + ) + } + + @Test + fun `starting enables persistent friend sharing and stopping disables it`() = runTest { + val persisted = mutableListOf() + val viewModel = viewModel( + persistShareWithFriends = persisted::add, + ) + advanceUntilIdle() + + viewModel.start() + advanceUntilIdle() + viewModel.stop() + advanceUntilIdle() + + assertEquals(listOf(true, false), persisted) + assertFalse(viewModel.state.value.shareWithFriendsEnabled) + } + + @Test + fun `share operations are dispatched before invoking lifecycle work`() = runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + var starts = 0 + val viewModel = viewModel( + scope = CoroutineScope(dispatcher), + operationDispatcher = dispatcher, + startShare = { + starts++ + Either.Right( + ShareState.Sharing( + endpoint = "share", + address = "share.example.test", + ), + ) + }, + ) + advanceUntilIdle() + + viewModel.start() + + assertEquals(0, starts) + runCurrent() + assertEquals(1, starts) + } + + @Test + fun `rapid duplicate starts are serialized and start the world once`() = runTest { + var starts = 0 + val viewModel = viewModel( + startShare = { + starts++ + Either.Right( + ShareState.Sharing( + endpoint = "share", + address = "share.example.test", + ), + ) + }, + ) + advanceUntilIdle() + + viewModel.start() + viewModel.start() + advanceUntilIdle() + + assertEquals(1, starts) + assertTrue(viewModel.state.value.shareState is ShareState.Sharing) + assertFalse(viewModel.state.value.operationInProgress) + } + + @Test + fun `identity changes are rejected while a world share is active`() = runTest { + val identityActions = FakeIdentityActions( + current = localIdentity(), + imported = localIdentity(endpoint = "replacement"), + ) + val viewModel = viewModel( + shareState = MutableStateFlow( + ShareState.Sharing( + endpoint = "share", + address = "share.example.test", + ), + ), + identityActions = identityActions, + ) + advanceUntilIdle() + + viewModel.setImportEndpoint("replacement") + viewModel.setImportToken("token") + viewModel.importIdentity() + advanceUntilIdle() + + assertEquals(0, identityActions.importCalls) + assertEquals( + "connect_share.error.identity_active", + viewModel.state.value.safeMessage?.translationKey, + ) + } + + @Test + fun `enabled friend sharing resumes automatically in a new world`() = runTest { + var starts = 0 + val viewModel = viewModel( + worldAvailable = false, + initialShareWithFriends = true, + startShare = { + starts++ + Either.Right( + ShareState.Sharing( + endpoint = "share", + address = "share.example.test", + ), + ) + }, + ) + advanceUntilIdle() + + viewModel.setWorldAvailable(true) + viewModel.resumeIfEnabled() + + assertEquals(1, starts) + assertTrue(viewModel.state.value.shareWithFriendsEnabled) + } + + @Test + fun `presence privacy updates atomically and persists`() = runTest { + val persisted = mutableListOf() + val viewModel = viewModel( + persistPresencePrivacy = persisted::add, + ) + advanceUntilIdle() + val privacy = PresencePrivacy( + showOnline = true, + showPlaying = true, + showCurrentServer = true, + showJoinable = false, + ) + + viewModel.setPresencePrivacy(privacy) + advanceUntilIdle() + + assertEquals(privacy, viewModel.state.value.presencePrivacy) + assertEquals(listOf(privacy), persisted) + } + + private fun TestScope.viewModel( + shareState: MutableStateFlow = + MutableStateFlow(ShareState.Idle), + pending: MutableStateFlow> = + MutableStateFlow(emptyList()), + worldAvailable: Boolean = true, + scope: CoroutineScope = CoroutineScope(StandardTestDispatcher(testScheduler)), + operationDispatcher: CoroutineDispatcher = + StandardTestDispatcher(testScheduler), + identityActions: EndpointIdentityUiActions = + FakeIdentityActions(localIdentity()), + answerAdmission: (UUID, Boolean) -> Unit = { _, _ -> }, + initialShareWithFriends: Boolean = false, + persistShareWithFriends: (Boolean) -> Unit = {}, + persistPresencePrivacy: (PresencePrivacy) -> Unit = {}, + startShare: + suspend (ShareOptions) -> Either = + { options -> + Either.Right( + ShareState.Sharing( + endpoint = "share", + address = "${options.maxGuests}.example.test", + ), + ) + }, + ) = ShareViewModel( + scope = scope, + shareState = shareState, + pendingAdmissions = pending, + initialWorldAvailable = worldAvailable, + identityActions = identityActions, + initialShareWithFriendsEnabled = initialShareWithFriends, + operationDispatcher = operationDispatcher, + persistShareWithFriendsEnabled = persistShareWithFriends, + persistPresencePrivacy = persistPresencePrivacy, + startShare = startShare, + stopShare = { Either.Right(Unit) }, + answerAdmission = answerAdmission, + ) + + private fun pending(name: String) = PendingAdmission( + requestId = UUID.randomUUID(), + identity = AdmissionIdentity.Authenticated( + name = name, + uuid = UUID.randomUUID(), + source = AuthSource.CONNECT, + ), + ) + + private fun localIdentity(endpoint: String = "generated") = + EndpointIdentitySummary( + endpoint = endpoint, + endpointSource = CredentialSource.GENERATED, + tokenSource = CredentialSource.GENERATED, + ) + + private class FakeIdentityActions( + private val current: EndpointIdentitySummary, + private val imported: EndpointIdentitySummary = current, + ) : EndpointIdentityUiActions { + var importCalls = 0 + var lastImportedToken: String? = null + + override suspend fun current(): EndpointIdentitySummary = current + + override suspend fun import( + endpoint: String, + token: String, + ): Either { + importCalls++ + lastImportedToken = token + return Either.Right(imported) + } + + override suspend fun importTokenFile( + endpoint: String, + tokenFile: Path, + ): Either = + Either.Right(imported) + + override suspend fun reset(): + Either = + Either.Right(imported.copy(endpoint = "replacement")) + } +} diff --git a/share/forge-1.20.1/build.gradle.kts b/share/forge-1.20.1/build.gradle.kts new file mode 100644 index 000000000..81bf3bc5b --- /dev/null +++ b/share/forge-1.20.1/build.gradle.kts @@ -0,0 +1,219 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar +import net.neoforged.moddevgradle.legacyforge.dsl.MixinExtension + +plugins { + id("connect.shadow-conventions") + id("net.neoforged.moddev.legacyforge") + id("org.jetbrains.kotlin.jvm") +} + +base { + archivesName = "connect-share-forge-1.20.1" +} + +java { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + toolchain.languageVersion = JavaLanguageVersion.of(21) +} + +kotlin { + jvmToolchain(21) + compilerOptions.jvmTarget.set(JvmTarget.JVM_17) + sourceSets.main { + kotlin.srcDir("../fabric-1.20.1/src/main/kotlin") + kotlin.exclude( + "com/minekube/connect/share/fabric/v1_20_1/FabricConnectShare1201Client.kt", + "com/minekube/connect/share/fabric/v1_20_1/FriendCardNetworking.kt", + ) + } +} + +legacyForge { + version = "1.20.1-47.4.22" + validateAccessTransformers = true + runs { + create("client") { client() } + } + if (!providers.gradleProperty("connectShareArtifactSmoke").isPresent) { + mods { + create("connect_share") { + sourceSet(sourceSets.main.get()) + } + } + } +} + +sourceSets.main { + java.srcDir("../fabric-1.20.1/src/main/java") + resources.srcDir("../fabric-1.20.1/src/main/resources") + resources.exclude( + "fabric.mod.json", + "connect-share-fabric-1.20.1.mixins.json", + ) +} + +val forgeMixinConfig = "connect-share-forge-1.20.1.mixins.json" +val forgeMixinRefmapName = "connect-share-forge-1.20.1.refmap.json" +val forgeMixin = extensions.getByType() +val forgeMixinRefmap = forgeMixin.add(sourceSets.main.get(), forgeMixinRefmapName) +forgeMixin.config(forgeMixinConfig) + +repositories { + maven("https://thedarkcolour.github.io/KotlinForForge/") + maven("https://repo.opencollab.dev/maven-releases") + maven("https://repo.opencollab.dev/maven-snapshots") + maven("https://dl.cloudsmith.io/public/libp2p/jvm-libp2p/maven/") + maven("https://dl.cloudsmith.io/public/consensys/maven/maven/") + maven("https://jitpack.io") { + content { includeGroupByRegex("com\\.github\\..*") } + } +} + +val connectShareParentRuntime by configurations.creating { + isCanBeConsumed = false + isCanBeResolved = true +} + +dependencies { + implementation("thedarkcolour:kotlinforforge:4.12.0") + annotationProcessor("org.spongepowered:mixin:0.8.5:processor") + compileOnly("org.jspecify:jspecify:1.0.0") + implementation(projects.core) { + exclude(group = "io.netty") + } + implementation(projects.share.common) { + exclude(group = "io.netty") + } + implementation(projects.share.fabricCommon) { + exclude(group = "io.netty") + } + connectShareParentRuntime(projects.share.fabricCommon) { + exclude(group = "io.libp2p") + exclude(group = "io.netty") + exclude(group = "org.jetbrains.kotlin") + exclude(group = "org.jetbrains.kotlinx") + exclude(group = "com.google.errorprone", module = "javac") + } + testImplementation(kotlin("test")) + testRuntimeOnly("org.junit.platform:junit-platform-launcher") +} + +relocate("arrow") +relocate("aopalliance") +relocate("cloud.commandframework") +relocate("com.google.common") +relocate("com.google.gson") +relocate("com.google.inject") +relocate("com.google.protobuf") +relocate("com.google.thirdparty") +relocate("com.google") +relocate("it.unimi.dsi.fastutil") +relocate("io.grpc") +relocate("io.leangen.geantyref") +relocate("jakarta.inject") +relocate("javax.inject") +relocate("javax.annotation") +relocate("okhttp3") +relocate("okio") +relocate("org.bstats") +relocate("org.geysermc.configutils") +relocate("org.yaml.snakeyaml") + +val libp2pRuntimeJar = tasks.named("libp2pRuntimeJar") { + dependsOn(":core:classes") + from(rootProject.project(":core").layout.buildDirectory.dir("classes/java/main")) { + include( + "com/minekube/connect/tunnel/p2p/DirectP2pNodeRuntime*.class", + "com/minekube/connect/tunnel/p2p/Libp2pEndpointRuntime*.class", + "com/minekube/connect/tunnel/p2p/impl/Libp2pTunnelTransportRuntime*.class", + ) + } +} + +val minecraftGameProfileFactory = + "com/minekube/connect/share/fabric/v1_20_1/" + + "MinecraftGameProfileFactory.class" +val connectShareShadowJar = tasks.named("shadowJar") { + configurations = listOf(connectShareParentRuntime) + archiveBaseName.set("connect-share-forge-1.20.1") + archiveVersion.set(project.version.toString()) + archiveClassifier.set("dev-parent-shadow") + mergeServiceFiles() + from(rootProject.file("LICENSE")) + exclude(minecraftGameProfileFactory) + exclude("org/checkerframework/**") + exclude("org/jetbrains/annotations/**") + exclude("org/jspecify/**") + exclude("com/google/errorprone/**") + exclude("com/google/j2objc/**") + exclude("edu/umd/cs/findbugs/**") + exclude("org/codehaus/mojo/animal_sniffer/**") + exclude("module-info.class") + exclude("META-INF/versions/*/module-info.class") +} +val connectShareJar = tasks.register("connectShareJar") { + dependsOn(connectShareShadowJar, libp2pRuntimeJar) + archiveBaseName.set("connect-share-forge-1.20.1") + archiveVersion.set(project.version.toString()) + archiveClassifier.set("dev-shadow") + duplicatesStrategy = DuplicatesStrategy.EXCLUDE + manifest.attributes( + "MixinConfigs" to forgeMixinConfig, + ) + from({ zipTree(connectShareShadowJar.get().archiveFile.get().asFile) }) + from(libp2pRuntimeJar) { + into("META-INF/connect") + } + from(sourceSets.main.get().output) { + include(minecraftGameProfileFactory) + } + from(forgeMixinRefmap) +} +val reobfConnectShareJar = obfuscation.reobfuscate( + connectShareJar, + sourceSets.main.get(), +) { + archiveBaseName.set("connect-share-forge-1.20.1") + archiveVersion.set(project.version.toString()) + archiveClassifier.set("") +} +tasks.assemble { dependsOn(reobfConnectShareJar) } + +tasks.processResources { + inputs.property("version", project.version) + filesMatching("META-INF/mods.toml") { + expand("version" to project.version) + } +} + +tasks.jar { + manifest.attributes( + "MixinConfigs" to forgeMixinConfig, + ) + from(rootProject.file("LICENSE")) +} + +tasks.test { + useJUnitPlatform() + dependsOn(reobfConnectShareJar) + systemProperty( + "connectShareArtifact", + reobfConnectShareJar.flatMap { it.archiveFile }.get().asFile.absolutePath, + ) +} + +val verifyConnectShareArtifactSize = tasks.register("verifyConnectShareArtifactSize") { + dependsOn(reobfConnectShareJar) + val artifact = reobfConnectShareJar.flatMap { it.archiveFile } + inputs.file(artifact) + doLast { + val bytes = artifact.get().asFile.length() + val limit = 63L * 1024L * 1024L + check(bytes <= limit) { + "Connect Share Forge 1.20.1 exceeds the 63 MiB release budget ($bytes bytes)" + } + } +} +tasks.check { dependsOn(verifyConnectShareArtifactSize) } diff --git a/share/forge-1.20.1/src/main/kotlin/com/minekube/connect/share/forge/v1_20_1/ForgeConnectShare1201Client.kt b/share/forge-1.20.1/src/main/kotlin/com/minekube/connect/share/forge/v1_20_1/ForgeConnectShare1201Client.kt new file mode 100644 index 000000000..16e75e354 --- /dev/null +++ b/share/forge-1.20.1/src/main/kotlin/com/minekube/connect/share/forge/v1_20_1/ForgeConnectShare1201Client.kt @@ -0,0 +1,88 @@ +package com.minekube.connect.share.forge.v1_20_1 + +import com.minekube.connect.share.fabric.ApprovedJoinTracker +import com.minekube.connect.share.fabric.FriendCardIssuer +import com.minekube.connect.share.fabric.FriendCardReceiver +import com.minekube.connect.share.fabric.LoadedMod +import com.minekube.connect.share.fabric.ModSide +import com.minekube.connect.share.fabric.v1_20_1.ConnectShare1201Platform +import com.minekube.connect.share.fabric.v1_20_1.ConnectShare1201Runtime +import com.minekube.connect.share.fabric.v1_20_1.Minecraft1201LoginBridge +import com.minekube.connect.share.friend.ModLoader +import java.nio.file.Path +import kotlinx.coroutines.CoroutineScope +import net.minecraft.client.Minecraft +import net.minecraftforge.common.MinecraftForge +import net.minecraftforge.event.GameShuttingDownEvent +import net.minecraftforge.event.TickEvent +import net.minecraftforge.eventbus.api.SubscribeEvent +import net.minecraftforge.fml.ModList +import net.minecraftforge.fml.common.Mod +import net.minecraftforge.fml.loading.FMLPaths + +@Mod(value = "connect_share") +class ForgeConnectShare1201Client { + private val platform = ForgePlatform() + + init { + Minecraft1201LoginBridge.installLoginContinuation( + ForgeLoginNegotiation::continueApprovedLogin, + ) + ConnectShare1201Runtime(platform).initialize() + MinecraftForge.EVENT_BUS.register(platform) + } + + private class ForgePlatform : ConnectShare1201Platform { + private val tickCallbacks = mutableListOf<(Minecraft) -> Unit>() + private val stopCallbacks = mutableListOf<() -> Unit>() + + override val modVersion: String = ModList.get() + .getModContainerById("connect_share") + .orElseThrow() + .modInfo.version.toString() + override val loader = ModLoader.FORGE + override val loadedMods: List = ModList.get().mods.map { + LoadedMod( + id = it.modId, + version = it.version.toString(), + side = ModSide.UNIVERSAL, + builtIn = it.modId == "minecraft" || it.modId == "forge", + ) + } + override val configDirectory: Path = FMLPaths.CONFIGDIR.get() + override val gatewayThreadFactory = ForgeGatewayThreadFactory + + override fun onEndClientTick(callback: (Minecraft) -> Unit) { + tickCallbacks += callback + } + + override fun onClientStopping(callback: () -> Unit) { + stopCallbacks += callback + } + + override fun installFriendCardNetworking( + scope: CoroutineScope, + issuer: FriendCardIssuer, + receiver: FriendCardReceiver, + approvedJoins: ApprovedJoinTracker, + ) = ForgeFriendCardNetworking.install( + scope, + issuer, + receiver, + approvedJoins, + ) + + @SubscribeEvent + fun onClientTick(event: TickEvent.ClientTickEvent) { + if (event.phase == TickEvent.Phase.END) { + val minecraft = Minecraft.getInstance() + tickCallbacks.forEach { it(minecraft) } + } + } + + @SubscribeEvent + fun onGameShuttingDown(event: GameShuttingDownEvent) { + stopCallbacks.forEach { it() } + } + } +} diff --git a/share/forge-1.20.1/src/main/kotlin/com/minekube/connect/share/forge/v1_20_1/ForgeFriendCardNetworking.kt b/share/forge-1.20.1/src/main/kotlin/com/minekube/connect/share/forge/v1_20_1/ForgeFriendCardNetworking.kt new file mode 100644 index 000000000..62c5d6014 --- /dev/null +++ b/share/forge-1.20.1/src/main/kotlin/com/minekube/connect/share/forge/v1_20_1/ForgeFriendCardNetworking.kt @@ -0,0 +1,138 @@ +package com.minekube.connect.share.forge.v1_20_1 + +import com.minekube.connect.share.fabric.ApprovedJoinTracker +import com.minekube.connect.share.fabric.ConnectShareClient +import com.minekube.connect.share.fabric.FriendCardIssuer +import com.minekube.connect.share.fabric.FriendCardReceiver +import java.util.UUID +import java.util.concurrent.atomic.AtomicReference +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import net.minecraft.client.Minecraft +import net.minecraft.network.FriendlyByteBuf +import net.minecraft.resources.ResourceLocation +import net.minecraft.server.level.ServerPlayer +import net.minecraftforge.common.MinecraftForge +import net.minecraftforge.event.entity.player.PlayerEvent +import net.minecraftforge.network.NetworkDirection +import net.minecraftforge.network.NetworkRegistry +import net.minecraftforge.network.PacketDistributor +import net.minecraftforge.network.simple.SimpleChannel + +object ForgeFriendCardNetworking { + private const val PROTOCOL = "1" + private const val MAX_CARD_CHARS = 16_384 + private val channel: SimpleChannel = NetworkRegistry.newSimpleChannel( + ResourceLocation("connect_share", "friend_cards"), + { PROTOCOL }, + { it == PROTOCOL }, + { it == PROTOCOL }, + ) + private val installed = AtomicReference() + + fun install( + scope: CoroutineScope, + issuer: FriendCardIssuer, + receiver: FriendCardReceiver, + approvedJoins: ApprovedJoinTracker, + ) { + if ( + !installed.compareAndSet( + null, + Handlers(scope, issuer, receiver, approvedJoins), + ) + ) { + return + } + channel.messageBuilder( + FriendCardMessage::class.java, + 0, + NetworkDirection.PLAY_TO_SERVER, + ) + .encoder { message, buffer -> + buffer.writeUtf(message.invitation, MAX_CARD_CHARS) + buffer.writeUUID(message.relationshipId) + } + .decoder { buffer -> + FriendCardMessage( + buffer.readUtf(MAX_CARD_CHARS), + buffer.readUUID(), + ) + } + .consumerMainThread { message, source -> + val player = source.get().sender ?: return@consumerMainThread + val handlers = installed.get() ?: return@consumerMainThread + val proof = handlers.approvedJoins.consume( + player.gameProfile.name, + player.uuid, + message.invitation, + ) ?: return@consumerMainThread + handlers.scope.launch(Dispatchers.IO) { + handlers.receiver.receive( + invitation = message.invitation, + displayName = player.gameProfile.name, + authenticatedMinecraftUuid = proof.authenticatedMinecraftUuid, + allowAutomaticJoin = true, + relationshipId = message.relationshipId, + ) + } + } + .add() + channel.messageBuilder( + FriendCardRequestMessage::class.java, + 1, + NetworkDirection.PLAY_TO_CLIENT, + ) + .encoder { _, _ -> } + .decoder { FriendCardRequestMessage } + .consumerMainThread { _, _ -> + val handlers = installed.get() ?: return@consumerMainThread + val exchange = ConnectShareClient + .consumeFriendCardExchangeConsent() + ?: return@consumerMainThread + handlers.scope.launch(Dispatchers.IO) { + handlers.issuer.issue().getOrNull()?.let { invitation -> + Minecraft.getInstance().execute { + if (Minecraft.getInstance().connection != null) { + channel.sendToServer( + FriendCardMessage( + invitation, + exchange.relationshipId, + ), + ) + handlers.scope.launch(Dispatchers.IO) { + handlers.receiver.confirmOutgoing(exchange.peerId) + } + } + } + } + } + } + .add() + MinecraftForge.EVENT_BUS.addListener { event -> + val player = event.entity as? ServerPlayer ?: return@addListener + val handlers = installed.get() ?: return@addListener + if (handlers.approvedJoins.hasProof(player.gameProfile.name, player.uuid)) { + channel.send( + PacketDistributor.PLAYER.with { player }, + FriendCardRequestMessage, + ) + } + } + } + + private data class Handlers( + val scope: CoroutineScope, + val issuer: FriendCardIssuer, + val receiver: FriendCardReceiver, + val approvedJoins: ApprovedJoinTracker, + ) + + private data class FriendCardMessage( + val invitation: String, + val relationshipId: UUID = UUID.randomUUID(), + ) + + private data object FriendCardRequestMessage +} diff --git a/share/forge-1.20.1/src/main/kotlin/com/minekube/connect/share/forge/v1_20_1/ForgeGatewayThreadFactory.kt b/share/forge-1.20.1/src/main/kotlin/com/minekube/connect/share/forge/v1_20_1/ForgeGatewayThreadFactory.kt new file mode 100644 index 000000000..20da0bea7 --- /dev/null +++ b/share/forge-1.20.1/src/main/kotlin/com/minekube/connect/share/forge/v1_20_1/ForgeGatewayThreadFactory.kt @@ -0,0 +1,15 @@ +package com.minekube.connect.share.forge.v1_20_1 + +import java.util.concurrent.ThreadFactory +import java.util.concurrent.atomic.AtomicInteger +import net.minecraftforge.fml.util.thread.SidedThreadGroups + +internal object ForgeGatewayThreadFactory : ThreadFactory { + private val threadNumber = AtomicInteger() + + override fun newThread(task: Runnable): Thread = + SidedThreadGroups.SERVER.newThread(task).apply { + name = "Connect Share Forge gateway-${threadNumber.incrementAndGet()}" + isDaemon = true + } +} diff --git a/share/forge-1.20.1/src/main/kotlin/com/minekube/connect/share/forge/v1_20_1/ForgeLoginNegotiation.kt b/share/forge-1.20.1/src/main/kotlin/com/minekube/connect/share/forge/v1_20_1/ForgeLoginNegotiation.kt new file mode 100644 index 000000000..05df6cecf --- /dev/null +++ b/share/forge-1.20.1/src/main/kotlin/com/minekube/connect/share/forge/v1_20_1/ForgeLoginNegotiation.kt @@ -0,0 +1,31 @@ +package com.minekube.connect.share.forge.v1_20_1 + +import arrow.core.Either +import arrow.core.getOrElse + +internal object ForgeLoginNegotiation { + fun continueApprovedLogin(listener: Any, accept: Runnable) { + Either.catch { + val stateField = listener.javaClass.declaredFields.single { field -> + field.type.enumConstants + ?.map { (it as Enum<*>).name } + ?.containsAll(REQUIRED_STATES) == true + } + val negotiating = stateField.type.enumConstants + .single { (it as Enum<*>).name == NEGOTIATING } + stateField.isAccessible = true + stateField.set(listener, negotiating) + }.getOrElse { failure -> + throw IllegalStateException( + "Connect Share could not continue Forge login negotiation", + failure, + ) + } + } + + private const val NEGOTIATING = "NEGOTIATING" + private val REQUIRED_STATES = listOf( + NEGOTIATING, + "READY_TO_ACCEPT", + ) +} diff --git a/share/forge-1.20.1/src/main/resources/META-INF/mods.toml b/share/forge-1.20.1/src/main/resources/META-INF/mods.toml new file mode 100644 index 000000000..0562f73b8 --- /dev/null +++ b/share/forge-1.20.1/src/main/resources/META-INF/mods.toml @@ -0,0 +1,36 @@ +modLoader="javafml" +loaderVersion="[47,)" +license="MIT" +issueTrackerURL="https://github.com/minekube/connect-java/issues" + +[[mods]] +modId="connect_share" +version="${version}" +displayName="Connect Share" +displayURL="https://github.com/minekube/connect-java" +authors="Minekube" +displayTest="IGNORE_SERVER_VERSION" +description=''' +Minecraft's universal private party system. Link once, then see, request, and join. +''' + +[[dependencies.connect_share]] +modId="forge" +mandatory=true +versionRange="[47.4.22,)" +ordering="NONE" +side="CLIENT" + +[[dependencies.connect_share]] +modId="minecraft" +mandatory=true +versionRange="[1.20.1]" +ordering="NONE" +side="CLIENT" + +[[dependencies.connect_share]] +modId="kotlinforforge" +mandatory=true +versionRange="[4.12,)" +ordering="BEFORE" +side="CLIENT" diff --git a/share/forge-1.20.1/src/main/resources/connect-share-forge-1.20.1.mixins.json b/share/forge-1.20.1/src/main/resources/connect-share-forge-1.20.1.mixins.json new file mode 100644 index 000000000..b0bad4546 --- /dev/null +++ b/share/forge-1.20.1/src/main/resources/connect-share-forge-1.20.1.mixins.json @@ -0,0 +1,23 @@ +{ + "required": true, + "minVersion": "0.8", + "package": "com.minekube.connect.share.fabric.v1_20_1.mixin", + "compatibilityLevel": "JAVA_17", + "refmap": "connect-share-forge-1.20.1.refmap.json", + "mixins": [ + "ConnectionAccessor", + "ServerConnectionListenerAccessor", + "ServerConnectionListenerMixin", + "ServerLoginPacketListenerMixin" + ], + "client": [ + "IntegratedServerAccessor", + "IntegratedServerMixin", + "LanServerPingerAccessor", + "PauseScreenMixin", + "TitleScreenMixin" + ], + "injectors": { + "defaultRequire": 1 + } +} diff --git a/share/forge-1.20.1/src/main/resources/pack.mcmeta b/share/forge-1.20.1/src/main/resources/pack.mcmeta new file mode 100644 index 000000000..335d7d8e9 --- /dev/null +++ b/share/forge-1.20.1/src/main/resources/pack.mcmeta @@ -0,0 +1,6 @@ +{ + "pack": { + "description": "Connect Share resources", + "pack_format": 15 + } +} diff --git a/share/forge-1.20.1/src/test/kotlin/com/minekube/connect/share/forge/v1_20_1/Forge1201ArtifactTest.kt b/share/forge-1.20.1/src/test/kotlin/com/minekube/connect/share/forge/v1_20_1/Forge1201ArtifactTest.kt new file mode 100644 index 000000000..ca22786c7 --- /dev/null +++ b/share/forge-1.20.1/src/test/kotlin/com/minekube/connect/share/forge/v1_20_1/Forge1201ArtifactTest.kt @@ -0,0 +1,61 @@ +package com.minekube.connect.share.forge.v1_20_1 + +import java.nio.file.Path +import java.util.jar.JarFile +import kotlin.test.Test +import kotlin.test.assertNotNull +import kotlin.test.assertFalse +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class Forge1201ArtifactTest { + @Test + fun `artifact declares Forge client metadata and mixins`() { + val artifact = Path.of(checkNotNull(System.getProperty("connectShareArtifact"))) + JarFile(artifact.toFile()).use { jar -> + val metadata = jar.getInputStream( + assertNotNull(jar.getJarEntry("META-INF/mods.toml")), + ).bufferedReader().readText() + assertTrue("modId=\"connect_share\"" in metadata) + assertTrue("modId=\"kotlinforforge\"" in metadata) + val mixinConfig = jar.getInputStream( + assertNotNull( + jar.getJarEntry("connect-share-forge-1.20.1.mixins.json"), + ), + ).bufferedReader().readText() + assertTrue( + "\"refmap\": \"connect-share-forge-1.20.1.refmap.json\"" in + mixinConfig, + ) + assertNotNull( + jar.getJarEntry("connect-share-forge-1.20.1.refmap.json"), + ) + assertNotNull(jar.getJarEntry("pack.mcmeta")) + assertNotNull(jar.getJarEntry("META-INF/connect/libp2p-runtime.jar")) + assertNotNull( + jar.getJarEntry( + "com/minekube/connect/share/forge/v1_20_1/" + + "ForgeFriendCardNetworking.class", + ), + ) + assertEquals( + "connect-share-forge-1.20.1.mixins.json", + jar.manifest.mainAttributes.getValue("MixinConfigs"), + ) + val names = jar.entries().asSequence().map { it.name }.toList() + assertFalse(names.any { it.startsWith("io/libp2p/") }) + assertFalse(names.any { it.startsWith("io/netty/") }) + assertFalse(names.any { it.startsWith("kotlin/") }) + val entry = assertNotNull( + jar.getJarEntry( + "com/minekube/connect/share/forge/v1_20_1/" + + "ForgeConnectShare1201Client.class", + ), + ) + val header = jar.getInputStream(entry).readNBytes(8) + val major = (header[6].toInt() and 0xff) shl 8 or + (header[7].toInt() and 0xff) + assertEquals(61, major, "Forge 1.20.1 must remain Java 17 compatible") + } + } +} diff --git a/share/forge-1.20.1/src/test/kotlin/com/minekube/connect/share/forge/v1_20_1/ForgeLoginNegotiationTest.kt b/share/forge-1.20.1/src/test/kotlin/com/minekube/connect/share/forge/v1_20_1/ForgeLoginNegotiationTest.kt new file mode 100644 index 000000000..4ad114d86 --- /dev/null +++ b/share/forge-1.20.1/src/test/kotlin/com/minekube/connect/share/forge/v1_20_1/ForgeLoginNegotiationTest.kt @@ -0,0 +1,31 @@ +package com.minekube.connect.share.forge.v1_20_1 + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse + +class ForgeLoginNegotiationTest { + @Test + fun `approved share login enters Forge negotiation before acceptance`() { + val listener = FakeLoginListener() + var accepted = false + + ForgeLoginNegotiation.continueApprovedLogin( + listener, + Runnable { accepted = true }, + ) + + assertEquals(FakeLoginState.NEGOTIATING, listener.state) + assertFalse(accepted) + } + + private class FakeLoginListener { + var state = FakeLoginState.HELLO + } + + private enum class FakeLoginState { + HELLO, + NEGOTIATING, + READY_TO_ACCEPT, + } +} diff --git a/share/neoforge-1.21.1/build.gradle.kts b/share/neoforge-1.21.1/build.gradle.kts new file mode 100644 index 000000000..1d5290107 --- /dev/null +++ b/share/neoforge-1.21.1/build.gradle.kts @@ -0,0 +1,181 @@ +import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + id("connect.shadow-conventions") + id("net.neoforged.moddev") + id("org.jetbrains.kotlin.jvm") +} + +base { + archivesName = "connect-share-neoforge-1.21.1" +} + +java { + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 + toolchain.languageVersion = JavaLanguageVersion.of(21) +} + +kotlin { + jvmToolchain(21) + compilerOptions.jvmTarget.set(JvmTarget.JVM_21) + sourceSets.main { + kotlin.srcDir("../fabric-1.21.1/src/main/kotlin") + kotlin.exclude( + "com/minekube/connect/share/fabric/v1_21_1/FabricConnectShare1211Client.kt", + "com/minekube/connect/share/fabric/v1_21_1/FriendCardNetworking.kt", + ) + } +} + +neoForge { + version = "21.1.247" + validateAccessTransformers = true + runs { + create("client") { client() } + } + if (!providers.gradleProperty("connectShareArtifactSmoke").isPresent) { + mods { + create("connect_share") { + sourceSet(sourceSets.main.get()) + } + } + } +} + +sourceSets.main { + java.srcDir("../fabric-1.21.1/src/main/java") + resources.srcDir("../fabric-1.21.1/src/main/resources") + resources.exclude("fabric.mod.json") +} + +repositories { + maven("https://thedarkcolour.github.io/KotlinForForge/") + maven("https://repo.opencollab.dev/maven-releases") + maven("https://repo.opencollab.dev/maven-snapshots") + maven("https://dl.cloudsmith.io/public/libp2p/jvm-libp2p/maven/") + maven("https://dl.cloudsmith.io/public/consensys/maven/maven/") + maven("https://jitpack.io") { + content { includeGroupByRegex("com\\.github\\..*") } + } +} + +val connectShareParentRuntime by configurations.creating { + isCanBeConsumed = false + isCanBeResolved = true +} + +dependencies { + implementation("thedarkcolour:kotlinforforge:5.12.0") + compileOnly("org.jspecify:jspecify:1.0.0") + implementation(projects.core) { + exclude(group = "io.netty") + } + implementation(projects.share.common) { + exclude(group = "io.netty") + } + implementation(projects.share.fabricCommon) { + exclude(group = "io.netty") + } + connectShareParentRuntime(projects.share.fabricCommon) { + exclude(group = "io.libp2p") + exclude(group = "io.netty") + exclude(group = "org.jetbrains.kotlin") + exclude(group = "org.jetbrains.kotlinx") + exclude(group = "com.google.errorprone", module = "javac") + } + testImplementation(kotlin("test")) + testRuntimeOnly("org.junit.platform:junit-platform-launcher") +} + +tasks.processResources { + inputs.property("version", project.version) + filesMatching("META-INF/neoforge.mods.toml") { + expand("version" to project.version) + } +} + +relocate("arrow") +relocate("aopalliance") +relocate("cloud.commandframework") +relocate("com.google.common") +relocate("com.google.gson") +relocate("com.google.inject") +relocate("com.google.protobuf") +relocate("com.google.thirdparty") +relocate("com.google") +relocate("it.unimi.dsi.fastutil") +relocate("io.grpc") +relocate("io.leangen.geantyref") +relocate("jakarta.inject") +relocate("javax.inject") +relocate("javax.annotation") +relocate("okhttp3") +relocate("okio") +relocate("org.bstats") +relocate("org.geysermc.configutils") +relocate("org.yaml.snakeyaml") + +val libp2pRuntimeJar = tasks.named("libp2pRuntimeJar") { + dependsOn(":core:classes") + from(rootProject.project(":core").layout.buildDirectory.dir("classes/java/main")) { + include( + "com/minekube/connect/tunnel/p2p/DirectP2pNodeRuntime*.class", + "com/minekube/connect/tunnel/p2p/Libp2pEndpointRuntime*.class", + "com/minekube/connect/tunnel/p2p/impl/Libp2pTunnelTransportRuntime*.class", + ) + } +} +val connectShareShadowJar = tasks.named("shadowJar") { + configurations = listOf(connectShareParentRuntime) + archiveBaseName.set("connect-share-neoforge-1.21.1") + archiveVersion.set(project.version.toString()) + archiveClassifier.set("parent-shadow") + mergeServiceFiles() + from(rootProject.file("LICENSE")) + exclude("org/checkerframework/**") + exclude("org/jetbrains/annotations/**") + exclude("org/jspecify/**") + exclude("com/google/errorprone/**") + exclude("com/google/j2objc/**") + exclude("edu/umd/cs/findbugs/**") + exclude("org/codehaus/mojo/animal_sniffer/**") + exclude("module-info.class") + exclude("META-INF/versions/*/module-info.class") +} +val connectShareJar = tasks.register("connectShareJar") { + dependsOn(connectShareShadowJar, libp2pRuntimeJar) + archiveBaseName.set("connect-share-neoforge-1.21.1") + archiveVersion.set(project.version.toString()) + archiveClassifier.set("") + duplicatesStrategy = DuplicatesStrategy.EXCLUDE + from({ zipTree(connectShareShadowJar.get().archiveFile.get().asFile) }) + from(libp2pRuntimeJar) { + into("META-INF/connect") + } +} +tasks.assemble { dependsOn(connectShareJar) } + +tasks.test { + useJUnitPlatform() + dependsOn(connectShareJar) + systemProperty( + "connectShareArtifact", + connectShareJar.flatMap { it.archiveFile }.get().asFile.absolutePath, + ) +} + +val verifyConnectShareArtifactSize = tasks.register("verifyConnectShareArtifactSize") { + dependsOn(connectShareJar) + val artifact = connectShareJar.flatMap { it.archiveFile } + inputs.file(artifact) + doLast { + val bytes = artifact.get().asFile.length() + val limit = 63L * 1024L * 1024L + check(bytes <= limit) { + "Connect Share NeoForge 1.21.1 exceeds the 63 MiB release budget ($bytes bytes)" + } + } +} +tasks.check { dependsOn(verifyConnectShareArtifactSize) } diff --git a/share/neoforge-1.21.1/src/main/kotlin/com/minekube/connect/share/neoforge/v1_21_1/NeoForgeConnectShare1211Client.kt b/share/neoforge-1.21.1/src/main/kotlin/com/minekube/connect/share/neoforge/v1_21_1/NeoForgeConnectShare1211Client.kt new file mode 100644 index 000000000..aa0410c54 --- /dev/null +++ b/share/neoforge-1.21.1/src/main/kotlin/com/minekube/connect/share/neoforge/v1_21_1/NeoForgeConnectShare1211Client.kt @@ -0,0 +1,93 @@ +package com.minekube.connect.share.neoforge.v1_21_1 + +import com.minekube.connect.share.fabric.ApprovedJoinTracker +import com.minekube.connect.share.fabric.FriendCardIssuer +import com.minekube.connect.share.fabric.FriendCardReceiver +import com.minekube.connect.share.fabric.LoadedMod +import com.minekube.connect.share.fabric.ModSide +import com.minekube.connect.share.fabric.v1_21_1.ConnectShare1211Platform +import com.minekube.connect.share.fabric.v1_21_1.ConnectShare1211Runtime +import com.minekube.connect.share.friend.ModLoader +import java.nio.file.Path +import kotlinx.coroutines.CoroutineScope +import net.minecraft.client.Minecraft +import net.neoforged.bus.api.IEventBus +import net.neoforged.bus.api.SubscribeEvent +import net.neoforged.fml.ModList +import net.neoforged.fml.common.Mod +import net.neoforged.fml.loading.FMLPaths +import net.neoforged.neoforge.common.NeoForge +import net.neoforged.neoforge.client.event.ClientTickEvent +import net.neoforged.neoforge.event.GameShuttingDownEvent +import net.neoforged.neoforge.network.event.RegisterPayloadHandlersEvent + +@Mod("connect_share") +class NeoForgeConnectShare1211Client(modEventBus: IEventBus) { + private val platform = NeoForgePlatform() + + init { + modEventBus.addListener( + RegisterPayloadHandlersEvent::class.java, + ) { event -> + NeoForgeFriendCardNetworking.register(event) + } + ConnectShare1211Runtime(platform).initialize() + NeoForge.EVENT_BUS.register(platform) + } + + private class NeoForgePlatform : ConnectShare1211Platform { + private val tickCallbacks = mutableListOf<(Minecraft) -> Unit>() + private val stopCallbacks = mutableListOf<() -> Unit>() + + override val modVersion: String = ModList.get() + .getModContainerById("connect_share") + .orElseThrow() + .modInfo.version.toString() + override val loader = ModLoader.NEOFORGE + override val loadedMods: List = ModList.get().mods.map { + LoadedMod( + id = it.modId, + version = it.version.toString(), + side = ModSide.UNIVERSAL, + builtIn = it.modId == "minecraft" || it.modId == "neoforge", + ) + } + override val configDirectory: Path = FMLPaths.CONFIGDIR.get() + + override fun onEndClientTick(callback: (Minecraft) -> Unit) { + tickCallbacks += callback + } + + override fun onClientStopping(callback: () -> Unit) { + stopCallbacks += callback + } + + override fun installFriendCardNetworking( + scope: CoroutineScope, + issuer: FriendCardIssuer, + receiver: FriendCardReceiver, + approvedJoins: ApprovedJoinTracker, + ) = NeoForgeFriendCardNetworking.install( + scope, + issuer, + receiver, + approvedJoins, + ) + + @SubscribeEvent + fun onPlayerLoggedIn(event: net.neoforged.neoforge.event.entity.player.PlayerEvent.PlayerLoggedInEvent) { + NeoForgeFriendCardNetworking.onPlayerLoggedIn(event) + } + + @SubscribeEvent + fun onClientTick(event: ClientTickEvent.Post) { + val minecraft = Minecraft.getInstance() + tickCallbacks.forEach { it(minecraft) } + } + + @SubscribeEvent + fun onGameShuttingDown(event: GameShuttingDownEvent) { + stopCallbacks.forEach { it() } + } + } +} diff --git a/share/neoforge-1.21.1/src/main/kotlin/com/minekube/connect/share/neoforge/v1_21_1/NeoForgeFriendCardNetworking.kt b/share/neoforge-1.21.1/src/main/kotlin/com/minekube/connect/share/neoforge/v1_21_1/NeoForgeFriendCardNetworking.kt new file mode 100644 index 000000000..ced6c04b9 --- /dev/null +++ b/share/neoforge-1.21.1/src/main/kotlin/com/minekube/connect/share/neoforge/v1_21_1/NeoForgeFriendCardNetworking.kt @@ -0,0 +1,147 @@ +package com.minekube.connect.share.neoforge.v1_21_1 + +import com.minekube.connect.share.fabric.ApprovedJoinTracker +import com.minekube.connect.share.fabric.ConnectShareClient +import com.minekube.connect.share.fabric.FriendCardIssuer +import com.minekube.connect.share.fabric.FriendCardReceiver +import java.util.UUID +import java.util.concurrent.atomic.AtomicReference +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import net.minecraft.client.Minecraft +import net.minecraft.network.FriendlyByteBuf +import net.minecraft.network.codec.StreamCodec +import net.minecraft.network.protocol.common.custom.CustomPacketPayload +import net.minecraft.resources.ResourceLocation +import net.minecraft.server.level.ServerPlayer +import net.neoforged.neoforge.network.PacketDistributor +import net.neoforged.neoforge.network.event.RegisterPayloadHandlersEvent + +object NeoForgeFriendCardNetworking { + private const val PROTOCOL = "1" + private val installed = AtomicReference() + + fun register(event: RegisterPayloadHandlersEvent) { + val registrar = event.registrar(PROTOCOL).optional() + registrar.playToClient( + FriendCardRequestPayload.TYPE, + FriendCardRequestPayload.CODEC, + ) { _, _ -> + val handlers = installed.get() ?: return@playToClient + val exchange = ConnectShareClient + .consumeFriendCardExchangeConsent() + ?: return@playToClient + handlers.scope.launch(Dispatchers.IO) { + handlers.issuer.issue().getOrNull()?.let { invitation -> + Minecraft.getInstance().execute { + if (Minecraft.getInstance().connection != null) { + PacketDistributor.sendToServer( + FriendCardPayload( + invitation, + exchange.relationshipId, + ), + ) + handlers.scope.launch(Dispatchers.IO) { + handlers.receiver.confirmOutgoing(exchange.peerId) + } + } + } + } + } + } + registrar.playToServer( + FriendCardPayload.TYPE, + FriendCardPayload.CODEC, + ) { payload, context -> + val player = context.player() as? ServerPlayer + ?: return@playToServer + val handlers = installed.get() ?: return@playToServer + val proof = handlers.approvedJoins.consume( + player.gameProfile.name, + player.uuid, + payload.invitation, + ) ?: return@playToServer + handlers.scope.launch(Dispatchers.IO) { + handlers.receiver.receive( + invitation = payload.invitation, + displayName = player.gameProfile.name, + authenticatedMinecraftUuid = proof.authenticatedMinecraftUuid, + allowAutomaticJoin = true, + relationshipId = payload.relationshipId, + ) + } + } + } + + fun install( + scope: CoroutineScope, + issuer: FriendCardIssuer, + receiver: FriendCardReceiver, + approvedJoins: ApprovedJoinTracker, + ) { + installed.set(Handlers(scope, issuer, receiver, approvedJoins)) + } + + fun onPlayerLoggedIn( + event: net.neoforged.neoforge.event.entity.player.PlayerEvent.PlayerLoggedInEvent, + ) { + val player = event.entity as? ServerPlayer ?: return + val handlers = installed.get() ?: return + if (handlers.approvedJoins.hasProof(player.gameProfile.name, player.uuid)) { + PacketDistributor.sendToPlayer(player, FriendCardRequestPayload) + } + } + + private data class Handlers( + val scope: CoroutineScope, + val issuer: FriendCardIssuer, + val receiver: FriendCardReceiver, + val approvedJoins: ApprovedJoinTracker, + ) +} + +private data class FriendCardPayload( + val invitation: String, + val relationshipId: UUID = UUID.randomUUID(), +) : CustomPacketPayload { + override fun type(): CustomPacketPayload.Type = TYPE + + companion object { + private const val MAX_CARD_CHARS = 16_384 + val TYPE: CustomPacketPayload.Type = + CustomPacketPayload.Type( + ResourceLocation.fromNamespaceAndPath( + "connect-share", + "friend-card", + ), + ) + val CODEC: StreamCodec = + CustomPacketPayload.codec( + { payload, buffer -> + buffer.writeUtf(payload.invitation, MAX_CARD_CHARS) + buffer.writeUUID(payload.relationshipId) + }, + { buffer -> + FriendCardPayload( + buffer.readUtf(MAX_CARD_CHARS), + buffer.readUUID(), + ) + }, + ) + } +} + +private data object FriendCardRequestPayload : CustomPacketPayload { + override fun type(): CustomPacketPayload.Type = TYPE + + val TYPE: CustomPacketPayload.Type = + CustomPacketPayload.Type( + ResourceLocation.fromNamespaceAndPath( + "connect-share", + "friend-card-request", + ), + ) + val CODEC: StreamCodec = + StreamCodec.unit(FriendCardRequestPayload) +} diff --git a/share/neoforge-1.21.1/src/main/resources/META-INF/neoforge.mods.toml b/share/neoforge-1.21.1/src/main/resources/META-INF/neoforge.mods.toml new file mode 100644 index 000000000..29841dfd2 --- /dev/null +++ b/share/neoforge-1.21.1/src/main/resources/META-INF/neoforge.mods.toml @@ -0,0 +1,38 @@ +modLoader="javafml" +loaderVersion="[4,)" +license="MIT" +issueTrackerURL="https://github.com/minekube/connect-java/issues" + +[[mods]] +modId="connect_share" +version="${version}" +displayName="Connect Share" +displayURL="https://github.com/minekube/connect-java" +authors="Minekube" +description=''' +Minecraft's universal private party system. Link once, then see, request, and join. +''' + +[[dependencies.connect_share]] +modId="neoforge" +type="required" +versionRange="[21.1.247,)" +ordering="NONE" +side="CLIENT" + +[[dependencies.connect_share]] +modId="minecraft" +type="required" +versionRange="[1.21.1]" +ordering="NONE" +side="CLIENT" + +[[dependencies.connect_share]] +modId="kotlinforforge" +type="required" +versionRange="[5.12,)" +ordering="BEFORE" +side="CLIENT" + +[[mixins]] +config="connect-share-fabric-1.21.1.mixins.json" diff --git a/share/neoforge-1.21.1/src/main/resources/pack.mcmeta b/share/neoforge-1.21.1/src/main/resources/pack.mcmeta new file mode 100644 index 000000000..fc699de55 --- /dev/null +++ b/share/neoforge-1.21.1/src/main/resources/pack.mcmeta @@ -0,0 +1,6 @@ +{ + "pack": { + "description": "Connect Share resources", + "pack_format": 34 + } +} diff --git a/share/neoforge-1.21.1/src/test/kotlin/com/minekube/connect/share/neoforge/v1_21_1/NeoForge1211ArtifactTest.kt b/share/neoforge-1.21.1/src/test/kotlin/com/minekube/connect/share/neoforge/v1_21_1/NeoForge1211ArtifactTest.kt new file mode 100644 index 000000000..1c996f580 --- /dev/null +++ b/share/neoforge-1.21.1/src/test/kotlin/com/minekube/connect/share/neoforge/v1_21_1/NeoForge1211ArtifactTest.kt @@ -0,0 +1,48 @@ +package com.minekube.connect.share.neoforge.v1_21_1 + +import java.nio.file.Path +import java.util.jar.JarFile +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class NeoForge1211ArtifactTest { + @Test + fun `artifact declares NeoForge client metadata and mixins`() { + val artifact = Path.of(checkNotNull(System.getProperty("connectShareArtifact"))) + JarFile(artifact.toFile()).use { jar -> + val metadata = jar.getInputStream( + assertNotNull(jar.getJarEntry("META-INF/neoforge.mods.toml")), + ).bufferedReader().readText() + assertTrue("modId=\"connect_share\"" in metadata) + assertTrue("modId=\"kotlinforforge\"" in metadata) + assertNotNull( + jar.getJarEntry("connect-share-fabric-1.21.1.mixins.json"), + ) + assertNotNull(jar.getJarEntry("META-INF/connect/libp2p-runtime.jar")) + assertNotNull( + jar.getJarEntry( + "com/minekube/connect/share/neoforge/v1_21_1/" + + "NeoForgeFriendCardNetworking.class", + ), + ) + assertNotNull(jar.getJarEntry("pack.mcmeta")) + val names = jar.entries().asSequence().map { it.name }.toList() + assertFalse(names.any { it.startsWith("io/libp2p/") }) + assertFalse(names.any { it.startsWith("io/netty/") }) + assertFalse(names.any { it.startsWith("kotlin/") }) + val entry = assertNotNull( + jar.getJarEntry( + "com/minekube/connect/share/neoforge/v1_21_1/" + + "NeoForgeConnectShare1211Client.class", + ), + ) + val header = jar.getInputStream(entry).readNBytes(8) + val major = (header[6].toInt() and 0xff) shl 8 or + (header[7].toInt() and 0xff) + assertEquals(65, major, "NeoForge 1.21.1 must remain Java 21 compatible") + } + } +} diff --git a/spigot/src/main/java/com/minekube/connect/addon/data/SpigotDataHandler.java b/spigot/src/main/java/com/minekube/connect/addon/data/SpigotDataHandler.java index 40392754f..92dc3f074 100644 --- a/spigot/src/main/java/com/minekube/connect/addon/data/SpigotDataHandler.java +++ b/spigot/src/main/java/com/minekube/connect/addon/data/SpigotDataHandler.java @@ -158,7 +158,7 @@ public boolean channelRead(Object packet) throws Exception { return true; // next is LOGIN_START_PACKET } if (ClassNames.LOGIN_START_PACKET.isInstance(packet)) { - debug("Processing LOGIN_START_PACKET for " + sessionCtx.getPlayer().getUsername()); + debug("Processing LOGIN_START_PACKET for " + logPlayer()); if (!enforceBedrockIdentity()) { return false; } @@ -230,7 +230,7 @@ public boolean channelRead(Object packet) throws Exception { GameProfile profileToUse = returnedProfile != null ? (GameProfile) returnedProfile : gameProfile; if (config.isDebug()) { - debug("callPlayerPreLoginEvents returned profile: " + profileToUse); + debug("callPlayerPreLoginEvents returned a profile for " + logPlayer()); } ClassNames.START_CLIENT_VERIFICATION.invoke(packetListener, profileToUse); @@ -243,6 +243,12 @@ public boolean channelRead(Object packet) throws Exception { return true; } + private String logPlayer() { + return sessionCtx.getSessionProposal().hasBedrockPrincipalV2() + ? "" + : sessionCtx.getPlayer().getUsername(); + } + boolean enforceBedrockIdentity() { if (bedrockIdentityEnforcer == null) { return true; diff --git a/spigot/src/main/java/com/minekube/connect/listener/SpigotListener.java b/spigot/src/main/java/com/minekube/connect/listener/SpigotListener.java index 4b5f14867..cdf2ab9b6 100644 --- a/spigot/src/main/java/com/minekube/connect/listener/SpigotListener.java +++ b/spigot/src/main/java/com/minekube/connect/listener/SpigotListener.java @@ -55,10 +55,14 @@ public void onPlayerLogin(PlayerLoginEvent event) { if (player != null) { //todo we should probably move this log message earlier in the process, so that we know // that Connect has done its job - logger.translatedInfo( - "connect.ingame.login_name", - player.getUsername(), player.getUniqueId() - ); + if (api.getVerifiedBedrockPrincipal(player).isPresent()) { + logger.info("A verified Bedrock principal v2 session joined"); + } else { + logger.translatedInfo( + "connect.ingame.login_name", + player.getUsername(), player.getUniqueId() + ); + } languageManager.loadLocale(player.getLanguageTag()); } } diff --git a/velocity/build.gradle.kts b/velocity/build.gradle.kts index 29da2bc68..82d75f7bb 100644 --- a/velocity/build.gradle.kts +++ b/velocity/build.gradle.kts @@ -5,8 +5,8 @@ var guavaVersion = "25.1-jre" java { // For Velocity API - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 } dependencies { diff --git a/velocity/src/main/java/com/minekube/connect/listener/VelocityLateReassertListener.java b/velocity/src/main/java/com/minekube/connect/listener/VelocityLateReassertListener.java index 8dfc57de0..1deaccf62 100644 --- a/velocity/src/main/java/com/minekube/connect/listener/VelocityLateReassertListener.java +++ b/velocity/src/main/java/com/minekube/connect/listener/VelocityLateReassertListener.java @@ -183,7 +183,7 @@ void onGameProfileRequestLate(GameProfileRequestEvent event) { return; } event.setGameProfile(wanted); - logger.debug("Re-asserted the game profile of Connect session {}", player.getUsername()); + logger.debug("Re-asserted the game profile of a Connect session"); } private boolean enabled() {